#!/usr/bin/env python3
"""
Parametric generate → evaluate → select → memory → OOS verify loop on the literature spine.

Does **not** retrain a model. Compounds: tested variants, composite fitness, distilled
failure rules, and an out-of-sample gate the generator never uses for ranking.

Defaults (research protocol)::

    IS  = start … train-end   (score + mutate + select)
    OOS = test-start … end    (hard promotion gate)

Smoke (fast)::

    cd /Users/robzingale/trading_bot && PYTHONUNBUFFERED=1 .venv/bin/python \\
      RenTech/strategy_stack/agentic_strategy_loop.py \\
      --smoke --out-dir RenTech/data/logs/agentic_loop/smoke

Canonical (slow; full Theta) ::

    cd /Users/robzingale/trading_bot && PYTHONUNBUFFERED=1 .venv/bin/python \\
      RenTech/strategy_stack/agentic_strategy_loop.py \\
      --start 2016-01-04 --train-end 2021-12-31 \\
      --test-start 2022-01-03 --end 2024-12-31 \\
      --generations 4 --proposals-per-gen 20 --seed-n 40 \\
      --out-dir RenTech/data/logs/agentic_loop/lit_2016_2024
"""
from __future__ import annotations

import argparse
import hashlib
import json
import math
import random
import sys
import time
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import numpy as np
import pandas as pd

_REPO = Path(__file__).resolve().parents[2]
if str(_REPO) not in sys.path:
    sys.path.insert(0, str(_REPO))

from RenTech.strategy_stack import research_literature_theta_strategies as L
from RenTech.strategy_stack.literature_search_agent import _compile_signal, _compile_trade
from RenTech.strategy_stack.literature_strategy_catalog import StrategySpec, build_catalog_100

_DEFAULT_THETA = _REPO / "RenTech" / "data" / "theta_chunks"

# Families that cleared Sharpe≥1.5 under bid/ask in the 2016–2022 lit sweep (AGENTS.md).
_PREFERRED_FAMILIES = frozenset({"short_rr", "putwrite", "mild_vix_put_spread"})

# Seeded lessons: consult before proposing (do not re-discover from scratch).
_SEED_LESSONS: list[dict[str, Any]] = [
    {
        "rule_id": "seed_atm_short_vol_weak",
        "action": "discourage_family",
        "family": "vrp_short_straddle",
        "reason": (
            "ATM short straddles/strangles (VRP/VIX/VVIX/RV) rarely cleared Sharpe≥1.5 "
            "under bid/ask in 2016–2022 lit sweep"
        ),
    },
    {
        "rule_id": "seed_low_vix_strangle_weak",
        "action": "discourage_family",
        "family": "low_vix_strangle",
        "reason": "Same bid/ask short-vol grid underperformance as ATM straddles",
    },
    {
        "rule_id": "seed_prefer_rr_putwrite_putvert",
        "action": "prefer_family",
        "families": list(_PREFERRED_FAMILIES),
        "reason": "Short RR/skew, putwrite, mild-VIX put credit spreads cleared the bar",
    },
]


def _utc_now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _norm_ts(d: pd.Timestamp) -> pd.Timestamp:
    return L._norm(d)


def fingerprint(spec: StrategySpec) -> str:
    raw = (
        f"{spec.family}|{spec.hold}|{spec.sig_kind}|{spec.sig_params}|"
        f"{spec.trade_kind}|{spec.trade_params}"
    )
    return hashlib.sha1(raw.encode()).hexdigest()[:16]


def max_dd_frac(eq: pd.Series) -> float:
    if eq.empty:
        return float("nan")
    peak = eq.cummax()
    dd = (eq / peak.replace(0, np.nan) - 1.0).min()
    return float(dd) if pd.notna(dd) else float("nan")


