"""
Compare TradingView Trade List vs Python Backtest Signals
=========================================================

Robust TV/Python parity diagnostic tool. Loads a TradingView trade-list
export, runs the Python strategy with the same params, matches trades using
timeframe-aware timestamps (TV fills at open[T+1]; Python signals fire at
bar-close T), then classifies each divergence by root cause.

Usage
-----
    # Basic comparison — auto-detects TF from data filename
    python tools/compare_tv_trades.py \\
        --tv-trades data/ActivationScores_COINBASE_BTCUSD_2026-03-26.csv \\
        --data data/COINBASE_BTCUSD-4H.csv \\
        --params results/winners/optimization_winner_activation_scores_COINBASE_BTCUSD_4H.csv

    # MLP strategy comparison
    python tools/compare_tv_trades.py \\
        --strategy-file strategy_mlp_scores.py \\
        --tv-trades data/MLPScores_COINBASE_BTCUSD_2026-06-17.csv \\
        --data data/COINBASE_BTCUSD-6H.csv \\
        --params results/winners/optimization_winner_strategy_mlp_scores_COINBASE_BTCUSD_6H.csv

    # Verbose: show score context for every divergent trade
    python tools/compare_tv_trades.py ... --verbose

    # Show exit-detail tables for entry-matched but exit-mismatched trades
    python tools/compare_tv_trades.py ... --exit-detail

    # Override timeframe (if not inferrable from filename)
    python tools/compare_tv_trades.py ... --timeframe 4H

Timestamp offset note
---------------------
Pine fills at open[T+1], so TV records entry at time[T+1] while Python
records the signal at time[T].  For a 4H chart this is a 4-hour forward
offset.  The tool compensates automatically: when matching trades it
computes  TV_entry_time - TF_offset ≈ Python_signal_time.
Tolerance is ±2 bars by default (adjustable with --tolerance-bars).

Root-cause classification
-------------------------
  SCORE_DIFF    : Python score vs TV exported score differ near crossunder
  CASCADE       : Python state diverged from TV after a prior exit mismatch
  TRAILING_STOP : Same entry, exit diverges (trailing stop fired differently)
  EXIT_CONF     : Exit confirmation threshold caused a 1-bar timing difference
  UNKNOWN       : Cannot determine without TV score export on those specific bars
"""

import argparse
import importlib.util
import json
import os
import sys
from pathlib import Path
from typing import Optional

import numpy as np
import pandas as pd

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import SCORE_START, TRAIN_END, TRAIN_START

sys.path.append(str(Path(__file__).resolve().parent.parent))

# ── Commission model ─────────────────────────────────────────────────────────
# Must match the Pine strategy (commission_value=0.5 → 0.5%/side) and
# strategies/validate_strategy.py COMMISSION_RATE. TV's per-trade "Net PnL %" is
# already commission-inclusive, so Python P&L must pay the same to compare
# apples-to-apples. Otherwise the constant ~1%/trade offset compounds into a
# misleading multi-x cumulative gap (e.g. BTC 6H: 7,649% gross vs 2,553% TV).
COMMISSION_RATE = 0.005  # 0.5% per side


def net_pnl_pct(entry_px: float, exit_px: float,
                commission_rate: float = COMMISSION_RATE) -> float:
    """Per-trade P&L % net of commission — identical model to
    strategies/validate_strategy.py: pay commission_rate on the entry notional
    and on the exit notional (0.5%/side default)."""
    eff_entry = entry_px * (1 + commission_rate)
    eff_exit  = exit_px * (1 - commission_rate)
    return (eff_exit - eff_entry) / eff_entry * 100


# ── Timeframe helpers ────────────────────────────────────────────────────────

TF_DURATIONS = {
    '4H':  pd.Timedelta(hours=4),
    '6H':  pd.Timedelta(hours=6),
    '8H':  pd.Timedelta(hours=8),
    '12H': pd.Timedelta(hours=12),
    '1D':  pd.Timedelta(days=1),
}


_MINUTES_TO_TF = {'240': '4H', '360': '6H', '480': '8H', '720': '12H'}


def detect_timeframe(data_path: str) -> Optional[str]:
    """Infer timeframe from the data CSV filename.

    Handles both dash/underscore-suffix forms (COINBASE_BTCUSD-4H.csv) and
    the TV minute-based export form (COINBASE_BTCUSD, 360.csv).
    """
    name = Path(data_path).stem.upper()
    for tf in TF_DURATIONS:
        if name.endswith(f'-{tf}') or name.endswith(f'_{tf}'):
            return tf
    # minute-based TV export filenames: "ASSET, 360" → 6H
    for mins, tf in _MINUTES_TO_TF.items():
        if name.endswith(f', {mins}'):
            return tf
    # "ASSET, 1D"
    if name.endswith(', 1D'):
        return '1D'
    return None


def tf_offset(tf_str: str) -> pd.Timedelta:
    return TF_DURATIONS.get(tf_str, pd.Timedelta(hours=4))


# ── TV trade loader ───────────────────────────────────────────────────────────

def load_tv_trades(path: str) -> list[dict]:
    """
    Parse a TradingView exported trade-list CSV.

    TV exports rows in order: Exit first, then Entry for each trade.
    Returns a list of dicts with keys:
        trade_num, entry_dt, exit_dt, entry_price, exit_price,
        net_pnl_pct, exit_signal, is_margin_call
    sorted by entry_dt.
    """
    df = pd.read_csv(path, encoding='utf-8-sig')
    df.columns = df.columns.str.strip()

    def col(*names: str) -> str:
        for name in names:
            if name in df.columns:
                return name
        raise KeyError(f"None of these columns are present in {path}: {', '.join(names)}")

    trade_col = col('Trade #', 'Trade number', 'Trade Number')
    type_col = col('Type')
    time_col = col('Date and time', 'Date/Time', 'Date')
    price_col = col('Price USD', 'Price')
    pnl_col = col('Net P&L %', 'Net PnL %', 'Net profit %')
    signal_col = next((name for name in ('Signal', 'Order') if name in df.columns), None)

    raw: dict[int, dict] = {}
    for _, row in df.iterrows():
        try:
            num = int(row[trade_col])
        except (ValueError, KeyError):
            continue
        t_type = str(row.get(type_col, '')).strip().lower()
        if num not in raw:
            raw[num] = {}
        if 'entry' in t_type:
            raw[num]['entry'] = row
        elif 'exit' in t_type:
            raw[num]['exit'] = row

    trades = []
    for num in sorted(raw):
        t = raw[num]
        if 'entry' not in t or 'exit' not in t:
            continue
        e_row = t['entry']
        x_row = t['exit']
        try:
            entry_dt  = pd.Timestamp(str(e_row[time_col]))
            exit_dt   = pd.Timestamp(str(x_row[time_col]))
            entry_px  = float(e_row[price_col])
            exit_px   = float(x_row[price_col])
            pnl_pct   = float(x_row[pnl_col])
            signal    = str(x_row.get(signal_col, '')).strip() if signal_col else ''
        except Exception:
            continue
        trades.append({
            'trade_num':    num,
            'entry_dt':     entry_dt,
            'exit_dt':      exit_dt,
            'entry_price':  entry_px,
            'exit_price':   exit_px,
            'net_pnl_pct':  pnl_pct,
            'exit_signal':  signal,
            'is_margin':    'margin' in signal.lower(),
        })
    return trades


