"""
Webhook receiver - TradingView Pine -> Bybit Demo
SCELTA B + close handler: replica fedele della strategy Pine
- Riceve alert (BUY/SELL) -> apre posizione, chiude opposta se c'è
- Riceve alert {action: close} -> chiude posizione aperta (time-stop / segnale opposto)
- NIENTE TP/SL tecnici (li decide Pine via comment "Max exit bars"/"Opposite signal")
- MANIFESTO: ordini limitati a order value 500 USDT con leva 3x (vedi rettangolo_config)
"""
import os
import sys
import json
import hmac
import hashlib
import time
from decimal import Decimal, InvalidOperation, ROUND_DOWN
from datetime import datetime, timezone
from flask import Flask, request, jsonify
import requests

# Config centralizzata (MANIFESTO Charter: order value max 500 USDT, leva 3x)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rettangolo_config import ORDER_VALUE_USD, LEVERAGE as MANIFESTO_LEVERAGE, max_qty_for_order_value

# --- Config ---
# SICUREZZA: secrets letti da variabili d'ambiente o da API_KEY_BYBIT.env
# (escluso da git). Settare PRIMA di eseguire il receiver live:
#   $env:WEBHOOK_SECRET = '<secret-concordato-con-Pine-Script>'
#   $env:BYBIT_API_KEY = '<la-tua-bybit-key>'
#   $env:BYBIT_API_SECRET = '<il-tuo-bybit-secret>'
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
BYBIT_API_KEY = os.environ.get("BYBIT_API_KEY", "")
BYBIT_API_SECRET = os.environ.get("BYBIT_API_SECRET", "")
BYBIT_BASE = "https://api-demo.bybit.com"
LEVERAGE_TARGET = 3

if not WEBHOOK_SECRET or not BYBIT_API_KEY or not BYBIT_API_SECRET:
    raise SystemExit(
        "ERRORE: WEBHOOK_SECRET, BYBIT_API_KEY, BYBIT_API_SECRET devono "
        "essere settate come variabili d'ambiente prima di eseguire il "
        "receiver live.\n"
        "Esempio PowerShell:\n"
        "  $env:WEBHOOK_SECRET='<secret>'\n"
        "  $env:BYBIT_API_KEY='<key>'\n"
        "  $env:BYBIT_API_SECRET='<secret>'\n"
    )

# --- Bybit V5 client ---
def bybit_request(method, path, params=None, signed=True):
    ts = str(int(time.time() * 1000))
    recv_window = "5000"
    if signed:
        param_str = ""
        if method == "GET" and params:
            sorted_items = sorted(params.items())
            param_str = "&".join([f"{k}={v}" for k, v in sorted_items])
            url_path = path
            if param_str:
                url_path = f"{path}?{param_str}"
        elif method == "POST" and params:
            param_str = json.dumps(params, separators=(", ", ": "))
            url_path = path
        else:
            url_path = path
        sign_payload = f"{ts}{BYBIT_API_KEY}{recv_window}{param_str}"
        signature = hmac.new(
            BYBIT_API_SECRET.encode("utf-8"),
            sign_payload.encode("utf-8"),
            hashlib.sha256
        ).hexdigest()
        headers = {
            "X-BAPI-API-KEY": BYBIT_API_KEY,
            "X-BAPI-SIGN": signature,
            "X-BAPI-TIMESTAMP": ts,
            "X-BAPI-RECV-WINDOW": recv_window,
            "Content-Type": "application/json"
        }
        url = f"{BYBIT_BASE}{url_path}"
    else:
        headers = {"Content-Type": "application/json"}
        url = f"{BYBIT_BASE}{path}"

    if method == "GET":
        r = requests.get(url, headers=headers, timeout=10)
    else:
        r = requests.post(url, headers=headers, data=json.dumps(params) if params else None, timeout=10)
    return r.json() if r.text else {}

def set_leverage(symbol, leverage=3):
    try:
        body = {"category": "linear", "symbol": symbol, "buyLeverage": str(leverage), "sellLeverage": str(leverage)}
        r = bybit_request("POST", "/v5/position/set-leverage", body)
        if r.get("retCode") == 0:
            return True, "leverage set"
        if r.get("retCode") == 110043:
            return True, "leverage already at target (skip)"
        return False, f"leverage err: {r.get('retMsg')} (code {r.get('retCode')})"
    except Exception as e:
        return False, f"leverage exception: {e}"