def pos_year_frac(eq: pd.Series) -> float:
    if eq.empty:
        return float("nan")
    fracs: list[float] = []
    for y in sorted(eq.index.year.unique()):
        sub = eq[eq.index.year == y]
        if len(sub) < 8:
            continue
        a, b = float(sub.iloc[0]), float(sub.iloc[-1])
        if a <= 0 or not math.isfinite(a) or not math.isfinite(b):
            continue
        fracs.append(1.0 if b > a else 0.0)
    if not fracs:
        return float("nan")
    return float(np.mean(fracs))


def composite_fitness(
    *,
    sharpe: float,
    max_dd: float,
    trades: int,
    stability: float,
    target_sharpe: float,
    min_trades: int,
    dd_cap: float,
    w_sharpe: float = 0.45,
    w_dd: float = 0.25,
    w_trades: float = 0.15,
    w_stab: float = 0.15,
) -> float:
    """Bounded multi-metric score; higher is better. NaNs → 0 contribution."""
    sh = float(sharpe) if math.isfinite(sharpe) else 0.0
    sh_term = max(0.0, min(2.0, sh / max(target_sharpe, 1e-6)))

    dd = float(max_dd) if math.isfinite(max_dd) else -1.0
    # dd is ≤0; map −dd_cap → 0, 0 → 1
    dd_term = max(0.0, min(1.0, 1.0 + dd / max(dd_cap, 1e-6)))

    tr_term = max(0.0, min(1.0, float(trades) / max(min_trades, 1)))
    st = float(stability) if math.isfinite(stability) else 0.0
    st_term = max(0.0, min(1.0, st))

    return float(
        w_sharpe * sh_term + w_dd * dd_term + w_trades * tr_term + w_stab * st_term
    )


def _jitter_value(v: Any, rng: random.Random) -> Any:
    if isinstance(v, bool):
        return (not v) if rng.random() < 0.15 else v
    if isinstance(v, int) and not isinstance(v, bool):
        step = rng.choice([-2, -1, 0, 1, 2])
        return max(1, int(v) + step)
    if isinstance(v, float):
        if abs(v) < 1e-12:
            return float(v)
        scale = 1.0 + rng.uniform(-0.18, 0.18)
        out = float(v) * scale
        # Keep signs for deltas / gaps
        if v < 0:
            out = -abs(out)
        return round(out, 6)
    return v


def mutate_spec(
    parent: StrategySpec,
    *,
    gen: int,
    seq: int,
    rng: random.Random,
    seen: set[str],
    discouraged: set[str],
) -> StrategySpec | None:
    """Parametric mutate of hold / sig_params / trade_params; same family/kinds."""
    for _ in range(24):
        hold = max(1, int(parent.hold) + rng.choice([-2, -1, 0, 0, 1, 2]))
        sp = tuple(_jitter_value(x, rng) for x in parent.sig_params)
        tp = tuple(_jitter_value(x, rng) for x in parent.trade_params)
        # Keep DTE ints sane when present in trade_params[0]
        if tp and isinstance(tp[0], int):
            tp = (max(7, min(55, int(tp[0]))),) + tp[1:]
        if sp and parent.sig_kind in {"vrp", "vvix", "rvcomp", "rr"} and len(sp) >= 2:
            # second or relevant slot often DTE
            sp_list = list(sp)
            for j, x in enumerate(sp_list):
                if isinstance(x, int) and 5 <= x <= 60:
                    sp_list[j] = max(7, min(55, int(x)))
            sp = tuple(sp_list)

        child = StrategySpec(
            sid=f"G{gen:02d}M{seq:03d}",
            family=parent.family,
            description=f"mut({parent.sid}): {parent.description}",
            hold=hold,
            sig_kind=parent.sig_kind,
            sig_params=sp,
            trade_kind=parent.trade_kind,
            trade_params=tp,
        )
        fp = fingerprint(child)
        if fp in seen:
            continue
        if child.family in discouraged and rng.random() < 0.7:
            continue
        seen.add(fp)
        return child
    return None