# ── Python strategy runner ────────────────────────────────────────────────────

def strategy_stem(strategy_file: str) -> str:
    """Return winner-file stem for a strategy filename.

    Examples:
      strategy_activation_scores.py -> activation_scores
      strategy_mlp_scores.py        -> mlp_scores
    """
    stem = Path(strategy_file).stem
    return stem.removeprefix('strategy_')


def load_params(params_path: str) -> dict:
    if not params_path or not os.path.exists(params_path):
        return {}
    if params_path.endswith('.csv'):
        row = pd.read_csv(params_path).iloc[0].to_dict()
        # Drop non-param stat columns
        drop = {'Sharpe Ratio', 'Sortino Ratio', 'Calmar Ratio', 'Total Trades',
                'Total P&L %', 'P&L/DD Ratio', 'Max Drawdown %', '% In Market',
                'verified', 'GPU_Score', 'Composite', 'CPU_Score', '_pnl_dd_percentile'}
        return {k: v for k, v in row.items() if k not in drop}
    if params_path.endswith('.json'):
        with open(params_path) as f:
            raw = json.load(f)
        # JSON may be a params-range file (start/stop/step) or flat dict
        flat = {}
        for k, v in raw.items():
            if isinstance(v, dict):
                flat[k] = v.get('values', [None])[0] if 'values' in v else v.get('start', 0)
            else:
                flat[k] = v
        return flat
    return {}


def run_python_strategy(data_path: str, params: dict,
                        strategy_file: str = 'strategy_mlp_scores.py') -> pd.DataFrame:
    """Load data CSV, run generate_signals, return signals DataFrame."""
    strategy_path = Path(strategy_file)
    if not strategy_path.is_absolute():
        strategy_path = Path(__file__).resolve().parent.parent / 'strategies' / strategy_file
    if not strategy_path.exists():
        raise FileNotFoundError(f'Strategy file not found: {strategy_path}')

    spec = importlib.util.spec_from_file_location('strategy_module', strategy_path)
    mod = importlib.util.module_from_spec(spec)
    sys.modules['strategy_module'] = mod
    spec.loader.exec_module(mod)

    df = pd.read_csv(data_path)
    df.columns = df.columns.str.lower().str.strip()
    df['time'] = pd.to_datetime(df['time'], utc=True).dt.tz_localize(None)

    # Keep full history so crossunder state is correct — Pine also sees all bars for scoring,
    # but only executes trades after its startDate (timeCondition gate).
    # We filter the resulting trade list to startDate below, not the input data.

    signals = mod.generate_signals(df, **params)
    signals.attrs['strategy_stem'] = strategy_stem(strategy_file)
    return signals


def tv_score_column(signals: pd.DataFrame) -> Optional[str]:
    """Return the TV-exported score column present in a signal/data frame."""
    strategy = signals.attrs.get('strategy_stem')
    if strategy == 'mlp_scores':
        candidates = ('mlp_score', 'mlp_score_poc')
    elif strategy == 'activation_scores':
        candidates = ('activation_score_poc', 'activation_scores_poc')
    else:
        candidates = ('activation_score_poc', 'activation_scores_poc',
                      'mlp_score', 'mlp_score_poc')
    for col in candidates:
        if col in signals.columns:
            return col
    return None


def tv_score_value(row: pd.Series, signals: pd.DataFrame) -> float:
    col = tv_score_column(signals)
    if col is None:
        return float('nan')
    return row.get(col, float('nan'))


def _param_bool(value) -> bool:
    if isinstance(value, str):
        return value.strip().lower() in {'1', 'true', 'yes', 'y', 'on'}
    return bool(value)


