"""
Strategy Template: Walk-Forward Factor Selection with
Meta-Selection

WHAT THIS DOES
==============
A long-short US equity strategy in four stages:

  Stage 1  For each of ~400 stock characteristics, build a monthly
           "factor portfolio" return: weight each stock by its
           cross-sectional rank (rank - 0.5), long the high end, short the
           low end.
  Stage 2  Define a MENU of candidate strategy configurations (lookback
           window x number of factors x selection rule x weighting rule)
           and simulate each one WALK-FORWARD: at each rebalance it picks
           factors using only past data, then earns the realized returns.
           Every config therefore has its own out-of-sample track record.
  Stage 3  META-SELECTION: at each rebalance, rank configs by the Sharpe
           ratio of their own out-of-sample history so far, and blend the
           top K. The strategy's METHODOLOGY is chosen by past data -- not
           by you peeking at the test period. This is the whole point.
  Stage 4  Convert the blended configs' factor weights into stock-level
           weights (exact replication), output (id, eom, w).

WHY THE META LAYER MATTERS
==========================
If you backtest 36 configurations, pick the best one over 1990-2023, and
report its 1990-2023 Sharpe, that number is biased: you used the answer to
choose the model. Here the config live at month t is chosen from
performance up to t-1 only, so the post-1990 numbers are honest.

RUN IT
======
    python 04-StrategyTemplate.py --data C:/data/jkp

Expects ctff_chars.parquet / ctff_features.parquet in --data
(see 02-DownloadJKPdata.py). Prints Sharpe ratios by period.

YOUR ASSIGNMENT
===============
Search for "EXTENSION POINT" below. Each is a self-contained place to add
your own idea WITHOUT breaking temporal integrity. Suggested order:
  1. Add a weighting scheme (easiest -- e.g. inverse-volatility).
  2. Add a selection rule (e.g. Sortino, or Sharpe shrunk toward zero).
  3. Change the meta-criterion (e.g. trailing 10y Sharpe instead of
     expanding; or penalize configs with high turnover).
  4. Add a factor-construction variant (e.g. decile spread portfolios).
Re-run after each change and write down what happened in 2004-2013 and
2014-2023 -- the honest decades -- not just the full period.

CTF Admin Modifications (2026-09-30):
--------------------------------------
1. Narrowed the exception handler in select_cluster_sharpe from
   "except Exception" to "except (ValueError, RuntimeError)".
   Reason: the pipeline's security checks reject exception handlers that
   catch every exception without re-raising. The fallback to plain Sharpe
   ranking is unchanged for the errors clustering actually raises on a
   degenerate distance matrix.
"""

import argparse
import time

import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import squareform

# ---------------------------------------------------------------------------
# Hyperparameters. Fix these BEFORE looking at test-period results, and
# resist the urge to tune them afterwards -- that re-introduces the bias
# this design removes.
# ---------------------------------------------------------------------------
REBAL_MONTHS = 6      # rebalance / meta-selection frequency (months)
META_TOP_K = 3        # how many configs the meta layer blends
META_MIN_OBS = 120    # months of config history before meta-selection starts
MIN_OBS = 60          # months of data required for a factor to be usable
MIN_STOCKS = 10       # min stocks per side of a factor portfolio
MIN_HISTORY = 24      # months of history before the first rebalance
SHRINKAGE = 0.3       # covariance shrinkage in mean-variance weighting
N_CLUSTERS_MULT = 3   # clusters = n_factors * this, in ClusterSharpe


