#!/usr/bin/env python3
"""Generate the position backtest analysis notebook.

Run:  cd vanta && challenge/.venv/bin/python challenge/notebooks/position-backtest-analysis.py
Creates: challenge/notebooks/position-backtest-analysis.ipynb
"""

import nbformat as nbf

nb = nbf.v4.new_notebook()
nb.metadata["kernelspec"] = {
    "display_name": "Python 3",
    "language": "python",
    "name": "python3",
}

cells = []

def md(src):
    cells.append(nbf.v4.new_markdown_cell(src.strip()))

def code(src):
    cells.append(nbf.v4.new_code_cell(src.strip()))

# ── Cells ─────────────────────────────────────────────────────────────────────

md("""
# 📊 Position Backtest Analysis

**Pipeline:** Signal models (Crunch) → Position Manager (threshold + hysteresis) → Vanta-like execution (fees, carry, slippage)

This notebook:
1. Re-runs all 13 signal models and caches their prediction timeseries
2. Loads 1-minute BTCUSDT prices from the backtest cache
3. Pipes each model's signals through the position layer with multiple configs
4. Compares execution strategies: conservative vs aggressive thresholds, fee sensitivity
5. Identifies which signal-model + execution-config pairs maximise daily PnL after fees
""")

code("""
import sys, os, time, json, warnings
warnings.filterwarnings("ignore")

ROOT = os.path.abspath(os.path.join(os.getcwd(), "..", ".."))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "challenge"))
os.environ.setdefault("COORDINATOR_URL", "http://localhost:8000")

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker

from vanta.backtest import BacktestRunner
from miner import PositionManager, PositionBacktest, ReplayProvider, FeeModel

plt.rcParams["figure.figsize"] = (16, 6)
plt.rcParams["figure.dpi"] = 100
plt.style.use("seaborn-v0_8-whitegrid")

print("✓ Imports ready")
""")

md("""
## 1. Run Signal Backtests & Cache Predictions

We run each model once at 15-minute intervals over 6 months of BTCUSDT data.
Results are cached so subsequent cells are instant.
""")

code("""
from vanta.examples import (
    MeanReversionTracker, TrendFollowingTracker, RSITracker,
    BollingerBreakoutTracker, VolatilityRegimeTracker, DualTimeframeTracker,
    VolumeProfileTracker, EnsembleTracker, HourlyMomentumTracker,
    MTFTrendTracker, ExtremeMeanReversionTracker, ReturnAutocorrTracker,
)

# Also pull in the experimental strategies from research
sys.path.insert(0, os.path.join(ROOT, "challenge", "research"))
try:
    from run_strategies import (
        MultiTimeframeMomentum, MACDDivergence, MeanReversionBollinger,
        AdaptiveRSI, VWAPReversion,
    )
    HAS_RESEARCH = True
except ImportError:
    HAS_RESEARCH = False
    print("⚠ research/run_strategies.py not found, using built-in examples only")

MODEL_REGISTRY = {
    "MeanReversion": MeanReversionTracker,
    "TrendFollowing": TrendFollowingTracker,
    "RSI": RSITracker,
    "BollingerBreakout": BollingerBreakoutTracker,
    "VolatilityRegime": VolatilityRegimeTracker,
    "DualTimeframe": DualTimeframeTracker,
    "VolumeProfile": VolumeProfileTracker,
    "Ensemble_Builtin": EnsembleTracker,
    "HourlyMomentum": HourlyMomentumTracker,
    "MTFTrend": MTFTrendTracker,
    "ExtremeMeanRev": ExtremeMeanReversionTracker,
    "ReturnAutocorr": ReturnAutocorrTracker,
}

if HAS_RESEARCH:
    MODEL_REGISTRY.update({
        "MultiTF_Momentum": MultiTimeframeMomentum,
        "MACD_Divergence": MACDDivergence,
        "MeanRev_Bollinger": MeanReversionBollinger,
        "AdaptiveRSI": AdaptiveRSI,
        "VWAP_Reversion": VWAPReversion,
    })

print(f"Models to evaluate: {len(MODEL_REGISTRY)}")
for name in sorted(MODEL_REGISTRY):
    print(f"  • {name}")
""")