def get_position(symbol, retries=1, delay=0.35, log_errors=True):
    """Legge una posizione linear da Bybit.

    Bybit può rendere visibile una posizione con un piccolo ritardo dopo un market order.
    Per questo la funzione supporta alcuni tentativi. Restituisce la prima posizione con
    size > 0, gestendo sia One-Way (positionIdx=0) sia Hedge (1/2).
    """
    last_response = None
    for attempt in range(max(1, int(retries))):
        try:
            r = bybit_request(
                "GET",
                "/v5/position/list",
                {"category": "linear", "symbol": symbol},
            )
            last_response = r
            if r.get("retCode") == 0:
                positions = r.get("result", {}).get("list", []) or []
                for p in positions:
                    try:
                        size = float(p.get("size", 0) or 0)
                    except (TypeError, ValueError):
                        size = 0.0
                    side = str(p.get("side", "") or "").strip()
                    if size > 0 and side in ("Buy", "Sell"):
                        return {
                            "side": side,
                            "size": size,
                            "avgPrice": p.get("avgPrice"),
                            "positionIdx": int(p.get("positionIdx", 0) or 0),
                        }
            elif log_errors:
                log_event(
                    f"  WARN get_position {symbol}: {r.get('retMsg')} "
                    f"(code {r.get('retCode')})"
                )
        except Exception as e:
            if log_errors:
                log_event(f"  WARN get_position exception {symbol}: {e}")
        if attempt < max(1, int(retries)) - 1:
            time.sleep(delay)

    if log_errors and last_response and last_response.get("retCode") == 0:
        raw = last_response.get("result", {}).get("list", []) or []
        compact = [
            {
                "side": x.get("side"),
                "size": x.get("size"),
                "positionIdx": x.get("positionIdx"),
                "symbol": x.get("symbol"),
            }
            for x in raw
        ]
        log_event(f"  get_position: nessuna size>0 per {symbol}; raw={compact}")
    return None


def count_open_positions():
    """Ritorna il numero totale di posizioni aperte su Bybit (tutti i symbol linear).
    Usato per il safety cap MAX_OPEN_POSITIONS."""
    try:
        r = bybit_request("GET", "/v5/position/list", {"category": "linear", "settleCoin": "USDT"})
        if r.get("retCode") != 0:
            return -1  # errore, non bloccare
        positions = r.get("result", {}).get("list", [])
        return sum(1 for p in positions if float(p.get("size", 0) or 0) > 0)
    except Exception:
        return -1

def close_position(symbol, side, size, position_idx=0):
    close_side = "Sell" if side == "Buy" else "Buy"
    body = {
        "category": "linear",
        "symbol": symbol,
        "side": close_side,
        "orderType": "Market",
        "qty": str(size),
        "timeInForce": "GTC",
        "reduceOnly": True,
        "positionIdx": position_idx
    }
    return bybit_request("POST", "/v5/order/create", body)

def create_market_order(symbol, side, qty):
    body = {
        "category": "linear",
        "symbol": symbol,
        "side": side,
        "orderType": "Market",
        "qty": str(qty),
        "timeInForce": "GTC",
        "positionIdx": 0
    }
    return bybit_request("POST", "/v5/order/create", body)


def set_trading_stop(symbol, sl_price=None, tp_price=None):
    """Set SL e/o TP sulla posizione aperta del symbol. Bybit V5 /v5/position/trading-stop.
    sl_price / tp_price: trigger mark price. Almeno uno dei due obbligatorio.
    Se la posizione non esiste, Bybit ritorna errore (lo gestiamo come warning, non fatale)."""
    if sl_price is None and tp_price is None:
        return {"retCode": -1, "retMsg": "no sl/tp provided"}
    body = {"category": "linear", "symbol": symbol}
    if sl_price is not None:
        body["stopLoss"] = str(sl_price)
        body["slTriggerBy"] = "MarkPrice"
    if tp_price is not None:
        body["takeProfit"] = str(tp_price)
        body["tpTriggerBy"] = "MarkPrice"
    return bybit_request("POST", "/v5/position/trading-stop", body)

# --- Flask app ---
app = Flask(__name__)

LOG_FILE = r"G:\AI TRADING ENGINE\live_deploy\webhook_listener\logs\orders.log"
TRADES_CSV = r"G:\AI TRADING ENGINE\live_deploy\webhook_listener\logs\trades.csv"


