r"""
Tableau de Bord - Trade Journal automatico per Trading Engine Mattia.
Mavis 2026-07-20 - Skill trade-journal

Genera un tableau HTML con TUTTE le operazioni (aperture, chiusure, skip)
estratte da orders.log + webhook_receiver.log. Per ogni operazione aggiunge
commenti automatici su:
- Strategia (VPTR3, Rettangolo, ecc.)
- Regime P007 (LONG OK, SHORT OK, SKIP-REGIME)
- ZONA-MID (LONG zona bottom, SHORT zona top, SKIP zona mid)
- P006 SL clamp (-3% MARGINE charter)
- P008 max 5 posizioni
- Sizing Charter 1500 USDT (500 margin × 3x)
- Leva effettiva
- Esito (TP / SL / OPEN / SKIP-* / chiusura trailing / safety net)
- PnL se disponibile

Output: G:\AI TRADING ENGINE\live_deploy\tableau_de_bord.html
Servizio: gira ogni 5 minuti (configurabile).
"""
import os
import re
import sys
import time
import json
import logging
from datetime import datetime, timezone, timedelta
from pathlib import Path
from collections import defaultdict

# === PROFILI ACCOUNT (2026-09-20) ===========================================
# Un solo generatore, due profili: account 1 (PC locale) e account 2 (VPS).
# Il profilo si sceglie con la variabile d'ambiente TABLEAU_PROFILE e viene letto
# da `tableau_profile_<nome>.json` accanto a questo file. Senza variabile il
# comportamento e' IDENTICO a prima (account 1): nessuna regressione.
#
# Serve a evitare due copie del generatore che divergono: e' esattamente quello
# che e' successo fra il Tableau dell'account 1 e la copia sulla VPS.
ACCOUNT1_DEFAULTS = {
    "account": "account1",
    "account_label": "Account 1 - PC locale",
    "live_deploy": r"G:\AI TRADING ENGINE\live_deploy",
    "orders_log": "logs/orders.log",
    "webhook_log": "logs/webhook_receiver.log",
    "rettangolo_log": "webhook_listener/logs/rettangolo_runner.log",
    "output_html": "tableau_de_bord.html",
    "live_zero_utc": "2026-07-22T20:00:00+00:00",
    "live_zero_label": "2026-07-22 22:00 IT",
    # Account 1: solo le due strategie che il Tableau ha sempre mostrato.
    "strategy_whitelist": ["vptr3", "rettangolo"],
    "charter_notional": 1500.0,
    "journal_log": "logs/trade_journal.log",
    # Suffissi che vanno considerati COMUNQUE aperture (es. `reversal`, che
    # chiude e riapre). Vuoto per l'account 1: comportamento storico invariato.
    "entry_suffixes": [],
    # Nome storico del PID file dell'account 1: NON va cambiato, altrimenti
    # l'istanza in esecuzione non viene piu' riconosciuta dal singleton check.
    "pid_file": r"C:\Users\Mattia\AppData\Local\Temp\trade_journal.pid",
}

PROFILE_NAME = os.environ.get("TABLEAU_PROFILE", "account1")
PROFILE_FILE = Path(
    os.environ.get(
        "TABLEAU_PROFILE_PATH",
        str(Path(__file__).with_name(f"tableau_profile_{PROFILE_NAME}.json")),
    )
)
PROFILE = dict(ACCOUNT1_DEFAULTS)
if PROFILE_FILE.is_file():
    try:
        PROFILE.update(json.loads(PROFILE_FILE.read_text(encoding="utf-8")))
    except Exception as exc:  # profilo illeggibile: si prosegue con i default
        print(f"WARN profilo {PROFILE_FILE} illeggibile ({exc}); uso i default account 1")

ACCOUNT = PROFILE.get("account", "account1")
ACCOUNT_LABEL = PROFILE.get("account_label", "Account 1 - PC locale")
STRATEGY_WHITELIST = tuple(
    str(s).strip().lower() for s in PROFILE.get("strategy_whitelist", ["vptr3", "rettangolo"])
)
STRATEGY_LIST_LABEL = PROFILE.get(
    "strategy_list_label", " + ".join(s.upper() for s in STRATEGY_WHITELIST)
)
# Suffissi che restano aperture nonostante il suffisso (es. `reversal`).
ENTRY_SUFFIXES = tuple(
    str(s).strip().lower() for s in PROFILE.get("entry_suffixes", []) or []
)
TARGET_NOTIONAL = float(PROFILE.get("charter_notional", 1500.0))
# Il contesto di mercato costa una query Bybit per simbolo ad ogni ciclo. Sulla
# VPS esistono gia' problemi di rate limit sulle candele pubbliche, quindi il
# Tableau dell'account 2 gira SENZA contesto: il suo valore (aperture,
# compliance, esclusioni) non dipende da quelle query, e cosi' non si aggiunge
# pressione a un sistema che ne ha gia'.
SKIP_MARKET_CONTEXT = os.environ.get("TABLEAU_SKIP_MARKET_CONTEXT", "").strip().lower() in {
    "1", "true", "yes", "on"
}


def _is_entry_with_suffix(strategy: str) -> bool:
    """Vero per una strategia `base_suffisso` dove il suffisso e' un'apertura.

    Serve per i casi come `SQW_reversal`: una inversione chiude e riapre, quindi
    e' a tutti gli effetti una nuova entrata. Quali suffissi valgano come
    apertura e' una scelta di dominio, quindi e' configurabile nel profilo
    (`entry_suffixes`) e di default e' vuota: nessun ingresso viene aggiunto
    per supposizione.
    """
    if not ENTRY_SUFFIXES:
        return False
    return any(
        strategy == f"{base}_{suffix}"
        for base in STRATEGY_WHITELIST
        for suffix in ENTRY_SUFFIXES
    )

LIVE_DEPLOY = Path(PROFILE["live_deploy"])
ORDERS_LOG = LIVE_DEPLOY / PROFILE["orders_log"]
WEBHOOK_LOG = LIVE_DEPLOY / PROFILE["webhook_log"]
RETTANGOLO_LOG = LIVE_DEPLOY / PROFILE["rettangolo_log"]
OUTPUT_HTML = LIVE_DEPLOY / PROFILE["output_html"]
# Contratto standardizzato fra i due account: ogni macchina produce il proprio
# snapshot, e la pagina di confronto ne legge DUE dichiarando entrambi i
# perimetri. Cosi' il confronto non richiede mai di mescolare le sorgenti.
SNAPSHOT_SCHEMA_VERSION = 1
SNAPSHOT_FILE = LIVE_DEPLOY / PROFILE.get("snapshot_file", f"tableau_snapshot_{ACCOUNT}.json")

# === CODEX TABLEAU ZERO FILTER 2026-07-23 START ===
LIVE_ZERO_UTC = datetime.fromisoformat(PROFILE["live_zero_utc"])
LIVE_ZERO_IT_LABEL = PROFILE["live_zero_label"]