def slice_window(
    days: list[pd.Timestamp],
    panel: pd.DataFrame,
    iv_atm: dict[tuple[int, int], float | None],
    skew: dict[tuple[int, int, float, float], float | None],
    n_contracts: list[int],
    *,
    start: pd.Timestamp | None,
    end: pd.Timestamp | None,
) -> tuple[
    list[pd.Timestamp],
    pd.DataFrame,
    dict[tuple[int, int], float | None],
    dict[tuple[int, int, float, float], float | None],
    list[int],
]:
    """Remap session-index caches so ``iloc[i]`` / ``iv_atm[(i,…)]`` stay valid."""
    idxs: list[int] = []
    for i, d in enumerate(days):
        t = _norm_ts(d)
        if start is not None and t < start:
            continue
        if end is not None and t > end:
            continue
        idxs.append(i)
    days_w = [days[i] for i in idxs]
    idx_dates = [_norm_ts(d) for d in days_w]
    panel_w = panel.reindex(idx_dates)
    n_w = [n_contracts[i] for i in idxs]
    iv_w: dict[tuple[int, int], float | None] = {}
    for local, gi in enumerate(idxs):
        for (gi2, dte), val in iv_atm.items():
            if gi2 == gi:
                iv_w[(local, dte)] = val
    skew_w: dict[tuple[int, int, float, float], float | None] = {}
    for local, gi in enumerate(idxs):
        for (gi2, dte, pdel, cdel), val in skew.items():
            if gi2 == gi:
                skew_w[(local, dte, pdel, cdel)] = val
    return days_w, panel_w, iv_w, skew_w, n_w


def eval_spec(
    spec: StrategySpec,
    *,
    days: list[pd.Timestamp],
    panel: pd.DataFrame,
    get_chain: Any,
    iv_atm: dict,
    skew: dict,
    n_contracts: list[int],
    spy_wide: pd.DataFrame,
    capital: float,
    target_sharpe: float,
    min_trades: int,
    dd_cap: float,
) -> dict[str, Any]:
    sig = _compile_signal(spec, panel, iv_atm, skew, n_contracts, spy_wide)
    tfn = _compile_trade(spec)
    ex, pnls, ntr = L.run_signal_backtest(
        days, get_chain, panel, sig, int(spec.hold), tfn, spec.trade_params
    )
    eq, sh = L.equity_curve_from_realized(ex, pnls, days, capital)
    dd = max_dd_frac(eq)
    stab = pos_year_frac(eq)
    sh_f = float(sh) if math.isfinite(sh) else float("nan")
    fit = composite_fitness(
        sharpe=sh_f,
        max_dd=dd,
        trades=int(ntr),
        stability=stab,
        target_sharpe=target_sharpe,
        min_trades=min_trades,
        dd_cap=dd_cap,
    )
    end_eq = float(eq.iloc[-1]) if len(eq) else capital
    ret_pct = (end_eq / capital - 1.0) * 100.0 if capital > 0 else float("nan")
    return {
        "sid": spec.sid,
        "family": spec.family,
        "description": spec.description,
        "hold": int(spec.hold),
        "sig_kind": spec.sig_kind,
        "trade_kind": spec.trade_kind,
        "sig_params": list(spec.sig_params),
        "trade_params": list(spec.trade_params),
        "fingerprint": fingerprint(spec),
        "trades": int(ntr),
        "sharpe": sh_f if math.isfinite(sh_f) else None,
        "max_dd_frac": dd if math.isfinite(dd) else None,
        "pos_year_frac": stab if math.isfinite(stab) else None,
        "return_pct": round(ret_pct, 4) if math.isfinite(ret_pct) else None,
        "fitness": round(fit, 6),
        "spec": asdict(spec),
    }


def passes_hard_gates(row: dict[str, Any], *, min_trades: int, min_sharpe: float) -> bool:
    sh = row.get("sharpe")
    if sh is None or not math.isfinite(float(sh)):
        return False
    return int(row["trades"]) >= min_trades and float(sh) >= min_sharpe


