"""
Realistic backtest of all strategies in results/strategies_new/.

Costs modeled per trade:
  1.5 pip spread + 0.5 pip slippage + 0.35 pip commission = 2.35 pip total

RiskMgmt 100k rules enforced:
  - Max daily loss:   5%  of initial balance ($5,000)  → no trading rest of day if hit
  - Max total loss:  10%  of initial balance ($10,000)  → account blown, simulation ends
  - Position sizing: 1% equity risk per trade, 10-pip stop (no artificial lot cap)
  - Max leverage:    1:30 (EU regulation standard, RiskMgmt default)
  - Compounding:     position size grows with equity each trade

Out-of-sample window: 2024-01-01 onwards (never seen during factor research).

Usage:
    conda activate nexquant
    python scripts/realistic_backtest_all.py
    python scripts/realistic_backtest_all.py --target-monthly 4.0 --min-trades 50
    python scripts/realistic_backtest_all.py --workers 8
"""

from __future__ import annotations

import argparse
import json
import glob
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path

import numpy as np
import pandas as pd

# ── Constants ──────────────────────────────────────────────────────────────────
DATA_H5    = Path("git_ignore_folder/factor_implementation_source_data/intraday_pv.h5")
FACTOR_DIR = Path("results/factors/values")
STRAT_DIR  = Path("results/strategies_new")
OUTPUT_DIR = Path("results/realistic_backtest")

PIP               = 0.0001
COST_ENTRY        = 2.0 * PIP    # spread + slippage
COST_EXIT         = 0.35 * PIP   # commission
RISK_PCT          = 0.015         # 1.5% equity risk per trade
STOP              = 10 * PIP      # 10-pip hard stop
MAX_LEVERAGE      = 30            # 1:30 max leverage (RiskMgmt / EU standard)
RiskMgmt_MAX_DAILY    = 0.05          # 5% max daily loss of initial balance
RiskMgmt_MAX_TOTAL    = 0.10          # 10% max total loss of initial balance
OOS_START         = "2024-01-01"


def _load_market_data() -> tuple[pd.Series, str]:
    raw = pd.read_hdf(DATA_H5, key="data")
    instrument = raw.index.get_level_values("instrument").unique()[0]
    ohlcv = raw.xs(instrument, level="instrument").rename(columns={
        "$open": "open", "$high": "high", "$low": "low",
        "$close": "close", "$volume": "volume",
    })
    return ohlcv["close"], instrument


def _load_factor(name: str, full_idx: pd.Index, instrument: str) -> pd.Series | None:
    path = FACTOR_DIR / f"{name}.parquet"
    if not path.exists():
        return None
    df = pd.read_parquet(path)
    if isinstance(df.index, pd.MultiIndex):
        try:
            s = df.xs(instrument, level="instrument").iloc[:, 0]
        except KeyError:
            s = df.iloc[:, 0]
    else:
        s = df.iloc[:, 0]
    return s.reindex(full_idx)


def _build_signal(factor_names: list[str], full_idx: pd.Index,
                  instrument: str, code: str) -> pd.Series | None:
    """Build composite z-score signal (same logic as the strategy code uses)."""
    factors: dict[str, pd.Series] = {}
    for fn in factor_names:
        s = _load_factor(fn, full_idx, instrument)
        if s is None:
            return None
        factors[fn] = s

    # Try to reproduce the signal via the original strategy code
    close = pd.Series(np.zeros(len(full_idx)), index=full_idx)  # not used by signal code
    try:
        local_ns: dict = {"pd": pd, "np": np, "close": close, "factors": factors}
        exec(code, local_ns)  # noqa: S102
        sig = local_ns.get("signal")
        if sig is not None and isinstance(sig, pd.Series):
            return sig.reindex(full_idx).fillna(0).astype(int)
    except Exception:
        pass

    # Fallback: generic composite z-score (same as original loop)
    composite = pd.Series(0.0, index=full_idx)
    for fn, s in factors.items():
        s = s.fillna(0)
        std = s.std()
        if std > 0:
            composite += (s - s.mean()) / std
    sig = pd.Series(0, index=full_idx)
    sig[composite > 0.5] = 1
    sig[composite < -0.5] = -1
    return sig


