#!/usr/bin/env python3
"""Portfolio Optimizer — combine uncorrelated strategies for 15% monthly target.

Given N strategies with daily returns, find the optimal combination that:
- Maximizes monthly return
- Keeps max drawdown within RiskMgmt limits (10% total, 5% daily)
- Diversifies across uncorrelated strategies
"""

import json
import os
from pathlib import Path

import numpy as np
import pandas as pd

PROJECT = Path(__file__).resolve().parent.parent
RESULTS_DIR = PROJECT / "results" / "strategies_new"
STRATEGIES_DIR = PROJECT / "results" / "strategies"
FACTORS_DIR = PROJECT / "results" / "factors"
VALUES_DIR = FACTORS_DIR / "values"
OHLCV_PATH = Path(os.getenv("PREDIX_OHLCV_PATH",
    str(PROJECT / "git_ignore_folder" / "intraday_pv_all.h5")))

TARGET_MONTHLY = 15.0
MAX_DD = 0.10       # RiskMgmt: 10% max total drawdown
MAX_DAILY_DD = 0.05  # RiskMgmt: 5% max daily drawdown
MIN_TRADES = 30
MIN_SHARPE = 0.5


def load_strategies() -> list[dict]:
    """Load all strategy JSONs with real (non-fabricated) verified metrics."""
    strategies = []
    seen = set()
    for d in (STRATEGIES_DIR, RESULTS_DIR):
        if not d.exists():
            continue
        for p in d.glob("*.json"):
            try:
                r = json.loads(p.read_text())
            except Exception:
                continue
            if not isinstance(r, dict):
                continue
            name = r.get("strategy_name", p.stem)
            if name in seen:
                continue
            seen.add(name)

            s = r.get("summary", {})
            if not isinstance(s, dict):
                s = {}
            m = r.get("metrics", {})
            if not isinstance(m, dict):
                m = {}

            # Extract metrics (prefer summary, fallback to metrics)
            sharpe = float(s.get("sharpe") or m.get("sharpe") or 0)
            mon_pct = float(s.get("monthly_return_pct") or s.get("oos_monthly_return_pct")
                         or m.get("monthly_return_pct") or 0)
            max_dd = float(s.get("max_drawdown") or s.get("oos_max_drawdown")
                        or m.get("max_drawdown") or 0)
            win_rate = float(s.get("win_rate") or s.get("oos_win_rate")
                          or m.get("win_rate") or 0)
            n_trades = int(s.get("n_trades") or s.get("oos_n_trades")
                        or s.get("real_n_trades") or m.get("n_trades") or 0)
            total_ret = float(s.get("total_return") or m.get("total_return") or 0)

            # Filter fabricated
            if mon_pct == 200 and sharpe == 3.0 and abs(max_dd + 0.167) < 0.01:
                continue
            if mon_pct == -20 and max_dd == -1.0:
                continue
            if sharpe == 200:
                continue

            # Filter quality
            if n_trades < MIN_TRADES or sharpe < MIN_SHARPE:
                continue
            if mon_pct <= 0:
                continue

            strategies.append({
                "name": name,
                "file": str(p),
                "sharpe": sharpe,
                "monthly_pct": mon_pct,
                "max_dd": max_dd,
                "win_rate": win_rate,
                "n_trades": n_trades,
                "total_return": total_ret,
                "factors": r.get("factor_names") or r.get("factors_used") or [],
                "code": r.get("code", ""),
            })

    return strategies


def load_strategy_returns(strategy: dict, close_daily: pd.Series) -> pd.Series | None:
    """Reconstruct daily strategy returns from code and factor data."""
    code = strategy.get("code", "")
    if not code:
        return None

    factors_list = strategy.get("factors", [])
    if not factors_list:
        return None

    # Load factor values
    factor_series = {}
    for fname in factors_list:
        safe = str(fname).replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
        parq = VALUES_DIR / f"{safe}.parquet"
        if not parq.exists():
            continue
        try:
            s = pd.read_parquet(str(parq))
            if isinstance(s.index, pd.MultiIndex):
                s = s.xs("EURUSD", level="instrument")[s.columns[0]]
            # Align to close_daily index
            s = s.resample("D").last().reindex(close_daily.index).ffill(limit=5)
            factor_series[fname] = s
        except Exception:
            continue

    if len(factor_series) < 2:
        return None

    df_factors = pd.DataFrame(factor_series).dropna()
    if len(df_factors) < 100:
        return None

    # Execute strategy code on daily data
    local_vars = {"factors": df_factors, "close": close_daily.reindex(df_factors.index)}
    try:
        exec(code, {"np": np, "pd": pd, "numpy": np}, local_vars)
    except Exception:
        # Can't execute — use simple IC-weighted signal as fallback
        return None

    signal = local_vars.get("signal")
    if signal is None or not isinstance(signal, pd.Series):
        return None

    # Compute daily returns from signal
    common = close_daily.index.intersection(signal.index)
    c = close_daily.loc[common]
    s = signal.loc[common].clip(-1, 1).fillna(0)

    fwd_ret = c.pct_change().shift(-1)
    strat_ret = s.shift(1) * fwd_ret
    strat_ret = strat_ret.dropna()

    if len(strat_ret) < 30:
        return None

    return strat_ret