def _parse_any_ts_utc(ts: str):
    if not ts:
        return None
    raw = str(ts).strip().strip("[]")
    raw = raw.replace("Z", "+00:00")
    try:
        dt = datetime.fromisoformat(raw)
    except Exception:
        m = re.search(r"(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})", raw)
        if not m:
            return None
        try:
            dt = datetime.fromisoformat(m.group(1) + "T" + m.group(2))
        except Exception:
            return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc)


def _line_ts_utc(line: str):
    """Estrae il timestamp da una riga di log.

    BUG CORRETTO: la versione precedente cercava la PRIMA parentesi quadra della
    riga e provava a interpretarne il contenuto come data. Le righe di
    webhook_receiver.log hanno la forma:

        2026-09-20 08:00:06,540 [INFO] ...

    quindi la prima parentesi contiene "INFO": il parsing falliva, la funzione
    restituiva None e `_is_post_zero_line()` era sempre False. Verificato:
    58.675 righe su 58.675 (100%) venivano scartate, e le sezioni "Skip events"
    e "Casistiche" del Tableau risultavano strutturalmente sempre a zero.

    Nuovo ordine di ricerca:
      1. timestamp in TESTA alla riga (forma usata dai log di questo sistema);
      2. qualunque timestamp ISO presente nella riga;
      3. contenuto di una parentesi quadra, SOLO se e' una data interpretabile.
    """
    if not line:
        return None

    # 1. Testa della riga: '2026-09-20 08:00:06,540'
    m = re.match(
        r"\s*(?P<ts>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?"
        r"(?:Z|[+-]\d{2}:?\d{2})?)",
        line,
    )
    if m:
        parsed = _parse_any_ts_utc(m.group("ts").replace(",", "."))
        if parsed:
            return parsed

    # 2. Qualunque timestamp ISO nella riga.
    m = re.search(
        r"(?P<ts>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?"
        r"(?:Z|[+-]\d{2}:?\d{2})?)",
        line,
    )
    if m:
        parsed = _parse_any_ts_utc(m.group("ts").replace(",", "."))
        if parsed:
            return parsed

    # 3. Parentesi quadre: solo se contengono davvero una data.
    for m in re.finditer(r"\[(?P<br>[^\]]+)\]", line):
        parsed = _parse_any_ts_utc(m.group("br"))
        if parsed:
            return parsed

    return None


def _is_post_zero_ts(ts: str) -> bool:
    dt = _parse_any_ts_utc(ts)
    return bool(dt and dt >= LIVE_ZERO_UTC)


def _is_post_zero_line(line: str) -> bool:
    dt = _line_ts_utc(line)
    return bool(dt and dt >= LIVE_ZERO_UTC)
# === CODEX TABLEAU ZERO FILTER 2026-07-23 END ===




# === CODEX TABLEAU REAL TRADE FILTER 2026-07-23 START ===
TABLEAU_FILTER_STATS = {
    "raw_post_zero": 0,
    "technical_excluded": 0,
    "duplicate_excluded": 0,
    "real_trades": 0,
}


def _is_real_trade_op(op: dict) -> bool:
    """Vero se la riga e' un'APERTURA reale per il profilo attivo.

    La whitelist delle strategie viene dal profilo (`strategy_whitelist`): per
    l'account 1 resta `vptr3` + `rettangolo` come sempre; per l'account 2 serve
    l'elenco delle sue 10 strategie (`rettangolo_v2`, `SQW`, `ADX`, `RANGE`,
    `MA_TRAILING`, `supertrend_bosw`, `rettangolo_simple`, `RETTANGOLO_TV_SIMPLE`, ...).

    L'ordine dei controlli e' quello storico: prima whitelist ESATTA, poi gli
    scarti tecnici. Cosi' il risultato dell'account 1 non cambia.
    """
    try:
        qty = float(op.get("qty", 0) or 0)
        notional = float(op.get("notional", 0) or 0)
    except Exception:
        return False
    strategy = str(op.get("strategy", "")).strip().lower()
    order_id = str(op.get("order_id", "")).strip().lower()
    if qty <= 0 or notional <= 0:
        return False
    if strategy not in STRATEGY_WHITELIST and not _is_entry_with_suffix(strategy):
        return False
    if not order_id or order_id in ("no_position", "anti_dup_skip", "none", "null", "-"):
        return False
    if "close_no_pos" in strategy or "anti_dup" in strategy:
        return False
    return True


EXCLUSION_LABELS = {
    "CLOSE_NO_POSITION": "Exit arrivata senza posizione aperta",
    "ANTI_DUP": "Ingresso duplicato bloccato (anti-dup)",
    "RENDER_BLOCKED": "Ingresso bloccato da regola di render",
    "REAL_CLOSE": "Chiusura reale (esclusa giustamente dalle aperture)",
    "QTY_ZERO": "Quantita' o nozionale nullo",
    "NO_ORDER_ID": "Order ID assente o placeholder",
    "OUT_OF_SCOPE": "Strategia fuori dal perimetro del profilo",
    "ALTRO": "Altro",
}

# Interpretazione operativa di ogni motivo: trasforma un conteggio in una
# indicazione su cui si puo' agire.
EXCLUSION_NOTES = {
    "CLOSE_NO_POSITION": (
        "Exit ricevuta quando la posizione non era aperta: l'ordine di chiusura "
        "non ha prodotto nulla. Un valore alto indica alert di uscita "
        "disallineati rispetto alle posizioni reali."
    ),
    "ANTI_DUP": "Ingresso ripetuto bloccato dall'anti-duplicazione: comportamento voluto.",
    "RENDER_BLOCKED": (
        "Ingresso bloccato da una regola di render: verificare che sia la regola "
        "attesa e non un blocco non voluto."
    ),
    "REAL_CLOSE": (
        "Chiusura reale di una posizione: ha size e orderId, ma non e' "
        "un'apertura e va letta come tale."
    ),
    "QTY_ZERO": "Riga senza size: skip, placeholder o ricerca di posizione inesistente.",
    "NO_ORDER_ID": "Nessun orderId valido: la riga non e' tracciabile su Bybit.",
    "OUT_OF_SCOPE": "Strategia non inclusa nella whitelist di questo profilo.",
    "ALTRO": "Non classificata: da verificare.",
}


