#!/usr/bin/env python3
"""
Stock/ETF twin of ``agentic_strategy_loop.py`` — rank QS systematic sleeves by **ID**.

Generate → evaluate → select → memory → OOS verify on Yahoo OHLC (SPY/TLT/GLD/VIX/…).
Does **not** retrain a model. Variants are registry SIDs (S01…S50) plus equal-weight
**combo** books of surviving IDs (mutation without rewriting hard-coded thresholds).

Canonical::

    cd /Users/robzingale/trading_bot && PYTHONUNBUFFERED=1 .venv/bin/python \\
      RenTech/strategy_stack/agentic_stock_strategy_loop.py \\
      --start 2016-01-04 --train-end 2021-12-31 \\
      --test-start 2022-01-03 --end 2024-12-31 \\
      --generations 3 --out-dir RenTech/data/logs/agentic_stock_loop/qs_2016_2024

Smoke::

    cd /Users/robzingale/trading_bot && PYTHONUNBUFFERED=1 .venv/bin/python \\
      RenTech/strategy_stack/agentic_stock_strategy_loop.py --smoke
"""
from __future__ import annotations

import argparse
import hashlib
import json
import math
import random
import sys
import time
from datetime import datetime, timezone
from itertools import combinations
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.qs_systematic_library import (
    QS_ACTIONABLE_4,
    SYSTEMATIC_STRATEGIES,
    run_systematic_strategy,
)
from RenTech.strategy_stack.run_qs_top_ideas_backtest import (
    _align,
    _fetch_ohlc,
    _metrics,
    _spy_close_to_close,
)

# Rough family buckets from QS registry numbering (for memory / diversity).
_FAMILY_BY_PREFIX: list[tuple[range, str]] = [
    (range(1, 14), "seasonality"),
    (range(14, 21), "overnight"),
    (range(21, 33), "mean_reversion"),
    (range(33, 43), "momentum_rotation"),
    (range(43, 48), "volatility"),
    (range(48, 51), "pa_combo"),
]

_SEED_LESSONS: list[dict[str, Any]] = [
    {
        "rule_id": "seed_prefer_actionable4",
        "action": "prefer_sid",
        "sids": list(QS_ACTIONABLE_4),
        "reason": "Already used as stock-book diversifiers (low overlap with CM dip)",
    },
    {
        "rule_id": "seed_discourage_gap_fade",
        "action": "discourage_family",
        "family": "volatility",
        "reason": "Gap/VIX fades often look good in-sample and fail as diversifiers",
    },
]


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


def family_of(sid: str) -> str:
    if sid.startswith("COMBO_"):
        return "combo"
    # S01_… → 1
    try:
        n = int(sid.split("_", 1)[0][1:])
    except (ValueError, IndexError):
        return "other"
    for rng, fam in _FAMILY_BY_PREFIX:
        if n in rng:
            return fam
    return "other"


def fingerprint(variant_id: str, members: tuple[str, ...]) -> str:
    raw = variant_id + "|" + "+".join(sorted(members))
    return hashlib.sha1(raw.encode()).hexdigest()[:16]


def pos_year_frac(r: pd.Series) -> float:
    if r.empty:
        return float("nan")
    eq = (1.0 + r.fillna(0.0)).cumprod()
    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)
    return float(np.mean(fracs)) if fracs else float("nan")


def composite_fitness(
    *,
    sharpe: float,
    max_dd_pct: float,
    pct_invested: float,
    stability: float,
    abs_rho_spy: float,
    target_sharpe: float,
    dd_cap_pct: float,
    min_invested_pct: float,
) -> float:
    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_pct) if math.isfinite(max_dd_pct) else -100.0
    # max_dd_pct is negative (e.g. -12.5)
    dd_term = max(0.0, min(1.0, 1.0 + dd / max(dd_cap_pct, 1e-6)))
    inv = float(pct_invested) if math.isfinite(pct_invested) else 0.0
    inv_term = max(0.0, min(1.0, inv / max(min_invested_pct, 1e-6)))
    st = float(stability) if math.isfinite(stability) else 0.0
    # book_fit-style diversifier bonus
    div_term = max(0.0, min(1.0, 1.0 - abs(float(abs_rho_spy))))
    return float(
        0.40 * sh_term
        + 0.20 * dd_term
        + 0.10 * inv_term
        + 0.15 * max(0.0, min(1.0, st))
        + 0.15 * div_term
    )