# ===========================================================================
# Stage 1: factor portfolio returns
# ===========================================================================
def compute_factor_ls_returns(chars, feature_names, min_stocks, min_obs):
    """
    Linear-rank long-short return for every feature, every month.

    weight_i = pct_rank(feature_i) - 0.5          (mean ~ 0)
    LS_t     = sum_i(weight_i * ret_i) / sum_i(|weight_i|)

    CONVENTION (memorize this): the LS value stored at month t uses
    ret_exc_lead1m, i.e. it is the return earned over month t+1. It is
    KNOWN only at the end of month t+1. All later stages must respect this.
    """
    print("  Stage 1: factor LS returns...")
    t0 = time.time()

    valid_features = [f for f in feature_names if f in chars.columns]
    df = chars[["eom", "ret_exc_lead1m"] + valid_features]
    df = df[df["ret_exc_lead1m"].notna()]

    months = sorted(df["eom"].unique())
    ls_matrix = np.full((len(months), len(valid_features)), np.nan)

    for m_idx, eom in enumerate(months):
        month_data = df[df["eom"] == eom]
        if len(month_data) < min_stocks * 2:
            continue

        ret = month_data["ret_exc_lead1m"].values
        ranks = month_data[valid_features].rank(pct=True).values
        w = np.nan_to_num(ranks, nan=0.5) - 0.5
        not_nan = ~np.isnan(month_data[valid_features].values)
        w = w * not_nan

        num = (ret[:, None] * w).sum(axis=0)
        den = np.abs(w).sum(axis=0)
        ok = (not_nan.sum(axis=0) >= min_stocks * 2) & (den > 1e-10)
        row = np.full(len(valid_features), np.nan)
        row[ok] = num[ok] / den[ok]
        ls_matrix[m_idx] = row

    ls_df = pd.DataFrame(ls_matrix, index=pd.to_datetime(months),
                         columns=valid_features).sort_index()
    ls_df = ls_df[ls_df.columns[ls_df.notna().sum() >= min_obs]]
    print(f"    {ls_df.shape[0]} months x {ls_df.shape[1]} factors "
          f"({time.time()-t0:.0f}s)")
    return ls_df

    # ----------------------------------------------------------------------
    # EXTENSION POINT 1: factor construction.
    # Ideas: decile spread (top 10% minus bottom 10%, equal-weighted);
    # z-score weights instead of rank weights; industry-neutral ranks
    # (rank within `sic` groups); volatility-scaled factor returns.
    # Add a parallel function and a new menu dimension "ls_type".
    # ----------------------------------------------------------------------


# ===========================================================================
# Selection rules: history -> ordered list of factor names
# ===========================================================================
def _sharpe_series(tc):
    stds = tc.std().replace(0, np.nan)
    return ((tc.mean() / stds) * np.sqrt(12)).fillna(0)


def select_sharpe(tc, valid, n_factors):
    """Top-N factors by annualized Sharpe over the lookback slice."""
    return list(_sharpe_series(tc[valid]).nlargest(
        min(n_factors, len(valid))).index)


def select_cluster_sharpe(tc, valid, n_factors):
    """
    Diversified top-N: cluster factors by return correlation, then take the
    best-Sharpe factor from each of the best N clusters. Prevents picking
    five flavours of the same momentum factor.
    """
    tcv = tc[valid]
    if len(tcv) < 12 or len(valid) < n_factors:
        return select_sharpe(tc, valid, n_factors)

    corr = tcv.corr().fillna(0).clip(-1, 1)
    dist = (1.0 - corr.abs()).values.copy()
    np.fill_diagonal(dist, 0)
    dist = np.clip((dist + dist.T) / 2, 0, None)
    try:
        condensed = np.nan_to_num(squareform(dist, checks=False),
                                  nan=1.0, posinf=1.0, neginf=0.0)
        Z = linkage(condensed, method="average")
        n_clusters = min(max(n_factors * N_CLUSTERS_MULT, 20), len(valid))
        labels = fcluster(Z, t=n_clusters, criterion="maxclust")
    # CTF-FIX: catch only what clustering raises on a degenerate distance matrix
    except (ValueError, RuntimeError):
        return select_sharpe(tc, valid, n_factors)

    scores = _sharpe_series(tcv)
    cdf = pd.DataFrame({"factor": valid, "cluster": labels,
                        "score": scores.fillna(-999).values})
    best = cdf.loc[cdf.groupby("cluster")["score"].idxmax()]
    best = best[best["score"] > -998].sort_values("score", ascending=False)
    return best.head(n_factors)["factor"].tolist()


SELECT_FN = {
    "Sharpe": select_sharpe,
    "ClusterSharpe": select_cluster_sharpe,
    # ----------------------------------------------------------------------
    # EXTENSION POINT 2: add a selection rule and list it in the menu below.
    # Ideas: Sortino ratio; Sharpe shrunk toward the cross-factor mean
    # (penalizes short lucky streaks); |Sharpe| with sign-flipping (short
    # the factor if its Sharpe is negative); selection on t-statistics.
    # Signature: fn(tc, valid, n_factors) -> list of factor names, where
    # tc is the lookback slice of LS returns (NaN already filled with 0).
    # ----------------------------------------------------------------------
}


# ===========================================================================
# Weighting rules: history + chosen factors -> weight vector (sums to 1)
# ===========================================================================
def weight_ew(tc, factors):
    return np.full(len(factors), 1.0 / len(factors))


def weight_rank_sharpe(tc, factors):
    """Best factor by Sharpe gets the largest weight (rank weights)."""
    order = _sharpe_series(tc[factors]).rank(method="first")
    w = order.values.astype(float)
    return w / w.sum()