def _run_engine(sig_arr: np.ndarray, px_arr: np.ndarray,
                ts_arr: np.ndarray) -> dict:
    """
    RiskMgmt-compliant backtest engine.

    Rules enforced:
      - Daily loss limit: if daily PnL < -5% of initial ($5k), no new trades that day
      - Total loss limit: if equity < $90k (10% below initial), simulation ends (account blown)
      - Position sizing: 1% equity risk per trade, 10-pip stop, max leverage 1:30
      - Full compounding: position size recalculated from current equity each trade
    """
    INITIAL   = 100_000.0
    equity    = INITIAL
    peak      = INITIAL
    max_dd    = 0.0
    pos       = 0
    entry_px  = 0.0
    pos_size  = 0.0
    n_wins    = 0
    trade_rets: list[float] = []
    blown     = False

    # Daily tracking
    current_day   = None
    day_start_eq  = INITIAL
    day_blocked   = False

    for i in range(1, len(px_arr)):
        p     = float(px_arr[i])
        sig_i = int(sig_arr[i])
        day   = ts_arr[i].astype("datetime64[D]")

        # ── New day: reset daily loss tracker ────────────────────────────────
        if day != current_day:
            current_day  = day
            day_start_eq = equity
            day_blocked  = False

        # ── Close position if signal flips ────────────────────────────────────
        if pos != 0 and sig_i != pos:
            exit_p  = p - pos * COST_EXIT
            raw_pnl = (exit_p - entry_px) * pos_size * pos
            equity += raw_pnl

            if equity > peak:
                peak = equity
            dd = (peak - equity) / peak
            if dd > max_dd:
                max_dd = dd

            ret = raw_pnl / (pos_size * entry_px) if (pos_size * entry_px) > 0 else 0.0
            trade_rets.append(ret)
            if raw_pnl > 0:
                n_wins += 1
            pos = 0

            # Check daily loss limit
            if (equity - day_start_eq) / INITIAL < -RiskMgmt_MAX_DAILY:
                day_blocked = True

            # Check total loss limit → account blown
            if equity < INITIAL * (1 - RiskMgmt_MAX_TOTAL):
                blown = True
                break

        # ── Open new position (if not blocked) ───────────────────────────────
        if sig_i != 0 and pos == 0 and not day_blocked and not blown:
            pos      = sig_i
            entry_px = p + pos * COST_ENTRY
            # Full compounding: size from current equity, capped by max leverage
            max_by_leverage = equity * MAX_LEVERAGE / p
            pos_size = min(equity * RISK_PCT / STOP, max_by_leverage)

    ret_arr  = np.array(trade_rets) if trade_rets else np.array([0.0])
    n_trades = len(trade_rets)
    total_ret = (equity - INITIAL) / INITIAL
    sharpe = float("nan")
    if n_trades > 1 and ret_arr.std() > 0:
        sharpe = float(ret_arr.mean() / ret_arr.std() * np.sqrt(n_trades))

    return dict(
        end_equity=equity,
        total_return=total_ret,
        max_drawdown=-max_dd,
        sharpe=sharpe,
        n_trades=n_trades,
        win_rate=n_wins / n_trades if n_trades else 0.0,
        trade_rets=ret_arr,
        blown=blown,
    )


def _monthly_ret(total_ret: float, n_months: float) -> float:
    return float((1 + total_ret) ** (1 / max(n_months, 1)) - 1)