def fetch_panels(yahoo_period: str) -> dict[str, pd.DataFrame]:
    tickers = {
        "spy": "SPY",
        "tlt": "TLT",
        "gld": "GLD",
        "vix": "^VIX",
        "xlp": "XLP",
        "xlu": "XLU",
        "qqq": "QQQ",
    }
    out: dict[str, pd.DataFrame] = {}
    for key, tkr in tickers.items():
        print(f"  fetch {tkr} …", flush=True)
        out[key] = _fetch_ohlc(tkr, yahoo_period)
    return out


def slice_panels(
    raw: dict[str, pd.DataFrame],
    start: pd.Timestamp,
    end: pd.Timestamp,
) -> tuple[dict[str, pd.DataFrame], pd.DatetimeIndex]:
    panels = {k: _align(v, start, end) for k, v in raw.items()}
    idx = panels["spy"].index
    return panels, idx


def daily_for_variant(
    members: tuple[str, ...],
    panels: dict[str, pd.DataFrame],
    idx: pd.DatetimeIndex,
) -> pd.Series:
    parts = [run_systematic_strategy(sid, panels, idx) for sid in members]
    if len(parts) == 1:
        return parts[0]
    return pd.concat(parts, axis=1).mean(axis=1)


def eval_variant(
    *,
    variant_id: str,
    members: tuple[str, ...],
    panels: dict[str, pd.DataFrame],
    idx: pd.DatetimeIndex,
    capital: float,
    target_sharpe: float,
    dd_cap_pct: float,
    min_invested_pct: float,
) -> dict[str, Any]:
    r = daily_for_variant(members, panels, idx)
    m = _metrics(r, capital)
    spy_bh = _spy_close_to_close(panels["spy"]).reindex(idx).fillna(0.0)
    rho = float(pd.DataFrame({"s": r, "spy": spy_bh}).corr().iloc[0, 1])
    if not math.isfinite(rho):
        rho = 0.0
    stab = pos_year_frac(r)
    fit = composite_fitness(
        sharpe=float(m["sharpe"]),
        max_dd_pct=float(m["max_dd_pct"]),
        pct_invested=float(m.get("pct_days_invested", 0.0)),
        stability=stab,
        abs_rho_spy=abs(rho),
        target_sharpe=target_sharpe,
        dd_cap_pct=dd_cap_pct,
        min_invested_pct=min_invested_pct,
    )
    book_fit = float(m["sharpe"]) * (1.0 - abs(rho))
    fam = family_of(members[0]) if len(members) == 1 else "combo"
    return {
        "variant_id": variant_id,
        "members": list(members),
        "n_members": len(members),
        "family": fam,
        "fingerprint": fingerprint(variant_id, members),
        "sharpe": m["sharpe"],
        "max_dd_pct": m["max_dd_pct"],
        "return_pct": m["total_return_pct"],
        "cagr_pct": m["cagr_pct"],
        "pct_invested": m.get("pct_days_invested", 0.0),
        "rho_spy": round(rho, 4),
        "book_fit": round(book_fit, 4),
        "pos_year_frac": round(stab, 4) if math.isfinite(stab) else None,
        "fitness": round(fit, 6),
    }


def passes_hard_gates(
    row: dict[str, Any],
    *,
    min_sharpe: float,
    max_dd_pct: float,
    min_invested_pct: float,
) -> bool:
    sh = row.get("sharpe")
    if sh is None or not math.isfinite(float(sh)) or float(sh) < min_sharpe:
        return False
    dd = float(row.get("max_dd_pct") or -999.0)
    if dd < -abs(max_dd_pct):  # more negative than allowed
        return False
    inv = float(row.get("pct_invested") or 0.0)
    return inv >= min_invested_pct