def normalize_symbol(symbol):
    """Bybit V5 usa 'BTCUSDT' (senza .P) per linear perpetual. Pine {{ticker}}
    puo' mandare:
      - 'DASHUSDT.P' (con .P)
      - 'BYBIT:DASHUSDT.P' (prefisso exchange + .P)
      - 'BINANCE:DASHUSDT.P' (prefisso exchange + .P)
      - 'DASHUSDT' (gia' normalizzato)
    Normalizza tutto a 'DASHUSDT'."""
    if not symbol:
        return ""
    s = symbol.strip().upper()
    # rimuovi prefisso exchange "EXCHANGE:" se presente
    if ":" in s:
        s = s.split(":", 1)[1]
    # rimuovi suffisso ".P" (linear perpetual Pine/TradingView)
    if s.endswith(".P"):
        s = s[:-2]
    return s


def get_qty_rules(symbol):
    """Legge da Bybit i vincoli reali della quantità per il contratto linear.

    Ritorna qtyStep, minOrderQty e minNotionalValue come Decimal.
    L'endpoint instruments-info è pubblico e non richiede firma.
    """
    try:
        r = bybit_request(
            "GET",
            "/v5/market/instruments-info",
            {"category": "linear", "symbol": symbol},
            signed=False,
        )
        if r.get("retCode") != 0:
            return None, f"instruments-info err: {r.get('retMsg')} (code {r.get('retCode')})"

        instruments = r.get("result", {}).get("list", [])
        if not instruments:
            return None, f"strumento non trovato su Bybit: {symbol}"

        lot = instruments[0].get("lotSizeFilter", {})
        qty_step = Decimal(str(lot.get("qtyStep", "0")))
        min_qty = Decimal(str(lot.get("minOrderQty", "0")))
        min_notional = Decimal(str(lot.get("minNotionalValue", "0")))

        if qty_step <= 0:
            return None, f"qtyStep non valido per {symbol}: {qty_step}"

        return {
            "qty_step": qty_step,
            "min_qty": min_qty,
            "min_notional": min_notional,
        }, None
    except (InvalidOperation, TypeError, ValueError) as e:
        return None, f"regole quantità non valide per {symbol}: {e}"
    except Exception as e:
        return None, f"errore lettura regole quantità per {symbol}: {e}"


def floor_qty_to_step(qty, qty_step):
    """Tronca qty al multiplo inferiore di qtyStep, senza arrotondare in eccesso."""
    qty_dec = Decimal(str(qty))
    step_dec = Decimal(str(qty_step))
    if qty_dec <= 0 or step_dec <= 0:
        return Decimal("0")
    units = (qty_dec / step_dec).to_integral_value(rounding=ROUND_DOWN)
    return units * step_dec


def decimal_to_plain_string(value):
    """Converte Decimal in stringa non scientifica accettata da Bybit."""
    return format(value, "f")


def normalize_order_qty(symbol, qty, current_price=None):
    """Normalizza la quantità secondo qtyStep/minOrderQty/minNotionalValue di Bybit."""
    rules, err = get_qty_rules(symbol)
    if err:
        return None, err, None

    try:
        qty_dec = floor_qty_to_step(qty, rules["qty_step"])
        if qty_dec <= 0:
            return None, f"qty {qty} diventa 0 con qtyStep {rules['qty_step']}", rules

        if qty_dec < rules["min_qty"]:
            return None, (
                f"qty {decimal_to_plain_string(qty_dec)} inferiore a minOrderQty "
                f"{decimal_to_plain_string(rules['min_qty'])}"
            ), rules

        if current_price and float(current_price) > 0 and rules["min_notional"] > 0:
            notional = qty_dec * Decimal(str(current_price))
            if notional < rules["min_notional"]:
                return None, (
                    f"nozionale {decimal_to_plain_string(notional)} inferiore a "
                    f"minNotionalValue {decimal_to_plain_string(rules['min_notional'])}"
                ), rules

        return decimal_to_plain_string(qty_dec), None, rules
    except (InvalidOperation, TypeError, ValueError) as e:
        return None, f"qty non normalizzabile: {e}", rules


# Strategia Pine (comment in strategy.close / strategy.close_all) che Pine
# puo' riportare nel payload come `comment` o `action`. Quando uno di questi
# match, il payload va trattato come CLOSE (non come ENTRY).
# Pine: strategy.close("Long", comment="Opposite signal")
# Pine: strategy.close_all(comment="Max exit bars")
CLOSE_KEYWORDS = ("max exit", "opposite signal", "max_exit", "exit_bar", "exit bar")