code("""
# Run signal backtests
SIGNAL_CACHE_FILE = os.path.join(ROOT, "challenge", "research", "output", "signal_cache.json")

START, END = "2025-07-01", "2026-01-01"
SUBJECT = "BTCUSDT"

signal_results = {}  # name → BacktestResult
signal_metrics = {}  # name → metrics dict

# Try loading from cache first
if os.path.exists(SIGNAL_CACHE_FILE):
    print(f"Loading signal cache from {SIGNAL_CACHE_FILE}...")
    with open(SIGNAL_CACHE_FILE) as f:
        cache = json.load(f)
    if cache.get("config", {}).get("start") == START and cache.get("config", {}).get("end") == END:
        for name, data in cache.get("signals", {}).items():
            if name in MODEL_REGISTRY:
                provider = ReplayProvider(signals={int(k): v for k, v in data["predictions"].items()})
                signal_results[name] = provider
                signal_metrics[name] = data["metrics"]
        print(f"  Loaded {len(signal_results)} models from cache")

# Run any missing models
missing = [n for n in MODEL_REGISTRY if n not in signal_results]
if missing:
    print(f"Running {len(missing)} missing models...")
    for name in missing:
        t0 = time.perf_counter()
        model = MODEL_REGISTRY[name]()
        try:
            result = BacktestRunner(model=model).run(
                subject=SUBJECT, start=START, end=END,
                window_size=120, prediction_interval_seconds=900, horizon_seconds=3600,
            )
            # Build ReplayProvider from predictions
            sigs = {}
            for p in result._predictions:
                ts = p.get("ts")
                if ts is not None:
                    key = int(ts.timestamp()) if hasattr(ts, "timestamp") else int(ts)
                    sigs[key] = p.get("expected_return", 0.0)
            signal_results[name] = ReplayProvider(signals=sigs)
            signal_metrics[name] = result.metrics
            elapsed = time.perf_counter() - t0
            ic = result.metrics.get("ic", 0)
            print(f"  ✓ {name:25s} IC={ic:+.4f}  ({elapsed:.1f}s, {len(sigs)} signals)")
        except Exception as e:
            print(f"  ✗ {name:25s} ERROR: {e}")

# Save cache
cache_data = {"config": {"start": START, "end": END, "subject": SUBJECT}, "signals": {}}
for name, provider in signal_results.items():
    if isinstance(provider, ReplayProvider):
        cache_data["signals"][name] = {
            "predictions": {str(k): v for k, v in provider._signals.items()},
            "metrics": signal_metrics.get(name, {}),
        }
os.makedirs(os.path.dirname(SIGNAL_CACHE_FILE), exist_ok=True)
with open(SIGNAL_CACHE_FILE, "w") as f:
    json.dump(cache_data, f)
print(f"\\nSignal cache saved ({len(signal_results)} models)")
""")

md("""
## 2. Load Price Data
""")

code("""
from pathlib import Path

cache_dir = Path(ROOT) / "challenge" / ".cache" / "backtest" / "binance" / SUBJECT / "candle" / "1m"
frames = [pd.read_parquet(f) for f in sorted(cache_dir.glob("*.parquet"))]
price_df = pd.concat(frames).sort_values("ts_event").reset_index(drop=True)
price_df["ts_unix"] = price_df["ts_event"].apply(lambda t: int(t.timestamp()))
prices = dict(zip(price_df["ts_unix"], price_df["close"].astype(float)))

print(f"Loaded {len(prices):,} price points")
print(f"Period: {price_df['ts_event'].min().date()} → {price_df['ts_event'].max().date()}")
print(f"Price range: ${min(prices.values()):,.0f} → ${max(prices.values()):,.0f}")
""")

md("""
## 3. Signal Quality Overview

Before position management — how good are the raw signals?
""")

code("""
# Signal quality table
rows = []
for name in sorted(signal_metrics):
    m = signal_metrics[name]
    provider = signal_results[name]
    sigs = list(provider._signals.values())

    rows.append({
        "Model": name,
        "IC": m.get("ic", 0),
        "Hit Rate": m.get("hit_rate", 0),
        "Sortino": m.get("sortino_ratio", 0),
        "Mean Signal": np.mean(sigs),
        "Std Signal": np.std(sigs),
        "Signal Range": f"[{min(sigs):.5f}, {max(sigs):.5f}]",
        "Preds": len(sigs),
    })

signal_df = pd.DataFrame(rows).sort_values("IC", ascending=False).reset_index(drop=True)
signal_df.index += 1
signal_df.index.name = "Rank"
display(signal_df.style.format({
    "IC": "{:+.4f}", "Hit Rate": "{:.3f}", "Sortino": "{:+.4f}",
    "Mean Signal": "{:+.6f}", "Std Signal": "{:.6f}",
}).background_gradient(subset=["IC"], cmap="RdYlGn"))
""")

code("""
# Signal distribution plots
fig, axes = plt.subplots(3, 4, figsize=(20, 12))
axes = axes.flatten()

for i, name in enumerate(signal_df["Model"].values[:12]):
    ax = axes[i]
    sigs = list(signal_results[name]._signals.values())
    ax.hist(sigs, bins=80, alpha=0.7, edgecolor="black", linewidth=0.3)
    ax.axvline(x=0, color="red", linestyle="--", alpha=0.5)
    ic = signal_metrics[name].get("ic", 0)
    ax.set_title(f"{name}\\nIC={ic:+.4f}", fontsize=10)
    ax.set_xlabel("Signal")

for j in range(i + 1, len(axes)):
    axes[j].set_visible(False)

plt.suptitle("Signal Distributions (top 12 by IC)", fontsize=14, y=1.01)
plt.tight_layout()
plt.show()
""")

md("""
## 4. Position Backtest: All Models × All Configs

We test each model with several execution configurations:

| Config | Entry Threshold | Exit Threshold | Description |
|--------|----------------|----------------|-------------|
| Aggressive | 0.0005 | 0.0001 | Trades frequently, captures small moves |
| Moderate | 0.002 | 0.0005 | Balanced |
| Conservative | 0.005 | 0.001 | Fewer trades, stronger conviction required |
| Very Conservative | 0.01 | 0.002 | Only strongest signals |
""")

