#!/usr/bin/env python3
"""Research-only dynamic ensemble overlay from strategy equity curves.

This does not route orders through the main backtester. It combines already
realized daily return series from multiple strategy runs using lagged rolling
Sharpe weights.
"""

from __future__ import annotations

import argparse
import json
from dataclasses import dataclass
from pathlib import Path

import numpy as np
import pandas as pd


TRADING_DAYS_PER_YEAR = 252.0


@dataclass(frozen=True)
class CurveInput:
    label: str
    path: Path


def _parse_curve_arg(value: str) -> CurveInput:
    if "=" not in value:
        raise argparse.ArgumentTypeError("curve must be LABEL=/abs/path/to/daily_equity_curve.parquet")
    label, raw_path = value.split("=", 1)
    label = label.strip()
    path = Path(raw_path.strip())
    if not label:
        raise argparse.ArgumentTypeError("curve label must be non-empty")
    if not path.exists():
        raise argparse.ArgumentTypeError(f"curve file not found: {path}")
    return CurveInput(label=label, path=path)


def _load_curve(curve: CurveInput) -> pd.DataFrame:
    df = pd.read_parquet(curve.path)
    if "date" not in df.columns or "equity" not in df.columns:
        raise ValueError(f"{curve.path} must contain date/equity columns")
    out = df[["date", "equity"]].copy()
    out["date"] = pd.to_datetime(out["date"])
    out = out.sort_values("date")
    out[curve.label] = out["equity"].pct_change().fillna(0.0)
    return out[["date", curve.label]]


def _rolling_sharpe(series: pd.Series, window: int) -> pd.Series:
    mean = series.rolling(window, min_periods=window).mean()
    std = series.rolling(window, min_periods=window).std(ddof=0)
    sharpe = mean / std.replace(0.0, np.nan)
    return sharpe * np.sqrt(TRADING_DAYS_PER_YEAR)


def build_ensemble_returns(
    daily_returns: pd.DataFrame,
    window: int = 63,
    fallback: str = "equal",
) -> tuple[pd.DataFrame, pd.DataFrame]:
    strategy_cols = [c for c in daily_returns.columns if c != "date"]
    if len(strategy_cols) < 2:
        raise ValueError("need at least two strategy return series")

    weights = pd.DataFrame({"date": daily_returns["date"]})
    rolling = {}
    for col in strategy_cols:
        rolling[col] = _rolling_sharpe(daily_returns[col], window).shift(1)
    sharpes = pd.DataFrame({"date": daily_returns["date"], **rolling})

    positive = sharpes[strategy_cols].clip(lower=0.0)
    positive_sum = positive.sum(axis=1)
    if fallback == "equal":
        fallback_weights = pd.DataFrame(
            np.full((len(daily_returns), len(strategy_cols)), 1.0 / len(strategy_cols)),
            columns=strategy_cols,
        )
    else:
        raise ValueError(f"unsupported fallback: {fallback}")

    normalized = positive.div(positive_sum.replace(0.0, np.nan), axis=0)
    resolved = normalized.where(positive_sum.gt(0), fallback_weights)
    weights = pd.concat([weights, resolved], axis=1)

    ensemble = daily_returns.copy()
    ensemble["ensemble_return"] = 0.0
    for col in strategy_cols:
        ensemble["ensemble_return"] += ensemble[col] * weights[col]
    return ensemble, weights


def summarize_ensemble(ensemble_returns: pd.DataFrame, initial_equity: float) -> dict:
    out = ensemble_returns[["date", "ensemble_return"]].copy()
    out["equity"] = initial_equity * (1.0 + out["ensemble_return"]).cumprod()
    out["peak"] = out["equity"].cummax()
    out["drawdown_pct"] = (out["equity"] / out["peak"] - 1.0) * 100.0

    total_return_pct = (out["equity"].iloc[-1] / out["equity"].iloc[0] - 1.0) * 100.0
    years = max(len(out) / TRADING_DAYS_PER_YEAR, 1.0 / TRADING_DAYS_PER_YEAR)
    annualized_return_pct = ((out["equity"].iloc[-1] / out["equity"].iloc[0]) ** (1.0 / years) - 1.0) * 100.0
    daily_mean = out["ensemble_return"].mean()
    daily_std = out["ensemble_return"].std(ddof=0)
    sharpe = float(daily_mean / daily_std * np.sqrt(TRADING_DAYS_PER_YEAR)) if daily_std > 0 else 0.0

    out["year"] = out["date"].dt.year
    yearly_returns = {}
    for year, year_df in out.groupby("year"):
        yearly_returns[str(year)] = (
            (1.0 + year_df["ensemble_return"]).prod() - 1.0
        ) * 100.0

    return {
        "total_return_pct": total_return_pct,
        "annualized_return_pct": annualized_return_pct,
        "max_drawdown_pct": float(-out["drawdown_pct"].min()),
        "sharpe_ratio": sharpe,
        "days": int(len(out)),
        "yearly_returns_pct": yearly_returns,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Research-only rolling-Sharpe ensemble overlay")
    parser.add_argument("--curve", action="append", type=_parse_curve_arg, required=True, help="LABEL=/abs/path/to/daily_equity_curve.parquet")
    parser.add_argument("--window", type=int, default=63, help="Rolling Sharpe lookback in trading days")
    parser.add_argument("--initial-equity", type=float, default=100000.0)
    parser.add_argument("--fallback", choices=["equal"], default="equal")
    parser.add_argument("--output-dir", required=True, help="Directory to write ensemble artifacts")
    args = parser.parse_args()

    curves = args.curve
    merged = None
    for curve in curves:
        loaded = _load_curve(curve)
        merged = loaded if merged is None else merged.merge(loaded, on="date", how="inner")

    if merged is None or merged.empty:
        raise SystemExit("no overlapping dates across curves")

    ensemble, weights = build_ensemble_returns(merged, window=args.window, fallback=args.fallback)
    summary = summarize_ensemble(ensemble, args.initial_equity)

    out_dir = Path(args.output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    ensemble_out = ensemble[["date", "ensemble_return"]].copy()
    ensemble_out["equity"] = args.initial_equity * (1.0 + ensemble_out["ensemble_return"]).cumprod()
    ensemble_out.to_parquet(out_dir / "daily_equity_curve.parquet", index=False)
    weights.to_parquet(out_dir / "weights.parquet", index=False)
    (out_dir / "summary.json").write_text(json.dumps(summary, indent=2))

    print(json.dumps(summary, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