def is_close_intent(data):
    """Ritorna (is_close, reason) se il payload Pine indica una chiusura.

    Casi gestiti:
      - action: "close"                        (esplicito)
      - action: "max_exit_bar" / "exit_bar"    (varianti Pine)
      - comment contiene "Max exit bars" o "Opposite signal"
        (stringhe esatte usate da strategy.close* in tutti i Pine VPTR3)
    """
    action = str(data.get("action", "")).strip().lower()
    comment = str(data.get("comment", "")).strip().lower()
    if action == "close":
        return True, "action=close"
    if action in ("max_exit_bar", "exit_bar", "maxexit", "exit"):
        return True, f"action={action}"
    for kw in CLOSE_KEYWORDS:
        if kw in action or kw in comment:
            return True, f"matched keyword '{kw}'"
    return False, None


def log_trade(symbol, side, qty, entry_price, exit_price=None, pnl_pct=None, pnl_usd=None, reason="", strategy="vptr3"):
    """Logga un trade in CSV strutturato per monitoraggio statistiche.
    strategy: 'vptr3' (Pine webhook) o 'rettangolo' (Python bot)"""
    try:
        os.makedirs(os.path.dirname(TRADES_CSV), exist_ok=True)
        file_exists = os.path.isfile(TRADES_CSV)
        ts = datetime.now(timezone.utc).astimezone().isoformat()
        with open(TRADES_CSV, "a", encoding="utf-8") as f:
            if not file_exists:
                f.write("timestamp,symbol,side,qty,entry_price,exit_price,pnl_pct,pnl_usd,reason,strategy\n")
            f.write(",".join([
                ts,
                symbol,
                side,
                str(qty),
                str(entry_price) if entry_price else "",
                str(exit_price) if exit_price else "",
                str(pnl_pct) if pnl_pct is not None else "",
                str(pnl_usd) if pnl_usd is not None else "",
                reason.replace(",", ";"),
                strategy
            ]) + "\n")
    except Exception as e:
        log_event(f"  log_trade err: {e}")


# stato delle posizioni aperte: symbol -> dict con entry_price, qty, side, entry_ts
OPEN_POSITIONS = {}

def log_event(msg):
    ts = datetime.now(timezone.utc).astimezone().isoformat()
    line = f"[{ts}] {msg}"
    print(line, flush=True)
    try:
        os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
        with open(LOG_FILE, "a", encoding="utf-8") as f:
            f.write(line + "\n")
    except Exception:
        pass