code("""
CONFIGS = {
    "Aggressive":        {"entry": 0.0005, "exit": 0.0001},
    "Moderate":          {"entry": 0.002,  "exit": 0.0005},
    "Conservative":      {"entry": 0.005,  "exit": 0.001},
    "Very Conservative": {"entry": 0.01,   "exit": 0.002},
}

FEES = FeeModel(spread_cost_bps=10, carry_annual_pct=10.95, slippage_bps=2)
NO_FEES = FeeModel(spread_cost_bps=0, carry_annual_pct=0, slippage_bps=0)

# Run ALL combos
all_results = {}  # (model, config) → PositionBacktestResult
all_results_no_fees = {}

t0 = time.perf_counter()
for model_name, provider in signal_results.items():
    for config_name, params in CONFIGS.items():
        pm = PositionManager(
            entry_threshold=params["entry"],
            exit_threshold=params["exit"],
            max_leverage=2.5, leverage_scale=1.0,
        )
        result = PositionBacktest(pm, provider, FEES).run(
            prices=prices, symbol=SUBJECT,
        )
        all_results[(model_name, config_name)] = result

        # Also run without fees for comparison
        pm2 = PositionManager(
            entry_threshold=params["entry"],
            exit_threshold=params["exit"],
            max_leverage=2.5, leverage_scale=1.0,
        )
        result_nf = PositionBacktest(pm2, provider, NO_FEES).run(
            prices=prices, symbol=SUBJECT,
        )
        all_results_no_fees[(model_name, config_name)] = result_nf

elapsed = time.perf_counter() - t0
print(f"Ran {len(all_results)} position backtests in {elapsed:.1f}s")
print(f"({len(signal_results)} models × {len(CONFIGS)} configs × 2 fee modes)")
""")

md("""
## 5. Results Heatmap: Avg Daily PnL by Model × Config
""")

code("""
# Build pivot table
rows = []
for (model, config), result in all_results.items():
    m = result.metrics
    nf = all_results_no_fees[(model, config)].metrics
    rows.append({
        "Model": model,
        "Config": config,
        "Avg Daily PnL": m["avg_daily_pnl"],
        "Gross PnL (no fees)": nf["total_pnl"],
        "Net PnL": m["total_pnl"],
        "Total Fees": m["total_fees"],
        "Fee Drag %": (m["total_fees"] / max(abs(nf["total_pnl"]), 1e-9)) * 100 if nf["total_pnl"] != 0 else 0,
        "Trades": m["num_trades"],
        "Win Rate": m["win_rate"],
        "Sharpe": m["sharpe"],
        "Sortino": m["sortino"],
        "Max DD": m["max_drawdown"],
        "Avg Hold (h)": m["avg_hold_hours"],
        "Signal IC": signal_metrics.get(model, {}).get("ic", 0),
    })

results_df = pd.DataFrame(rows)

# Heatmap: Avg Daily PnL
pivot_pnl = results_df.pivot(index="Model", columns="Config", values="Avg Daily PnL")
pivot_pnl = pivot_pnl[["Aggressive", "Moderate", "Conservative", "Very Conservative"]]
pivot_pnl = pivot_pnl.sort_values("Moderate", ascending=False)

fig, ax = plt.subplots(figsize=(12, max(8, len(pivot_pnl) * 0.5)))
im = ax.imshow(pivot_pnl.values, cmap="RdYlGn", aspect="auto")

ax.set_xticks(range(len(pivot_pnl.columns)))
ax.set_xticklabels(pivot_pnl.columns, fontsize=11)
ax.set_yticks(range(len(pivot_pnl.index)))
ax.set_yticklabels(pivot_pnl.index, fontsize=10)

# Annotate cells
for i in range(len(pivot_pnl.index)):
    for j in range(len(pivot_pnl.columns)):
        val = pivot_pnl.iloc[i, j]
        color = "white" if abs(val) > pivot_pnl.values.std() else "black"
        ax.text(j, i, f"{val:+.5f}", ha="center", va="center", fontsize=9, color=color)

plt.colorbar(im, ax=ax, label="Avg Daily PnL", shrink=0.8)
ax.set_title("Average Daily PnL: Model × Execution Config (with Vanta fees)", fontsize=13)
plt.tight_layout()
plt.show()
""")

code("""
# Heatmap: Number of trades
pivot_trades = results_df.pivot(index="Model", columns="Config", values="Trades")
pivot_trades = pivot_trades[["Aggressive", "Moderate", "Conservative", "Very Conservative"]]
pivot_trades = pivot_trades.loc[pivot_pnl.index]  # same order

fig, ax = plt.subplots(figsize=(12, max(8, len(pivot_trades) * 0.5)))
im = ax.imshow(pivot_trades.values, cmap="YlOrRd", aspect="auto")

ax.set_xticks(range(len(pivot_trades.columns)))
ax.set_xticklabels(pivot_trades.columns, fontsize=11)
ax.set_yticks(range(len(pivot_trades.index)))
ax.set_yticklabels(pivot_trades.index, fontsize=10)

for i in range(len(pivot_trades.index)):
    for j in range(len(pivot_trades.columns)):
        val = pivot_trades.iloc[i, j]
        ax.text(j, i, f"{val:.0f}", ha="center", va="center", fontsize=9)

plt.colorbar(im, ax=ax, label="Number of Trades", shrink=0.8)
ax.set_title("Trade Count: Model × Execution Config", fontsize=13)
plt.tight_layout()
plt.show()
""")

md("""
## 6. Fee Impact Analysis

How much of the gross alpha is eaten by fees?
""")