def weight_mean_var(tc, factors):
    """w ~ Sigma^{-1} mu with diagonal shrinkage; long-only, sums to 1."""
    tcf = tc[factors].fillna(0)
    n = len(factors)
    if len(tcf) < max(12, n + 2):
        return np.full(n, 1.0 / n)
    mu, cov = tcf.mean().values, tcf.cov().values
    cov = (1 - SHRINKAGE) * cov + SHRINKAGE * (np.trace(cov) / n) * np.eye(n)
    try:
        w = np.linalg.inv(cov) @ mu
    except np.linalg.LinAlgError:
        return np.full(n, 1.0 / n)
    w = np.maximum(w, 0)
    return w / w.sum() if w.sum() > 1e-10 else np.full(n, 1.0 / n)


WEIGHT_FN = {
    "EW": weight_ew,
    "RankSharpe": weight_rank_sharpe,
    "MeanVar": weight_mean_var,
    # ----------------------------------------------------------------------
    # EXTENSION POINT 3: add a weighting scheme and list it in the menu.
    # Ideas: inverse volatility (w_k ~ 1/sigma_k); risk parity; equal
    # risk contribution; mean-variance with a long-short factor book.
    # Signature: fn(tc, factors) -> np.array of weights summing to 1.
    # ----------------------------------------------------------------------
}


# ===========================================================================
# The candidate menu. Keep it SYMMETRIC (a full cross), not cherry-picked.
# ===========================================================================
CONFIG_GRID = [
    {"window": w, "n_factors": n, "selection": s, "weighting": g}
    for w in (10, 20, None)            # lookback years; None = expanding
    for n in (5, 10)                   # number of factors held
    for s in SELECT_FN                 # selection rules registered above
    for g in WEIGHT_FN                 # weighting rules registered above
]
# ---------------------------------------------------------------------------
# EXTENSION POINT 4: menu design. Add dimensions (rebalance frequency,
# ls_type from Extension 1, ...) or values -- but beware: a bigger menu
# means more noise for the meta layer to sort through, and each config
# needs ~10 years of track record before it can be chosen. Quality over
# quantity.
# ---------------------------------------------------------------------------


def config_label(cfg):
    w = "all" if cfg["window"] is None else f"{cfg['window']}y"
    return f"{w}/{cfg['n_factors']}/{cfg['selection']}/{cfg['weighting']}"