@app.route("/webhook", methods=["POST"])
def webhook():
    # 1. Parse JSON
    try:
        data = request.get_json(force=True, silent=True) or {}
    except Exception:
        log_event("ERR: payload non JSON")
        return jsonify({"ok": False, "err": "not json"}), 400

    # 1b. Payload vuoto (TV a volte manda ping/health-check senza body)
    if not data or (isinstance(data, dict) and not any(data.values())):
        return jsonify({"ok": True, "result": "empty payload ignored", "ts": int(time.time())}), 200

    # 2. Check secret
    if data.get("secret") != WEBHOOK_SECRET:
        log_event(f"ERR: secret sbagliato da {request.remote_addr}: {data}")
        return jsonify({"ok": False, "err": "bad secret"}), 403

    # 3. Check symbol required
    symbol = data.get("symbol")
    if not symbol:
        log_event(f"ERR: symbol mancante: {data}")
        return jsonify({"ok": False, "err": "missing symbol"}), 400

    # 4. Estrai strategy dal payload (default 'vptr3' = Pine webhook)
    payload_strategy = data.get("strategy", "vptr3")

    # 4. CLOSE INTENT: esplicito (action=close) o implicito via comment Pine
    # Pine emette strategy.close("Long", comment="Opposite signal")
    # e strategy.close_all(comment="Max exit bars") - entrambi vanno gestiti come CLOSE
    is_close, close_reason = is_close_intent(data)
    if is_close:
        symbol = normalize_symbol(symbol)
        log_event(f"WEBHOOK CLOSE: {symbol} (reason: {close_reason})")
        # La posizione può diventare visibile con ritardo; ritenta prima di concludere che non esiste.
        pos = get_position(symbol, retries=4, delay=0.50)
        if not pos:
            # Fallback sicuro: usa lo stato locale dell'entry e invia comunque un reduceOnly.
            # reduceOnly impedisce di aprire accidentalmente una posizione opposta.
            local_pos = OPEN_POSITIONS.get(symbol)
            if local_pos and float(local_pos.get("qty", 0) or 0) > 0:
                fallback_idx = int(local_pos.get("positionIdx", 0) or 0)
                log_event(
                    f"  WARN posizione non visibile via API; tento close reduceOnly da stato locale: "
                    f"{local_pos.get('side')} size={local_pos.get('qty')} idx={fallback_idx}"
                )
                close_resp = close_position(
                    symbol,
                    local_pos.get("side"),
                    local_pos.get("qty"),
                    fallback_idx,
                )
                log_event(f"  close fallback resp: {close_resp}")
                if close_resp.get("retCode") == 0:
                    oid = close_resp.get("result", {}).get("orderId")
                    OPEN_POSITIONS.pop(symbol, None)
                    log_event(f"  POSIZIONE CHIUSA (fallback): orderId={oid}")
                    return jsonify({
                        "ok": True,
                        "action": "close",
                        "reason": close_reason,
                        "orderId": oid,
                        "symbol": symbol,
                        "closedSize": local_pos.get("qty"),
                        "source": "local_state_reduce_only",
                    }), 200
                # Se Bybit segnala che non c'è più posizione, consideriamo la close già soddisfatta.
                msg = str(close_resp.get("retMsg", "") or "").lower()
                if any(x in msg for x in ("position is zero", "current position is zero", "no position")):
                    OPEN_POSITIONS.pop(symbol, None)
                    log_event("  >> Posizione già chiusa su Bybit; stato locale ripulito")
                    return jsonify({
                        "ok": True,
                        "action": "close",
                        "result": "position already closed",
                        "reason": close_reason,
                    }), 200
                return jsonify({
                    "ok": False,
                    "err": f"close fallback failed: {close_resp.get('retMsg')}",
                    "code": close_resp.get("retCode"),
                }), 500

            log_event(f"  >> Nessuna posizione aperta su {symbol}, niente da chiudere")
            return jsonify({"ok": True, "action": "close", "result": "no position to close", "reason": close_reason}), 200
        log_event(f"  >> Chiudo posizione esistente {pos['side']} size={pos['size']}")
        close_resp = close_position(symbol, pos["side"], pos["size"], pos["positionIdx"])
        log_event(f"  close resp: {close_resp}")
        if close_resp.get("retCode") != 0:
            return jsonify({"ok": False, "err": f"close failed: {close_resp.get('retMsg')}"}), 500
        oid = close_resp.get("result", {}).get("orderId")
        log_event(f"  POSIZIONE CHIUSA: orderId={oid}")
        # calcola PnL usando il fill di entry salvato
        entry_info = OPEN_POSITIONS.pop(symbol, None)
        # reason per il trade CSV: max_exit_bars / opposite_signal / close esplicito
        csv_reason = "max_exit_bars" if "max exit" in close_reason else \
                     "opposite_signal" if "opposite signal" in close_reason else "close"
        if entry_info:
            exit_price = close_resp.get("result", {}).get("avgPrice")
            if not exit_price:
                # fallback: leggi posizione (sarà 0 size) e usa last trade price
                try:
                    ticker = bybit_request("GET", "/v5/market/tickers", {"category": "linear", "symbol": symbol})
                    exit_price = float(ticker.get("result", {}).get("list", [{}])[0].get("lastPrice", 0))
                except Exception:
                    exit_price = 0
            entry_price_raw = entry_info["entry_price"]
            qty_closed = pos["size"]
            try:
                # entry_price da Bybit e' una stringa, serve cast float
                entry_price = float(entry_price_raw) if entry_price_raw else 0.0
                exit_price_f = float(exit_price) if exit_price else 0.0
                if entry_price > 0 and exit_price_f > 0:
                    if pos["side"] == "Buy":
                        pnl_pct = (exit_price_f - entry_price) / entry_price * 100
                    else:
                        pnl_pct = (entry_price - exit_price_f) / entry_price * 100
                    pnl_usd = pnl_pct * entry_price * qty_closed / 100
                else:
                    pnl_pct, pnl_usd = None, None
                log_trade(symbol, pos["side"], qty_closed, entry_price_raw, exit_price, pnl_pct, pnl_usd, reason=csv_reason, strategy=payload_strategy)
                if pnl_pct is not None:
                    log_event(f"  PnL registrato: {pnl_pct:.2f}% (${pnl_usd:.2f})")
                else:
                    log_event("  PnL non calcolabile (entry o exit price mancanti)")
            except Exception as e:
                # non bloccare il 200 OK se il calcolo PnL fallisce — la close su Bybit e' andata
                log_event(f"  WARN PnL calc fallita: {e}")
                log_trade(symbol, pos["side"], qty_closed, entry_price_raw, exit_price, None, None, reason=csv_reason+"_no_pnl", strategy=payload_strategy)
        return jsonify({"ok": True, "action": "close", "reason": close_reason, "orderId": oid, "symbol": symbol, "closedSize": pos["size"]})

    # 5. ENTRY: richiede side + qty
    side = data.get("side")
    qty = data.get("qty")
    if not (side and qty):
        log_event(f"ERR: side/qty mancanti per entry: {data}")
        return jsonify({"ok": False, "err": "missing side or qty for entry"}), 400

    try:
        qty = float(qty)
    except (TypeError, ValueError):
        log_event(f"ERR: qty non numerico: {qty}")
        return jsonify({"ok": False, "err": "qty not numeric"}), 400

    # 5a. MANIFESTO: tronca qty se eccede ORDER_VALUE_USD nozionale con leva 3x.
    # Recupera current_price: prima dal payload ("price" opzionale), poi da Bybit ticker.
    symbol_pre = symbol  # potrebbe avere .P, ma per il ticker va normalizzato dopo
    current_price = None
    payload_price = data.get("price")
    if payload_price is not None:
        try:
            current_price = float(payload_price)
        except (TypeError, ValueError):
            current_price = None
    if current_price is None or current_price <= 0:
        # fallback: Bybit ticker
        try:
            t = bybit_request("GET", "/v5/market/tickers", {"category": "linear", "symbol": normalize_symbol(symbol_pre)})
            current_price = float(t.get("result", {}).get("list", [{}])[0].get("lastPrice", 0))
        except Exception:
            current_price = 0
    if current_price and current_price > 0:
        max_qty = max_qty_for_order_value(current_price)
        if qty > max_qty:
            log_event(f"  MANIFESTO: qty Pine {qty} > max {max_qty:.6f} per order_value ${ORDER_VALUE_USD} @ price {current_price} -> tronco")
            # Non arrotondare qui: il passo valido cambia per ogni contratto.
            # La normalizzazione definitiva viene effettuata dopo normalize_symbol().
            qty = max_qty
        else:
            log_event(f"  MANIFESTO: qty {qty} OK (nozionale ${qty * current_price:.2f} <= ${ORDER_VALUE_USD})")

    # FIX 2026-07-16: Pine `{{strategy.order.action}}` puo' ritornare "buy"/"sell" lowercase
    # (in base al case sensitivity della strategia TV). Normalizziamo per evitare 400 inutili.
    # Episodes: 15/07 20:00, 16/07 02:00, 16/07 05:00 (3 entry Pine rifiutati in 12h).
    if side.lower() in ("buy", "sell"):
        side = side.capitalize()  # "buy" -> "Buy", "BUY" -> "Buy"
    if side not in ("Buy", "Sell"):
        log_event(f"ERR: side non valido: {side}")
        return jsonify({"ok": False, "err": "side must be Buy or Sell"}), 400

    # Normalizza il simbolo prima di interrogare le regole del contratto Bybit.
    symbol_raw = symbol
    symbol = normalize_symbol(symbol)
    log_event(f"  normalized symbol: {symbol_raw} -> {symbol}")

    # Normalizza qty sul qtyStep reale del singolo contratto.
    normalized_qty, qty_err, qty_rules = normalize_order_qty(symbol, qty, current_price)
    if qty_err:
        log_event(f"ERR: qty non valida per {symbol}: {qty_err}")
        return jsonify({"ok": False, "err": qty_err, "code": 10001}), 400

    if str(normalized_qty) != str(qty):
        log_event(
            f"  QTY STEP: {qty} -> {normalized_qty} "
            f"(qtyStep={decimal_to_plain_string(qty_rules['qty_step'])}, "
            f"minQty={decimal_to_plain_string(qty_rules['min_qty'])})"
        )
    qty = normalized_qty
    log_event(f"WEBHOOK ENTRY: {side} {qty} {symbol}")

    # 6. Set leverage 3x
    ok, msg = set_leverage(symbol, LEVERAGE_TARGET)
    log_event(f"  set_leverage: {msg}")
    if not ok:
        return jsonify({"ok": False, "err": msg}), 500

    # 7. Controlla posizione esistente
    pos = get_position(symbol)
    if pos:
        log_event(f"  pos esistente: {pos['side']} size={pos['size']} avg={pos['avgPrice']}")
        if pos["side"] != side:
            # OPPOSTA: chiudi (mirror di strategy.close)
            log_event(f"  >> Chiudo posizione opposta {pos['side']} {pos['size']}")
            close_resp = close_position(symbol, pos["side"], pos["size"], pos["positionIdx"])
            log_event(f"  close resp: {close_resp}")
            if close_resp.get("retCode") != 0:
                return jsonify({"ok": False, "err": f"close failed: {close_resp.get('retMsg')}"}), 500
        else:
            # stessa direzione gia aperta: skip (pyramiding=0)
            log_event(f"  >> Stessa direzione gia' aperta, skip apertura")
            return jsonify({"ok": True, "action": "skipped", "reason": "same side already open"}), 200

    # 7b. SAFETY CAP: se siamo gia' al massimo di posizioni aperte, skippa
    # (Pine riceve 200 OK con "skipped" cosi' non ritrasmette)
    from rettangolo_config import MAX_OPEN_POSITIONS
    n_open = count_open_positions()
    if n_open >= MAX_OPEN_POSITIONS:
        log_event(f"  SAFETY CAP: {n_open} posizioni aperte >= MAX {MAX_OPEN_POSITIONS} -> skip entry {side} {qty} {symbol}")
        return jsonify({"ok": True, "action": "skipped", "reason": f"safety cap: {n_open}/{MAX_OPEN_POSITIONS} posizioni aperte"}), 200

    # 8. Apri nuova posizione
    order = create_market_order(symbol, side, qty)
    log_event(f"  order resp: {order}")
    if order.get("retCode") != 0:
        return jsonify({"ok": False, "err": order.get("retMsg"), "code": order.get("retCode")}), 500

    oid = order.get("result", {}).get("orderId")
    log_event(f"  ORDER APERTA: orderId={oid} {side} {qty} {symbol}")
    # 8b. SL/TP opzionali dal payload (opzione C: BB- / BB mid calcolati da Python lato mittente)
    sltp_result = None
    sl_in = data.get("sl_price")
    tp_in = data.get("tp_price")
    if sl_in is not None or tp_in is not None:
        try:
            sl_f = float(sl_in) if sl_in is not None else None
            tp_f = float(tp_in) if tp_in is not None else None
            sltp_resp = set_trading_stop(symbol, sl_f, tp_f)
            log_event(f"  sltp resp (post-entry): {sltp_resp}")
            if sltp_resp.get("retCode") == 0:
                sltp_result = {"sl": sl_f, "tp": tp_f}
                log_event(f"  SLTP SETTATO post-entry: {symbol} sl={sl_f} tp={tp_f}")
            else:
                log_event(f"  WARN sltp post-entry fallito: {sltp_resp.get('retMsg')}")
        except (TypeError, ValueError) as e:
            log_event(f"  WARN sl/tp dal payload non numerici: sl={sl_in} tp={tp_in} ({e})")
    # Prova a leggere avgPrice direttamente dalla response (Bybit V5 market order fill istantaneo)
    # Fallback a get_position SOLO se avgPrice manca (evita 1 chiamata API extra nel 99% dei casi)
    fill_price = order.get("result", {}).get("avgPrice")
    # Attendi la materializzazione della posizione: l'orderId conferma l'accettazione,
    # non necessariamente che /v5/position/list sia già aggiornato nello stesso istante.
    opened_pos = get_position(symbol, retries=8, delay=0.50)
    if opened_pos:
        log_event(
            f"  POSIZIONE CONFERMATA: {opened_pos['side']} size={opened_pos['size']} "
            f"idx={opened_pos['positionIdx']} avg={opened_pos['avgPrice']}"
        )
        if not fill_price:
            fill_price = opened_pos.get("avgPrice")
    else:
        log_event(
            "  WARN ordine accettato ma posizione non ancora visibile; "
            "salvo comunque lo stato locale per una close reduceOnly sicura"
        )
    if fill_price:
        # cast a string per consistenza con entry_info
        try:
            fill_price_f = float(fill_price)
        except (TypeError, ValueError):
            fill_price_f = None
        if fill_price_f is not None:
            OPEN_POSITIONS[symbol] = {
                "side": side,
                "qty": opened_pos["size"] if opened_pos else qty,
                "positionIdx": opened_pos["positionIdx"] if opened_pos else 0,
                "entry_price": str(fill_price_f),
                "entry_ts": datetime.now(timezone.utc).astimezone().isoformat(),
                "orderId": oid,
            }
            log_trade(symbol, side, qty, str(fill_price_f), reason="entry", strategy=payload_strategy)
            log_event(f"  fill price registrato: {fill_price_f}")
    if not fill_price:
        OPEN_POSITIONS[symbol] = {
            "side": side,
            "qty": opened_pos["size"] if opened_pos else qty,
            "positionIdx": opened_pos["positionIdx"] if opened_pos else 0,
            "entry_price": "",
            "entry_ts": datetime.now(timezone.utc).astimezone().isoformat(),
            "orderId": oid,
        }
        log_trade(symbol, side, qty, None, reason="entry_no_fill", strategy=payload_strategy)
    return jsonify({"ok": True, "orderId": oid, "symbol": symbol, "side": side, "qty": qty, "sltp": sltp_result})