code("""
# Fee drag comparison (Moderate config)
fee_rows = []
for model_name in sorted(signal_results):
    key = (model_name, "Moderate")
    if key in all_results and key in all_results_no_fees:
        gross = all_results_no_fees[key].metrics["total_pnl"]
        net = all_results[key].metrics["total_pnl"]
        fees = all_results[key].metrics["total_fees"]
        spread = all_results[key].metrics["total_spread"]
        carry = all_results[key].metrics["total_carry"]
        slippage = all_results[key].metrics["total_slippage"]
        fee_rows.append({
            "Model": model_name,
            "Gross PnL": gross,
            "Net PnL": net,
            "Total Fees": fees,
            "Spread": spread,
            "Carry": carry,
            "Slippage": slippage,
            "Fee/Gross %": (fees / abs(gross) * 100) if abs(gross) > 1e-9 else float("inf"),
        })

fee_df = pd.DataFrame(fee_rows).sort_values("Net PnL", ascending=False)

fig, axes = plt.subplots(1, 2, figsize=(18, max(6, len(fee_df) * 0.4)))

# Left: Gross vs Net PnL
ax = axes[0]
y = range(len(fee_df))
ax.barh(y, fee_df["Gross PnL"].values, alpha=0.5, label="Gross (no fees)", color="steelblue")
ax.barh(y, fee_df["Net PnL"].values, alpha=0.8, label="Net (with fees)", color="darkorange")
ax.set_yticks(y)
ax.set_yticklabels(fee_df["Model"].values, fontsize=9)
ax.axvline(x=0, color="black", linewidth=0.5)
ax.legend(fontsize=10)
ax.set_title("Gross vs Net PnL (Moderate config)", fontsize=12)
ax.set_xlabel("Total PnL")

# Right: Fee breakdown
ax = axes[1]
ax.barh(y, fee_df["Spread"].values, label="Spread", color="tab:red", alpha=0.7)
ax.barh(y, fee_df["Carry"].values, left=fee_df["Spread"].values, label="Carry", color="tab:orange", alpha=0.7)
ax.barh(y, fee_df["Slippage"].values,
        left=(fee_df["Spread"] + fee_df["Carry"]).values,
        label="Slippage", color="tab:purple", alpha=0.7)
ax.set_yticks(y)
ax.set_yticklabels(fee_df["Model"].values, fontsize=9)
ax.legend(fontsize=10)
ax.set_title("Fee Breakdown (Moderate config)", fontsize=12)
ax.set_xlabel("Total Fees")

plt.tight_layout()
plt.show()
""")

md("""
## 7. Equity Curves: Top Models

Daily cumulative PnL for the best-performing model × config combinations.
""")

code("""
# Find top 6 combos by avg daily PnL
top_combos = results_df.nlargest(6, "Avg Daily PnL")[["Model", "Config"]].values.tolist()

fig, axes = plt.subplots(2, 3, figsize=(20, 10))
axes = axes.flatten()

for i, (model, config) in enumerate(top_combos):
    ax = axes[i]
    result = all_results[(model, config)]
    daily = result.to_daily_dataframe()
    if daily.empty:
        ax.set_title(f"{model} / {config}\\n(no data)")
        continue

    ax.plot(daily["date"], daily["cumulative_pnl"], linewidth=1.2, color="steelblue")
    ax.fill_between(daily["date"], daily["drawdown"], 0, alpha=0.15, color="red")
    ax.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)

    m = result.metrics
    ax.set_title(
        f"{model} / {config}\\n"
        f"PnL={m['total_pnl']:+.4f}  Sharpe={m['sharpe']:+.2f}  "
        f"Trades={m['num_trades']:.0f}  Fees={m['total_fees']:.4f}",
        fontsize=9,
    )
    ax.tick_params(axis="x", rotation=30, labelsize=8)

plt.suptitle("Top 6 Model×Config Equity Curves (with Vanta fees)", fontsize=14, y=1.01)
plt.tight_layout()
plt.show()
""")

code("""
# Also show bottom 6 for contrast
bottom_combos = results_df.nsmallest(6, "Avg Daily PnL")[["Model", "Config"]].values.tolist()

fig, axes = plt.subplots(2, 3, figsize=(20, 10))
axes = axes.flatten()

for i, (model, config) in enumerate(bottom_combos):
    ax = axes[i]
    result = all_results[(model, config)]
    daily = result.to_daily_dataframe()
    if daily.empty:
        ax.set_title(f"{model} / {config}\\n(no data)")
        continue

    ax.plot(daily["date"], daily["cumulative_pnl"], linewidth=1.2, color="crimson")
    ax.fill_between(daily["date"], daily["drawdown"], 0, alpha=0.15, color="red")
    ax.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)

    m = result.metrics
    ax.set_title(
        f"{model} / {config}\\n"
        f"PnL={m['total_pnl']:+.4f}  Sharpe={m['sharpe']:+.2f}  "
        f"Trades={m['num_trades']:.0f}  Fees={m['total_fees']:.4f}",
        fontsize=9,
    )
    ax.tick_params(axis="x", rotation=30, labelsize=8)

plt.suptitle("Bottom 6 Model×Config Equity Curves (worst performers)", fontsize=14, y=1.01)
plt.tight_layout()
plt.show()
""")

md("""
## 8. Optimal Config per Model

For each model, which execution config maximises net daily PnL?
""")

code("""
# Best config per model
best_per_model = results_df.loc[results_df.groupby("Model")["Avg Daily PnL"].idxmax()]
best_per_model = best_per_model.sort_values("Avg Daily PnL", ascending=False).reset_index(drop=True)
best_per_model.index += 1
best_per_model.index.name = "Rank"

display(best_per_model[[
    "Model", "Config", "Avg Daily PnL", "Net PnL", "Total Fees",
    "Trades", "Win Rate", "Sharpe", "Max DD", "Signal IC",
]].style.format({
    "Avg Daily PnL": "{:+.6f}", "Net PnL": "{:+.4f}",
    "Total Fees": "{:.4f}", "Win Rate": "{:.3f}",
    "Sharpe": "{:+.2f}", "Max DD": "{:+.4f}", "Signal IC": "{:+.4f}",
}).background_gradient(subset=["Avg Daily PnL"], cmap="RdYlGn"))
""")