def build_simple_signal(factors_list: list[str], close_daily: pd.Series) -> tuple[pd.Series, pd.Series]:
    """Build simple IC-weighted daily signal (fallback when code fails)."""
    import json as _json

    factor_series = {}
    ic_values = {}
    for fname in factors_list:
        safe = str(fname).replace("/", "_").replace("\\", "_").replace(" ", "_")[:150]
        parq = VALUES_DIR / f"{safe}.parquet"
        jf = FACTORS_DIR / f"{safe}.json"
        if not parq.exists():
            continue
        ic = 0.0
        if jf.exists():
            ic = float(_json.loads(jf.read_text()).get("ic", 0))
        try:
            s = pd.read_parquet(str(parq))
            if isinstance(s.index, pd.MultiIndex):
                s = s.xs("EURUSD", level="instrument")[s.columns[0]]
            s = s.resample("D").last().reindex(close_daily.index).ffill(limit=5)
            factor_series[fname] = s
            ic_values[fname] = ic
        except Exception:
            continue

    df = pd.DataFrame(factor_series).dropna()
    if len(df) < 50:
        return pd.Series(), pd.Series()

    # z-score composite
    window = 20
    z = (df - df.rolling(window).mean()) / (df.rolling(window).std() + 1e-8)

    composite = pd.Series(0.0, index=df.index)
    total_ic = sum(abs(v) for v in ic_values.values())
    if total_ic == 0:
        total_ic = 1.0
    for col in df.columns:
        ic = ic_values.get(col, 0)
        w = abs(ic) / total_ic
        sign = -1 if ic < 0 else 1
        composite += sign * w * z[col]

    signal = pd.Series(0, index=df.index)
    signal[composite > 0.5] = 1
    signal[composite < -0.5] = -1

    # Compute returns
    common = close_daily.index.intersection(signal.index)
    c = close_daily.loc[common]
    s = signal.loc[common].clip(-1, 1).fillna(0)
    fwd_ret = c.pct_change().shift(-1)
    strat_ret = s.shift(1) * fwd_ret
    return signal, strat_ret.dropna()


def compute_portfolio_metrics(returns: list[pd.Series], weights: list[float],
                              close_daily: pd.Series) -> dict:
    """Compute portfolio-level metrics from weighted strategy returns."""
    if not returns:
        return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}

    # Align all return series
    common_idx = returns[0].index
    for r in returns[1:]:
        common_idx = common_idx.intersection(r.index)
    if len(common_idx) < 50:
        return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}

    aligned = pd.DataFrame({i: r.loc[common_idx] for i, r in enumerate(returns)}).dropna()
    if len(aligned) < 30:
        return {"monthly_pct": 0, "max_dd": 0, "sharpe": 0}

    # Weighted portfolio return
    port_ret = pd.Series(0.0, index=aligned.index)
    for i in range(len(returns)):
        port_ret += weights[i] * aligned[i]

    # Equity curve
    eq = (1 + port_ret).cumprod()
    peak = eq.cummax()
    max_dd = float(((eq - peak) / peak).min())

    total_ret = float(eq.iloc[-1] - 1)
    n_days = (port_ret.index[-1] - port_ret.index[0]).days
    n_months = max(n_days / 30.44, 1)
    monthly = float((1 + total_ret) ** (1 / n_months) - 1)

    sharpe = float(port_ret.mean() / port_ret.std() * np.sqrt(252)) if port_ret.std() > 0 else 0
    daily_dd = float(port_ret.min())  # Worst daily return

    return {
        "monthly_pct": monthly * 100,
        "max_dd": max_dd,
        "sharpe": sharpe,
        "daily_worst": daily_dd,
        "n_days": len(port_ret),
        "n_months": n_months,
    }


