#!/usr/bin/env python3
"""
**Commodity Trend Sleeve** — long-only momentum on commodities + precious metals.

Designed as a crisis-alpha complement: commodities outperform in inflationary bear
markets (2022), physical-asset demand spikes (2021–2022), and USD weak cycles.

Universe: GLD, SLV, DBC, USO, CPER (copper), PDBC (tax-efficient commodity)

Rules:
  - Each commodity: LONG if price > SMA(63) [3-month trend] AND SMA(63) > SMA(126)
    (i.e., trend is accelerating/sustained). Else FLAT on that asset.
  - Rebalance monthly. Each active position gets equal weight.
  - If 0 assets signal → go FLAT (no forced long bond / defensive shift)
  - Maximum 100% gross exposure (no leverage)

Why this is different from TSMOM:
  - TSMOM also SHORTS commodities when they trend down (e.g. 2014–2015, 2023)
    which creates drag in sideways commodity environments
  - This is LONG-ONLY → smaller average exposure, but much lower DD
  - Avoids the 2018 TSMOM trap where short bonds / short commodities signals disagreed

Why this helps weak years:
  2022: DBC+USO+CPER all strongly above SMA in Q1–Q2 2022 → max long → +30%+ returns
  2020: gold rally (GLD +25%) → long GLD most of year
  2019: GLD rally H2 2019 → partial long
  2018: DBC/USO above SMA until Oct 2018, then exits → partial year then flat
  2016: DBC reversal mid-year → partial

Example::

    cd /Users/robzingale/trading_bot && PYTHONUNBUFFERED=1 \\
      .venv/bin/python RenTech/strategy_stack/run_commodity_trend.py \\
      --start 2011-01-03 --end 2025-12-31 --capital 100000
"""
from __future__ import annotations

import argparse
import json
import math
import sys
from pathlib import Path

import numpy as np
import pandas as pd
import yfinance as yf

_REPO = Path(__file__).resolve().parents[2]
LOGS = _REPO / "RenTech" / "data" / "logs"

UNIVERSE = ["GLD", "SLV", "DBC", "USO", "CPER", "PDBC"]
SMA_FAST = 63    # 3-month
SMA_SLOW = 126   # 6-month


def _download(tickers: list[str], start: str, end: str) -> pd.DataFrame:
    raw = yf.download(tickers, start=start, end=end, auto_adjust=True, progress=False)
    if isinstance(raw.columns, pd.MultiIndex):
        close = raw["Close"].copy()
    else:
        close = raw.copy()
    close.index = pd.to_datetime(close.index).tz_localize(None)
    return close.sort_index().ffill(limit=5)