md("""
## 9. IC → Net PnL Relationship

Does signal quality (IC) predict position-layer profitability?
""")

code("""
# Scatter: IC vs Net PnL for each config
fig, ax = plt.subplots(figsize=(12, 7))

colors = {"Aggressive": "red", "Moderate": "steelblue", "Conservative": "green", "Very Conservative": "purple"}
for config_name, color in colors.items():
    subset = results_df[results_df["Config"] == config_name]
    ax.scatter(subset["Signal IC"], subset["Avg Daily PnL"],
               alpha=0.7, s=60, label=config_name, color=color, edgecolor="white", linewidth=0.5)

    # Annotate best/worst
    if not subset.empty:
        best = subset.loc[subset["Avg Daily PnL"].idxmax()]
        worst = subset.loc[subset["Avg Daily PnL"].idxmin()]
        for _, row in [("best", best), ("worst", worst)]:
            ax.annotate(row["Model"], (row["Signal IC"], row["Avg Daily PnL"]),
                       fontsize=7, alpha=0.7, ha="center", va="bottom")

ax.axhline(y=0, color="black", linestyle="--", linewidth=0.5, alpha=0.5)
ax.axvline(x=0, color="black", linestyle="--", linewidth=0.5, alpha=0.5)
ax.set_xlabel("Signal IC (from challenge backtest)", fontsize=12)
ax.set_ylabel("Avg Daily PnL (after Vanta fees)", fontsize=12)
ax.set_title("Signal Quality vs Position-Layer Profitability", fontsize=14)
ax.legend(fontsize=10)
plt.tight_layout()
plt.show()
""")

md("""
## 10. Drawdown Analysis

Which combos violate Vanta's hard limits?
- **Daily drawdown limit**: 5% (MAX_DAILY_DRAWDOWN = 0.95)
- **Total drawdown limit**: 10% (MAX_TOTAL_DRAWDOWN = 0.90)
""")

code("""
VANTA_MAX_DAILY_DD = -0.05
VANTA_MAX_TOTAL_DD = -0.10

dd_rows = []
for (model, config), result in all_results.items():
    m = result.metrics
    daily = result.to_daily_dataframe()
    if daily.empty:
        continue

    min_daily = daily["pnl"].min() if not daily.empty else 0
    max_total_dd = m["max_drawdown"]

    dd_rows.append({
        "Model": model,
        "Config": config,
        "Worst Day": min_daily,
        "Max Drawdown": max_total_dd,
        "Daily DD Breach": "❌" if min_daily < VANTA_MAX_DAILY_DD else "✅",
        "Total DD Breach": "❌" if max_total_dd < VANTA_MAX_TOTAL_DD else "✅",
        "Eliminated": min_daily < VANTA_MAX_DAILY_DD or max_total_dd < VANTA_MAX_TOTAL_DD,
    })

dd_df = pd.DataFrame(dd_rows).sort_values("Max Drawdown", ascending=True)

# Show elimination stats
n_eliminated = dd_df["Eliminated"].sum()
print(f"⚠ {n_eliminated}/{len(dd_df)} model×config combos would be ELIMINATED by Vanta drawdown limits")
print(f"  Daily DD breaches: {(dd_df['Daily DD Breach'] == '❌').sum()}")
print(f"  Total DD breaches: {(dd_df['Total DD Breach'] == '❌').sum()}")
print()

# Show safe combos
safe = dd_df[~dd_df["Eliminated"]].sort_values("Max Drawdown", ascending=False)
if not safe.empty:
    print(f"✅ {len(safe)} safe combos (survive Vanta limits):")
    display(safe[["Model", "Config", "Worst Day", "Max Drawdown", "Daily DD Breach", "Total DD Breach"]].head(20))
else:
    print("No combos survive Vanta drawdown limits — need better signals or lower leverage")
""")

md("""
## 11. Full Leaderboard

All model × config combinations ranked by avg daily PnL, with survival status.
""")

code("""
# Merge drawdown info into results
leaderboard = results_df.merge(
    dd_df[["Model", "Config", "Eliminated", "Daily DD Breach", "Total DD Breach"]],
    on=["Model", "Config"], how="left",
)
leaderboard = leaderboard.sort_values("Avg Daily PnL", ascending=False).reset_index(drop=True)
leaderboard.index += 1
leaderboard.index.name = "Rank"

display(leaderboard[[
    "Model", "Config", "Avg Daily PnL", "Net PnL", "Total Fees",
    "Trades", "Win Rate", "Sharpe", "Max DD",
    "Daily DD Breach", "Total DD Breach",
]].style.format({
    "Avg Daily PnL": "{:+.6f}", "Net PnL": "{:+.4f}", "Total Fees": "{:.4f}",
    "Win Rate": "{:.3f}", "Sharpe": "{:+.2f}", "Max DD": "{:+.4f}",
}).background_gradient(subset=["Avg Daily PnL"], cmap="RdYlGn"))
""")

md("""
## 12. Normalised Signals — Removing the Scale Mismatch

The raw results above are misleading: most models output signals 10–40× smaller
than actual return magnitudes (σ_signal ≈ 0.0003 vs σ_actual ≈ 0.004).  Fixed
thresholds like `entry=0.002` are unreachable for quiet models.

Here we z-score each model's signals so threshold = number of standard deviations.
This tests whether any model has *directional edge* regardless of output scale.
""")