def _replay_positions(
    entries: np.ndarray,
    exits: np.ndarray,
    close_arr: np.ndarray,
    high_arr: np.ndarray,
    trail_stop_pct: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Replay Pine's flat/long lifecycle from raw conditions."""
    n = len(entries)
    in_pos = np.zeros(n, dtype=np.int32)
    exec_entry = np.zeros(n, dtype=bool)
    exec_exit = np.zeros(n, dtype=bool)

    position = False
    trade_high = 0.0
    fill_bar = -1

    for i in range(n):
        if position:
            trail_hit = False
            if trail_stop_pct > 0.0:
                if i != fill_bar and high_arr[i] > trade_high:
                    trade_high = high_arr[i]
                trail_hit = close_arr[i] <= trade_high * (1.0 - trail_stop_pct)

            if exits[i] or trail_hit:
                position = False
                exec_exit[i] = True
                trade_high = 0.0
                fill_bar = -1
        else:
            if entries[i]:
                position = True
                exec_entry[i] = True
                trade_high = close_arr[i]
                fill_bar = i + 1
        in_pos[i] = 1 if position else 0

    return in_pos, exec_entry, exec_exit


def apply_pine_time_gate(
    signals: pd.DataFrame,
    params: dict,
    start: pd.Timestamp,
    end: Optional[pd.Timestamp] = None,
) -> pd.DataFrame:
    """Rebuild executed trades with Pine's startDate/endDate gate.

    Strategy signal generation intentionally runs on full history so scores and
    crossunders have the same warm-up context as Pine. Pine still gates
    strategy.entry()/strategy.close() with `timeCondition`, so pre-start raw
    entries must not carry position state into the scoring window.
    """
    required = {'time', 'activation_score', 'close', 'high'}
    if not required.issubset(signals.columns):
        return signals

    df = signals.copy()
    if signals.attrs.get('strategy_stem') == 'mlp_scores':
        from strategies.strategy_mlp_scores import _score_to_signals

        replay_params = dict(params)
        replay_params['_pine_time_start'] = start
        replay_params['_pine_time_end'] = end
        stoch_peak = pd.to_numeric(
            df.get('stoch_peak_norm', pd.Series(0.0, index=df.index)),
            errors='coerce',
        ).fillna(0.0).to_numpy(dtype=np.float64)
        replayed = _score_to_signals(
            df,
            replay_params,
            stoch_peak,
            df['close'].to_numpy(dtype=np.float64),
            df['high'].to_numpy(dtype=np.float64),
        )
        replayed.attrs.update(signals.attrs)
        return replayed

    score = df['activation_score']
    entry_threshold = float(params.get('i_long_entry_activation_threshold', 106.0))
    exit_threshold = float(params.get('i_long_exit_activation_threshold', 140.5))
    exit_conf_threshold = float(params.get('i_long_exit_activation_confirmation_threshold', 32.4125))
    use_exit_conf = _param_bool(params.get('i_use_long_exit_confirmation', True))
    use_entry_conf = _param_bool(params.get('i_use_long_entry_confirmation', False))

    if use_exit_conf:
        exit_raw = (
            (score.shift(2) >= exit_threshold) &
            (score.shift(1) < exit_threshold) &
            (score < exit_conf_threshold)
        )
    else:
        stoch_peak = pd.Series(False, index=df.index)
        if 'stoch_peak_norm' in df.columns:
            stoch_peak = df['stoch_peak_norm'].fillna(0.0) != 0.0
        exit_raw = (
            (score.shift(2) >= exit_threshold) &
            (score.shift(1) < exit_threshold) &
            stoch_peak
        )

    if use_entry_conf:
        entry_raw = (
            (score.shift(2) >= entry_threshold) &
            (score.shift(1) < entry_threshold) &
            (score > score.shift(1)) &
            ~exit_raw.shift(1, fill_value=False)
        )
    else:
        entry_raw = (score.shift(1) >= entry_threshold) & (score < entry_threshold)

    regime_window = int(params.get('i_regime_window', 0))
    regime_min = float(params.get('i_regime_entry_min_score', -1000.0))
    if regime_window > 0:
        regime_ok = score.rolling(window=regime_window, min_periods=1).mean() > regime_min
    else:
        regime_ok = pd.Series(True, index=df.index)

    if _param_bool(params.get('i_mvrv_suppress_bear', False)) and 'mvrv_regime' in df.columns:
        regime_ok = regime_ok & (df['mvrv_regime'] >= 0)

    time = pd.to_datetime(df['time'])
    time_ok = time >= start
    if end is not None:
        time_ok = time_ok & (time <= end)

    entry_raw = (entry_raw & regime_ok & time_ok).fillna(False).to_numpy(dtype=bool)
    exit_raw = (exit_raw & time_ok).fillna(False).to_numpy(dtype=bool)
    trail_stop_pct = float(params.get('i_trailing_stop_threshold', 0.0)) / 100.0

    in_pos, exec_entry, exec_exit = _replay_positions(
        entry_raw,
        exit_raw,
        df['close'].to_numpy(dtype=np.float64),
        df['high'].to_numpy(dtype=np.float64),
        trail_stop_pct,
    )
    df['position'] = in_pos
    df['execute_entry'] = exec_entry
    df['execute_exit'] = exec_exit
    return df


def extract_python_trades(signals: pd.DataFrame) -> list[dict]:
    """Walk signals DataFrame and extract entry/exit pairs."""
    trades = []
    in_pos = False
    entry_dt = entry_px = entry_idx = None

    for idx, row in signals.iterrows():
        if not in_pos and row['execute_entry']:
            in_pos    = True
            entry_dt  = row['time']
            entry_px  = row['close']
            entry_idx = idx
        elif in_pos and row['execute_exit']:
            in_pos = False
            exit_dt  = row['time']
            exit_px  = row['close']
            gross_pnl = (exit_px - entry_px) / entry_px * 100
            trades.append({
                'entry_dt':      entry_dt,
                'exit_dt':       exit_dt,
                'entry_price':   entry_px,
                'exit_price':    exit_px,
                # pnl_pct is commission-aware (net) so it compares apples-to-apples
                # with TV's net_pnl_pct everywhere it is used; gross kept for ref.
                'pnl_pct':       net_pnl_pct(entry_px, exit_px),
                'gross_pnl_pct': gross_pnl,
                'entry_idx':     entry_idx,
                'exit_idx':      idx,
            })
            entry_dt = entry_px = entry_idx = None
    return trades


# ── Trade matching ────────────────────────────────────────────────────────────

def match_trades(
    tv_trades:   list[dict],
    py_trades:   list[dict],
    tf_str:      str,
    tolerance_bars: int = 2,
) -> tuple[list[tuple], list[dict], list[dict]]:
    """
    Match TV and Python trades by entry time.

    TV fills at open[T+1], so TV entry time = Python signal bar time + 1 bar.
    We subtract one TF offset from the TV entry time before comparing.

    Returns:
        matched       : list of (py_trade, tv_trade) pairs
        py_only       : Python trades with no TV match
        tv_only       : TV trades (non-margin) with no Python match
    """
    offset    = tf_offset(tf_str)
    tolerance = offset * tolerance_bars

    tv_real = [t for t in tv_trades if not t['is_margin']]
    available_tv = list(tv_real)
    unmatched_py = list(py_trades)
    matched = []

    for py in py_trades:
        best     = None
        best_err = tolerance + pd.Timedelta(seconds=1)
        for tv in available_tv:
            # Normalise TV time by subtracting 1-bar offset
            tv_adj = tv['entry_dt'] - offset
            err = abs(py['entry_dt'] - tv_adj)
            if err <= tolerance and err < best_err:
                best     = tv
                best_err = err
        if best is not None:
            matched.append((py, best))
            available_tv.remove(best)
            if py in unmatched_py:
                unmatched_py.remove(py)

    return matched, unmatched_py, available_tv


# ── TV chart-marker consistency ───────────────────────────────────────────────

def _marker_mask(signals: pd.DataFrame, *columns: str) -> Optional[pd.Series]:
    """Return truthy marker mask for any available exported Data Window column."""
    masks = []
    for col in columns:
        if col not in signals.columns:
            continue
        marker = pd.to_numeric(signals[col], errors='coerce').fillna(0.0) != 0.0
        masks.append(marker)
    if not masks:
        return None
    out = masks[0].copy()
    for mask in masks[1:]:
        out = out | mask
    return out


def check_tv_entry_markers(
    tv_trades: list[dict],
    signals: pd.DataFrame,
    tf_str: str,
    start: pd.Timestamp,
    end: Optional[pd.Timestamp] = None,
) -> Optional[dict]:
    """Compare Strategy Tester entries with exported Pine entry markers.

    The TV trade list records fills one bar after the Pine signal. The chart
    export records the signal-bar `Entry (...)` marker. When these disagree,
    trade-list parity is not trustworthy even if Python and chart scores match.
    """
    entry_marker = _marker_mask(signals, 'entry (standard)', 'entry (confirmed)')
    if entry_marker is None or 'time' not in signals.columns:
        return None

    missed_entry = _marker_mask(signals, 'missed entry')
    if missed_entry is None:
        missed_entry = pd.Series(False, index=signals.index)

    real_tv = [t for t in tv_trades if not t['is_margin']]
    offset = tf_offset(tf_str)
    signal_times = pd.to_datetime(signals['time'])
    time_to_idx = {ts: idx for idx, ts in zip(signals.index, signal_times)}

    tv_without_marker = []
    tv_signal_times = set()
    for tv in real_tv:
        signal_dt = tv['entry_dt'] - offset
        tv_signal_times.add(signal_dt)
        idx = time_to_idx.get(signal_dt)
        if idx is None:
            tv_without_marker.append((tv, signal_dt, 'no chart bar at adjusted entry time'))
        elif not bool(entry_marker.loc[idx]):
            tv_without_marker.append((tv, signal_dt, 'no exported Entry marker on adjusted entry bar'))

    chart_exec = entry_marker & ~missed_entry & (signal_times >= start)
    if end is not None:
        chart_exec = chart_exec & (signal_times <= end)
    chart_exec_times = set(signal_times[chart_exec])
    chart_without_tv = sorted(chart_exec_times - tv_signal_times)

    return {
        'available': True,
        'tv_count': len(real_tv),
        'tv_with_marker': len(real_tv) - len(tv_without_marker),
        'tv_without_marker': tv_without_marker,
        'chart_exec_count': len(chart_exec_times),
        'chart_without_tv': chart_without_tv,
    }


# ── Score context helpers ─────────────────────────────────────────────────────

def score_context(signals: pd.DataFrame, bar_idx: int, context: int = 5) -> str:
    """Return a compact table of activation scores around bar_idx."""
    lo = max(0, bar_idx - context)
    hi = min(len(signals) - 1, bar_idx + context)
    rows = signals.iloc[lo:hi + 1]
    tv_col = tv_score_column(signals)
    tv_label = tv_col or 'tv_score'

    lines = [f"  {'bar':>4}  {'time':>19}  {'score':>9}  {tv_label:>12}  {'entry':>5}  {'exit':>5}"]
    lines.append('  ' + '-' * 68)
    for i, (ridx, row) in enumerate(rows.iterrows()):
        marker = ' <<<' if ridx == bar_idx else ''
        tv_score = tv_score_value(row, signals)
        tv_score_s = f"{tv_score:.2f}" if not np.isnan(tv_score) else '   n/a'
        score_v = f"{row['activation_score']:.2f}" if 'activation_score' in signals.columns else '   n/a'
        entry_f = 'E' if row.get('execute_entry', False) else '.'
        exit_f  = 'X' if row.get('execute_exit',  False) else '.'
        lines.append(
            f"  {ridx:>4}  {str(row['time'])[:19]:>19}  {score_v:>9}  {tv_score_s:>12}  {entry_f:>5}  {exit_f:>5}{marker}"
        )
    return '\n'.join(lines)


def classify_tv_only(tv_trade: dict, signals: pd.DataFrame, entry_threshold: float,
                     tf_str: str) -> str:
    """
    Try to explain why Python missed a TV entry.
    Looks for the Python bar corresponding to TV entry - 1 bar.
    """
    offset = tf_offset(tf_str)
    py_signal_time = tv_trade['entry_dt'] - offset
    tol = offset * 2

    mask = (signals['time'] >= py_signal_time - tol) & (signals['time'] <= py_signal_time + tol)
    near = signals[mask]
    if near.empty:
        return 'UNKNOWN (no matching bar found in data)'

    idx = near.index[near['time'].sub(py_signal_time).abs().argmin()]
    row = signals.loc[idx]
    score_now  = row.get('activation_score', float('nan'))
    score_prev = signals.loc[idx - 1, 'activation_score'] if idx > 0 else float('nan')

    tv_score_now = tv_score_value(row, signals)
    score_diff  = abs(score_now - tv_score_now) if not np.isnan(tv_score_now) else float('nan')

    crossunder_py = (not np.isnan(score_prev)) and (score_prev >= entry_threshold) and (score_now < entry_threshold)

    parts = []
    if crossunder_py:
        parts.append('Python DID fire crossunder here (state diverged earlier?)')
    else:
        parts.append(f'Python score at signal bar: {score_now:.1f} (prev {score_prev:.1f}, threshold {entry_threshold})')
        if score_now >= entry_threshold:
            parts.append('→ Score never dropped below threshold (score difference vs TV)')
        else:
            parts.append('→ Score was already below threshold (no prior above-threshold bar)')

    if not np.isnan(score_diff) and score_diff > 5:
        parts.append(f'TV exported score={tv_score_now:.1f} vs Python score={score_now:.1f} (Δ={score_diff:.1f}) → SCORE_DIFF')

    return ' | '.join(parts)


def classify_py_only(py_trade: dict, signals: pd.DataFrame, entry_threshold: float,
                     use_entry_conf: bool = False) -> str:
    """Try to explain why Python fired an entry that TV didn't.

    With use_entry_conf=True the crossunder was at bars t-2→t-1; the entry
    fires at t (confirmation bar).  Without confirmation it is t-1→t.
    """
    idx = py_trade['entry_idx']
    has_score = 'activation_score' in signals.columns
    tv_col = tv_score_column(signals)
    has_tv_score = tv_col is not None

    parts = []

    if use_entry_conf and idx >= 2:
        # Actual crossunder bars: t-2 → t-1
        xprev = signals.loc[idx - 2, 'activation_score'] if has_score else float('nan')
        xcurr = signals.loc[idx - 1, 'activation_score'] if has_score else float('nan')
        xprev_tv = signals.loc[idx - 2, tv_col] if has_tv_score else float('nan')
        xcurr_tv = signals.loc[idx - 1, tv_col] if has_tv_score else float('nan')

        if not (np.isnan(xprev) or np.isnan(xcurr)):
            crossunder = (xprev >= entry_threshold) and (xcurr < entry_threshold)
            parts.append(
                f'Python crossunder (t-2→t-1): {xprev:.1f} → {xcurr:.1f}'
                f' (threshold {entry_threshold}): {"YES" if crossunder else "no crossunder?"}'
            )
        if not (np.isnan(xprev_tv) or np.isnan(xcurr_tv)):
            tv_xu = (xprev_tv >= entry_threshold) and (xcurr_tv < entry_threshold)
            parts.append(
                f'TV exported score (t-2→t-1): {xprev_tv:.1f} → {xcurr_tv:.1f}:'
                f' {"YES (TV should also fire)" if tv_xu else "NO (TV rightly quiet)"}'
            )
            if not np.isnan(xcurr) and abs(xcurr - xcurr_tv) > 5:
                parts.append(f'Score delta at crossunder bar: {abs(xcurr - xcurr_tv):.1f} → SCORE_DIFF')
    else:
        # Standard crossunder (no confirmation): t-1 → t
        score_now  = signals.loc[idx, 'activation_score'] if has_score else float('nan')
        score_prev = signals.loc[idx - 1, 'activation_score'] if idx > 0 and has_score else float('nan')
        tv_score   = signals.loc[idx, tv_col] if has_tv_score else float('nan')

        if not (np.isnan(score_prev) or np.isnan(score_now)):
            crossunder = (score_prev >= entry_threshold) and (score_now < entry_threshold)
            parts.append(
                f'Python crossunder: {score_prev:.1f} → {score_now:.1f}'
                f' (threshold {entry_threshold}): {"YES" if crossunder else "no crossunder?"}'
            )
        if not np.isnan(tv_score):
            tv_prev = signals.loc[idx - 1, tv_col] if idx > 0 else float('nan')
            tv_xu = (not np.isnan(tv_prev)) and (tv_prev >= entry_threshold) and (tv_score < entry_threshold)
            parts.append(
                f'TV exported score crossunder: {tv_prev:.1f} → {tv_score:.1f}:'
                f' {"YES (TV should also fire)" if tv_xu else "NO (TV rightly quiet)"}'
            )
            if not np.isnan(score_now) and abs(score_now - tv_score) > 5:
                parts.append(f'Score delta: {abs(score_now - tv_score):.1f} → SCORE_DIFF')

    if not parts:
        return 'UNKNOWN'
    return ' | '.join(parts)


def classify_exit_mismatch(py_trade: dict, tv_trade: dict, signals: pd.DataFrame,
                           trail_stop_pct: float, exit_threshold: float) -> str:
    """Classify why matched entry has different exit.

    Determines whether Python's exit was triggered by the trailing stop or by a
    score crossunder, by checking whether the 2-bar crossunder pattern is present
    at the Python exit bar.  Trailing stop and score-crossunder exits both call
    strategy.close("long") in Pine, so TV's trade list cannot distinguish them —
    only the Python-side patterns can.
    """
    py_exit = py_trade['exit_dt']
    tv_exit = tv_trade['exit_dt']
    diff    = (tv_exit - py_exit).total_seconds() / 3600

    parts = [f'Python exit {py_exit.date()} / TV exit {tv_exit.date()} (Δ {diff:+.0f}h)']
    parts.append(f'Python P&L {py_trade["pnl_pct"]:+.1f}% / TV P&L {tv_trade["net_pnl_pct"]:+.1f}%')

    exit_idx = py_trade['exit_idx']
    exit_row = signals.iloc[exit_idx] if exit_idx < len(signals) else None

    score = exit_row['activation_score'] if (exit_row is not None and 'activation_score' in signals.columns) else None

    # Determine if Python's exit was score-based (2-bar crossunder) or trailing-stop.
    # Score crossunder: score[T-2] >= exit_threshold AND score[T-1] < exit_threshold.
    # If that pattern holds at the Python exit bar, it's a score crossunder mismatch.
    is_score_crossunder = False
    if score is not None and exit_idx >= 2 and 'activation_score' in signals.columns:
        prev1 = signals.iloc[exit_idx - 1]['activation_score']  # score[T-1]
        prev2 = signals.iloc[exit_idx - 2]['activation_score']  # score[T-2]
        if prev2 >= exit_threshold and prev1 < exit_threshold:
            is_score_crossunder = True

    if is_score_crossunder:
        parts.append(f'Python exit bar score: {score:.1f} → SCORE_CROSSUNDER_MISMATCH')
        parts.append(
            f'TV and Python exited on different score-crossunder events. '
            f'If score parity passes, this is usually downstream of an earlier lifecycle/export mismatch.'
        )
    elif trail_stop_pct > 0:
        parts.append(f'Trailing stop {trail_stop_pct:.0f}% active → TRAILING_STOP')
        if score is not None:
            parts.append(f'Python exit bar score: {score:.1f}')
        parts.append(
            'Python trail_high uses bar high (same as Pine); stop triggered at close. '
            'Residual timing difference is intrinsic.'
        )
    else:
        parts.append(f'No trailing stop; exit_threshold={exit_threshold}')
        if score is not None:
            parts.append(f'Python exit bar score: {score:.1f} → EXIT_CONF')

    return ' | '.join(parts)


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description='Compare TradingView trade list vs Python backtest — TV parity diagnostic',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument('--tv-trades',  required=True,
                        help='TradingView exported trade-list CSV')
    parser.add_argument('--data',       required=True,
                        help='OHLCV+indicator data CSV (e.g. data/COINBASE_BTCUSD-4H.csv)')
    parser.add_argument('--params',     default=None,
                        help='Winner params CSV or JSON (auto-detected from data filename if omitted)')
    parser.add_argument('--strategy-file', default='strategy_mlp_scores.py',
                        help='Strategy Python file in strategies/ (default: strategy_mlp_scores.py)')
    parser.add_argument('--timeframe',  default=None,
                        help='Timeframe override (4H/6H/8H/12H/1D). Auto-detected from data filename.')
    parser.add_argument('--tolerance-bars', type=int, default=2,
                        help='Entry-match tolerance in bars (default: 2)')
    parser.add_argument('--verbose',    action='store_true',
                        help='Show score context table for every divergent trade')
    parser.add_argument('--score-context', type=int, default=4,
                        help='Bars of score context to show per divergence in verbose mode (default: 4)')
    parser.add_argument('--exit-detail', action='store_true',
                        help='Show detailed exit-mismatch tables for entry-matched trades')
    args = parser.parse_args()

    # ── Timeframe ──────────────────────────────────────────────────────────────
    tf_str = args.timeframe or detect_timeframe(args.data)
    if tf_str is None:
        print('[WARN] Could not detect timeframe from filename. Defaulting to 4H.')
        print('       Pass --timeframe 4H/6H/8H/12H/1D to override.')
        tf_str = '4H'
    print(f'Timeframe: {tf_str}  (TV fills 1 bar ahead = {tf_offset(tf_str)} offset)')

    # Data-integrity preflight: a deficient/truncated export makes trade-list
    # parity meaningless (and score parity won't catch it). Warn loudly; don't abort.
    try:
        from tools.validate_chart_data import validate_file
        _derrs, _dwarns, _ = validate_file(args.data)
        if _derrs:
            print('\n  ⚠️  CHART-DATA INTEGRITY ERRORS — parity results below may be invalid:')
            for _e in _derrs:
                print(f'       ERROR: {_e}')
            print('       → re-export full clean history (tools/validate_chart_data.py)\n')
    except Exception:  # noqa: BLE001
        pass

    # ── Params auto-detect ─────────────────────────────────────────────────────
    params_path = args.params
    if params_path is None:
        # Try to infer from data filename
        stem = Path(args.data).stem                      # e.g. COINBASE_BTCUSD-4H
        asset_tf = stem.replace('-', '_').upper()        # e.g. COINBASE_BTCUSD_4H
        strategy_name = strategy_stem(args.strategy_file)
        candidate = Path('results/winners') / f'optimization_winner_{strategy_name}_{asset_tf}.csv'
        if candidate.exists():
            params_path = str(candidate)
            print(f'Auto-detected params: {params_path}')
        else:
            print(f'[WARN] No params file found for strategy {strategy_name}. Using strategy defaults.')

    # ── Load & run ─────────────────────────────────────────────────────────────
    print(f'\nLoading TV trades from: {args.tv_trades}')
    tv_trades = load_tv_trades(args.tv_trades)
    tv_real   = [t for t in tv_trades if not t['is_margin']]
    tv_margin = [t for t in tv_trades if t['is_margin']]
    print(f'TV trades: {len(tv_real)} real signal  +  {len(tv_margin)} margin-call')

    params = load_params(params_path) if params_path else {}

    print(f'Running Python strategy {args.strategy_file} on: {args.data}')
    signals = run_python_strategy(args.data, params, strategy_file=args.strategy_file)
    pine_start = pd.Timestamp(SCORE_START)
    pine_end = pd.Timestamp(TRAIN_END)
    signals = apply_pine_time_gate(signals, params, start=pine_start, end=pine_end)
    all_py_trades = extract_python_trades(signals)
    # Filter to Pine's startDate (timeCondition gate) — TV never trades before this date.
    # Pine still computes scores on all history (for correct crossunder state), but
    # longCondition is gated by timeCondition, so pre-startDate crossunders don't fire.
    # SCORE_START is the single source of truth (config.py); matches Pine's startDate input.
    py_trades = [t for t in all_py_trades if t['entry_dt'] >= pine_start]
    print(
        f'Python trades: {len(py_trades)} '
        f'(Pine timeCondition {pine_start.date()} → {pine_end.date()}; '
        f'{len(all_py_trades)} total after time gate)'
    )

    # ── Key params ─────────────────────────────────────────────────────────────
    entry_threshold  = float(params.get('i_long_entry_activation_threshold', 106.0))
    exit_threshold   = float(params.get('i_long_exit_activation_threshold',  140.5))
    trail_stop_pct   = float(params.get('i_trailing_stop_threshold', 0.0))
    use_entry_conf   = params.get('i_use_long_entry_confirmation', False)
    use_exit_conf    = params.get('i_use_long_exit_confirmation', True)

    print(f'\nKey params:')
    print(f'  Entry threshold:  {entry_threshold}  (use_entry_conf={use_entry_conf})')
    print(f'  Exit threshold:   {exit_threshold}  (use_exit_conf={use_exit_conf})')
    print(f'  Trailing stop:    {trail_stop_pct}%')

    # ── Match ──────────────────────────────────────────────────────────────────
    matched, py_only, tv_only = match_trades(tv_trades, py_trades, tf_str, args.tolerance_bars)

    # Categorise matched: full match (exit within tolerance) vs exit mismatch
    exit_tolerance = tf_offset(tf_str) * args.tolerance_bars
    full_match, exit_mismatch = [], []
    for py, tv in matched:
        tv_exit_adj = tv['exit_dt'] - tf_offset(tf_str)
        if abs(py['exit_dt'] - tv_exit_adj) <= exit_tolerance:
            full_match.append((py, tv))
        else:
            exit_mismatch.append((py, tv))

    # IS/OOS split for Python-only: trades after the last TV trade date are OOS
    # (TV export ends there; Python continues firing signals beyond it — not a parity bug)
    last_tv_dt   = max(t['exit_dt'] for t in tv_real) if tv_real else pd.Timestamp('2099-01-01')
    py_only_is   = [p for p in py_only if p['entry_dt'] <= last_tv_dt]
    py_only_oos  = [p for p in py_only if p['entry_dt'] >  last_tv_dt]
    marker_check = check_tv_entry_markers(
        tv_trades,
        signals,
        tf_str,
        pine_start,
        last_tv_dt - tf_offset(tf_str) if tv_real else None,
    )

    W = 80
    print(f'\n{"="*W}')
    print(f'  MATCH SUMMARY')
    print(f'{"="*W}')
    print(f'  Total TV real trades  : {len(tv_real):>4}  (coverage through {last_tv_dt.date()})')
    print(f'  Total Python trades   : {len(py_trades):>4}')
    print(f'  Entry + exit matched  : {len(full_match):>4}  ({len(full_match)/max(len(tv_real),1)*100:.1f}% of TV)')
    print(f'  Entry matched, exit Δ : {len(exit_mismatch):>4}')
    oos_note = f'  (IS: {len(py_only_is)}, OOS after {last_tv_dt.date()}: {len(py_only_oos)} — OOS are expected, not bugs)'
    print(f'  Python-only (extra)   : {len(py_only):>4}{oos_note}')
    print(f'  TV-only (missed)      : {len(tv_only):>4}')
    if tv_margin:
        print(f'  TV margin-call trades : {len(tv_margin):>4}  (not modelled in Python)')
    print(f'{"="*W}')

    if marker_check is not None:
        tv_missing = marker_check['tv_without_marker']
        chart_missing = marker_check['chart_without_tv']
        if tv_missing or chart_missing or args.verbose:
            print(f'\n  TV/chart entry-marker check:')
            print(
                f'    TV entries with exported chart marker : '
                f'{marker_check["tv_with_marker"]}/{marker_check["tv_count"]}'
            )
            print(
                f'    Chart executable markers in TV window : '
                f'{marker_check["chart_exec_count"]}  '
                f'({len(chart_missing)} not present in TV trade list)'
            )
            if tv_missing:
                print('    First TV entries without chart marker:')
                for tv, signal_dt, reason in tv_missing[:5]:
                    print(
                        f'      TV#{tv["trade_num"]} signal {signal_dt} '
                        f'(fill {tv["entry_dt"]}) — {reason}'
                    )
            if chart_missing:
                print('    First chart executable markers missing from TV:')
                for signal_dt in chart_missing[:5]:
                    print(f'      {signal_dt}')

    # ── Aggregate P&L ─────────────────────────────────────────────────────────
    # Compound net (commission-aware) returns so the headline lines up with TV,
    # whose Cumulative P&L % is already commission-inclusive. Gross kept for ref.
    py_equity_net = py_equity_gross = 1.0
    for t in py_trades:
        py_equity_net   *= (1 + t['pnl_pct'] / 100)
        py_equity_gross *= (1 + t['gross_pnl_pct'] / 100)
    py_pnl_net   = (py_equity_net - 1) * 100
    py_pnl_gross = (py_equity_gross - 1) * 100

    try:
        tv_df_raw  = pd.read_csv(args.tv_trades, encoding='utf-8-sig')
        tv_df_raw.columns = tv_df_raw.columns.str.strip()
        cum_col = next(
            (name for name in ('Cumulative P&L %', 'Cumulative PnL %')
             if name in tv_df_raw.columns),
            None,
        )
        tv_cum_pnl = tv_df_raw[cum_col].dropna().iloc[-1] if cum_col else float('nan')
    except Exception:
        tv_cum_pnl = float('nan')

    pct = COMMISSION_RATE * 100
    counts_aligned = len(py_trades) == len(tv_real)
    tv_cum_valid = not (isinstance(tv_cum_pnl, float) and np.isnan(tv_cum_pnl)) and tv_cum_pnl
    print()
    print(f"  {'Python cumulative P&L (%.1f%%/side comm):' % pct:<42}{py_pnl_net:>12,.1f}%")
    print(f"  {'TV cumulative P&L:':<42}{tv_cum_pnl:>12,.1f}%")
    if tv_cum_valid and counts_aligned:
        rel = abs(py_pnl_net - tv_cum_pnl) / abs(tv_cum_pnl) * 100
        print(f"    → net vs TV relative diff: {rel:.1f}%  "
              f"(residual = fill price / slippage / timing)")
    elif tv_cum_valid:
        # Totals compound DIFFERENT trade sets — not apples-to-apples. Most common
        # cause: an open-at-end position TV lists (e.g. -34%) that Python never
        # closes (Python emits closed trades only). Compare per-trade, not totals.
        print(f"    → ⚠️  trade counts differ (Python {len(py_trades)} vs TV "
              f"{len(tv_real)}); cumulative totals are NOT comparable. "
              f"See per-trade table / unmatched section below.")
    print(f"  {'Python gross (no commission, reference):':<42}{py_pnl_gross:>12,.1f}%")
    print()

    # ── Fully-matched trades table ─────────────────────────────────────────────
    if full_match:
        print(f'\n{"─"*W}')
        print(f'  FULLY MATCHED ({len(full_match)} trades)')
        print(f'{"─"*W}')
        hdr = f"  {'#':>3}  {'Py Entry':>10} {'TV Entry':>10}  {'Py Exit':>10} {'TV Exit':>10}  {'Py P&L%':>8} {'TV P&L%':>8}  {'Δ P&L%':>7}"
        print(hdr)
        print('  ' + '-' * (W - 2))
        for py, tv in full_match:
            tv_entry_adj = tv['entry_dt'] - tf_offset(tf_str)
            tv_exit_adj  = tv['exit_dt']  - tf_offset(tf_str)
            entry_d = (py['entry_dt'] - tv_entry_adj).total_seconds() / 3600
            exit_d  = (py['exit_dt']  - tv_exit_adj).total_seconds()  / 3600
            pnl_d   = py['pnl_pct'] - tv['net_pnl_pct']
            flag    = f'(+{exit_d:.0f}h)' if abs(exit_d) > 1 else ''
            print(
                f"  {tv['trade_num']:>3}  "
                f"{str(py['entry_dt'].date()):>10} {str(tv['entry_dt'].date()):>10}  "
                f"{str(py['exit_dt'].date()):>10} {str(tv['exit_dt'].date()):>10} {flag:6s}  "
                f"{py['pnl_pct']:>8.1f}% {tv['net_pnl_pct']:>8.1f}%  {pnl_d:>+7.1f}%"
            )

    # ── Exit mismatches ────────────────────────────────────────────────────────
    if exit_mismatch:
        print(f'\n{"─"*W}')
        print(f'  EXIT-MISMATCHED (same entry, different exit) — {len(exit_mismatch)} trades')
        print(f'{"─"*W}')
        for py, tv in exit_mismatch:
            print(f"\n  TV#{tv['trade_num']}:")
            reason = classify_exit_mismatch(py, tv, signals, trail_stop_pct, exit_threshold)
            for part in reason.split(' | '):
                print(f'    {part}')
            if args.exit_detail and py['exit_idx'] is not None:
                print(score_context(signals, py['exit_idx'], args.score_context))

    # ── Python-only trades (IS) ────────────────────────────────────────────────
    if py_only_is:
        print(f'\n{"─"*W}')
        print(f'  PYTHON-ONLY / IS ({len(py_only_is)} trades — Python fires in IS period, TV does not)')
        print(f'{"─"*W}')
        for py in py_only_is:
            reason = classify_py_only(py, signals, entry_threshold, use_entry_conf=bool(use_entry_conf))
            print(f"\n  Entry {py['entry_dt'].date()}  Exit {py['exit_dt'].date()}  P&L {py['pnl_pct']:+.1f}%")
            for part in reason.split(' | '):
                print(f'    {part}')
            if args.verbose and py['entry_idx'] is not None:
                print(score_context(signals, py['entry_idx'], args.score_context))

    # ── Python-only trades (OOS) ───────────────────────────────────────────────
    if py_only_oos:
        print(f'\n{"─"*W}')
        print(f'  PYTHON-ONLY / OOS ({len(py_only_oos)} trades — after TV export ends {last_tv_dt.date()}, not a parity issue)')
        print(f'{"─"*W}')
        for py in py_only_oos:
            print(f"  Entry {py['entry_dt'].date()}  Exit {py['exit_dt'].date()}  P&L {py['pnl_pct']:+.1f}%  [OOS]")

    # ── TV-only trades ─────────────────────────────────────────────────────────
    if tv_only:
        print(f'\n{"─"*W}')
        print(f'  TV-ONLY ({len(tv_only)} trades — TV fires, Python does not)')
        print(f'{"─"*W}')
        for tv in tv_only:
            reason = classify_tv_only(tv, signals, entry_threshold, tf_str)
            print(f"\n  TV#{tv['trade_num']}  Entry {tv['entry_dt'].date()}  Exit {tv['exit_dt'].date()}  P&L {tv['net_pnl_pct']:+.1f}%")
            for part in reason.split(' | '):
                print(f'    {part}')
            # Show score context around expected Python bar
            offset     = tf_offset(tf_str)
            py_bar_dt  = tv['entry_dt'] - offset
            tol        = offset * 2
            mask       = (signals['time'] >= py_bar_dt - tol) & (signals['time'] <= py_bar_dt + tol)
            near       = signals[mask]
            if not near.empty and args.verbose:
                idx = near.index[near['time'].sub(py_bar_dt).abs().argmin()]
                print(score_context(signals, idx, args.score_context))

    # ── Margin-call trades ─────────────────────────────────────────────────────
    if tv_margin:
        print(f'\n{"─"*W}')
        print(f'  TV MARGIN-CALL TRADES (micro-size, not modelled in Python)')
        print(f'{"─"*W}')
        for tv in tv_margin:
            print(f"  TV#{tv['trade_num']}  Entry {tv['entry_dt'].date()}  Exit {tv['exit_dt'].date()}  P&L {tv['net_pnl_pct']:+.1f}%")

    # ── Diagnostic recommendations ────────────────────────────────────────────
    print(f'\n{"="*W}')
    print('  DIAGNOSTIC RECOMMENDATIONS')
    print(f'{"="*W}')
    score_diff_count = 0
    for py in py_only_is:   # IS-only: OOS trades are expected, not SCORE_DIFF
        r = classify_py_only(py, signals, entry_threshold, use_entry_conf=bool(use_entry_conf))
        if 'SCORE_DIFF' in r:
            score_diff_count += 1
    for tv in tv_only:
        r = classify_tv_only(tv, signals, entry_threshold, tf_str)
        if 'SCORE_DIFF' in r:
            score_diff_count += 1

    cascade_risk = len(py_only_is) > len(tv_only) + 5

    # Classify exit mismatches into SCORE_CROSSUNDER_MISMATCH vs TRAILING_STOP.
    exit_score_xunder_count = 0
    exit_trailing_count = 0
    for py_trade, tv_trade in exit_mismatch:
        label = classify_exit_mismatch(py_trade, tv_trade, signals, trail_stop_pct, exit_threshold)
        if 'SCORE_CROSSUNDER_MISMATCH' in label:
            exit_score_xunder_count += 1
        elif 'TRAILING_STOP' in label:
            exit_trailing_count += 1
        else:
            exit_trailing_count += 1  # unknown → bucket with trailing stop

    recs = []
    if score_diff_count > 0:
        recs.append(
            f'  [{score_diff_count} SCORE_DIFF] Python activation_score differs from the TV-exported score on crossunder bars.\n'
            f'    → For activation_scores, run tools/diagnose_tv_parity.py --data {args.data} --params {params_path}\n'
            f'      to see per-component breakdown.\n'
            f'    → For MLP, run tools/check_mlp_parity.py --data {args.data} --weights <mlp_weights_file> --params {params_path}\n'
            f'      and re-export from TradingView if mlp_score is missing or stale.'
        )
    if cascade_risk:
        recs.append(
            f'  [CASCADE RISK] Python has {len(py_only_is)} extra IS trades vs {len(tv_only)} TV-only.\n'
            f'    → Extra Python trades often stem from a single prior exit divergence that\n'
            f'      leaves Python "flat" while TV is "long", allowing Python to re-enter\n'
            f'      on bars where TV is already in position.'
        )
    if py_only_is or tv_only:
        recs.append(
            f'  [UNMATCHED TRADES] {len(py_only_is)} Python-only IS trades and {len(tv_only)} TV-only trades remain.\n'
            f'    → Do not treat this run as trade-list parity. First confirm the chart export\n'
            f'      covers the full TV trade-list range and that the Strategy Tester export was\n'
            f'      generated from the same symbol, timeframe, preset, and script revision.'
        )
    if marker_check is not None and (
        marker_check['tv_without_marker'] or marker_check['chart_without_tv']
    ):
        recs.append(
            f'  [TV/CHART EXPORT MISMATCH] '
            f'{len(marker_check["tv_without_marker"])} TV entries lack a matching chart entry marker; '
            f'{len(marker_check["chart_without_tv"])} chart executable markers are absent from the TV trade list.\n'
            f'    → The exported Data Window and Strategy Tester files do not describe the same\n'
            f'      Pine lifecycle, or the Strategy Tester export is from a different preset/script revision.\n'
            f'      Re-export both from the same chart state before interpreting Python lifecycle differences.'
        )
    if exit_score_xunder_count > 0:
        recs.append(
            f'  [{exit_score_xunder_count} SCORE_CROSSUNDER_MISMATCH exits] TV/Python exits landed on different score-crossunder events.\n'
            f'    → First check score parity. If it passes, treat these as downstream lifecycle/export-state\n'
            f'      effects from an earlier unmatched entry or exit, not as score-computation drift.\n'
            f'    → Use --exit-detail flag to see score context on exit bars.'
        )
    if exit_trailing_count > 0:
        recs.append(
            f'  [{exit_trailing_count} TRAILING_STOP exits] Trailing stop {trail_stop_pct:.0f}% active.\n'
            f'    → Python trail_high uses bar high (same as Pine); timing difference from close-evaluation vs\n'
            f'      intrabar evaluation in Pine. Not a bug — intrinsic fill difference.\n'
            f'    → Use --exit-detail flag to see score context on exit bars.'
        )
    if not recs:
        recs.append('  Parity looks good! Minor differences are expected from fill-price offset.')
    for r in recs:
        print(r)
        print()

    print(f'  Tip: run with --verbose for score context tables on all divergent trades.')
    print(f'       activation_scores: run tools/diagnose_tv_parity.py for per-component breakdown.')
    print(f'       MLP: run tools/check_mlp_parity.py before interpreting trade-list differences.')
    print(f'{"="*W}')


if __name__ == '__main__':
    main()