def _exclusion_reason(op: dict) -> str:
    """Motivo per cui una riga di orders.log NON e' un'apertura reale.

    Prima esisteva un solo contatore ("righe tecniche escluse"): un numero
    grande senza spiegazione. Con la ripartizione il numero diventa diagnostica:
    per esempio sull'account 2 le exit senza posizione sono ~70, e un valore
    cosi' alto e' un'informazione operativa, non un dettaglio.
    """
    strategy = str(op.get("strategy", "")).strip().lower()
    order_id = str(op.get("order_id", "")).strip().lower()
    # 1. i marcatori nel nome della strategia sono il segnale piu' specifico
    if "close_no_pos" in strategy:
        return "CLOSE_NO_POSITION"
    if "anti_dup" in strategy:
        return "ANTI_DUP"
    if "render_blocked" in strategy or "render_short_blocked" in strategy:
        return "RENDER_BLOCKED"
    # 2. righe senza size (skip, placeholder)
    try:
        if float(op.get("qty", 0) or 0) <= 0 or float(op.get("notional", 0) or 0) <= 0:
            return "QTY_ZERO"
    except Exception:
        return "QTY_ZERO"
    # 3. chiusure reali: hanno size e orderId, ma non sono aperture
    if strategy.endswith("_close"):
        return "REAL_CLOSE"
    # 4. order id non valido
    if not order_id or order_id in ("no_position", "anti_dup_skip", "none", "null", "-"):
        return "NO_ORDER_ID"
    # 5. strategia non prevista dal profilo
    if strategy not in STRATEGY_WHITELIST:
        return "OUT_OF_SCOPE"
    return "ALTRO"


def _dedupe_trade_ops(ops: list) -> list:
    stats = TABLEAU_FILTER_STATS
    stats["raw_post_zero"] = len(ops)
    stats["technical_excluded"] = 0
    stats["duplicate_excluded"] = 0
    stats["real_trades"] = 0
    # ripartizione degli scarti per motivo (diagnostica, non solo un totale)
    stats["exclusion_reasons"] = defaultdict(int)

    clean = []
    seen = set()
    for op in ops:
        if not _is_real_trade_op(op):
            stats["technical_excluded"] += 1
            stats["exclusion_reasons"][_exclusion_reason(op)] += 1
            continue
        order_id = str(op.get("order_id", "")).strip()
        if order_id:
            key = ("order_id", order_id)
        else:
            ts = str(op.get("ts", ""))[:16]
            key = (
                "fallback",
                str(op.get("symbol", "")),
                str(op.get("side", "")).lower(),
                str(op.get("qty", "")),
                str(op.get("price", "")),
                ts,
            )
        if key in seen:
            stats["duplicate_excluded"] += 1
            continue
        seen.add(key)
        clean.append(op)
    stats["real_trades"] = len(clean)
    return clean
# === CODEX TABLEAU REAL TRADE FILTER 2026-07-23 END ===

# Configurazione
POLL_SECONDS = int(PROFILE.get("poll_seconds", 300))
# Parametrizzati per profilo: altrimenti il profilo account 2 scriverebbe il suo
# journal (e il suo PID) negli stessi file dell'account 1, mescolando i due.
LOG_FILE = LIVE_DEPLOY / PROFILE.get("journal_log", "logs/trade_journal.log")
PID_FILE = Path(
    PROFILE.get(
        "pid_file",
        str(Path(os.environ.get("TEMP", ".")) / f"trade_journal_{ACCOUNT}.pid"),
    )
)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)