code("""
# Z-score normalise each model's signals
normalised_providers = {}
signal_stats = {}

for name, provider in signal_results.items():
    sigs = provider._signals
    vals = np.array(list(sigs.values()))
    std = np.std(vals)
    mean = np.mean(vals)

    if std < 1e-12:
        normalised_providers[name] = provider  # can't normalise constant
        signal_stats[name] = {"mean": mean, "std": std, "scale": 1.0}
        continue

    scaled = {k: (v - mean) / std for k, v in sigs.items()}
    normalised_providers[name] = ReplayProvider(signals=scaled)
    signal_stats[name] = {"mean": mean, "std": std, "scale": std}

# Verify
print("Signal normalisation:")
print(f"{'Model':>25s}  {'Raw Std':>10s}  {'Normed Std':>10s}  {'Scale':>10s}")
print("-" * 60)
for name in sorted(normalised_providers):
    raw_std = signal_stats[name]["std"]
    normed_vals = list(normalised_providers[name]._signals.values())
    normed_std = np.std(normed_vals) if normed_vals else 0
    print(f"{name:>25s}  {raw_std:>10.6f}  {normed_std:>10.4f}  {signal_stats[name]['scale']:>10.6f}")
""")

code("""
# Run position backtests with normalised signals
# Thresholds now in sigma units
NORM_CONFIGS = {
    "0.5σ entry": {"entry": 0.5,  "exit": 0.15},
    "1.0σ entry": {"entry": 1.0,  "exit": 0.3},
    "1.5σ entry": {"entry": 1.5,  "exit": 0.5},
    "2.0σ entry": {"entry": 2.0,  "exit": 0.7},
}

norm_results = {}
norm_results_no_fees = {}

t0 = time.perf_counter()
for model_name, provider in normalised_providers.items():
    for config_name, params in NORM_CONFIGS.items():
        pm = PositionManager(
            entry_threshold=params["entry"],
            exit_threshold=params["exit"],
            max_leverage=2.5, leverage_scale=1.0,
        )
        result = PositionBacktest(pm, provider, FEES).run(
            prices=prices, symbol=SUBJECT,
        )
        norm_results[(model_name, config_name)] = result

        pm2 = PositionManager(
            entry_threshold=params["entry"],
            exit_threshold=params["exit"],
            max_leverage=2.5, leverage_scale=1.0,
        )
        result_nf = PositionBacktest(pm2, provider, NO_FEES).run(
            prices=prices, symbol=SUBJECT,
        )
        norm_results_no_fees[(model_name, config_name)] = result_nf

elapsed = time.perf_counter() - t0
print(f"Ran {len(norm_results)} normalised backtests in {elapsed:.1f}s")
""")

code("""
# Build normalised results table
norm_rows = []
for (model, config), result in norm_results.items():
    m = result.metrics
    nf = norm_results_no_fees[(model, config)].metrics
    norm_rows.append({
        "Model": model,
        "Config": config,
        "Avg Daily PnL": m["avg_daily_pnl"],
        "Gross PnL": nf["total_pnl"],
        "Net PnL": m["total_pnl"],
        "Total Fees": m["total_fees"],
        "Trades": m["num_trades"],
        "Win Rate": m["win_rate"],
        "Sharpe": m["sharpe"],
        "Max DD": m["max_drawdown"],
        "Avg Hold (h)": m["avg_hold_hours"],
        "Signal IC": signal_metrics.get(model, {}).get("ic", 0),
    })

norm_df = pd.DataFrame(norm_rows)

# Heatmap
pivot_norm = norm_df.pivot(index="Model", columns="Config", values="Avg Daily PnL")
pivot_norm = pivot_norm[["0.5σ entry", "1.0σ entry", "1.5σ entry", "2.0σ entry"]]
pivot_norm = pivot_norm.sort_values("1.0σ entry", ascending=False)

fig, ax = plt.subplots(figsize=(12, max(8, len(pivot_norm) * 0.5)))
im = ax.imshow(pivot_norm.values, cmap="RdYlGn", aspect="auto")

ax.set_xticks(range(len(pivot_norm.columns)))
ax.set_xticklabels(pivot_norm.columns, fontsize=11)
ax.set_yticks(range(len(pivot_norm.index)))
ax.set_yticklabels(pivot_norm.index, fontsize=10)

for i in range(len(pivot_norm.index)):
    for j in range(len(pivot_norm.columns)):
        val = pivot_norm.iloc[i, j]
        color = "white" if abs(val) > pivot_norm.values.std() else "black"
        ax.text(j, i, f"{val:+.5f}", ha="center", va="center", fontsize=9, color=color)

plt.colorbar(im, ax=ax, label="Avg Daily PnL", shrink=0.8)
ax.set_title("Normalised Signals: Avg Daily PnL (threshold in σ units, with Vanta fees)", fontsize=13)
plt.tight_layout()
plt.show()
""")