def backtest_strategy(json_path: str, close: pd.Series, instrument: str) -> dict | None:
    try:
        d = json.load(open(json_path))
    except Exception:
        return None

    factor_names = d.get("factor_names", [])
    code         = d.get("code", "")
    name         = d.get("strategy_name", Path(json_path).stem)

    if not factor_names:
        return None

    sig = _build_signal(factor_names, close.index, instrument, code)
    if sig is None:
        return None

    # Full period
    full = _run_engine(sig.values, close.values, close.index.values)
    n_days_full = (close.index[-1] - close.index[0]).days
    n_months_full = n_days_full / 30.44

    # OOS only
    oos_mask = close.index >= OOS_START
    if oos_mask.sum() < 1000:
        return None
    oos_close = close[oos_mask]
    oos_sig   = sig[oos_mask]
    oos = _run_engine(oos_sig.values, oos_close.values, oos_close.index.values)
    n_months_oos = (oos_close.index[-1] - oos_close.index[0]).days / 30.44

    return dict(
        name=name,
        path=json_path,
        factors=factor_names,
        # Full
        full_monthly_pct=_monthly_ret(full["total_return"], n_months_full) * 100,
        full_annual_pct=((1 + _monthly_ret(full["total_return"], n_months_full)) ** 12 - 1) * 100,
        full_dd_pct=full["max_drawdown"] * 100,
        full_sharpe=full["sharpe"],
        full_trades=full["n_trades"],
        full_winrate=full["win_rate"] * 100,
        full_blown=full["blown"],
        # OOS
        oos_monthly_pct=_monthly_ret(oos["total_return"], n_months_oos) * 100,
        oos_annual_pct=((1 + _monthly_ret(oos["total_return"], n_months_oos)) ** 12 - 1) * 100,
        oos_dd_pct=oos["max_drawdown"] * 100,
        oos_sharpe=oos["sharpe"],
        oos_trades=oos["n_trades"],
        oos_winrate=oos["win_rate"] * 100,
        oos_end_equity=oos["end_equity"],
        oos_blown=oos["blown"],
        n_months_oos=n_months_oos,
    )


def _worker(args: tuple) -> dict | None:
    json_path, close_bytes, instrument = args
    close = pd.read_pickle(close_bytes) if isinstance(close_bytes, (str, Path)) else close_bytes
    return backtest_strategy(json_path, close, instrument)