def diagnose_failure(row: dict[str, Any], *, min_trades: int, min_sharpe: float, oos: bool) -> dict[str, Any]:
    sh = row.get("sharpe")
    trades = int(row["trades"])
    if trades < min_trades:
        reason = "few_trades"
        rule = f"avoid near-zero activity: family={row['family']} hold≈{row['hold']} needs more signals"
    elif sh is None or not math.isfinite(float(sh)):
        reason = "undefined_sharpe"
        rule = f"ban underdefined equity path: family={row['family']}"
    elif float(sh) < min_sharpe:
        reason = "oos_low_sharpe" if oos else "is_low_sharpe"
        rule = (
            f"ban fingerprint={row['fingerprint']} "
            f"(family={row['family']} {('OOS' if oos else 'IS')} Sharpe={float(sh):.2f}<{min_sharpe})"
        )
    else:
        reason = "other"
        rule = f"reject {row['sid']} family={row['family']}"
    return {
        "timestamp": _utc_now(),
        "sid": row["sid"],
        "family": row["family"],
        "fingerprint": row["fingerprint"],
        "window": "OOS" if oos else "IS",
        "sharpe": sh,
        "trades": trades,
        "fitness": row.get("fitness"),
        "fail_reason": reason,
        "rule": rule,
        "action": "ban_fingerprint",
    }


class MemoryStore:
    def __init__(self, path: Path, seed: list[dict[str, Any]] | None = None) -> None:
        self.path = path
        self.lessons: list[dict[str, Any]] = list(seed or [])
        self.banned_fps: set[str] = set()
        self.discouraged_families: set[str] = set()
        self.preferred_families: set[str] = set()
        for lesson in self.lessons:
            self._apply(lesson)
        self.path.parent.mkdir(parents=True, exist_ok=True)
        if not self.path.exists():
            for lesson in self.lessons:
                self._append(lesson)

    def _apply(self, lesson: dict[str, Any]) -> None:
        action = str(lesson.get("action", ""))
        if action == "ban_fingerprint" and lesson.get("fingerprint"):
            self.banned_fps.add(str(lesson["fingerprint"]))
        if action == "discourage_family" and lesson.get("family"):
            self.discouraged_families.add(str(lesson["family"]))
        if action == "prefer_family":
            for f in lesson.get("families") or []:
                self.preferred_families.add(str(f))

    def _append(self, lesson: dict[str, Any]) -> None:
        with self.path.open("a", encoding="utf-8") as f:
            f.write(json.dumps(lesson, default=str) + "\n")

    def add(self, lesson: dict[str, Any]) -> None:
        self.lessons.append(lesson)
        self._apply(lesson)
        self._append(lesson)

    def is_banned(self, fp: str) -> bool:
        return fp in self.banned_fps


def pick_parents(
    candidates: list[dict[str, Any]],
    *,
    n: int,
    rng: random.Random,
    preferred: set[str],
    discouraged: set[str],
) -> list[dict[str, Any]]:
    if not candidates:
        return []
    ranked = sorted(candidates, key=lambda r: float(r.get("fitness") or 0.0), reverse=True)
    weights: list[float] = []
    for r in ranked:
        w = 1.0 + float(r.get("fitness") or 0.0)
        if r["family"] in preferred:
            w *= 1.6
        if r["family"] in discouraged:
            w *= 0.35
        weights.append(max(w, 0.05))
    out: list[dict[str, Any]] = []
    pool = list(ranked)
    wts = list(weights)
    for _ in range(min(n, len(pool))):
        tot = sum(wts)
        pick = rng.random() * tot
        acc = 0.0
        idx = 0
        for i, wt in enumerate(wts):
            acc += wt
            if acc >= pick:
                idx = i
                break
        out.append(pool.pop(idx))
        wts.pop(idx)
    return out


