#!/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."""
    import math
    if str(payload.get("schema_version", "")) == "2":
        try:
            amount = float(payload["qty"])
            size = float(payload["position_size"])
            if not math.isfinite(amount) or not math.isfinite(size) or amount <= 0:
                return "INVALID", "invalid numeric payload"
            target = str(payload.get("market_position", "")).lower()
            if (target == "long" and size <= 0) or (target == "short" and size >= 0) or (target == "flat" and size != 0):
                return "INVALID", "position size contradicts target side"
        except (KeyError, TypeError, ValueError):
            return "INVALID", "missing or invalid numeric payload"
    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:
        if str(payload.get("schema_version", "")) == "2" or current_raw or previous_raw:
            return "INVALID", "missing or invalid schema-2 position transition"
        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 partial_exit_fraction(payload: dict, qty: float) -> float:
    """Quota della posizione TV precedente chiusa dall'ordine parziale."""
    try:
        remaining = abs(float(payload.get("position_size")))
        before = remaining + qty
        fraction = qty / before
    except (TypeError, ValueError, ZeroDivisionError):
        return 0.0
    return fraction if 0 < fraction <= 1 else 0.0


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", "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)))
    )
    reversal_existing_size = 0.0
    if position_intent == "REVERSAL":
        positions = bybit.fetch_positions()
        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.warning("REVERSAL SOURCE POSITION ABSENT: %s; resync to target side=%s", symbol, side)
        else:
            pos_side = str(pos.get("side", "")).lower()
            if pos_side == side:
                reason = "REVERSAL_ALREADY_APPLIED"
                log.warning("%s request_id=%s symbol=%s side=%s", reason, request_id, symbol, side)
                mark_skipped(conn, request_id, reason)
                return
            owner_ok, owner_reason, owner = position_owner_allows_close(
                conn, symbol, strategy_owner, str(pos.get("side", ""))
            )
            if not owner_ok:
                reason = f"PROTOCOL_REJECTED REVERSAL_OWNERSHIP {owner_reason}"
                log.error("%s request_id=%s owner=%s", reason, request_id, owner)
                mark_skipped(conn, request_id, reason)
                return
            reversal_existing_size = float(pos.get("size", 0) or 0)
            log.info("REVERSAL VERIFIED: %s old_side=%s old_qty=%s new_side=%s", symbol, pos_side, reversal_existing_size, side)
    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
        if is_partial_exit:
            fraction = partial_exit_fraction(payload, qty)
            if not 0 < fraction <= 1:
                reason = "PROTOCOL_REJECTED PARTIAL_EXIT_INVALID_POSITION_SIZE"
                log.error("%s request_id=%s qty=%s position_size=%r", reason, request_id, qty, payload.get("position_size"))
                mark_skipped(conn, request_id, reason)
                return
            close_qty = pos_size * fraction
        else:
            close_qty = pos_size
        if is_partial_exit:
            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 and position_intent != "REVERSAL":
            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

    if position_intent == "REVERSAL":
        target_position_qty = qty
        qty = bybit.round_qty(symbol, reversal_existing_size + target_position_qty)
        if qty <= reversal_existing_size:
            raise ValueError(
                f"REVERSAL combined qty invalid: existing={reversal_existing_size} target={target_position_qty} combined={qty}"
            )
        log.info("REVERSAL COMBINED ORDER: %s close_qty=%s target_new_qty=%s order_qty=%s",
                 symbol, reversal_existing_size, target_position_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 and position_intent != "REVERSAL":
            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
    execution_strategy = f"{strategy}_reversal" if position_intent == "REVERSAL" else strategy
    log_order_unified(conn, request_id, order_id, symbol, side, qty, logged_price, logged_notional, execution_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()
