#!/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) """ from __future__ import annotations 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, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # ============================================================================ # PATHS & LOGGING # ============================================================================ LIVE_DEPLOY = Path("/opt/charter-live/live_deploy_v2") 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") # One sqlite connection is shared by the HTTP threads and the worker. Serialize # complete DB operations so execute/commit pairs cannot overlap. DB_LOCK = threading.RLock() def db_serialized(func): def wrapper(*args, **kwargs): with DB_LOCK: return func(*args, **kwargs) return wrapper # ============================================================================ # 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 = 5581 MIN_NOTIONAL_USD = 5.0 NOTIONAL_SAFETY_MARGIN = 1.20 PENDING_MAX_AGE_SECONDS = max(60, int(os.environ.get("V2_PENDING_MAX_AGE_SECONDS", "600"))) # === 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 # === FIX 2026-08-05 (Mattia 20:37): VIRTUALUSDT escluso da P007 regime + P007b CHOP === # Mattia ha deciso: VIRTUAL entra con qualsiasi segnale Pine, senza regime/CHOP check. # Solo SL Charter P006 -3% come safety net. # Motivo: VIRTUAL spesso in regime CHOP/COMPRESSION ma Pine ha pattern validi # che il check EMA50/ADX scarta. Mattia preferisce aprire e gestire con SL fisso. EXCLUDED_FROM_REGIME_CHECK = {"VIRTUALUSDT"} EXCLUDED_FROM_CHOP_CHECK = {"VIRTUALUSDT"} # ============================================================================ # 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à) # ============================================================================ @db_serialized 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.execute(""" CREATE TABLE IF NOT EXISTS position_ownership ( symbol TEXT PRIMARY KEY, strategy TEXT NOT NULL, side TEXT NOT NULL, entry_request_id TEXT, entry_order_id TEXT, opened_at TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'webhook' ) """) conn.commit() log.info("DB inizializzato: %s", DB_PATH) return conn @db_serialized def enqueue_request(conn, payload: dict, tunnel_pending: bool = False) -> str: """Inserisce richiesta nel DB. Ritorna request_id.""" request_id = str(uuid.uuid4()) conn.execute( "INSERT INTO queue (request_id, received_at, payload, tunnel_pending) VALUES (?, ?, ?, ?)", (request_id, datetime.now(timezone.utc).isoformat(), json.dumps(payload), 1 if tunnel_pending else 0) ) conn.commit() return request_id @db_serialized 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() @db_serialized def mark_skipped(conn, request_id: str, reason: str): """Chiude definitivamente una richiesta filtrata o idempotente.""" conn.execute( "UPDATE queue SET status='skipped', error=?, processed_at=? " "WHERE request_id=? AND status='pending'", (reason, datetime.now(timezone.utc).isoformat(), request_id), ) conn.commit() @db_serialized def mark_expired(conn, request_id: str, reason: str = "expired_pending_ttl"): """Mette in quarantena una richiesta troppo vecchia; non potra essere rieseguita.""" conn.execute( "UPDATE queue SET status='expired', error=?, processed_at=? " "WHERE request_id=? AND status='pending'", (reason, datetime.now(timezone.utc).isoformat(), request_id), ) conn.commit() def _parse_queue_time(value): try: parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) except (TypeError, ValueError): return None @db_serialized def expire_stale_pending(conn, max_age_seconds: int = PENDING_MAX_AGE_SECONDS): """Terminalizza i pending scaduti. Mai rieseguire un vecchio segnale TV.""" now = datetime.now(timezone.utc) expired = [] rows = conn.execute( "SELECT request_id, received_at FROM queue WHERE status='pending'" ).fetchall() for request_id, received_at in rows: received = _parse_queue_time(received_at) if received is None or (now - received).total_seconds() > max_age_seconds: age = "unknown" if received is None else str(int((now - received).total_seconds())) reason = f"expired_pending_ttl age_seconds={age} limit={max_age_seconds}" mark_expired(conn, request_id, reason) expired.append(request_id) log.error("PENDING EXPIRED/QUARANTINED: request_id=%s %s", request_id, reason) return expired @db_serialized def request_is_fresh_pending(conn, request_id: str, max_age_seconds: int = PENDING_MAX_AGE_SECONDS) -> bool: """Difesa in profondita prima di qualsiasi chiamata ordine a Bybit.""" row = conn.execute( "SELECT status, received_at FROM queue WHERE request_id=?", (request_id,) ).fetchone() if not row: log.error("QUEUE STATE MISSING: request_id=%s; processing blocked", request_id) return False status, received_at = row if status != "pending": log.warning("QUEUE TERMINAL SKIP: request_id=%s status=%s", request_id, status) return False received = _parse_queue_time(received_at) if received is None or (datetime.now(timezone.utc) - received).total_seconds() > max_age_seconds: mark_expired(conn, request_id) log.error("STALE REQUEST BLOCKED before Bybit: request_id=%s", request_id) return False return True @db_serialized 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] @db_serialized 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).""" expire_stale_pending(conn) 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] @db_serialized 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 @db_serialized def unblock_all_tunnel_pending(conn): """Sblocca solo richieste fresche e le restituisce per la coda in memoria.""" expire_stale_pending(conn) rows = conn.execute( "SELECT request_id, payload FROM queue " "WHERE status='pending' AND tunnel_pending=1 ORDER BY id LIMIT 100" ).fetchall() request_ids = [request_id for request_id, _ in rows] cur = None if request_ids: placeholders = ",".join("?" for _ in request_ids) cur = conn.execute( f"UPDATE queue SET tunnel_pending=0 WHERE status='pending' " f"AND tunnel_pending=1 AND request_id IN ({placeholders})", request_ids, ) conn.commit() updated = cur.rowcount if cur is not None else 0 if updated != len(rows): log.warning("TUNNEL UNBLOCK count drift: selected=%d updated=%d", len(rows), updated) return [(request_id, json.loads(payload)) for request_id, payload in rows] def _normalized_marker(value) -> str: return " ".join(str(value or "").lower().replace("_", " ").replace("-", " ").split()) def payload_has_close_intent(payload: dict) -> bool: """Riconosce in modo uniforme tutte le forme Pine note per le uscite.""" markers = ( "max exit", "exit bars", "opposite signal", "exit short", "exit long", "short exit", "long exit", "trailing short", "trailing long", "trailing exit", "close entry", "strategy close", ) return any( any(marker in _normalized_marker(payload.get(field)) for marker in markers) for field in ("comment", "alert_message", "order_id") ) def classify_position_intent(payload: dict, side: str) -> tuple[str, str]: """Classifica l'ordine TV dalla transizione, senza dedurlo dal testo libero.""" aliases = { "flat": "flat", "long": "long", "short": "short", "market position flat": "flat", "market position long": "long", "market position short": "short", } current_raw = _normalized_marker(payload.get("market_position")) previous_raw = _normalized_marker(payload.get("prev_market_position")) current = aliases.get(current_raw) previous = aliases.get(previous_raw) if current is None or previous is None: return "LEGACY", f"missing/invalid transition prev={previous_raw!r} current={current_raw!r}" entry_side = {"long": "buy", "short": "sell"} exit_side = {"long": "sell", "short": "buy"} if previous == "flat" and current in entry_side: expected = entry_side[current] return ("ENTRY", f"flat->{current}") if side == expected else ( "INVALID", f"flat->{current} requires side={expected}, got {side}" ) if previous in exit_side and current == "flat": expected = exit_side[previous] return ("FULL_EXIT", f"{previous}->flat") if side == expected else ( "INVALID", f"{previous}->flat requires side={expected}, got {side}" ) if previous in exit_side and current == previous: if side == exit_side[previous]: return "PARTIAL_EXIT", f"{previous}->{current} opposite-side order" return "SCALE_IN", f"{previous}->{current} same-side order" if previous in exit_side and current in entry_side and current != previous: expected = entry_side[current] return ("REVERSAL", f"{previous}->{current}") if side == expected else ( "INVALID", f"{previous}->{current} requires side={expected}, got {side}" ) return "INVALID", f"unsupported transition {previous}->{current} side={side}" def canonical_strategy_name(value) -> str: """Nome stabile usato per impedire chiusure incrociate tra strategie.""" raw = _normalized_marker(value) if raw in ("vptr3", "vptr v3"): return "vptr3" if raw in ("ma trailing", "ma trailing close"): return "ma_trailing" if raw in ("rsi swing", "rsi swing breakout"): return "rsi_swing" if raw in ("rettangolo tv simple", "rettangolo simple", "rett simple"): return "rettangolo_simple" if raw in ("rettangolo", "rettangolo v2"): return "rettangolo" return raw.replace(" ", "_") or "unknown" def canonical_position_side(value) -> str: raw = str(value or "").strip().lower() if raw in ("buy", "long"): return "long" if raw in ("sell", "short"): return "short" return "unknown" @db_serialized def set_position_owner(conn, symbol: str, strategy: str, side: str, request_id: str, order_id: str, source: str = "webhook"): conn.execute( "INSERT INTO position_ownership " "(symbol,strategy,side,entry_request_id,entry_order_id,opened_at,source) " "VALUES (?,?,?,?,?,?,?) " "ON CONFLICT(symbol) DO UPDATE SET strategy=excluded.strategy,side=excluded.side," "entry_request_id=excluded.entry_request_id,entry_order_id=excluded.entry_order_id," "opened_at=excluded.opened_at,source=excluded.source", (symbol, canonical_strategy_name(strategy), canonical_position_side(side), request_id, order_id, datetime.now(timezone.utc).isoformat(), source), ) conn.commit() @db_serialized def get_position_owner(conn, symbol: str): row = conn.execute( "SELECT strategy,side,entry_request_id,entry_order_id,opened_at,source " "FROM position_ownership WHERE symbol=?", (symbol,) ).fetchone() if not row: return None return { "strategy": row[0], "side": row[1], "entry_request_id": row[2], "entry_order_id": row[3], "opened_at": row[4], "source": row[5], } @db_serialized def clear_position_owner(conn, symbol: str): conn.execute("DELETE FROM position_ownership WHERE symbol=?", (symbol,)) conn.commit() @db_serialized def position_owner_allows_close(conn, symbol: str, strategy: str, position_side: str): """Fail-closed: una strategia chiude solo la posizione che le appartiene.""" owner = get_position_owner(conn, symbol) incoming = canonical_strategy_name(strategy) actual_side = canonical_position_side(position_side) if owner is None: return False, f"position_owner_missing incoming={incoming}", None if owner["strategy"] != incoming: return False, ( f"position_owner_mismatch owner={owner['strategy']} incoming={incoming}" ), owner if owner["side"] != actual_side: return False, ( f"position_side_mismatch owner={owner['side']} bybit={actual_side}" ), owner return True, "position_owner_match", owner @db_serialized 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() @db_serialized def log_order_unified(conn, request_id, order_id, symbol, side, qty, price, notional, strategy): """Registra prima nel DB; il file testuale e' un audit non bloccante.""" 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) # Il DB e' la prova operativa primaria. Un problema al file di audit non deve # trasformare un ordine Bybit gia' eseguito in PROCESSING_FAILED. 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() try: with open(ORDERS_LOG, "a", encoding="utf-8") as f: f.write(line + "\n") except OSError as exc: log.error("ORDERS_LOG append failed (DB order preserved): %s", exc) @db_serialized def get_request_status(conn, request_id: str): return conn.execute( "SELECT status, order_id, error FROM queue WHERE request_id=?", (request_id,), ).fetchone() @db_serialized def get_queue_status_snapshot(conn): counts = { status: conn.execute( "SELECT COUNT(*) FROM queue WHERE status=?", (status,) ).fetchone()[0] for status in ("pending", "completed", "failed", "skipped", "expired") } owners = [ { "symbol": row[0], "strategy": row[1], "side": row[2], "entry_request_id": row[3], "entry_order_id": row[4], "opened_at": row[5], "source": row[6], } for row in conn.execute( "SELECT symbol,strategy,side,entry_request_id,entry_order_id," "opened_at,source FROM position_ownership ORDER BY symbol" ).fetchall() ] counts["tunnel_blocked"] = conn.execute( "SELECT COUNT(*) FROM queue WHERE status='pending' AND tunnel_pending=1" ).fetchone()[0] return counts, owners # ============================================================================ # 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.""" if not request_is_fresh_pending(conn, request_id): return 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() strategy_owner = canonical_strategy_name(strategy) # FIX 2026-08-09 Mavis: RSI_SWING_BREAKOUT Pine TV (FETUSDT 3H) → rettangolo_simple downstream # Stessa logica SL al swing + R:R 1.5. Mappato qui per non duplicare codice in sltp_engine. if strategy.lower() == "rsi_swing_breakout": log.info("STRATEGY mapping: RSI_SWING_BREAKOUT -> rettangolo_simple for %s %s", symbol, side) strategy = "rettangolo_simple" 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). position_intent, intent_reason = classify_position_intent(payload, side) log.info("INTENT CLASSIFIED: request_id=%s intent=%s reason=%s", request_id, position_intent, intent_reason) if position_intent in ("INVALID", "REVERSAL", "SCALE_IN"): reason = f"PROTOCOL_REJECTED intent={position_intent}: {intent_reason}" log.error("%s request_id=%s payload_order_id=%r comment=%r", reason, request_id, payload.get("order_id"), comment) mark_skipped(conn, request_id, reason) return is_partial_exit = position_intent == "PARTIAL_EXIT" is_close_intent = ( position_intent == "FULL_EXIT" or (position_intent == "LEGACY" and (qty <= 0 or payload_has_close_intent(payload))) ) if is_close_intent and qty > 0: log.info("CLOSE INTENT detected via normalized marker: comment=%r alert_message=%r order_id=%r " "(qty=%s side=%s strategy=%s)", comment, payload.get("alert_message", ""), payload.get("order_id", ""), qty, side, strategy) if is_close_intent or is_partial_exit: 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) clear_position_owner(conn, symbol) log_order_unified(conn, request_id, "no_position", symbol, side, 0, 0, 0, f"{strategy}_close_no_pos") mark_skipped(conn, request_id, "no_position") return pos_size = float(pos["size"]) pos_side = str(pos.get("side", "Buy")) owner_ok, owner_reason, owner = position_owner_allows_close( conn, symbol, strategy_owner, pos_side ) if not owner_ok: log.error( "CLOSE BLOCKED OWNERSHIP: request_id=%s symbol=%s strategy=%s " "bybit_side=%s reason=%s owner=%s", request_id, symbol, strategy_owner, pos_side, owner_reason, owner, ) mark_skipped(conn, request_id, f"CLOSE_BLOCKED_OWNERSHIP {owner_reason}") return close_side = "Sell" if pos_side == "Buy" else "Buy" if side != close_side.lower(): reason = f"PROTOCOL_REJECTED exit side={side} does not reduce Bybit side={pos_side}" log.error("%s request_id=%s", reason, request_id) mark_skipped(conn, request_id, reason) return close_qty = min(qty, pos_size) if is_partial_exit else pos_size close_qty = bybit.round_qty(symbol, close_qty) if close_qty <= 0: raise ValueError(f"Exit qty invalid after normalization: raw={qty} position={pos_size}") 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("%s: %s current pos side=%s size=%s → placing %s qty=%s reduceOnly=True @ market", position_intent, symbol, pos_side, pos_size, close_side, close_qty) try: order = bybit._request("POST", "/v5/order/create", { "category": "linear", "symbol": symbol, "side": close_side, "orderType": "Market", "qty": str(close_qty), "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 = close_qty * current_price log.info("%s OK %s: orderId=%s side=%s qty=%s reduceOnly=True (position was %s)", position_intent, request_id, order_id, close_side, close_qty, pos_size) suffix = "partial_exit" if is_partial_exit else "close" log_order_unified(conn, request_id, order_id, symbol, close_side.lower(), close_qty, current_price, notional_close, f"{strategy}_{suffix}") if not is_partial_exit or close_qty >= pos_size: clear_position_owner(conn, symbol) mark_processed(conn, request_id, order_id=order_id) return # 0a. RENDER TOTAL-BLOCK (Mattia 10/08): blocco DEFINITIVO di RENDERUSDT. # Niente nuove posizioni (né LONG né SHORT). PASSA solo se e' un close intent (chiusura pos esistente). # Logga tutto su render_blocked.jsonl. La posizione LONG attuale 10/08 verra' chiusa manualmente da Mattia. # Si applica SOLO a qty>0 + no close_intent (is_close_intent gia' gestito sopra). if symbol == "RENDERUSDT": try: positions_rb = bybit.fetch_positions() existing_rb = next((p for p in positions_rb if p.get("symbol") == symbol and float(p.get("size", 0) or 0) > 0), None) existing_side = (str(existing_rb.get("side", "")).lower() if existing_rb else None) # Se l'operazione e' CHIUSURA di posizione esistente (side opposto) → PASSA if existing_rb is not None and existing_side != side: log.info("RENDER TOTAL-BLOCK: %s side=%s ma c'e' posizione %s esistente size=%s → PASSA (chiusura pos esistente)", symbol, side, existing_side, float(existing_rb.get("size", 0) or 0)) else: # Nessuna posizione o posizione stesso side: sarebbe apertura → BLOCCA + LOGGA mark_rb = 0.0 try: mark_rb = float(bybit.get_last_price(symbol) or 0) except Exception: pass log_entry_rb = { "ts": datetime.now(timezone.utc).isoformat(), "alert": { "symbol": symbol, "side": side, "qty": qty, "price": price, "comment": comment, "alert_message": str(payload.get("alert_message", "")), "order_id": str(payload.get("order_id", "")), "strategy": strategy, "tf": tf, }, "bybit_state": { "mark": mark_rb, "open_position": ({"side": existing_rb.get("side"), "size": float(existing_rb.get("size", 0) or 0)} if existing_rb else None), }, "reason": f"RENDER DEFINITIVELY blocked (2026-08-10, no new {side} positions, only close intent passes)" } rb_log_path = LOG_DIR / "render_blocked.jsonl" rb_log_path.parent.mkdir(parents=True, exist_ok=True) with open(rb_log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_entry_rb, ensure_ascii=False) + "\n") log.warning("RENDER BLOCKED + LOGGED: %s side=%s qty=%s price=%s pos=%s → vedi logs/render_blocked.jsonl", symbol, side, qty, price, existing_rb) log_order_unified(conn, request_id, "render_blocked", symbol, side, qty, price or 0, 0, f"{strategy}_render_blocked") mark_skipped(conn, request_id, "render_blocked") return 200, {"status": "skipped", "reason": f"RENDER definitively blocked, logged to render_blocked.jsonl"} except Exception as e: # FAIL-OPEN: se il check fallisce, l'ordine passa (per non bloccare operazioni per bug tecnico) log.error("RENDER TOTAL-BLOCK check exception for %s %s: %s (FAIL-OPEN, ordine passa)", symbol, side, e) try: positions_rb = bybit.fetch_positions() existing_rb = next((p for p in positions_rb if p.get("symbol") == symbol and float(p.get("size", 0) or 0) > 0), None) # Se c'è posizione LONG, e' chiusura → PASSA if existing_rb is not None and str(existing_rb.get("side", "")).lower() == "buy": log.info("RENDER SHORT-BLOCK: %s side=Sell ma c'e' posizione LONG esistente size=%s → PASSA (chiusura long)", symbol, float(existing_rb.get("size", 0) or 0)) else: # Nessuna posizione o posizione SHORT: sarebbe apertura SHORT → BLOCCA + LOGGA mark_rb = 0.0 try: mark_rb = float(bybit.get_last_price(symbol) or 0) except Exception: pass log_entry_rb = { "ts": datetime.now(timezone.utc).isoformat(), "alert": { "symbol": symbol, "side": side, "qty": qty, "price": price, "comment": comment, "alert_message": str(payload.get("alert_message", "")), "order_id": str(payload.get("order_id", "")), "strategy": strategy, "tf": tf, }, "bybit_state": { "mark": mark_rb, "open_position": ({"side": existing_rb.get("side"), "size": float(existing_rb.get("size", 0) or 0)} if existing_rb else None), }, "reason": "RENDER SHORT blocked (Long-only test 2026-08-09, log only)" } rb_log_path = LOG_DIR / "render_shorts_skipped.jsonl" rb_log_path.parent.mkdir(parents=True, exist_ok=True) with open(rb_log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_entry_rb, ensure_ascii=False) + "\n") log.warning("RENDER SHORT BLOCKED + LOGGED: %s side=%s qty=%s price=%s pos=%s → vedi logs/render_shorts_skipped.jsonl", symbol, side, qty, price, existing_rb) log_order_unified(conn, request_id, "render_short_blocked", symbol, side, qty, price or 0, 0, f"{strategy}_render_short_blocked") mark_skipped(conn, request_id, "render_short_blocked") return 200, {"status": "skipped", "reason": "RENDER SHORT blocked (Long-only test), logged to render_shorts_skipped.jsonl"} except Exception as e: # FAIL-OPEN: se il check fallisce, l'ordine passa (per non bloccare operazioni per bug tecnico) log.error("RENDER SHORT-BLOCK check exception for %s %s: %s (FAIL-OPEN, ordine passa)", symbol, side, e) # 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_skipped(conn, request_id, 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}") # === FIX 2026-08-05 (Mattia 22:30): Pine price placeholder check === # Pine manda price=1.0 (placeholder) per le sue strategie (VPTR3, Rettangolo_Simple, MA_Trailing). # Se Pine price=1.0 e Bybit ha un prezzo reale molto diverso, Pine è sicuramente un placeholder. # Fix: se Pine price è sospetto (< 0.5 placeholder noto, oppure > 2x o < 0.5x di Bybit), # usa Bybit price per il sizing enforcement (sotto) e per SL/TP auto-fill. # Bybit price è SEMPRE il riferimento per la nozionale finale (l'ordine esegue a Bybit market). PINE_PLACEHOLDER_PRICE = 1.0 # Pine manda sempre 1.0 come placeholder bybit_market_price = None try: bybit_market_price = bybit.get_last_price(symbol) except Exception as e: log.warning("Bybit market price fetch fallito (non bloccante): %s", e) pine_price_suspicious = False if bybit_market_price and bybit_market_price > 0: if abs(price - PINE_PLACEHOLDER_PRICE) < 1e-9 and abs(bybit_market_price - PINE_PLACEHOLDER_PRICE) > 1e-9: pine_price_suspicious = True log.info("Pine price=1.0 placeholder rilevato, uso Bybit market price=%.6f per sizing/SL/TP", bybit_market_price) elif price > 0 and (price > bybit_market_price * 2 or price < bybit_market_price * 0.5): pine_price_suspicious = True log.warning("Pine price=%.6f sospetto (Bybit=%.6f, ratio fuori 0.5-2x), uso Bybit per sizing/SL/TP", price, bybit_market_price) if pine_price_suspicious and bybit_market_price: price = bybit_market_price # sovrascrivi Pine placeholder con Bybit reale # 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). # FIX 2026-08-05 Mattia: VIRTUALUSDT escluso da P007 (vedi EXCLUDED_FROM_REGIME_CHECK sopra) strategy_name = payload.get("strategy", "unknown").lower() if strategy_name in ("vptr3", "vptr_v3") and symbol.upper() not in EXCLUDED_FROM_REGIME_CHECK: # 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 mark_skipped(conn, request_id, f"P007 SKIP-REGIME: {regime_reason}") 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") and symbol.upper() not in EXCLUDED_FROM_CHOP_CHECK: 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']) skip_reason = f"P007b CHOP: regime={regime_info['regime']} adx={adx_str} ({regime_info['reason']})" mark_skipped(conn, request_id, skip_reason) return 200, {"status": "skipped", "reason": skip_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) skip_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" mark_skipped(conn, request_id, skip_reason) return 200, {"status": "skipped", "reason": skip_reason} 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) skip_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" mark_skipped(conn, request_id, skip_reason) return 200, {"status": "skipped", "reason": skip_reason} 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) skip_reason = f"ZONA-MID check failed: {e} (fail-closed, no entry in zona dubbia)" mark_skipped(conn, request_id, skip_reason) return 200, {"status": "skipped", "reason": skip_reason} # Normalizzazione obbligatoria immediatamente prima di Bybit. # Deve essere applicata anche quando il sizing Pine e' gia' entro la tolleranza: # in quel caso i vecchi rami di auto-bump/sizing non chiamavano round_qty e # Bybit rifiutava asset come CELO (qtyStep=0.1, es. qty=20761.245). raw_qty = qty qty = bybit.round_qty(symbol, qty) if qty <= 0: raise ValueError(f"Normalized qty invalid for {symbol}: raw={raw_qty} normalized={qty}") if abs(qty - raw_qty) > 1e-12: log.warning("QTY-STEP NORMALIZED: %s raw=%s normalized=%s", symbol, raw_qty, qty) notional = qty * price order = bybit.create_market_order(symbol=symbol, side=side, qty=qty) 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}") set_position_owner(conn, symbol, strategy_owner, side, request_id, order_id) # Il market order puo essere confermato prima che la posizione sia visibile. # Recuperiamo il riferimento reale di fill/mark prima di calcolare le protezioni. actual_entry_price = price actual_mark_price = None actual_position_qty = qty expected_position_side = "buy" if side.lower() in ("buy", "long") else "sell" for attempt in range(4): try: positions_after = bybit.fetch_positions(symbol) position_after = next( (p for p in positions_after if str(p.get("symbol", "")).upper() == symbol and str(p.get("side", "")).lower() == expected_position_side and float(p.get("size", 0) or 0) > 0), None, ) if position_after: actual_entry_price = float( position_after.get("avgPrice", 0) or position_after.get("entryPrice", 0) or price ) actual_mark_price = float(position_after.get("markPrice", 0) or 0) or None actual_position_qty = float(position_after.get("size", 0) or qty) break except Exception as e: log.warning("POST-FILL position lookup attempt %d failed for %s: %s", attempt + 1, symbol, e) time.sleep(0.25) if actual_mark_price is None: try: actual_mark_price = float(bybit.get_mark_price(symbol)) except Exception as e: log.warning("POST-FILL mark price unavailable for %s: %s", symbol, e) log.info("POST-FILL REFERENCE %s: orderId=%s entry=%s mark=%s qty=%s", symbol, order_id, actual_entry_price, actual_mark_price, actual_position_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.lower() in ("vptr3", "vptr_v3") 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 = actual_entry_price 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 actual_entry_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 - actual_entry_price) / actual_entry_price if _sl_loss_pct < CHARTER_P006_SL_CAP - 1e-6: # loss > 3% Charter (tolleranza FP) _old_sl = sl_price sl_price = actual_entry_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, actual_entry_price, side) # Dopo l'apertura, set SL/TP. Se il prezzo e' gia' oltre il livello Pine, # ricalcola una sola volta mantenendo la stessa distanza percentuale dal mark reale. protection_error = None 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 first_error: log.warning("set_trading_stop first attempt failed for %s: %s", symbol, first_error) try: mark_reference = actual_mark_price or float(bybit.get_mark_price(symbol)) direction = 1 if side.lower() in ("buy", "long") else -1 retry_sl = sl_price retry_tp = tp_price reference = actual_entry_price or price or mark_reference if retry_sl is not None and direction * (retry_sl - mark_reference) >= 0: distance = max(abs(retry_sl - reference) / reference, 0.003) retry_sl = mark_reference * (1 - direction * distance) if retry_tp is not None and direction * (retry_tp - mark_reference) <= 0: distance = max(abs(retry_tp - reference) / reference, 0.003) retry_tp = mark_reference * (1 + direction * distance) bybit.set_trading_stop(symbol, sl_price=retry_sl, tp_price=retry_tp) sl_price, tp_price = retry_sl, retry_tp log.warning("TRADING STOP RECOVERED %s: mark=%s SL=%s TP=%s", symbol, mark_reference, sl_price, tp_price) except Exception as retry_error: protection_error = ( f"ORDER_OPEN_UNPROTECTED first={first_error!s} retry={retry_error!s}" )[:1000] log.error("ORDER_OPEN_UNPROTECTED request_id=%s orderId=%s symbol=%s: %s", request_id, order_id, symbol, protection_error) # === FIX 2026-08-05 (Mattia 22:58): POST-ORDER SIZING CHECK === # Dopo esecuzione Bybit, verifico la nozionale EFFETTIVA (cumExecValue). # Se fuori tolleranza Charter 5% del target 1500 USDT, log ERROR visibile. # Cattura casi edge tipo Pine price non rilevato come placeholder (es. 0.9999) # o slippage estremo su ordini market. POST_ORDER_TOLERANCE = 0.10 # tolleranza 10% (5% sizing + 5% slippage) try: order_resp = order if isinstance(order, dict) else {} cum_exec_value = float(order_resp.get("cumExecValue", 0) or 0) cum_exec_qty = float(order_resp.get("cumExecQty", 0) or 0) avg_price_exec = float(order_resp.get("avgPrice", 0) or 0) # Se cumExecValue mancante, ricalcolo da avgPrice*cumExecQty if cum_exec_value <= 0 and avg_price_exec > 0 and cum_exec_qty > 0: cum_exec_value = avg_price_exec * cum_exec_qty if cum_exec_value > 0: drift = abs(cum_exec_value - TARGET_NOTIONAL) / TARGET_NOTIONAL if drift > POST_ORDER_TOLERANCE: log.error("POST-ORDER SIZING MISMATCH: %s nozionale_effettiva=%.2f USDT (target 1500±%.0f%%), drift=%.1f%%, qty=%s, avgPrice=%s. POSSIBILE Pine price placeholder non rilevato o slippage estremo. CHARTER 500x3=1500 USDT VIOLATO.", symbol, cum_exec_value, POST_ORDER_TOLERANCE * 100, drift * 100, cum_exec_qty, avg_price_exec) else: log.info("POST-ORDER SIZING OK %s: nozionale_effettiva=%.2f USDT (drift=%.2f%%, in tolleranza)", symbol, cum_exec_value, drift * 100) except Exception as e: log.warning("POST-ORDER check fallito (non blocca): %s", e) log.info("ORDER OK %s: orderId=%s", request_id, order_id) # 5. Log unified + DB logged_price = actual_entry_price or price logged_notional = qty * logged_price log_order_unified(conn, request_id, order_id, symbol, side, qty, logged_price, logged_notional, strategy) mark_processed(conn, request_id, order_id=order_id, error=protection_error) # 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] # FIX 6 (22/07 Mattia): endpoint per polling status richiesta. # Il runner fa polling qui per sapere se l'ordine e' stato realmente fillato. if path.startswith("/webhook/status/"): request_id = path.replace("/webhook/status/", "").strip() if not request_id: return self._send_json(400, {"error": "missing request_id"}) try: row = get_request_status(self.db, request_id) if not row: return self._send_json(404, {"error": "request_id not found", "request_id": request_id}) return self._send_json(200, { "request_id": request_id, "status": row[0] or "pending", # pending|completed|failed|skipped|expired "order_id": row[1], "error": row[2], }) except Exception as e: return self._send_json(500, {"error": str(e)}) if path == "/health": return self._send_json(200, { "ok": True, "service": "bybit-webhook", "ts": datetime.now(timezone.utc).isoformat(), }) if path == "/status": try: counts, owners = get_queue_status_snapshot(self.db) return self._send_json(200, { "ok": True, "queue_pending": counts["pending"], "queue_completed": counts["completed"], "queue_failed": counts["failed"], "queue_skipped": counts["skipped"], "queue_expired": counts["expired"], "queue_tunnel_blocked": counts["tunnel_blocked"], "queue_size_in_memory": self.in_queue.qsize(), "position_owners": owners, }) 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_items = unblock_all_tunnel_pending(self.db) for item in unblocked_items: self.in_queue.put(item) unblocked = len(unblocked_items) log.info("TUNNEL UP: sbloccate e accodate %d richieste fresche", 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: # Regola ferrea: anche le uscite richiedono autenticazione valida. # Non loggare mai secret o payload integrale. log.warning( "REJECTED WRONG_SECRET: symbol=%r side=%r strategy=%r fields=%s", data.get("symbol"), data.get("side"), data.get("strategy"), sorted(str(key) for key in data.keys() if str(key).lower() != "secret"), ) 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. Leggi prima lo stato tunnel: una richiesta bloccata va persistita, # ma NON deve entrare nella coda in memoria finche' il tunnel non torna UP. tunnel_blocked = False try: marker = (LOG_DIR / "tunnel_state.txt").read_text(encoding="utf-8").strip().lower() tunnel_blocked = marker == "down" except FileNotFoundError: pass except Exception as e: log.warning("Marker tunnel_state.txt read failed: %s", e) # 6. Persist + queue (I/O veloce su SQLite locale) try: request_id = enqueue_request(self.db, data, tunnel_pending=tunnel_blocked) if not tunnel_blocked: 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)}) if tunnel_blocked: log.warning("TUNNEL DOWN: richiesta %s persistita ma NON accodata; attende tunnel UP entro TTL", request_id) # 7. 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, '5'=5m, '15'=15m) per la strategia. - vptr3: legge da VPTR_V3_ASSETS.csv colonna timeframe - rettangolo: legge da rettangolo_assets.csv - rettangolo_simple: legge da RETTANGOLO_SIMPLE_ASSETS.csv - ma_trailing: legge da MA_TRAILING_ASSETS.csv - 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. FIX 2026-08-05 (Mattia 4 strategie): supporto RETTANGOLO_SIMPLE + MA_TRAILING. """ try: if strategy in ("vptr3", "vptr_v3"): 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"]) elif strategy == "rettangolo_simple": try: from sltp_engine import load_rettangolo_simple_assets except ImportError: log.warning("P007 get_strategy_timeframe: load_rettangolo_simple_assets non importabile, fallback %s", EMA50_DEFAULT_TIMEFRAME) return EMA50_DEFAULT_TIMEFRAME for asset in load_rettangolo_simple_assets(): if asset["symbol"] == symbol.upper() and asset.get("enabled", True): return _tf_string_to_bybit(asset["timeframe"]) elif strategy == "ma_trailing": try: from sltp_engine import load_ma_trailing_assets except ImportError: log.warning("P007 get_strategy_timeframe: load_ma_trailing_assets non importabile, fallback %s", EMA50_DEFAULT_TIMEFRAME) return EMA50_DEFAULT_TIMEFRAME for asset in load_ma_trailing_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()