def run_loop(args: argparse.Namespace) -> dict[str, Any]:
    out_dir = Path(args.out_dir).expanduser().resolve()
    out_dir.mkdir(parents=True, exist_ok=True)
    mem = MemoryStore(out_dir / "memory.jsonl", seed=_SEED_LESSONS)
    rng = random.Random(int(args.seed))

    start = pd.Timestamp(str(args.start))
    train_end = pd.Timestamp(str(args.train_end))
    test_start = pd.Timestamp(str(args.test_start))
    end = pd.Timestamp(str(args.end))
    if train_end >= test_start:
        raise SystemExit("--train-end must be strictly before --test-start")

    t0 = time.perf_counter()
    print(
        f"Preparing Theta {args.start}..{args.end} (IS≤{args.train_end}, OOS≥{args.test_start}) …",
        flush=True,
    )
    days, panel, get_chain, iv_atm, skew, n_contracts, spy_wide = L.prepare_theta_research_context(
        theta_dir=Path(args.theta_dir),
        capital=float(args.capital),
        start=str(args.start),
        end=str(args.end),
        max_days=int(args.max_days),
    )
    print(f"  sessions={len(days)} prepare={(time.perf_counter()-t0)/60:.2f} min", flush=True)

    days_is, panel_is, iv_is, skew_is, n_is = slice_window(
        days, panel, iv_atm, skew, n_contracts, start=start, end=train_end
    )
    days_oos, panel_oos, iv_oos, skew_oos, n_oos = slice_window(
        days, panel, iv_atm, skew, n_contracts, start=test_start, end=end
    )
    print(f"  IS sessions={len(days_is)}  OOS sessions={len(days_oos)}", flush=True)
    if len(days_is) < 40 or len(days_oos) < 20:
        raise SystemExit(f"Window too short: IS={len(days_is)} OOS={len(days_oos)}")

    catalog = build_catalog_100()
    seed_n = min(int(args.seed_n), len(catalog))
    # Prefer known-good families in the seed pool, then fill
    preferred_seed = [s for s in catalog if s.family in _PREFERRED_FAMILIES]
    other_seed = [s for s in catalog if s.family not in _PREFERRED_FAMILIES]
    rng.shuffle(preferred_seed)
    rng.shuffle(other_seed)
    seeds = (preferred_seed + other_seed)[:seed_n]

    seen: set[str] = {fingerprint(s) for s in seeds}
    population: list[StrategySpec] = list(seeds)
    survivors: list[dict[str, Any]] = []
    promoted: list[dict[str, Any]] = []
    history: list[dict[str, Any]] = []

    eval_kw_is = dict(
        days=days_is,
        panel=panel_is,
        get_chain=get_chain,
        iv_atm=iv_is,
        skew=skew_is,
        n_contracts=n_is,
        spy_wide=spy_wide,
        capital=float(args.capital),
        target_sharpe=float(args.target_sharpe),
        min_trades=int(args.min_trades_is),
        dd_cap=float(args.dd_cap),
    )
    eval_kw_oos = dict(
        days=days_oos,
        panel=panel_oos,
        get_chain=get_chain,
        iv_atm=iv_oos,
        skew=skew_oos,
        n_contracts=n_oos,
        spy_wide=spy_wide,
        capital=float(args.capital),
        target_sharpe=float(args.target_sharpe_oos),
        min_trades=int(args.min_trades_oos),
        dd_cap=float(args.dd_cap),
    )

    for gen in range(int(args.generations) + 1):
        # gen 0 = seeds only; later gens = mutate then evaluate batch
        if gen > 0:
            parents = survivors if survivors else [
                {"sid": s.sid, "family": s.family, "fitness": 0.0, "spec": asdict(s)}
                for s in seeds[: max(3, int(args.parents_per_gen))]
            ]
            parent_rows = pick_parents(
                parents,
                n=max(1, int(args.parents_per_gen)),
                rng=rng,
                preferred=mem.preferred_families,
                discouraged=mem.discouraged_families,
            )

            def _row_to_spec(r: dict[str, Any]) -> StrategySpec:
                sp = r["spec"] if "spec" in r else {
                    "sid": r["sid"],
                    "family": r["family"],
                    "description": r["description"],
                    "hold": r["hold"],
                    "sig_kind": r["sig_kind"],
                    "sig_params": r["sig_params"],
                    "trade_kind": r["trade_kind"],
                    "trade_params": r["trade_params"],
                }
                return StrategySpec(
                    sid=str(sp["sid"]),
                    family=str(sp["family"]),
                    description=str(sp["description"]),
                    hold=int(sp["hold"]),
                    sig_kind=str(sp["sig_kind"]),
                    sig_params=tuple(sp["sig_params"]),
                    trade_kind=str(sp["trade_kind"]),
                    trade_params=tuple(sp["trade_params"]),
                )

            fixed_parents = [_row_to_spec(r) for r in parent_rows]
            # Diversity: if parents collapse to one family, inject alternate seeds
            fams = {p.family for p in fixed_parents}
            if len(fams) == 1:
                alt = [s for s in seeds if s.family not in fams]
                rng.shuffle(alt)
                for s in alt[:2]:
                    fixed_parents.append(s)
            new_kids: list[StrategySpec] = []
            seq = 0
            while len(new_kids) < int(args.proposals_per_gen) and fixed_parents:
                parent = rng.choice(fixed_parents)
                child = mutate_spec(
                    parent,
                    gen=gen,
                    seq=seq,
                    rng=rng,
                    seen=seen,
                    discouraged=mem.discouraged_families,
                )
                seq += 1
                if child is None:
                    continue
                if mem.is_banned(fingerprint(child)):
                    continue
                new_kids.append(child)
            population = new_kids
            print(f"\n=== Gen {gen}: evaluating {len(population)} mutants ===", flush=True)
        else:
            print(f"\n=== Gen 0: evaluating {len(population)} seeds ===", flush=True)

        gen_rows: list[dict[str, Any]] = []
        for spec in population:
            row = eval_spec(spec, **eval_kw_is)
            gen_rows.append(row)
            sh_s = f"{row['sharpe']:.2f}" if row["sharpe"] is not None else "nan"
            print(
                f"  IS {row['sid']:10s}  fit={row['fitness']:.3f}  "
                f"Sh={sh_s:>5s}  tr={row['trades']:3d}  fam={row['family']}",
                flush=True,
            )
            if not passes_hard_gates(
                row, min_trades=int(args.min_trades_is), min_sharpe=float(args.min_sharpe_is)
            ):
                mem.add(
                    diagnose_failure(
                        row,
                        min_trades=int(args.min_trades_is),
                        min_sharpe=float(args.min_sharpe_is),
                        oos=False,
                    )
                )

        is_passers = [
            r
            for r in gen_rows
            if passes_hard_gates(
                r, min_trades=int(args.min_trades_is), min_sharpe=float(args.min_sharpe_is)
            )
        ]
        is_passers.sort(key=lambda r: float(r["fitness"]), reverse=True)
        top_k = is_passers[: int(args.survivors_per_gen)]

        # OOS verifier — generator never used these scores for mutation ranking
        verified: list[dict[str, Any]] = []
        for r in top_k:
            spec = StrategySpec(
                sid=r["sid"],
                family=r["family"],
                description=r["description"],
                hold=int(r["hold"]),
                sig_kind=r["sig_kind"],
                sig_params=tuple(r["sig_params"]),
                trade_kind=r["trade_kind"],
                trade_params=tuple(r["trade_params"]),
            )
            oos = eval_spec(spec, **eval_kw_oos)
            bundle = {
                **r,
                "oos": {
                    k: oos[k]
                    for k in (
                        "trades", "sharpe", "max_dd_frac", "pos_year_frac",
                        "return_pct", "fitness",
                    )
                },
            }
            oos_ok = passes_hard_gates(
                oos,
                min_trades=int(args.min_trades_oos),
                min_sharpe=float(args.min_sharpe_oos),
            )
            bundle["oos_pass"] = oos_ok
            oos_sh = f"{oos['sharpe']:.2f}" if oos["sharpe"] is not None else "nan"
            print(
                f"  OOS {r['sid']:10s}  pass={oos_ok}  Sh={oos_sh:>5s}  "
                f"tr={oos['trades']:3d}",
                flush=True,
            )
            if oos_ok:
                verified.append(bundle)
                promoted.append(bundle)
            else:
                mem.add(
                    diagnose_failure(
                        oos,
                        min_trades=int(args.min_trades_oos),
                        min_sharpe=float(args.min_sharpe_oos),
                        oos=True,
                    )
                )

        survivors = verified if verified else top_k[: max(1, int(args.survivors_per_gen) // 2)]
        # Keep parents as StrategySpec-capable rows for next mutate
        for s in survivors:
            if "spec" not in s:
                s["spec"] = {
                    "sid": s["sid"],
                    "family": s["family"],
                    "description": s["description"],
                    "hold": s["hold"],
                    "sig_kind": s["sig_kind"],
                    "sig_params": s["sig_params"],
                    "trade_kind": s["trade_kind"],
                    "trade_params": s["trade_params"],
                }

        gen_payload = {
            "generation": gen,
            "n_evaluated": len(gen_rows),
            "n_is_passers": len(is_passers),
            "n_oos_promoted": len(verified),
            "candidates": gen_rows,
            "oos_verified": verified,
        }
        history.append(gen_payload)
        (out_dir / f"gen_{gen:02d}.json").write_text(
            json.dumps(gen_payload, indent=2, default=str), encoding="utf-8"
        )
        print(
            f"  → IS passers={len(is_passers)}  OOS promoted={len(verified)}  "
            f"survivors_for_next={len(survivors)}",
            flush=True,
        )

    # Deduplicate promotions by fingerprint (best IS fitness)
    by_fp: dict[str, dict[str, Any]] = {}
    for p in promoted:
        fp = p["fingerprint"]
        if fp not in by_fp or float(p["fitness"]) > float(by_fp[fp]["fitness"]):
            by_fp[fp] = p
    final = sorted(by_fp.values(), key=lambda r: float(r["fitness"]), reverse=True)

    summary = {
        "command_meta": {
            "script": "agentic_strategy_loop.py",
            "start": str(args.start),
            "train_end": str(args.train_end),
            "test_start": str(args.test_start),
            "end": str(args.end),
            "max_days": int(args.max_days),
            "capital": float(args.capital),
            "generations": int(args.generations),
            "seed_n": seed_n,
            "proposals_per_gen": int(args.proposals_per_gen),
            "min_sharpe_is": float(args.min_sharpe_is),
            "min_sharpe_oos": float(args.min_sharpe_oos),
            "min_trades_is": int(args.min_trades_is),
            "min_trades_oos": int(args.min_trades_oos),
            "target_sharpe": float(args.target_sharpe),
            "dd_cap": float(args.dd_cap),
            "is_sessions": len(days_is),
            "oos_sessions": len(days_oos),
            "first_day": str(days[0].date()) if days else "",
            "last_day": str(days[-1].date()) if days else "",
            "elapsed_min": round((time.perf_counter() - t0) / 60.0, 2),
            "rng_seed": int(args.seed),
        },
        "memory_path": str(out_dir / "memory.jsonl"),
        "n_memory_lessons": len(mem.lessons),
        "n_banned_fingerprints": len(mem.banned_fps),
        "promoted": final,
        "top3": final[:3],
        "history_gens": len(history),
    }
    (out_dir / "summary.json").write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")

    metrics_lines = [
        f"command: agentic_strategy_loop.py IS[{args.start}..{args.train_end}] "
        f"OOS[{args.test_start}..{args.end}] gens={args.generations} seed_n={seed_n}",
        f"sessions: total={len(days)} IS={len(days_is)} OOS={len(days_oos)}",
        f"capital: {args.capital}",
        f"gates: IS Sharpe≥{args.min_sharpe_is} trades≥{args.min_trades_is}; "
        f"OOS Sharpe≥{args.min_sharpe_oos} trades≥{args.min_trades_oos}",
        f"promoted: {len(final)}  memory_lessons: {len(mem.lessons)}  "
        f"banned_fps: {len(mem.banned_fps)}",
        f"elapsed_min: {summary['command_meta']['elapsed_min']}",
        "",
        "top promoted (IS fitness rank, OOS must pass):",
    ]
    for i, p in enumerate(final[:5], 1):
        oos = p.get("oos") or {}
        metrics_lines.append(
            f"  {i}. {p['sid']} {p['family']}  "
            f"IS Sh={p.get('sharpe')} fit={p.get('fitness')}  "
            f"OOS Sh={oos.get('sharpe')} tr={oos.get('trades')}"
        )
    metrics_path = out_dir / "agentic_strategy_loop_metrics.txt"
    metrics_path.write_text("\n".join(metrics_lines) + "\n", encoding="utf-8")
    print(f"\nWrote {out_dir / 'summary.json'}", flush=True)
    print(f"Wrote {metrics_path}", flush=True)
    print(f"Memory {out_dir / 'memory.jsonl'} ({len(mem.lessons)} lessons)", flush=True)
    return summary


def build_argparser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--theta-dir", type=Path, default=_DEFAULT_THETA)
    ap.add_argument("--capital", type=float, default=100_000.0)
    ap.add_argument("--start", type=str, default="2016-01-04")
    ap.add_argument("--train-end", type=str, default="2021-12-31")
    ap.add_argument("--test-start", type=str, default="2022-01-03")
    ap.add_argument("--end", type=str, default="2024-12-31")
    ap.add_argument("--max-days", type=int, default=0, help="Cap sessions after date filters (smoke)")
    ap.add_argument("--generations", type=int, default=3, help="Mutation generations after gen-0 seeds")
    ap.add_argument("--seed-n", type=int, default=40, help="How many catalog seeds to evaluate in gen 0")
    ap.add_argument("--proposals-per-gen", type=int, default=16)
    ap.add_argument("--parents-per-gen", type=int, default=6)
    ap.add_argument("--survivors-per-gen", type=int, default=5)
    ap.add_argument("--min-sharpe-is", type=float, default=0.8)
    ap.add_argument("--min-sharpe-oos", type=float, default=0.5)
    ap.add_argument("--min-trades-is", type=int, default=20)
    ap.add_argument("--min-trades-oos", type=int, default=12)
    ap.add_argument("--target-sharpe", type=float, default=1.5, help="Fitness normalization for IS")
    ap.add_argument("--target-sharpe-oos", type=float, default=1.0)
    ap.add_argument("--dd-cap", type=float, default=0.25, help="|MaxDD| soft cap in fitness")
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument(
        "--out-dir",
        type=Path,
        default=_REPO / "RenTech" / "data" / "logs" / "agentic_loop" / "default",
    )
    ap.add_argument(
        "--smoke",
        action="store_true",
        help="Short calendar + few seeds/gens for a fast end-to-end check",
    )
    return ap


def main() -> None:
    ap = build_argparser()
    args = ap.parse_args()
    if args.smoke:
        args.start = "2016-01-04"
        args.train_end = "2016-09-30"
        args.test_start = "2016-10-03"
        args.end = "2017-06-30"
        args.generations = 2
        args.seed_n = 12
        args.proposals_per_gen = 6
        args.parents_per_gen = 4
        args.survivors_per_gen = 3
        args.min_sharpe_is = 0.3
        args.min_sharpe_oos = 0.0
        args.min_trades_is = 5
        args.min_trades_oos = 3
        if str(args.out_dir).endswith("default"):
            args.out_dir = _REPO / "RenTech" / "data" / "logs" / "agentic_loop" / "smoke"
        print("SMOKE presets applied", flush=True)
    run_loop(args)


if __name__ == "__main__":
    main()