def main():
    print("=" * 60)
    print("  Portfolio Optimizer — 15% Monthly Target")
    print("=" * 60)

    # Load OHLCV daily
    print("\nLoading data...")
    df = pd.read_hdf(OHLCV_PATH, key="data")
    close = df.xs("EURUSD", level="instrument")["$close"].sort_index()
    close_daily = close.resample("D").last().dropna()
    print(f"  Daily bars: {len(close_daily)}")

    # Load strategies
    strategies = load_strategies()
    print(f"  Real strategies: {len(strategies)}")

    # Build daily returns for each strategy
    print("\nBuilding strategy returns...")
    strat_returns = []
    strat_names = []
    for s in strategies[:50]:  # Limit to top 50 for speed
        rets = load_strategy_returns(s, close_daily)
        if rets is None or len(rets) < 30:
            # Use simple signal as fallback
            _, rets = build_simple_signal(s["factors"], close_daily)
        if rets is not None and len(rets) >= 30:
            strat_returns.append(rets)
            strat_names.append(s["name"])
            print(f"  [{len(strat_returns)}] {s['name'][:40]:40s} "
                  f"Sh={s['sharpe']:.1f} Mon={s['monthly_pct']:.1f}% Tr={s['n_trades']}")

    if len(strat_returns) < 2:
        print("\n  Not enough valid strategies.")
        return

    print(f"\n  Valid return series: {len(strat_returns)}")

    # Find best portfolio via greedy selection (low correlation, high return)
    print("\n--- Greedy Portfolio Selection ---")
    print(f"  Target: {TARGET_MONTHLY}% monthly | Max DD: {MAX_DD:.0%} | Max Daily DD: {MAX_DAILY_DD:.0%}")
    print()

    # Compute individual metrics
    individual = []
    for i, (rets, name) in enumerate(zip(strat_returns, strat_names)):
        eq = (1 + rets).cumprod()
        dd = float(((eq - eq.cummax()) / eq.cummax()).min())
        total = float(eq.iloc[-1] - 1)
        n = max((rets.index[-1] - rets.index[0]).days / 30.44, 1)
        mon = float((1 + total) ** (1 / n) - 1) * 100
        individual.append({"idx": i, "name": name, "monthly": mon, "dd": dd, "n": len(rets)})

    individual.sort(key=lambda x: x["monthly"], reverse=True)

    # Greedy: add strategies one by one if they don't increase correlation too much
    selected = []
    selected_rets = []

    for s in individual:
        if len(selected) >= 8:
            break
        # Check correlation with existing portfolio
        new_ret = strat_returns[s["idx"]]
        if selected_rets:
            common = new_ret.index
            for r in selected_rets:
                common = common.intersection(r.index)
            if len(common) < 30:
                continue
            cors = []
            for r in selected_rets:
                aligned_new = new_ret.loc[common]
                aligned_r = r.loc[common]
                if len(aligned_new) >= 30:
                    cors.append(abs(aligned_new.corr(aligned_r)))
            if cors and max(cors) > 0.5:
                print(f"  SKIP {s['name'][:40]} (max_corr={max(cors):.2f})")
                continue

        selected.append(s)
        selected_rets.append(new_ret)
        print(f"  ADD  {s['name'][:40]:40s} Mon={s['monthly']:+.1f}% DD={s['dd']:.3f} corr<0.5")

    # Evaluate portfolio
    if len(selected) >= 2:
        print(f"\n  Portfolio: {len(selected)} strategies")
        weights = [1.0 / len(selected)] * len(selected)
        rets = [strat_returns[s["idx"]] for s in selected]
        pm = compute_portfolio_metrics(rets, weights, close_daily)

        print(f"  Equal-weight metrics:")
        print(f"    Monthly return: {pm['monthly_pct']:.2f}%")
        print(f"    Max drawdown:   {pm['max_dd']:.3f}")
        print(f"    Sharpe:         {pm['sharpe']:.2f}")
        print(f"    Worst day:      {pm['daily_worst']:.3%}")
        print(f"    Period:         {pm['n_months']:.1f} months ({pm['n_days']} days)")

        # Leverage scaling
        max_safe_lev = min(
            MAX_DD / abs(pm["max_dd"]) if pm["max_dd"] != 0 else 30,
            MAX_DAILY_DD / abs(pm["daily_worst"]) if pm["daily_worst"] != 0 else 30,
            30,
        )
        leveraged_monthly = pm["monthly_pct"] * max_safe_lev
        print(f"\n  Max safe leverage: {max_safe_lev:.1f}× (limited by max DD {MAX_DD:.0%})")
        print(f"  Leveraged monthly:  {leveraged_monthly:.1f}%")

        if leveraged_monthly >= TARGET_MONTHLY:
            print(f"\n  ✓ MEETS TARGET! {leveraged_monthly:.1f}% ≥ {TARGET_MONTHLY}%")
        else:
            gap = TARGET_MONTHLY - leveraged_monthly
            needed_strategies = int(np.ceil(len(selected) * TARGET_MONTHLY / max(leveraged_monthly, 0.1)))
            print(f"\n  ✗ Below target. Need ~{needed_strategies} strategies or {TARGET_MONTHLY/max(pm['monthly_pct'],0.01):.1f}× better monthly.")

    # Save portfolio config
    out = {
        "target_monthly": TARGET_MONTHLY,
        "selected": [{"name": s["name"], "monthly": s["monthly"], "dd": s["dd"]} for s in selected],
        "portfolio": pm if len(selected) >= 2 else {},
    }
    out_path = RESULTS_DIR / "portfolio_config.json"
    out_path.write_text(json.dumps(out, indent=2, default=str))
    print(f"\n  Saved → {out_path}")


if __name__ == "__main__":
    main()