@app.route("/health", methods=["GET"])
def health():
    return jsonify({"ok": True, "ts": int(time.time()), "service": "bybit-webhook", "mode": "B-pine-faithful+close+sltp"})

@app.route("/webhook/sltp", methods=["POST"])
def webhook_sltp():
    """Set SL/TP su posizione aperta. Chiamato da script esterni (compute_signal Python,
    rettangolo_monitor, o manualmente) che calcolano BB- / BB mid e li passano qui.
    Payload: {secret, symbol, sl_price?, tp_price?}
    - sl_price / tp_price opzionali ma almeno uno obbligatorio
    - entrambi triggerBy = MarkPrice (scelta coerente con strategy.close Pine)"""
    try:
        data = request.get_json(force=True, silent=True) or {}
    except Exception:
        log_event("ERR sltp: payload non JSON")
        return jsonify({"ok": False, "err": "not json"}), 400
    if not data or (isinstance(data, dict) and not any(data.values())):
        return jsonify({"ok": True, "result": "empty payload ignored", "ts": int(time.time())}), 200
    if data.get("secret") != WEBHOOK_SECRET:
        log_event(f"ERR sltp: secret sbagliato da {request.remote_addr}: {data}")
        return jsonify({"ok": False, "err": "bad secret"}), 403
    symbol = data.get("symbol")
    if not symbol:
        log_event(f"ERR sltp: symbol mancante: {data}")
        return jsonify({"ok": False, "err": "missing symbol"}), 400
    sl_price = data.get("sl_price")
    tp_price = data.get("tp_price")
    if sl_price is None and tp_price is None:
        log_event(f"ERR sltp: almeno uno tra sl_price/tp_price: {data}")
        return jsonify({"ok": False, "err": "sl_price or tp_price required"}), 400
    try:
        sl_f = float(sl_price) if sl_price is not None else None
        tp_f = float(tp_price) if tp_price is not None else None
    except (TypeError, ValueError):
        log_event(f"ERR sltp: sl/tp non numerici: sl={sl_price} tp={tp_price}")
        return jsonify({"ok": False, "err": "sl/tp not numeric"}), 400

    symbol = normalize_symbol(symbol)
    log_event(f"WEBHOOK SLTP: {symbol} sl={sl_f} tp={tp_f}")
    # verifica posizione aperta (Bybit rifiuta trading-stop se non c'è posizione)
    pos = get_position(symbol)
    if not pos:
        log_event(f"  >> Nessuna posizione aperta su {symbol}, skip SLTP")
        return jsonify({"ok": True, "result": "no position, sltp skipped", "symbol": symbol}), 200
    resp = set_trading_stop(symbol, sl_f, tp_f)
    log_event(f"  sltp resp: {resp}")
    if resp.get("retCode") != 0:
        return jsonify({"ok": False, "err": f"sltp failed: {resp.get('retMsg')}", "code": resp.get("retCode")}), 500
    log_event(f"  SLTP SETTATO: {symbol} sl={sl_f} tp={tp_f}")
    return jsonify({"ok": True, "action": "sltp", "symbol": symbol, "sl": sl_f, "tp": tp_f, "orderId": resp.get("result", {}).get("orderId", "")})

@app.route("/", methods=["GET"])
def root():
    return "Bybit webhook receiver (SCELTA B + close handler + SLTP) - POST /webhook or /webhook/sltp"

if __name__ == "__main__":
    log_event("=== WEBHOOK SERVER AVVIATO (SCELTA B + CLOSE + SLTP) ===")
    app.run(host="127.0.0.1", port=5580, debug=False, use_reloader=False)