def main() -> None:
    parser = argparse.ArgumentParser(description="Realistic backtest of all strategies")
    parser.add_argument("--target-monthly", type=float, default=15.0,
                        help="Minimum OOS monthly return %% (default: 4.0)")
    parser.add_argument("--min-trades", type=int, default=30,
                        help="Minimum OOS trades (default: 30)")
    parser.add_argument("--max-dd", type=float, default=-8.0,
                        help="Maximum OOS drawdown %% (default: -8.0)")
    parser.add_argument("--workers", type=int, default=4,
                        help="Parallel workers (default: 4)")
    parser.add_argument("--top", type=int, default=20,
                        help="Show top N strategies (default: 20)")
    args = parser.parse_args()

    print(f"\nLoading market data...")
    close, instrument = _load_market_data()
    print(f"  {close.index[0].date()} → {close.index[-1].date()} | {len(close):,} bars")
    print(f"  OOS window: {OOS_START} onwards")
    print(f"  Costs: 2.35 pip/trade (1.5 spread + 0.5 slip + 0.35 comm)")
    print(f"  Filters: OOS monthly ≥ {args.target_monthly}% | trades ≥ {args.min_trades} | DD ≥ {args.max_dd}%\n")

    json_files = sorted(glob.glob(str(STRAT_DIR / "*.json")))
    print(f"Backtesting {len(json_files)} strategies with {args.workers} workers...\n")

    # Save close to temp file for multiprocessing
    import tempfile
    tmp = tempfile.NamedTemporaryFile(suffix=".pkl", delete=False)
    close.to_pickle(tmp.name)
    tmp.close()

    results = []
    done = 0
    errors = 0

    try:
        with ProcessPoolExecutor(max_workers=args.workers) as ex:
            futures = {
                ex.submit(backtest_strategy, fp, close, instrument): fp
                for fp in json_files
            }
            for fut in as_completed(futures):
                done += 1
                try:
                    res = fut.result()
                    if res is not None:
                        results.append(res)
                except Exception:
                    errors += 1
                if done % 100 == 0 or done == len(json_files):
                    print(f"  {done}/{len(json_files)} done, {len(results)} valid, {errors} errors")
    finally:
        os.unlink(tmp.name)

    if not results:
        print("No valid results.")
        return

    df = pd.DataFrame(results)

    # ── Save full results ──────────────────────────────────────────────────────
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    out_csv = OUTPUT_DIR / "all_strategies_realistic.csv"
    df.sort_values("oos_monthly_pct", ascending=False).to_csv(out_csv, index=False)
    print(f"\nFull results saved → {out_csv}")

    # ── Filter for target ──────────────────────────────────────────────────────
    hits = df[
        (df["oos_monthly_pct"] >= args.target_monthly) &
        (df["oos_trades"]      >= args.min_trades) &
        (df["oos_dd_pct"]      >= args.max_dd) &
        (df["oos_blown"]       == False)  # noqa: E712
    ].sort_values("oos_monthly_pct", ascending=False)

    print(f"\n{'='*70}")
    print(f"  Strategies meeting target: OOS monthly ≥ {args.target_monthly}% | "
          f"trades ≥ {args.min_trades} | DD ≥ {args.max_dd}%")
    print(f"  Found: {len(hits)} / {len(df)}")
    print(f"{'='*70}\n")

    top = hits.head(args.top)
    if top.empty:
        print("  No strategies met the criteria.")
        # Show best available
        best = df.sort_values("oos_monthly_pct", ascending=False).head(10)
        print(f"\n  Best available (by OOS monthly return):\n")
        _print_table(best)
    else:
        _print_table(top)

    # ── Save filtered results ──────────────────────────────────────────────────
    if not hits.empty:
        out_hits = OUTPUT_DIR / f"strategies_oos_{args.target_monthly}pct_monthly.csv"
        hits.to_csv(out_hits, index=False)
        print(f"\nFiltered results saved → {out_hits}")

    # ── RiskMgmt projection for #1 ────────────────────────────────────────────────
    best_row = (hits if not hits.empty else df.sort_values("oos_monthly_pct", ascending=False)).iloc[0]
    mon = best_row["oos_monthly_pct"]
    dd  = abs(best_row["oos_dd_pct"])
    gross = 100_000 * mon / 100
    challenge_m = 10 / max(mon, 0.01)
    print(f"\n{'='*70}")
    print(f"  RiskMgmt 100k projection — #{1}: {best_row['name']}")
    print(f"{'='*70}")
    print(f"  OOS monthly return:    {mon:+.2f}%")
    print(f"  Monthly gross profit:  ${gross:,.0f}")
    print(f"  Trader share (80%):    ${gross*0.8:,.0f} / month")
    print(f"  Trader annual (80%):   ${gross*0.8*12:,.0f} / year")
    print(f"  OOS Max Drawdown:      {-dd:.2f}%  (RiskMgmt limit: 10%)")
    print(f"  Challenge duration:    ~{challenge_m:.1f} months to hit +10%")
    print(f"  RiskMgmt safe?             {'YES ✓' if dd < 8 else 'BORDERLINE ⚠' if dd < 10 else 'NO ✗'}")


def _print_table(df: pd.DataFrame) -> None:
    hdr = f"{'#':>3}  {'Name':<35} {'OOS Mon%':>8} {'OOS DD%':>8} {'Sharpe':>7} {'WinR%':>6} {'Trades':>7} {'Blown':>6}  {'Factors'}"
    print(hdr)
    print("-" * len(hdr))
    for i, (_, r) in enumerate(df.iterrows(), 1):
        factors_str = ",".join(r["factors"][:2]) + ("…" if len(r["factors"]) > 2 else "")
        blown = "💥YES" if r.get("oos_blown") else "  no"
        print(f"{i:>3}  {r['name']:<35} {r['oos_monthly_pct']:>+7.2f}% "
              f"{r['oos_dd_pct']:>+7.2f}% {r['oos_sharpe']:>7.2f} "
              f"{r['oos_winrate']:>5.1f}% {r['oos_trades']:>7,}  {blown}  {factors_str}")


if __name__ == "__main__":
    main()