code("""
# Trade count heatmap for normalised
pivot_ntrades = norm_df.pivot(index="Model", columns="Config", values="Trades")
pivot_ntrades = pivot_ntrades[["0.5σ entry", "1.0σ entry", "1.5σ entry", "2.0σ entry"]]
pivot_ntrades = pivot_ntrades.loc[pivot_norm.index]

fig, ax = plt.subplots(figsize=(12, max(8, len(pivot_ntrades) * 0.5)))
im = ax.imshow(pivot_ntrades.values, cmap="YlOrRd", aspect="auto")

ax.set_xticks(range(len(pivot_ntrades.columns)))
ax.set_xticklabels(pivot_ntrades.columns, fontsize=11)
ax.set_yticks(range(len(pivot_ntrades.index)))
ax.set_yticklabels(pivot_ntrades.index, fontsize=10)

for i in range(len(pivot_ntrades.index)):
    for j in range(len(pivot_ntrades.columns)):
        val = pivot_ntrades.iloc[i, j]
        ax.text(j, i, f"{val:.0f}", ha="center", va="center", fontsize=9)

plt.colorbar(im, ax=ax, label="Number of Trades", shrink=0.8)
ax.set_title("Normalised Signals: Trade Count (threshold in σ units)", fontsize=13)
plt.tight_layout()
plt.show()
""")

code("""
# Gross PnL (no fees) — does any model have directional edge?
pivot_gross = norm_df.pivot(index="Model", columns="Config", values="Gross PnL")
pivot_gross = pivot_gross[["0.5σ entry", "1.0σ entry", "1.5σ entry", "2.0σ entry"]]
pivot_gross = pivot_gross.loc[pivot_norm.index]

fig, ax = plt.subplots(figsize=(12, max(8, len(pivot_gross) * 0.5)))
im = ax.imshow(pivot_gross.values, cmap="RdYlGn", aspect="auto")

ax.set_xticks(range(len(pivot_gross.columns)))
ax.set_xticklabels(pivot_gross.columns, fontsize=11)
ax.set_yticks(range(len(pivot_gross.index)))
ax.set_yticklabels(pivot_gross.index, fontsize=10)

for i in range(len(pivot_gross.index)):
    for j in range(len(pivot_gross.columns)):
        val = pivot_gross.iloc[i, j]
        color = "white" if abs(val) > pivot_gross.values.std() else "black"
        ax.text(j, i, f"{val:+.4f}", ha="center", va="center", fontsize=9, color=color)

plt.colorbar(im, ax=ax, label="Gross PnL (no fees)", shrink=0.8)
ax.set_title("Normalised Signals: GROSS PnL (no fees) — Does any model have directional edge?", fontsize=13)
plt.tight_layout()
plt.show()
""")

code("""
# Best normalised combo per model
best_norm = norm_df.loc[norm_df.groupby("Model")["Avg Daily PnL"].idxmax()]
best_norm = best_norm.sort_values("Avg Daily PnL", ascending=False).reset_index(drop=True)
best_norm.index += 1
best_norm.index.name = "Rank"

# Flag statistical significance (≥30 trades)
best_norm["Significant"] = best_norm["Trades"].apply(lambda t: "✅" if t >= 30 else "⚠️ <30 trades")

display(best_norm[[
    "Model", "Config", "Avg Daily PnL", "Gross PnL", "Net PnL", "Total Fees",
    "Trades", "Win Rate", "Sharpe", "Max DD", "Signal IC", "Significant",
]].style.format({
    "Avg Daily PnL": "{:+.6f}", "Gross PnL": "{:+.4f}", "Net PnL": "{:+.4f}",
    "Total Fees": "{:.4f}", "Win Rate": "{:.3f}",
    "Sharpe": "{:+.2f}", "Max DD": "{:+.4f}", "Signal IC": "{:+.4f}",
}).background_gradient(subset=["Avg Daily PnL"], cmap="RdYlGn"))
""")

code("""
# Equity curves for top 6 normalised combos
top_norm = norm_df.nlargest(6, "Avg Daily PnL")[["Model", "Config"]].values.tolist()

fig, axes = plt.subplots(2, 3, figsize=(20, 10))
axes_flat = axes.flatten()

for i, (model, config) in enumerate(top_norm):
    ax = axes_flat[i]
    result = norm_results[(model, config)]
    daily = result.to_daily_dataframe()
    if daily.empty:
        ax.set_title(f"{model} / {config}\\n(no data)")
        continue

    ax.plot(daily["date"], daily["cumulative_pnl"], linewidth=1.2, color="steelblue")
    ax.fill_between(daily["date"], daily["drawdown"], 0, alpha=0.15, color="red")
    ax.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)

    m = result.metrics
    n_trades = m["num_trades"]
    sig_flag = "" if n_trades >= 30 else " ⚠️"
    ax.set_title(
        f"{model} / {config}{sig_flag}\\n"
        f"PnL={m['total_pnl']:+.4f}  Sharpe={m['sharpe']:+.2f}  "
        f"Trades={n_trades:.0f}  WR={m['win_rate']:.2f}",
        fontsize=9,
    )
    ax.tick_params(axis="x", rotation=30, labelsize=8)

plt.suptitle("Top 6 Normalised Model×Config Equity Curves (with Vanta fees)", fontsize=14, y=1.01)
plt.tight_layout()
plt.show()
""")