def diagnose_failure(
    row: dict[str, Any],
    *,
    min_sharpe: float,
    max_dd_pct: float,
    min_invested_pct: float,
    oos: bool,
) -> dict[str, Any]:
    sh = row.get("sharpe")
    dd = float(row.get("max_dd_pct") or 0.0)
    inv = float(row.get("pct_invested") or 0.0)
    if sh is None or not math.isfinite(float(sh)) or float(sh) < min_sharpe:
        reason = "oos_low_sharpe" if oos else "is_low_sharpe"
    elif dd < -abs(max_dd_pct):
        reason = "drawdown"
    elif inv < min_invested_pct:
        reason = "low_activity"
    else:
        reason = "other"
    return {
        "timestamp": _utc_now(),
        "variant_id": row["variant_id"],
        "members": row["members"],
        "family": row["family"],
        "fingerprint": row["fingerprint"],
        "window": "OOS" if oos else "IS",
        "sharpe": sh,
        "max_dd_pct": row.get("max_dd_pct"),
        "fitness": row.get("fitness"),
        "fail_reason": reason,
        "rule": f"ban fingerprint={row['fingerprint']} ({reason})",
        "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_sids: 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_sid":
            for s in lesson.get("sids") or []:
                self.preferred_sids.add(str(s))

    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 all_registry_sids() -> list[str]:
    return [sid for sid, fn in SYSTEMATIC_STRATEGIES.items() if fn is not None]


def propose_combos(
    parent_sids: list[str],
    *,
    gen: int,
    n: int,
    rng: random.Random,
    seen: set[str],
    banned: set[str],
    sizes: tuple[int, ...] = (2, 3, 4),
) -> list[tuple[str, tuple[str, ...]]]:
    """Equal-weight combo books from parent IDs (mutation step for fixed registry)."""
    out: list[tuple[str, tuple[str, ...]]] = []
    pool = list(dict.fromkeys(parent_sids))  # stable unique
    if len(pool) < 2:
        return out
    candidates: list[tuple[str, ...]] = []
    for k in sizes:
        if len(pool) < k:
            continue
        for combo in combinations(sorted(pool), k):
            candidates.append(combo)
    rng.shuffle(candidates)
    seq = 0
    for members in candidates:
        if len(out) >= n:
            break
        vid = f"COMBO_G{gen:02d}_{seq:03d}_" + "+".join(m.split("_", 1)[0] for m in members)
        fp = fingerprint(vid, members)
        if fp in seen or fp in banned:
            continue
        seen.add(fp)
        out.append((vid, members))
        seq += 1
    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("Fetching Yahoo panels …", flush=True)
    raw = fetch_panels(str(args.yahoo_period))
    panels_is, idx_is = slice_panels(raw, start, train_end)
    panels_oos, idx_oos = slice_panels(raw, test_start, end)
    print(
        f"  IS {idx_is[0].date()}→{idx_is[-1].date()} ({len(idx_is)}d)  "
        f"OOS {idx_oos[0].date()}→{idx_oos[-1].date()} ({len(idx_oos)}d)",
        flush=True,
    )
    if len(idx_is) < 100 or len(idx_oos) < 60:
        raise SystemExit(f"Window too short: IS={len(idx_is)} OOS={len(idx_oos)}")

    registry = all_registry_sids()
    seed_n = min(int(args.seed_n), len(registry))
    # Prefer actionable-4 first, then fill
    preferred = [s for s in QS_ACTIONABLE_4 if s in registry]
    rest = [s for s in registry if s not in preferred]
    rng.shuffle(rest)
    seeds = (preferred + rest)[:seed_n]

    seen: set[str] = set()
    # population: list of (variant_id, members)
    population: list[tuple[str, tuple[str, ...]]] = [(sid, (sid,)) for sid in seeds]
    for vid, members in population:
        seen.add(fingerprint(vid, members))

    survivors: list[dict[str, Any]] = []
    promoted: list[dict[str, Any]] = []
    ranking_rows: list[dict[str, Any]] = []

    eval_kw_is = dict(
        panels=panels_is,
        idx=idx_is,
        capital=float(args.capital),
        target_sharpe=float(args.target_sharpe),
        dd_cap_pct=float(args.dd_cap_pct),
        min_invested_pct=float(args.min_invested_pct),
    )
    eval_kw_oos = dict(
        panels=panels_oos,
        idx=idx_oos,
        capital=float(args.capital),
        target_sharpe=float(args.target_sharpe_oos),
        dd_cap_pct=float(args.dd_cap_pct),
        min_invested_pct=float(args.min_invested_pct_oos),
    )

    for gen in range(int(args.generations) + 1):
        if gen > 0:
            parent_sids: list[str] = []
            for s in survivors:
                parent_sids.extend(s["members"])
            if not parent_sids:
                parent_sids = [sid for sid, _ in population[: max(4, int(args.parents_per_gen))]]
            # diversify: inject preferred / other-family seeds
            parent_sids = list(dict.fromkeys(parent_sids + list(mem.preferred_sids)[:4]))
            population = propose_combos(
                parent_sids,
                gen=gen,
                n=int(args.proposals_per_gen),
                rng=rng,
                seen=seen,
                banned=mem.banned_fps,
            )
            print(f"\n=== Gen {gen}: evaluating {len(population)} combo variants ===", flush=True)
        else:
            print(f"\n=== Gen 0: evaluating {len(population)} registry IDs ===", flush=True)

        gen_rows: list[dict[str, Any]] = []
        for vid, members in population:
            if mem.is_banned(fingerprint(vid, members)):
                continue
            # Soft discourage: skip some vol-family singles
            if (
                len(members) == 1
                and family_of(members[0]) in mem.discouraged_families
                and rng.random() < 0.5
                and gen > 0
            ):
                continue
            row = eval_variant(variant_id=vid, members=members, **eval_kw_is)
            gen_rows.append(row)
            ranking_rows.append({**row, "generation": gen, "window": "IS"})
            print(
                f"  IS {row['variant_id'][:42]:42s} fit={row['fitness']:.3f}  "
                f"Sh={row['sharpe']:5.2f}  DD={row['max_dd_pct']:6.1f}%  "
                f"ρ={row['rho_spy']:+.2f}  fam={row['family']}",
                flush=True,
            )
            if not passes_hard_gates(
                row,
                min_sharpe=float(args.min_sharpe_is),
                max_dd_pct=float(args.max_dd_is),
                min_invested_pct=float(args.min_invested_pct),
            ):
                mem.add(
                    diagnose_failure(
                        row,
                        min_sharpe=float(args.min_sharpe_is),
                        max_dd_pct=float(args.max_dd_is),
                        min_invested_pct=float(args.min_invested_pct),
                        oos=False,
                    )
                )

        is_passers = [
            r
            for r in gen_rows
            if passes_hard_gates(
                r,
                min_sharpe=float(args.min_sharpe_is),
                max_dd_pct=float(args.max_dd_is),
                min_invested_pct=float(args.min_invested_pct),
            )
        ]
        is_passers.sort(key=lambda r: float(r["fitness"]), reverse=True)
        # Family diversity among IS survivors
        top_k: list[dict[str, Any]] = []
        fam_counts: dict[str, int] = {}
        fam_cap = int(args.family_cap)
        for r in is_passers:
            fam = r["family"]
            if fam_cap > 0 and fam_counts.get(fam, 0) >= fam_cap and fam != "combo":
                continue
            top_k.append(r)
            fam_counts[fam] = fam_counts.get(fam, 0) + 1
            if len(top_k) >= int(args.survivors_per_gen):
                break

        verified: list[dict[str, Any]] = []
        for r in top_k:
            members = tuple(r["members"])
            oos = eval_variant(
                variant_id=r["variant_id"],
                members=members,
                **eval_kw_oos,
            )
            bundle = {**r, "oos": {k: oos[k] for k in (
                "sharpe", "max_dd_pct", "return_pct", "cagr_pct",
                "pct_invested", "rho_spy", "book_fit", "fitness", "pos_year_frac",
            )}}
            oos_ok = passes_hard_gates(
                oos,
                min_sharpe=float(args.min_sharpe_oos),
                max_dd_pct=float(args.max_dd_oos),
                min_invested_pct=float(args.min_invested_pct_oos),
            )
            bundle["oos_pass"] = oos_ok
            print(
                f"  OOS {r['variant_id'][:42]:42s} pass={oos_ok}  "
                f"Sh={oos['sharpe']:5.2f}  DD={oos['max_dd_pct']:6.1f}%  "
                f"ρ={oos['rho_spy']:+.2f}",
                flush=True,
            )
            ranking_rows.append({**oos, "generation": gen, "window": "OOS", "parent_id": r["variant_id"]})
            if oos_ok:
                verified.append(bundle)
                promoted.append(bundle)
            else:
                mem.add(
                    diagnose_failure(
                        oos,
                        min_sharpe=float(args.min_sharpe_oos),
                        max_dd_pct=float(args.max_dd_oos),
                        min_invested_pct=float(args.min_invested_pct_oos),
                        oos=True,
                    )
                )

        survivors = verified if verified else top_k[: max(1, int(args.survivors_per_gen) // 2)]
        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,
        }
        (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,
        )

    # Dedup 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)

    # Ranked ID table (singles only + combos)
    rank_path = out_dir / "ranking_by_id.csv"
    pd.DataFrame(final).to_csv(rank_path, index=False)

    summary = {
        "command_meta": {
            "script": "agentic_stock_strategy_loop.py",
            "start": str(args.start),
            "train_end": str(args.train_end),
            "test_start": str(args.test_start),
            "end": str(args.end),
            "capital": float(args.capital),
            "generations": int(args.generations),
            "seed_n": seed_n,
            "min_sharpe_is": float(args.min_sharpe_is),
            "min_sharpe_oos": float(args.min_sharpe_oos),
            "max_dd_is": float(args.max_dd_is),
            "max_dd_oos": float(args.max_dd_oos),
            "is_sessions": len(idx_is),
            "oos_sessions": len(idx_oos),
            "elapsed_min": round((time.perf_counter() - t0) / 60.0, 2),
            "rng_seed": int(args.seed),
            "data": "Yahoo OHLC (SPY,TLT,GLD,^VIX,XLP,XLU,QQQ)",
            "catalog": "qs_systematic_library.SYSTEMATIC_STRATEGIES",
        },
        "memory_path": str(out_dir / "memory.jsonl"),
        "n_memory_lessons": len(mem.lessons),
        "n_banned_fingerprints": len(mem.banned_fps),
        "promoted": final,
        "top_by_id": [
            {
                "rank": i,
                "variant_id": p["variant_id"],
                "members": p["members"],
                "family": p["family"],
                "is_sharpe": p.get("sharpe"),
                "is_fitness": p.get("fitness"),
                "oos_sharpe": (p.get("oos") or {}).get("sharpe"),
                "oos_return_pct": (p.get("oos") or {}).get("return_pct"),
                "oos_max_dd_pct": (p.get("oos") or {}).get("max_dd_pct"),
                "rho_spy_is": p.get("rho_spy"),
            }
            for i, p in enumerate(final, 1)
        ],
    }
    (out_dir / "summary.json").write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
    pd.DataFrame(ranking_rows).to_csv(out_dir / "all_evals.csv", index=False)

    lines = [
        f"command: agentic_stock_strategy_loop.py IS[{args.start}..{args.train_end}] "
        f"OOS[{args.test_start}..{args.end}] gens={args.generations} seed_n={seed_n}",
        f"data: Yahoo OHLC SPY/TLT/GLD/VIX/XLP/XLU/QQQ",
        f"catalog: QS SYSTEMATIC_STRATEGIES ({len(registry)} IDs)",
        f"sessions: IS={len(idx_is)} OOS={len(idx_oos)}",
        f"gates: IS Sh≥{args.min_sharpe_is} DD≥{-abs(args.max_dd_is)}% inv≥{args.min_invested_pct}%; "
        f"OOS Sh≥{args.min_sharpe_oos} DD≥{-abs(args.max_dd_oos)}%",
        f"promoted: {len(final)}  memory: {len(mem.lessons)}  banned_fps: {len(mem.banned_fps)}",
        f"elapsed_min: {summary['command_meta']['elapsed_min']}",
        "",
        "rank by ID (IS fitness; OOS must pass):",
    ]
    for i, p in enumerate(final[:15], 1):
        oos = p.get("oos") or {}
        lines.append(
            f"  {i:2d}. {p['variant_id']}"
            f"  members={'+'.join(p['members'])}"
            f"  IS Sh={p.get('sharpe')} fit={p.get('fitness')}"
            f"  OOS Sh={oos.get('sharpe')} DD={oos.get('max_dd_pct')}%"
        )
    metrics_path = out_dir / "agentic_stock_strategy_loop_metrics.txt"
    metrics_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"\nWrote {out_dir / 'summary.json'}", flush=True)
    print(f"Wrote {rank_path}", flush=True)
    print(f"Wrote {metrics_path}", flush=True)
    return summary


def build_argparser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    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("--capital", type=float, default=100_000.0)
    ap.add_argument("--yahoo-period", type=str, default="max")
    ap.add_argument("--generations", type=int, default=3)
    ap.add_argument("--seed-n", type=int, default=50, help="How many registry IDs in gen 0")
    ap.add_argument("--proposals-per-gen", type=int, default=20)
    ap.add_argument("--parents-per-gen", type=int, default=8)
    ap.add_argument("--survivors-per-gen", type=int, default=8)
    ap.add_argument("--family-cap", type=int, default=3, help="Max singles per family in IS top-K (0=off)")
    ap.add_argument("--min-sharpe-is", type=float, default=0.6)
    ap.add_argument("--min-sharpe-oos", type=float, default=0.3)
    ap.add_argument("--max-dd-is", type=float, default=25.0, help="Max |DD|%% allowed on IS")
    ap.add_argument("--max-dd-oos", type=float, default=30.0)
    ap.add_argument("--min-invested-pct", type=float, default=5.0)
    ap.add_argument("--min-invested-pct-oos", type=float, default=3.0)
    ap.add_argument("--target-sharpe", type=float, default=1.2)
    ap.add_argument("--target-sharpe-oos", type=float, default=0.8)
    ap.add_argument("--dd-cap-pct", type=float, default=25.0, help="Soft |DD|%% in fitness")
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument(
        "--out-dir",
        type=Path,
        default=_REPO / "RenTech" / "data" / "logs" / "agentic_stock_loop" / "default",
    )
    ap.add_argument("--smoke", action="store_true")
    return ap


def main() -> None:
    ap = build_argparser()
    args = ap.parse_args()
    if args.smoke:
        args.start = "2016-01-04"
        args.train_end = "2018-12-31"
        args.test_start = "2019-01-02"
        args.end = "2020-12-31"
        args.generations = 2
        args.seed_n = 20
        args.proposals_per_gen = 10
        args.survivors_per_gen = 6
        args.min_sharpe_is = 0.3
        args.min_sharpe_oos = 0.0
        args.max_dd_is = 40.0
        args.max_dd_oos = 50.0
        if str(args.out_dir).endswith("default"):
            args.out_dir = _REPO / "RenTech" / "data" / "logs" / "agentic_stock_loop" / "smoke"
        print("SMOKE presets applied", flush=True)
    run_loop(args)


if __name__ == "__main__":
    main()