logging.basicConfig(
    filename=LOG_FILE,
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger()


def parse_orders_log() -> list:
    """Parsa orders.log per estrarre TUTTE le operazioni di apertura."""
    ops = []
    if not ORDERS_LOG.exists():
        return ops
    pattern = re.compile(
        r"\[(?P<ts>[^\]]+)\] orderId=(?P<order_id>[^\s]+)\s+"
        r"(?P<symbol>\w+)\s+(?P<side>\w+)\s+"
        r"qty=(?P<qty>[\d.]+)\s+price=(?P<price>[\d.]+)\s+"
        r"notional=(?P<notional>[\d.]+)\s+strategy=(?P<strategy>\w+)"
    )
    with open(ORDERS_LOG, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            m = pattern.search(line)
            if m:
                if not _is_post_zero_ts(m.group("ts")):
                    continue
                ops.append({
                    "ts": m.group("ts"),
                    "order_id": m.group("order_id"),
                    "symbol": m.group("symbol"),
                    "side": m.group("side"),
                    "qty": float(m.group("qty")),
                    "price": float(m.group("price")),
                    "notional": float(m.group("notional")),
                    "strategy": m.group("strategy"),
                })
    return _dedupe_trade_ops(ops)


def get_market_context(bybit_client) -> dict:
    """Recupera il contesto di mercato ATTUALE per ogni symbol unico presente in orders.log.
    Ritorna dict {symbol: {trend, market_kind, regime, d1_range_pct, ema50_slope, price}}.
    Cache per symbol per evitare query ripetute (1 query per symbol)."""
    cache = {}
    if not ORDERS_LOG.exists():
        return cache
    symbols = {op.get("symbol") for op in parse_orders_log() if op.get("symbol")}

    for sym in symbols:
        try:
            kd = bybit_client.fetch_ohlcv(sym, "D", 5)
            if not kd or len(kd) < 2:
                continue
            d1 = kd[1]
            d1h = float(d1[2]); d1l = float(d1[3]); d1c = float(d1[4])
            d1_range_pct = (d1h - d1l) / d1l * 100
            market_kind = "volatile" if d1_range_pct > 5 else ("range ampio" if d1_range_pct > 2 else "chop/stretto")

            k4h = bybit_client.fetch_ohlcv(sym, "240", 60)
            closes = [float(k[4]) for k in k4h[:-1]] if k4h else []
            ema50 = None
            ema50_slope = None
            if len(closes) >= 52:
                # EMA50 attuale
                k = 2.0 / 51
                ema = sum(closes[:50]) / 50
                for c_val in closes[50:]:
                    ema = c_val * k + ema * (1 - k)
                ema50 = ema
                # EMA50 slope (confronto con calcolo di 1 candela fa)
                closes_prev = closes[:-1]
                ema_prev = sum(closes_prev[:50]) / 50
                for c_val in closes_prev[50:]:
                    ema_prev = c_val * k + ema_prev * (1 - k)
                ema50_slope = (ema50 - ema_prev) / ema_prev * 100 if ema_prev else 0

            cur = float(bybit_client.get_last_price(sym))
            if ema50:
                regime = "LONG OK" if cur > ema50 else "SHORT OK"
            else:
                regime = "?"
            if ema50_slope is not None:
                trend = "rialzista" if ema50_slope > 0.5 else ("ribassista" if ema50_slope < -0.5 else "chop/range")
            else:
                trend = "?"

            cache[sym] = {
                "trend": trend,
                "market_kind": market_kind,
                "regime": regime,
                "d1_range_pct": d1_range_pct,
                "ema50_slope": ema50_slope,
                "price": cur,
            }
        except Exception:
            pass
    return cache


def parse_webhook_log() -> dict:
    """Parsa webhook_receiver.log per estrarre eventi correlati (SL/TP auto-fill, P007 regime, ZONA-MID SKIP, P008, ORDER OK, P006 clamp, TRADING STOP, SAFETY NET)."""
    events = defaultdict(list)
    if not WEBHOOK_LOG.exists():
        return events
    # Patterns per eventi
    patterns = {
        "AUTO_FILL": re.compile(r"AUTO-FILL SL/TP per (\w+) (\w+) @ entry=([\d.]+): SL=([\d.]+) TP=([\d.]+) source=([^\(]+)"),
        "P007_OK": re.compile(r"P007 OK: close\[-2\]=([\d.]+) vs EMA50\[-2\]=([\d.]+)"),
        "P007_SKIP": re.compile(r"P007 SKIP-REGIME (\w+): close\[-2\]=([\d.]+) ([<>]) EMA50\[-2\]=([\d.]+)"),
        "P006_OVERRIDE": re.compile(r"CHARTER P006 OVERRIDE: (\w+) sl_loss_pct=([\d.-]+)"),
        "P008_SKIP": re.compile(r"P008 SKIP-MAX-OPEN ATTIVATO per (\w+) (\w+): (\d+) posizioni"),
        "ZONA_MID_SKIP": re.compile(r"ZONA-MID SKIP: (\w+) (\w+) entry=([\d.]+)"),
        "TRADING_STOP_OK": re.compile(r"TRADING STOP OK (\w+): SL=([\d.]+) TP=([\d.]+)"),
        "ORDER_OK": re.compile(r"ORDER OK ([a-f0-9-]+): orderId=([a-f0-9-]+)"),
        "P005_TP_OVERRIDE": re.compile(r"CHARTER P005 TP OVERRIDE"),  # legacy, non più presente
        "EMAIL_FAIL": re.compile(r"Email notification failed"),
    }
    with open(WEBHOOK_LOG, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            if not _is_post_zero_line(line):
                continue
            for evt, pat in patterns.items():
                m = pat.search(line)
                if m:
                    events[evt].append({"line": line.strip()[:200], "match": m.groups()})
    return events


def parse_rettangolo_log() -> list:
    """Parsa rettangolo_runner.log per estrarre segnali generati."""
    segnali = []
    if not RETTANGOLO_LOG.exists():
        return segnali
    pattern = re.compile(
        r"\[(?P<ts>[^\]]+)\]\s+(?P<symbol>\w+): SEGNALE (?P<signal>\w+) "
        r"entry=(?P<entry>[\d.]+) sl=(?P<sl>[\d.]+) tp=(?P<tp>[\d.]+)"
    )
    pattern_webhook = re.compile(
        r"\[(?P<ts>[^\]]+)\]\s+(?P<symbol>\w+): WEBHOOK OK: request_id=([a-f0-9]+)\.\.\."
    )
    with open(RETTANGOLO_LOG, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            if not _is_post_zero_line(line):
                continue
            m = pattern.search(line)
            if m:
                segnali.append({
                    "ts": m.group("ts"),
                    "symbol": m.group("symbol"),
                    "signal": m.group("signal"),
                    "entry": float(m.group("entry")),
                    "sl": float(m.group("sl")),
                    "tp": float(m.group("tp")),
                    "type": "segnale",
                })
            m = pattern_webhook.search(line)
            if m:
                segnali.append({
                    "ts": m.group("ts"),
                    "symbol": m.group("symbol"),
                    "type": "webhook_ok",
                })
    return segnali


def build_comment(op: dict, webhook_events: dict, rett_segnali: list, market_context: dict) -> str:
    """Costruisce un commento automatico per un'operazione, basato su tutti gli eventi correlati.
    Include due sezioni:
    1. Commento operativo (regole Charter rispettate o violate)
    2. Contesto di mercato (trend EMA50, regime attuale, market kind)
    """
    symbol = op["symbol"]
    side = op["side"]
    strategy = op["strategy"]

    # === SEZIONE 1: COMMENTO OPERATIVO (regole Charter) ===
    operativo = []
    if strategy == "vptr3":
        operativo.append("🎯 VPTR3")
    elif strategy == "rettangolo":
        operativo.append("📐 Rettangolo")
    else:
        operativo.append(f"❓ {strategy}")

    p007_skip = [e for e in webhook_events.get("P007_SKIP", []) if e["match"][0] == symbol]
    if p007_skip:
        operativo.append(f"⚠️ P007 SKIP-REGIME ({p007_skip[-1]['match'][1].lower()} contro trend EMA50)")
    else:
        p007_ok = [e for e in webhook_events.get("P007_OK", []) if symbol in e["line"]]
        if p007_ok:
            operativo.append("✓ P007 regime OK")

    zona_skip = [e for e in webhook_events.get("ZONA_MID_SKIP", []) if e["match"][0] == symbol]
    if zona_skip:
        operativo.append("🚫 ZONA-MID SKIP (entry in area mid)")
    else:
        if side.lower() in ("buy", "long"):
            operativo.append("✓ ZONA bottom OK (entry al LL)")
        else:
            operativo.append("✓ ZONA top OK (entry al HH)")

    p006 = [e for e in webhook_events.get("P006_OVERRIDE", []) if e["match"][0] == symbol]
    if p006:
        operativo.append("🔧 P006 OVERRIDE (SL clampato a -3% Charter)")
    else:
        operativo.append("✓ P006 SL Charter -3% OK")

    p008 = [e for e in webhook_events.get("P008_SKIP", []) if e["match"][0] == symbol]
    if p008:
        operativo.append("🚫 P008 SKIP (max 5 posizioni)")
    else:
        operativo.append("✓ P008 max 5 posizioni OK")

    if abs(op["notional"] - 1500.0) < 75:
        operativo.append("💰 Sizing Charter 1500 USDT")
    else:
        operativo.append(f"💰 Sizing ${op['notional']:.0f} (deviazione)")

    autofill = [e for e in webhook_events.get("AUTO_FILL", []) if e["match"][0] == symbol]
    if autofill:
        src = autofill[-1]["match"][5].strip()
        operativo.append(f"📋 SL/TP: {src}")

    ts_ok = [e for e in webhook_events.get("TRADING_STOP_OK", []) if e["match"][0] == symbol]
    if ts_ok:
        sl = ts_ok[-1]["match"][1]
        tp = ts_ok[-1]["match"][2]
        operativo.append(f"🎚️ SL={sl} TP={tp}")

    if strategy == "rettangolo":
        segnali_match = [s for s in rett_segnali if s.get("symbol") == symbol and s.get("type") == "segnale"]
        if segnali_match:
            last = segnali_match[-1]
            operativo.append(f"📐 Pattern: {last['signal']}")

    # === SEZIONE 2: CONTESTO DI MERCATO ATTUALE ===
    contesto = []
    ctx = market_context.get(symbol, {})
    if ctx:
        trend = ctx.get("trend", "?")
        market_kind = ctx.get("market_kind", "?")
        regime = ctx.get("regime", "?")
        d1_pct = ctx.get("d1_range_pct", 0)
        contesto.append(f"📊 Trend {trend}")
        contesto.append(f"📈 Mercato {market_kind} (D1 range {d1_pct:.2f}%)")
        contesto.append(f"🎯 Regime attuale: {regime}")
        # Coerenza: se P007 era OK al momento dell'apertura ma regime attuale è opposto, commento
        if "P007 regime OK" in " ".join(operativo):
            current_side = "LONG" if regime == "LONG OK" else "SHORT"
            op_side = "LONG" if side.lower() in ("buy", "long") else "SHORT"
            if current_side != op_side:
                contesto.append(f"⚠️ ATTENZIONE: regime attuale {regime} opposto al segnale {op_side} (possibile inversione)")

    sezione_operativa = " | ".join(operativo)
    sezione_contesto = " | ".join(contesto)
    if sezione_contesto:
        return f"OPERATIVO: {sezione_operativa} || CONTESTO: {sezione_contesto}"
    return f"OPERATIVO: {sezione_operativa}"


def generate_html(orders: list, webhook_events: dict, rett_segnali: list, market_context: dict) -> str:
    """Genera HTML del tableau de bord."""
    # Raggruppa operazioni per symbol+side+strategy
    grouped = defaultdict(list)
    for op in orders:
        key = (op["symbol"], op["side"], op["strategy"])
        grouped[key].append(op)

    # Statistiche globali
    total_ops = len(orders)
    total_notional = sum(op["notional"] for op in orders)
    by_strategy = defaultdict(lambda: {"count": 0, "notional": 0})
    for op in orders:
        by_strategy[op["strategy"]]["count"] += 1
        by_strategy[op["strategy"]]["notional"] += op["notional"]
    by_symbol = defaultdict(lambda: {"count": 0, "notional": 0})
    for op in orders:
        by_symbol[op["symbol"]]["count"] += 1
        by_symbol[op["symbol"]]["notional"] += op["notional"]

    # Skip counters (P007, P008, ZONA-MID, P006 OVERRIDE)
    p007_skips = len(webhook_events.get("P007_SKIP", []))
    p008_skips = len(webhook_events.get("P008_SKIP", []))
    zona_skips = len(webhook_events.get("ZONA_MID_SKIP", []))
    p006_overrides = len(webhook_events.get("P006_OVERRIDE", []))

    # Genera HTML
    html = []
    html.append("""<!DOCTYPE html>
<html lang="it">
<head>
    <meta charset="UTF-8">
    <title>Tableau de Bord - Test Operativo dal Punto Zero</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, sans-serif; background: #1a1a2e; color: #eaeaea; margin: 20px; }
        h1 { color: #00d9ff; border-bottom: 2px solid #00d9ff; padding-bottom: 10px; }
        h2 { color: #ff6b6b; margin-top: 30px; }
        h3 { color: #ffd93d; }
        .stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin: 20px 0; }
        .stat-card { background: #16213e; border-left: 4px solid #00d9ff; padding: 15px; border-radius: 5px; }
        .stat-card.danger { border-left-color: #ff6b6b; }
        .stat-card.success { border-left-color: #51cf66; }
        .stat-card.warning { border-left-color: #ffd93d; }
        .stat-value { font-size: 28px; font-weight: bold; color: #fff; }
        .stat-label { font-size: 12px; color: #aaa; text-transform: uppercase; }
        table { width: 100%; border-collapse: collapse; margin: 20px 0; background: #16213e; }
        th { background: #0f3460; color: #00d9ff; padding: 12px; text-align: left; }
        td { padding: 10px; border-bottom: 1px solid #2a2a4a; }
        tr:hover { background: #2a2a4a; }
        .badge { display: inline-block; padding: 3px 8px; border-radius: 3px; font-size: 11px; font-weight: bold; }
        .badge-long { background: #51cf66; color: #000; }
        .badge-short { background: #ff6b6b; color: #fff; }
        .badge-vptr3 { background: #845ec2; color: #fff; }
        .badge-rettangolo { background: #ffd93d; color: #000; }
        .comment { color: #c0c0c0; font-size: 12px; line-height: 1.4; margin: 4px 0; padding: 6px 10px; background: #0f3460; border-left: 3px solid #00d9ff; }
        .comment strong { color: #00d9ff; }
        .comment .warning { color: #ff6b6b; font-weight: bold; }
        .meta { color: #888; font-size: 11px; }
        .op-timestamp { color: #ffffff; font-size: 15px; font-weight: bold; font-family: 'Consolas', 'Courier New', monospace; white-space: nowrap; }
        .meta-date { color: #ffffff; font-size: 18px; font-weight: bold; margin: 12px 0 18px 0; padding: 10px 16px; background: #0f3460; border-left: 5px solid #00d9ff; border-radius: 4px; display: inline-block; }
        .summary-table { width: 100%; border-collapse: collapse; margin: 15px 0 25px 0; background: #16213e; }
        .summary-table th { background: #0f3460; color: #00d9ff; padding: 10px 14px; text-align: left; font-size: 13px; }
        .summary-table td { padding: 9px 14px; border-bottom: 1px solid #2a2a4a; font-size: 14px; color: #fff; }
        .summary-table tr:hover { background: #2a2a4a; }
        .summary-table .rank { color: #00d9ff; font-weight: bold; font-size: 16px; width: 50px; }
        .summary-table .count { color: #ffd93d; font-weight: bold; font-size: 16px; text-align: right; width: 100px; }
        .summary-table .bar-cell { width: 30%; }
        .summary-table .bar { background: #00d9ff; height: 10px; border-radius: 3px; min-width: 4px; }
        .summary-table .bar.zero { background: #444; }
        .market-ctx { display: flex; gap: 10px; flex-wrap: wrap; margin: 10px 0; }
        .ctx-pill { background: #0f3460; border: 1px solid #00d9ff; padding: 5px 10px; border-radius: 15px; font-size: 12px; }
    </style>
</head>
<body>""")
    html.append(f"<h1>📊 Tableau de Bord — Trading Engine Mattia</h1>")
    total_skip_events = sum(len(v) for v in webhook_events.values())
    # Il perimetro account va dichiarato in pagina: due Tableau (account 1 e
    # account 2) pubblicano numeri diversi e non confrontabili a colpo d'occhio.
    html.append(
        f"<div class='meta-date'><strong>PERIMETRO: {ACCOUNT_LABEL}</strong> "
        f"| strategie incluse: {STRATEGY_LIST_LABEL} "
        f"| Charter nozionale target: {TARGET_NOTIONAL:,.0f} USDT</div>"
    )
    html.append(f"<div class='meta-date'>PUNTO ZERO: {LIVE_ZERO_IT_LABEL} | Solo dati operativi post-zero</div>")
    html.append(f"<div class='meta-date'>🕐 AGGIORNATO: {datetime.now().strftime('%d/%m/%Y %H:%M:%S')} &nbsp;|&nbsp; Operazioni: {total_ops} &nbsp;|&nbsp; Skip events: {total_skip_events}</div>")

    # Market context overview
    if market_context:
        html.append("<h2>🌐 Contesto di mercato attuale (live)</h2>")
        html.append("<div class='market-ctx'>")
        for sym, ctx in sorted(market_context.items()):
            trend_icon = "📈" if ctx['trend'] == "rialzista" else ("📉" if ctx['trend'] == "ribassista" else "➖")
            regime_icon = "🟢" if ctx['regime'] == "LONG OK" else "🔴"
            html.append(f"<div class='ctx-pill'>{trend_icon} <strong>{sym}</strong>: {ctx['trend']} | {regime_icon} {ctx['regime']} | {ctx['market_kind']} (D1 {ctx['d1_range_pct']:.2f}%)</div>")
        html.append("</div>")

    # Stats grid
    html.append("<div class='stats-grid'>")
    html.append(f"<div class='stat-card success'><div class='stat-label'>Trade reali dal punto zero</div><div class='stat-value'>{total_ops}</div></div>")
    html.append(f"<div class='stat-card'><div class='stat-label'>Notional dal punto zero USDT</div><div class='stat-value'>${total_notional:,.0f}</div></div>")
    html.append(f"<div class='stat-card danger'><div class='stat-label'>Skip P007 (regime)</div><div class='stat-value'>{p007_skips}</div></div>")
    html.append(f"<div class='stat-card warning'><div class='stat-label'>Skip ZONA-MID</div><div class='stat-value'>{zona_skips}</div></div>")
    html.append(f"<div class='stat-card danger'><div class='stat-label'>Skip P008 (max 5)</div><div class='stat-value'>{p008_skips}</div></div>")
    html.append(f"<div class='stat-card warning'><div class='stat-label'>P006 OVERRIDE</div><div class='stat-value'>{p006_overrides}</div></div>")
    html.append(f"<div class='stat-card warning'><div class='stat-label'>Righe tecniche escluse</div><div class='stat-value'>{TABLEAU_FILTER_STATS.get('technical_excluded', 0)}</div></div>")
    html.append(f"<div class='stat-card danger'><div class='stat-label'>Duplicati esclusi</div><div class='stat-value'>{TABLEAU_FILTER_STATS.get('duplicate_excluded', 0)}</div></div>")
    html.append("</div>")

    # === RIPARTIZIONE DEGLI SCARTI PER MOTIVO ===============================
    # Prima esisteva solo il totale "righe tecniche escluse": un numero grande
    # senza spiegazione. La ripartizione lo rende diagnostico.
    reasons = TABLEAU_FILTER_STATS.get("exclusion_reasons") or {}
    if reasons:
        html.append("<h2>Esclusioni dalle aperture: ripartizione per motivo</h2>")
        html.append(
            f"<p class='meta'>Su {TABLEAU_FILTER_STATS.get('raw_post_zero', 0)} righe "
            f"nel perimetro, {TABLEAU_FILTER_STATS.get('real_trades', 0)} sono aperture reali "
            f"e {TABLEAU_FILTER_STATS.get('technical_excluded', 0)} sono state escluse per i "
            f"motivi seguenti.</p>"
        )
        html.append("<table class='summary-table'>")
        html.append(
            "<tr><th>Motivo</th><th class='count'>Righe</th><th>Interpretazione</th></tr>"
        )
        for code, count in sorted(reasons.items(), key=lambda kv: (-kv[1], kv[0])):
            label = EXCLUSION_LABELS.get(code, code)
            note = EXCLUSION_NOTES.get(code, "")
            html.append(
                f"<tr><td><strong>{label}</strong> <span class='meta'>({code})</span></td>"
                f"<td class='count'>{count}</td><td class='meta'>{note}</td></tr>"
            )
        html.append("</table>")

    # === TABELLA RIEPILOGATIVA CASISTICHE (ordinate per frequenza decrescente) ===
    # Descrizioni + icone per ogni tipo di evento del webhook
    eventi_info = {
        "AUTO_FILL":       {"icon": "✅", "label": "AUTO_FILL SL/TP Charter",      "tipo": "success"},
        "P007_OK":         {"icon": "✅", "label": "P007 regime EMA50 OK",         "tipo": "success"},
        "ORDER_OK":        {"icon": "✅", "label": "Ordine inviato a Bybit",       "tipo": "success"},
        "TRADING_STOP_OK": {"icon": "✅", "label": "Trailing stop impostato",      "tipo": "success"},
        "P007_SKIP":       {"icon": "⚠️", "label": "P007 SKIP regime (close vs EMA50)", "tipo": "skip"},
        "ZONA_MID_SKIP":   {"icon": "⚠️", "label": "ZONA-MID SKIP (entry 30-70% range)", "tipo": "skip"},
        "P008_SKIP":       {"icon": "⚠️", "label": "P008 SKIP max 5 posizioni [DISATTIVATO 20/07]", "tipo": "skip"},
        "P006_OVERRIDE":   {"icon": "🔧", "label": "P006 OVERRIDE (SL clamp -3% Charter)", "tipo": "override"},
        "P005_TP_OVERRIDE":{"icon": "🔧", "label": "P005 TP OVERRIDE (legacy)",   "tipo": "override"},
        "EMAIL_FAIL":      {"icon": "🛑", "label": "Email notifica fallita",       "tipo": "error"},
    }
    # Aggrega tutti gli eventi in lista (tipo, count)
    eventi_list = []
    for evt_type, info in eventi_info.items():
        cnt = len(webhook_events.get(evt_type, []))
        if cnt > 0 or evt_type in ("P007_SKIP", "ZONA_MID_SKIP", "P008_SKIP", "P006_OVERRIDE"):  # mostra sempre gli skip noti anche se 0
            eventi_list.append((evt_type, info, cnt))
    # Ordina per count decrescente
    eventi_list.sort(key=lambda x: -x[2])
    max_count = max((c for _, _, c in eventi_list), default=1) or 1

    html.append("<h2>📊 Casistiche (ordinate per frequenza)</h2>")
    html.append("<table class='summary-table'>")
    html.append("<tr><th>#</th><th>Casistica</th><th>Tipo</th><th class='count'>Count</th><th class='bar-cell'>% del massimo</th></tr>")
    for rank, (evt_type, info, cnt) in enumerate(eventi_list, start=1):
        tipo_color = {"success": "#51cf66", "skip": "#ff6b6b", "override": "#ffd93d", "error": "#ff006e"}.get(info["tipo"], "#aaa")
        bar_pct = (cnt / max_count * 100) if max_count > 0 else 0
        bar_class = "bar" if cnt > 0 else "bar zero"
        bar_style = f"width: {bar_pct:.1f}%;" if cnt > 0 else "width: 4px;"
        html.append(f"<tr>")
        html.append(f"<td class='rank'>{rank}</td>")
        html.append(f"<td>{info['icon']} <strong>{info['label']}</strong> <span class='meta'>({evt_type})</span></td>")
        html.append(f"<td><span style='color:{tipo_color}; font-weight:bold;'>{info['tipo'].upper()}</span></td>")
        html.append(f"<td class='count'>{cnt}</td>")
        html.append(f"<td class='bar-cell'><div class='{bar_class}' style='{bar_style}'></div></td>")
        html.append(f"</tr>")
    html.append("</table>")

    # Per strategy
    html.append("<h2>📈 Operazioni per strategia</h2>")
    html.append("<table><tr><th>Strategia</th><th>Operazioni</th><th>Notional USDT</th></tr>")
    for strat, data in sorted(by_strategy.items(), key=lambda x: -x[1]["count"]):
        html.append(f"<tr><td><span class='badge badge-{strat}'>{strat.upper()}</span></td><td>{data['count']}</td><td>${data['notional']:,.0f}</td></tr>")
    html.append("</table>")

    # Per symbol
    html.append("<h2>💎 Operazioni per asset</h2>")
    html.append("<table><tr><th>Symbol</th><th>Operazioni</th><th>Notional USDT</th></tr>")
    for sym, data in sorted(by_symbol.items(), key=lambda x: -x[1]["notional"]):
        html.append(f"<tr><td>{sym}</td><td>{data['count']}</td><td>${data['notional']:,.0f}</td></tr>")
    html.append("</table>")

    # Lista operazioni con commenti (ultime 100)
    html.append("<h2>📋 Trade reali recenti dal punto zero con commenti automatici</h2>")
    html.append("<table>")
    html.append("<tr><th>Timestamp</th><th>Symbol</th><th>Side</th><th>Qty</th><th>Price</th><th>Notional</th><th>Strategy</th><th>Order ID</th></tr>")
    for op in sorted(orders, key=lambda x: x["ts"], reverse=True)[:100]:
        side_class = "badge-long" if op["side"].lower() in ("buy", "long") else "badge-short"
        strat_class = f"badge-{op['strategy']}"
        html.append(f"<tr>")
        html.append(f"<td class='op-timestamp'>{op['ts'][:19]}</td>")
        html.append(f"<td>{op['symbol']}</td>")
        html.append(f"<td><span class='badge {side_class}'>{op['side'].upper()}</span></td>")
        html.append(f"<td>{op['qty']:.4f}</td>")
        html.append(f"<td>{op['price']:.5f}</td>")
        html.append(f"<td>${op['notional']:.2f}</td>")
        html.append(f"<td><span class='badge {strat_class}'>{op['strategy'].upper()}</span></td>")
        html.append(f"<td class='meta'>{op['order_id'][:8]}...</td>")
        html.append(f"</tr>")
        # Riga commento migliorato
        comment = build_comment(op, webhook_events, rett_segnali, market_context)
        # Evidenzia "OPERATIVO" e "CONTESTO" come strong
        comment_html = comment.replace("OPERATIVO:", "<strong>OPERATIVO:</strong>").replace("CONTESTO:", "<br><strong>CONTESTO:</strong>").replace("⚠️ ATTENZIONE", '<span class="warning">⚠️ ATTENZIONE</span>')
        html.append(f"<tr><td colspan='8'><div class='comment'>💬 {comment_html}</div></td></tr>")
    html.append("</table>")

    # Skip events recenti
    html.append("<h2>⚠️ Skip events (ultimi 50)</h2>")
    html.append("<table>")
    html.append("<tr><th>Tipo</th><th>Symbol</th><th>Side</th><th>Dettaglio</th></tr>")
    skip_count = 0
    for evt_type in ["P007_SKIP", "P008_SKIP", "ZONA_MID_SKIP", "P006_OVERRIDE"]:
        for e in webhook_events.get(evt_type, []):
            if skip_count >= 50:
                break
            match = e["match"]
            html.append(f"<tr><td><span class='badge badge-short'>{evt_type}</span></td>")
            html.append(f"<td>{match[0]}</td>")
            html.append(f"<td>{match[1] if len(match) > 1 else '-'}</td>")
            html.append(f"<td class='meta'>{e['line'][:200]}</td></tr>")
            skip_count += 1
    html.append("</table>")

    # Footer
    html.append(f"<p class='meta'>Generato da generate_tableau.py | Tableau operativo filtrato dal punto zero | VPTR3 + Rettangolo</p>")
    html.append("</body></html>")
    return "\n".join(html)


def build_snapshot(orders: list, webhook_events: dict, market_context: dict) -> dict:
    """Snapshot standardizzato di UN account (contratto per il confronto).

    Regola strutturale: una pagina = un account. Lo snapshot dichiara sempre il
    proprio perimetro, cosi' un consumatore non puo' sommare due account per
    distrazione: i numeri arrivano gia' etichettati e con l'unita' di misura
    esplicitata (questo Tableau conta APERTURE, non chiusure).
    """
    stats = TABLEAU_FILTER_STATS
    by_strategy: dict = defaultdict(lambda: {"count": 0, "notional": 0.0})
    by_asset: dict = defaultdict(lambda: {"count": 0, "notional": 0.0})
    # I log contengono la stessa strategia in grafi diversi (es. MA_TRAILING e
    # ma_trailing). Se le chiavi restassero case-sensitive lo snapshot avrebbe
    # due voci per la stessa strategia — e alcuni consumatori (PowerShell,
    # Excel) rifiutano un JSON con chiavi che collidono ignorando il caso.
    spellings: dict = defaultdict(set)
    total_notional = 0.0
    for op in orders:
        try:
            notional = float(op.get("notional", 0) or 0)
        except (TypeError, ValueError):
            notional = 0.0
        raw_strat = str(op.get("strategy", "")).strip()
        strat = raw_strat.upper()
        if raw_strat:
            spellings[strat].add(raw_strat)
        asset = str(op.get("symbol", "")).strip().upper()
        by_strategy[strat]["count"] += 1
        by_strategy[strat]["notional"] += notional
        by_asset[asset]["count"] += 1
        by_asset[asset]["notional"] += notional
        total_notional += notional

    timestamps = sorted(str(op.get("ts", "")) for op in orders if op.get("ts"))

    return {
        "schema_version": SNAPSHOT_SCHEMA_VERSION,
        "account": ACCOUNT,
        "account_label": ACCOUNT_LABEL,
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "perimeter": {
            "declared": True,
            "unit": "APERTURE",
            "live_zero_utc": LIVE_ZERO_UTC.isoformat(),
            "live_zero_label": LIVE_ZERO_IT_LABEL,
            "strategy_whitelist": list(STRATEGY_WHITELIST),
            "strategy_list_label": STRATEGY_LIST_LABEL,
            "entry_suffixes": list(ENTRY_SUFFIXES),
            "charter_notional": TARGET_NOTIONAL,
        },
        "totals": {
            "apertures": len(orders),
            "notional_total": round(total_notional, 2),
            "notional_avg": round(total_notional / len(orders), 2) if orders else 0.0,
            "distinct_assets": len(by_asset),
            "first_order_utc": timestamps[0] if timestamps else None,
            "last_order_utc": timestamps[-1] if timestamps else None,
        },
        "by_strategy": {
            k: {"count": v["count"], "notional": round(v["notional"], 2)}
            for k, v in sorted(by_strategy.items())
        },
        # Varianti grafiche della stessa strategia (es. MA_TRAILING / ma_trailing):
        # utili per capire da dove arriva una discrepanza di conteggio.
        "strategy_spellings": {
            k: sorted(v) for k, v in sorted(spellings.items()) if len(v) > 1
        },
        "by_asset": {
            k: {"count": v["count"], "notional": round(v["notional"], 2)}
            for k, v in sorted(by_asset.items())
        },
        "exclusions": {
            "raw_post_zero": stats.get("raw_post_zero", 0),
            "technical_excluded": stats.get("technical_excluded", 0),
            "duplicate_excluded": stats.get("duplicate_excluded", 0),
            "by_reason": dict(stats.get("exclusion_reasons") or {}),
        },
        "compliance_events": {name: len(items) for name, items in webhook_events.items()},
        "market_context_symbols": len(market_context or {}),
        "sources": {
            "orders_log": str(ORDERS_LOG),
            "webhook_log": str(WEBHOOK_LOG),
            "orders_log_size": ORDERS_LOG.stat().st_size if ORDERS_LOG.exists() else 0,
            "webhook_log_size": WEBHOOK_LOG.stat().st_size if WEBHOOK_LOG.exists() else 0,
        },
    }


def write_snapshot(snapshot: dict) -> Path:
    """Scrittura atomica: chi legge non deve mai trovare un JSON troncato."""
    SNAPSHOT_FILE.parent.mkdir(parents=True, exist_ok=True)
    tmp = SNAPSHOT_FILE.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(snapshot, indent=2, ensure_ascii=False), encoding="utf-8")
    os.replace(tmp, SNAPSHOT_FILE)
    return SNAPSHOT_FILE


def run_once(bybit=None) -> tuple[int, int, int]:
    """Esegue UN solo ciclo: parsing, HTML e snapshot. Ritorna (ops, eventi, simboli).

    Esiste per due motivi:
      * permette di eseguire il generatore da cron (anche come utente NON root),
        senza dover installare una unit systemd;
      * rende testabile un singolo ciclo senza avviare un loop infinito.

    Se il client Bybit non e' disponibile il contesto di mercato viene saltato: il
    Tableau resta utile (aperture, compliance, esclusioni) invece di non essere
    prodotto affatto.
    """
    orders = parse_orders_log()
    webhook_events = parse_webhook_log()
    rett_segnali = parse_rettangolo_log()
    market_context: dict = {}
    if bybit is not None:
        try:
            market_context = get_market_context(bybit)
        except Exception as exc:  # il contesto e' un extra, non un requisito
            log.warning("contesto di mercato non disponibile: %s", exc)
            market_context = {}

    html = generate_html(orders, webhook_events, rett_segnali, market_context)
    # Scrittura ATOMICA: prima si troncava il file e poi lo si riscriveva, quindi
    # un lettore nel mezzo riceveva una pagina incompleta.
    OUTPUT_HTML.parent.mkdir(parents=True, exist_ok=True)
    tmp_html = OUTPUT_HTML.with_suffix(".html.tmp")
    with open(tmp_html, "w", encoding="utf-8") as f:
        f.write(html)
    os.replace(tmp_html, OUTPUT_HTML)
    # Snapshot standardizzato: e' il contratto che permette il confronto fra
    # account senza mai mescolare le sorgenti.
    write_snapshot(build_snapshot(orders, webhook_events, market_context))
    return len(orders), sum(len(v) for v in webhook_events.values()), len(market_context)


def main_loop():
    log.info("=== TRADE JOURNAL STARTED (Mavis 2026-07-20) ===")
    log.info("Output: %s | Poll: %ds", OUTPUT_HTML, POLL_SECONDS)
    with open(PID_FILE, "w") as f:
        f.write(str(os.getpid()))

    # Inizializza client Bybit per query market context live. Se non e'
    # disponibile, o se il profilo lo esclude, si prosegue senza: meglio un
    # Tableau senza contesto di mercato che nessun Tableau.
    bybit = None
    if SKIP_MARKET_CONTEXT:
        log.info("contesto di mercato disattivato (TABLEAU_SKIP_MARKET_CONTEXT)")
    else:
        try:
            from bybit_demo_client import BybitDemoClient
            bybit = BybitDemoClient()
        except Exception as exc:
            log.warning("client Bybit non disponibile, proseguo senza contesto di mercato: %s", exc)
            bybit = None

    while True:
        try:
            ops, events, symbols = run_once(bybit)
            log.info("Tableau aggiornato: %d operazioni, %d skip events, %d symbols contesto",
                     ops, events, symbols)
        except Exception as e:
            log.error("Errore generazione tableau: %s", e)
        time.sleep(POLL_SECONDS)


if __name__ == "__main__":
    # `--once` esegue un solo ciclo ed esce: serve per cron (anche come utente
    # non root) e per verifiche rapide. Senza il flag resta il loop storico.
    if "--once" in sys.argv[1:]:
        log.info("=== TABLEAU ONE-SHOT (profilo %s) ===", ACCOUNT)
        try:
            bybit = None
            if SKIP_MARKET_CONTEXT:
                log.info("contesto di mercato disattivato (TABLEAU_SKIP_MARKET_CONTEXT)")
            else:
                try:
                    from bybit_demo_client import BybitDemoClient
                    bybit = BybitDemoClient()
                except Exception as exc:
                    log.warning("client Bybit non disponibile: %s", exc)
            ops, events, symbols = run_once(bybit)
            log.info("Tableau aggiornato: %d operazioni, %d skip events, %d symbols contesto",
                     ops, events, symbols)
            print(f"OK account={ACCOUNT} aperture={ops} eventi={events} simboli={symbols}")
            sys.exit(0)
        except Exception as exc:
            log.error("Errore generazione tableau (one-shot): %s", exc)
            print(f"ERRORE: {exc}")
            sys.exit(1)

    try:
        main_loop()
    except KeyboardInterrupt:
        log.info("STOPPED")
        sys.exit(0)