# ===========================================================================
# Stage 2: walk-forward simulation of every config
# ===========================================================================
def simulate_configs(ls_returns, configs, rebal_months, min_obs, min_history):
    """
    At each rebalance rp, every config selects factors/weights from LS rows
    <= rp - 1 month, then earns the realized LS returns until the next
    rebalance. Returns the per-config schedules and out-of-sample returns.
    """
    print("  Stage 2: walk-forward config simulation...")
    t0 = time.time()
    all_months = ls_returns.index.sort_values()
    rebal_points = list(all_months[::rebal_months])

    oos = pd.DataFrame(np.nan, index=all_months, columns=range(len(configs)))
    schedule = {}
    sel_cache = {}

    for i, rp in enumerate(rebal_points):
        sel_end = rp - pd.DateOffset(months=1)   # last KNOWN LS row
        ls_avail = ls_returns.loc[:sel_end]
        if len(ls_avail.dropna(how="all")) < min_history:
            continue

        next_rp = rebal_points[i + 1] if i + 1 < len(rebal_points) else None
        block = (all_months[(all_months >= rp) & (all_months < next_rp)]
                 if next_rp is not None else all_months[all_months >= rp])

        threshold = min(min_obs, max(6, len(ls_avail) // 2))
        valid = list(ls_avail.columns[ls_avail.notna().sum() >= threshold])
        if not valid:
            continue

        per_cfg = {}
        for ci, cfg in enumerate(configs):
            if cfg["window"] is None:
                tc = ls_avail
            else:
                start = (sel_end - pd.DateOffset(years=cfg["window"])
                         + pd.DateOffset(months=1))
                tc = ls_avail.loc[start:]
            tc = tc.fillna(0)
            if len(tc) < 12:
                continue

            key = (rp, cfg["window"], cfg["selection"], cfg["n_factors"])
            if key not in sel_cache:
                sel_cache[key] = SELECT_FN[cfg["selection"]](
                    tc, valid, cfg["n_factors"])
            factors = sel_cache[key]
            if not factors:
                continue

            weights = WEIGHT_FN[cfg["weighting"]](tc, factors)
            per_cfg[ci] = (factors, weights)
            oos.loc[block, ci] = (
                ls_returns.loc[block, factors].fillna(0).values @ weights)

        if per_cfg:
            schedule[rp] = per_cfg

    print(f"    {len(configs)} configs x {len(schedule)} rebalances "
          f"({time.time()-t0:.0f}s)")
    return rebal_points, schedule, oos


# ===========================================================================
# Stage 3: meta-selection
# ===========================================================================
def meta_select(rebal_points, schedule, oos, configs, top_k, meta_min_obs):
    """
    At each rebalance, blend the top_k configs by the annualized Sharpe of
    their OWN out-of-sample returns up to t-1. Until any config has
    meta_min_obs months of history, hold an equal blend of all configs.
    """
    print("  Stage 3: meta-selection...")
    chosen, log = {}, []
    for rp in rebal_points:
        if rp not in schedule:
            continue
        active = list(schedule[rp].keys())
        hist = oos.loc[:rp - pd.DateOffset(months=1)]
        counts = hist.notna().sum()

        eligible = [ci for ci in active if counts[ci] >= meta_min_obs]
        if eligible:
            score = {}
            for ci in eligible:
                s = hist[ci].dropna()
                sd = s.std()
                score[ci] = (s.mean() / sd) * np.sqrt(12) if sd > 0 else -np.inf
            top = sorted(score, key=score.get, reverse=True)[:top_k]
            mode = "meta"
            # --------------------------------------------------------------
            # EXTENSION POINT 5: the meta-criterion. This expanding Sharpe
            # is the simplest defensible choice. Ideas: trailing 10-year
            # Sharpe (adapts faster, noisier); minimum Sharpe across
            # sub-windows (rewards consistency); Sharpe net of an estimated
            # transaction-cost drag; performance-weighted blend of ALL
            # configs (softmax over scores) instead of a top-K cutoff.
            # Whatever you do, only use `hist` -- rows <= rp - 1 month.
            # --------------------------------------------------------------
        else:
            top, mode = active, "ew_prior"

        chosen[rp] = top
        log.append({"rebal": rp, "mode": mode,
                    "chosen": ";".join(config_label(configs[ci])
                                       for ci in top)})
    n_meta = sum(1 for r in log if r["mode"] == "meta")
    print(f"    {len(chosen)} rebalances ({n_meta} meta-selected)")
    return chosen, log


# ===========================================================================
# Stage 4: stock-level weights
# ===========================================================================
def compute_stock_weights(chars, schedule, chosen):
    """
    Blend the chosen configs equally and map factor weights to stocks:
      w_i = (1/K) * sum_cfg sum_k  fw_k * (rank_i - 0.5) / sum_j|rank_j - 0.5|
    Each config's stock portfolio exactly replicates its factor-level
    return, so Stage 3's statistics describe the portfolio actually held.
    """
    print("  Stage 4: stock-level weights...")
    t0 = time.time()
    rebal_dates = sorted(chosen.keys())
    if not rebal_dates:
        return pd.DataFrame(columns=["id", "eom", "w"])

    all_factors = sorted({f for rp in rebal_dates for ci in chosen[rp]
                          for f in schedule[rp][ci][0]})
    available = [f for f in all_factors if f in chars.columns]
    df_work = chars[["id", "eom"] + available]
    months = sorted(df_work["eom"].unique())

    results = []
    for eom in months:
        applicable = [d for d in rebal_dates if d <= eom]
        if not applicable:
            continue
        rp = applicable[-1]
        cfg_ids = chosen[rp]

        month_data = df_work[df_work["eom"] == eom]
        if len(month_data) < 50:
            continue

        needed = sorted({f for ci in cfg_ids for f in schedule[rp][ci][0]
                         if f in month_data.columns})
        if not needed:
            continue
        ranks_all = month_data[needed].rank(pct=True)

        stock_w = np.zeros(len(month_data))
        blend = 1.0 / len(cfg_ids)
        for ci in cfg_ids:
            factors, weights = schedule[rp][ci]
            for factor, fw in zip(factors, weights):
                if factor not in ranks_all.columns:
                    continue
                r = ranks_all[factor].values
                nan_mask = np.isnan(r)
                rw = np.where(nan_mask, 0.0, r - 0.5)
                denom = np.abs(rw).sum()
                if denom < 1e-10 or (~nan_mask).sum() < MIN_STOCKS * 2:
                    continue
                stock_w += blend * fw * rw / denom

        nz = np.abs(stock_w) > 1e-15
        if nz.any():
            results.append(pd.DataFrame({"id": month_data["id"].values[nz],
                                         "eom": eom, "w": stock_w[nz]}))

    output = pd.concat(results, ignore_index=True)
    print(f"    {len(output):,} rows, {output['eom'].nunique()} months "
          f"({time.time()-t0:.0f}s)")
    return output


# ===========================================================================
# Entry point (course interface): main(chars, features, daily_ret)
# ===========================================================================
def main(chars, features, daily_ret):
    # JKP CTF Rule 18 asks for an explicit seed. The strategy uses no
    # randomness, so this is a no-op kept for compliance only.
    np.random.seed(42)
    feature_names = features["features"].tolist()
    chars = chars.copy()
    chars["eom"] = pd.to_datetime(chars["eom"])
    n_months = chars["eom"].nunique()
    print(f"Data: {len(chars):,} stock-months, {len(feature_names)} "
          f"features, {n_months} months")

    if n_months < 200:   # small validation datasets: loosen thresholds
        min_obs, min_stocks = max(6, n_months // 5), 5
        min_history, meta_min_obs = 12, max(24, n_months // 3)
    else:
        min_obs, min_stocks = MIN_OBS, MIN_STOCKS
        min_history, meta_min_obs = MIN_HISTORY, META_MIN_OBS

    ls = compute_factor_ls_returns(chars, feature_names, min_stocks, min_obs)
    rebal_points, schedule, oos = simulate_configs(
        ls, CONFIG_GRID, REBAL_MONTHS, min_obs, min_history)
    chosen, meta_log = meta_select(
        rebal_points, schedule, oos, CONFIG_GRID, META_TOP_K, meta_min_obs)

    main.meta_log = meta_log          # diagnostics for graders/notebooks
    main.config_oos = oos
    main.config_labels = [config_label(c) for c in CONFIG_GRID]

    output = compute_stock_weights(chars, schedule, chosen)
    assert set(output.columns) == {"id", "eom", "w"}
    assert output["w"].notna().all() and len(output) > 0
    return output


# ===========================================================================
# Stand-alone runner with evaluation
# ===========================================================================
if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--data", required=True,
                    help="directory containing ctff_*.parquet")
    ap.add_argument("--out", default=None,
                    help="path for the weights CSV "
                         "(default: <data>/weights.csv)")
    args = ap.parse_args()

    chars = pd.read_parquet(f"{args.data}/ctff_chars.parquet")
    features = pd.read_parquet(f"{args.data}/ctff_features.parquet")
    weights = main(chars, features,
                   pd.DataFrame(columns=["id", "date", "ret_exc"]))

    # Gross portfolio returns: weights at eom t earn ret_exc_lead1m of t
    chars["eom"] = pd.to_datetime(chars["eom"])
    weights["eom"] = pd.to_datetime(weights["eom"])
    merged = weights.merge(
        chars[["id", "eom", "ret_exc_lead1m"]].dropna(),
        on=["id", "eom"], how="inner")
    port = (merged["w"] * merged["ret_exc_lead1m"]).groupby(
        merged["eom"]).sum().sort_index()

    def sharpe(r):
        return r.mean() / r.std() * np.sqrt(12) if len(r) >= 12 else np.nan

    print("\n  SHARPE RATIOS BY PERIOD (gross)")
    for name, (a, b) in {
        "1960-1989": ("1960", "1989"), "1990-2003": ("1990", "2003"),
        "2004-2013": ("2004", "2013"), "2014-2023": ("2014", "2023"),
        "1990-2023": ("1990", "2023"),
    }.items():
        sub = port.loc[a:b]
        print(f"  {name}: {sharpe(sub):7.3f}  ({len(sub)} months)")

    # One-way turnover (per month, as fraction of gross book)
    piv = weights.pivot_table(index="eom", columns="id", values="w",
                              fill_value=0.0)
    to = piv.diff().abs().sum(axis=1) / 2
    print(f"\n  Avg monthly one-way turnover: {to.iloc[1:].mean():.3f}")
    # NOTE: returns above are GROSS. A simple net adjustment:
    # net_t = gross_t - 2 * turnover_t * cost_per_side (e.g. 0.0025).

    # Portfolio weights in JKP CTF submission format (id, eom YYYY-MM-DD, w).
    # main() guarantees no missing weights; graders score only ctff_test rows
    # but all rows are exported. See https://jkpfactors.com/ctf/rules .
    out_path = args.out or f"{args.data}/weights.csv"
    submission = weights[["id", "eom", "w"]].copy()
    submission["eom"] = submission["eom"].dt.strftime("%Y-%m-%d")
    submission.to_csv(out_path, index=False)
    print(f"  Wrote {len(submission):,} weight rows -> {out_path}")