code("""
# Side-by-side: raw vs normalised — did normalisation help?
print("RAW vs NORMALISED — Best combo per model")
print("=" * 110)
print(f"{'Model':>25s}  {'Raw Best':>18s} {'Raw PnL':>10s} {'Raw #':>6s}  "
      f"{'Norm Best':>12s} {'Norm PnL':>10s} {'Norm #':>6s}  {'Delta':>10s}")
print("-" * 110)

for model_name in sorted(signal_results):
    # Best raw
    raw_combos = [(c, all_results[(model_name, c)].metrics) for c in CONFIGS]
    best_raw = max(raw_combos, key=lambda x: x[1]["avg_daily_pnl"])

    # Best normalised
    norm_combos = [(c, norm_results[(model_name, c)].metrics) for c in NORM_CONFIGS]
    best_n = max(norm_combos, key=lambda x: x[1]["avg_daily_pnl"])

    delta = best_n[1]["avg_daily_pnl"] - best_raw[1]["avg_daily_pnl"]
    marker = "↑" if delta > 1e-7 else ("↓" if delta < -1e-7 else "=")

    print(f"{model_name:>25s}  {best_raw[0]:>18s} {best_raw[1]['avg_daily_pnl']:>+10.6f} {best_raw[1]['num_trades']:>6.0f}  "
          f"{best_n[0]:>12s} {best_n[1]['avg_daily_pnl']:>+10.6f} {best_n[1]['num_trades']:>6.0f}  "
          f"{delta:>+10.6f} {marker}")
""")

md("""
## 13. Key Takeaways

Run this cell to get an automated summary of findings.
""")

code("""
print("=" * 70)
print("  POSITION BACKTEST ANALYSIS — KEY FINDINGS")
print("=" * 70)

# ── Raw signal results ──
best = leaderboard.iloc[0]
print(f"\\n── RAW SIGNALS (fixed thresholds) ──")
print(f"🏆 Best combo: {best['Model']} / {best['Config']}")
print(f"   Avg daily PnL: {best['Avg Daily PnL']:+.6f}")
print(f"   Sharpe: {best['Sharpe']:+.2f},  Trades: {best['Trades']:.0f}")

# Best surviving combo
surviving = leaderboard[leaderboard["Eliminated"] == False]
if not surviving.empty:
    best_safe = surviving.iloc[0]
    print(f"🛡️ Best surviving: {best_safe['Model']} / {best_safe['Config']}")
    print(f"   Avg daily PnL: {best_safe['Avg Daily PnL']:+.6f},  Max DD: {best_safe['Max DD']:+.4f}")

# ── Normalised signal results ──
print(f"\\n── NORMALISED SIGNALS (σ-based thresholds) ──")
best_norm_row = norm_df.loc[norm_df["Avg Daily PnL"].idxmax()]
print(f"🏆 Best combo: {best_norm_row['Model']} / {best_norm_row['Config']}")
print(f"   Avg daily PnL: {best_norm_row['Avg Daily PnL']:+.6f}")
print(f"   Sharpe: {best_norm_row['Sharpe']:+.2f},  Trades: {best_norm_row['Trades']:.0f}")

sig_trades = best_norm_row["Trades"]
if sig_trades < 30:
    print(f"   ⚠️  Only {sig_trades:.0f} trades — NOT statistically significant (need ≥30)")

# Count models with positive gross PnL (ignoring fees) at 1σ
gross_at_1sig = norm_df[norm_df["Config"] == "1.0σ entry"]["Gross PnL"]
models_at_1sig = norm_df[norm_df["Config"] == "1.0σ entry"]["Model"]
n_gross_pos = (gross_at_1sig > 0).sum()
print(f"\\n📊 Models with positive GROSS PnL at 1σ threshold: {n_gross_pos}/{len(gross_at_1sig)}")
for model, gpnl in zip(models_at_1sig, gross_at_1sig):
    if gpnl > 0:
        ic = signal_metrics.get(model, {}).get("ic", 0)
        net = norm_df[(norm_df["Model"] == model) & (norm_df["Config"] == "1.0σ entry")]["Net PnL"].values[0]
        trades = norm_df[(norm_df["Model"] == model) & (norm_df["Config"] == "1.0σ entry")]["Trades"].values[0]
        sig = "✅" if trades >= 30 else "⚠️"
        print(f"   {sig} {model}: gross={gpnl:+.4f}  net={net:+.4f}  IC={ic:+.4f}  trades={trades:.0f}")

n_net_pos = norm_df.groupby("Model")["Avg Daily PnL"].max()
n_profitable = (n_net_pos > 0).sum()
print(f"\\n💰 Models profitable after fees (any config): {n_profitable}/{len(n_net_pos)}")

# ── Fee impact ──
moderate_fees = results_df[results_df["Config"] == "Moderate"]["Total Fees"]
print(f"\\n💸 Average fee drag (Moderate config): {moderate_fees.mean():.4f}")

# ── IC correlation ──
ic_vals = norm_df[norm_df["Config"] == "1.0σ entry"]["Signal IC"].values
pnl_vals = norm_df[norm_df["Config"] == "1.0σ entry"]["Avg Daily PnL"].values
if len(ic_vals) > 2:
    corr = np.corrcoef(ic_vals, pnl_vals)[0, 1]
    print(f"\\n📈 IC → Net PnL correlation (normalised, 1σ): {corr:+.3f}")

# ── Verdict ──
print(f"\\n{'─'*70}")
if n_profitable == 0:
    print("⚠️  VERDICT: No model has edge after Vanta fees.")
    print("   The signal layer needs better models before the position layer matters.")
    print("   Priorities: improve IC, re-tune multi-TF models against correct tick delivery.")
elif n_profitable <= 2:
    print(f"🔍 VERDICT: {n_profitable} model(s) show marginal edge — investigate further.")
    print("   Check statistical significance (≥30 trades) and out-of-sample stability.")
else:
    print(f"✅ VERDICT: {n_profitable} models show edge — ready for position layer optimisation.")

print("=" * 70)
""")

nb.cells = cells

# Write notebook
import os
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "position-backtest-analysis.ipynb")
with open(out_path, "w") as f:
    nbf.write(nb, f)
print(f"Wrote {out_path}")