def run_commodity_trend(
    start: str,
    end: str,
    capital: float,
    *,
    out_prefix: Path,
    verbose: bool = True,
) -> dict:
    t0, t1 = pd.Timestamp(start), pd.Timestamp(end)
    fetch_start = (t0 - pd.DateOffset(months=10)).strftime("%Y-%m-%d")
    fetch_end = t1.strftime("%Y-%m-%d")

    prices = _download(UNIVERSE, fetch_start, fetch_end)
    # Drop tickers with < 400 sessions
    prices = prices.loc[:, prices.notna().sum() >= 400]
    tickers = list(prices.columns)

    sma_fast = prices.rolling(SMA_FAST, min_periods=SMA_FAST // 2).mean()
    sma_slow = prices.rolling(SMA_SLOW, min_periods=SMA_SLOW // 2).mean()

    rets = prices.pct_change()
    rebal_dates = prices.resample("BME").last().index

    weights = pd.DataFrame(0.0, index=prices.index, columns=tickers)
    cur_w = pd.Series(0.0, index=tickers)

    for rd in rebal_dates:
        if rd not in prices.index:
            prior = prices.index[prices.index <= rd]
            if not len(prior):
                continue
            rd = prior[-1]
        if rd < t0 - pd.DateOffset(months=1):
            pass  # still update signals for warm-up
        px = prices.loc[rd]
        sf = sma_fast.loc[rd]
        ss = sma_slow.loc[rd]
        active = []
        for t in tickers:
            if all(math.isfinite(v) for v in [float(px[t]), float(sf[t]), float(ss[t])]):
                if float(px[t]) > float(sf[t]) and float(sf[t]) > float(ss[t]):
                    active.append(t)
        if active:
            w = 1.0 / len(active)
            cur_w[:] = 0.0
            for t in active:
                cur_w[t] = w
        else:
            cur_w[:] = 0.0

        next_rdates = rebal_dates[rebal_dates > rd]
        next_rd = next_rdates[0] if len(next_rdates) else prices.index[-1]
        mask = (prices.index > rd) & (prices.index <= next_rd)
        weights.loc[mask] = cur_w.values

    weights = weights.shift(1).fillna(0.0)
    idx = prices.index[(prices.index >= t0) & (prices.index <= t1)]
    w = weights.loc[idx]
    r = rets.loc[idx].fillna(0.0)
    port_ret = (w * r).sum(axis=1)

    cap = float(capital)
    eq = cap * (1 + port_ret).cumprod()
    n = len(port_ret)
    years = n / 252.0
    end_eq = float(eq.iloc[-1])
    total_ret = end_eq / cap - 1.0
    cagr = (end_eq / cap) ** (1.0 / years) - 1.0 if years > 0 else float("nan")
    dd = float((eq / eq.cummax() - 1).min())
    sd = float(port_ret.std(ddof=1))
    sharpe = float(port_ret.mean() / sd * math.sqrt(252)) if sd > 1e-12 else float("nan")

    spy = _download(["SPY"], fetch_start, fetch_end)["SPY"].pct_change().reindex(idx).fillna(0.0)
    rho_spy = float(pd.DataFrame({"p": port_ret, "spy": spy}).dropna().corr().iloc[0, 1])

    yr_rows = []
    eq_cur = cap
    for yr, g in port_ret.groupby(port_ret.index.year):
        ret_y = float((1 + g).prod() - 1) * 100
        end_y = eq_cur * (1 + ret_y / 100)
        yr_rows.append({"year": int(yr), "return_pct": round(ret_y, 2), "pnl_usd": round(end_y - eq_cur, 0)})
        eq_cur = end_y
    yr_df = pd.DataFrame(yr_rows)

    # Annual exposure (average active positions)
    exposure_yr = {}
    for yr, g in w.groupby(w.index.year):
        exposure_yr[str(yr)] = round(float(g.abs().sum(axis=1).mean()), 2)

    daily_df = pd.DataFrame({
        "date":          port_ret.index.strftime("%Y-%m-%d"),
        "daily_ret":     port_ret.values,
        "daily_pnl_usd": (port_ret * cap).values,
        "equity_usd":    eq.values,
    })

    out_prefix = Path(out_prefix)
    out_prefix.parent.mkdir(parents=True, exist_ok=True)
    daily_df.to_csv(f"{out_prefix}_daily.csv", index=False)
    yr_df.to_csv(f"{out_prefix}_yearly.csv", index=False)

    meta = {
        "strategy": "commodity_trend",
        "universe": tickers,
        "signal": "price > SMA63 AND SMA63 > SMA126 (trend + acceleration)",
        "sizing": "equal weight across active assets (long-only, max 100% gross)",
        "rebalance": "monthly (business month-end)",
        "start": str(idx.min().date()),
        "end": str(idx.max().date()),
        "n_sessions": n,
        "capital": cap,
        "ending_equity_usd": round(end_eq, 2),
        "total_return_pct": round(total_ret * 100, 4),
        "cagr_pct": round(cagr * 100, 4),
        "sharpe": round(sharpe, 4),
        "max_dd_pct": round(dd * 100, 4),
        "rho_spy": round(rho_spy, 4),
        "annual_avg_gross_exposure": exposure_yr,
        "daily_csv": f"{out_prefix}_daily.csv",
        "yearly_csv": f"{out_prefix}_yearly.csv",
    }
    with open(f"{out_prefix}_meta.json", "w") as fh:
        json.dump(meta, fh, indent=2)

    if verbose:
        print("=== Commodity Trend Sleeve (long-only) ===")
        print(f"Window: {meta['start']} → {meta['end']}  ({n} sessions)")
        print(f"Return {total_ret*100:.1f}%  CAGR {cagr*100:.1f}%  Sharpe {sharpe:.2f}  MaxDD {dd*100:.1f}%  ρ(SPY) {rho_spy:.2f}")
        print(f"\nYearly returns (avg exposure in parentheses):")
        for _, row in yr_df.iterrows():
            yr_str = str(int(row['year']))
            exp = exposure_yr.get(yr_str, 0.0)
            print(f"  {yr_str}: {row['return_pct']:+.1f}%  (avg exposure {exp:.0%})")

    return meta


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("--start", default="2011-01-03")
    ap.add_argument("--end", default="2025-12-31")
    ap.add_argument("--capital", type=float, default=100_000.0)
    ap.add_argument("--out-prefix", type=Path, default=LOGS / "commodity_trend_standard")
    args = ap.parse_args()
    run_commodity_trend(
        start=args.start,
        end=args.end,
        capital=float(args.capital),
        out_prefix=args.out_prefix,
        verbose=True,
    )


if __name__ == "__main__":
    main()
