#!/usr/bin/env python3
"""Backtest of the strategies taught by Gautam Jha ("Stock Learners"), for the gautam-audit reel.

Rules live in projects/gautam-audit/gautam_rules.json (edit there, not here). Every rule there carries the quote +
timestamp it comes from (see projects/gautam-audit/BACKTEST_PLAN.md). Dated copies in projects/gautam-audit/prereg/.
Nothing in here is tuned on results.

Strategy "twoline" — his "world's best strategy" (Stock Learners, wI9b968AvW8, 7 Jun 2026, 1.83M views):
  * mark the PREVIOUS day's high and low (daily chart) = two "liquidity" lines; trade on the 1-minute chart;
  * after price has gone through the high line, short when the next candle breaks the low of a red candle formed at a
    "fresh" place (the new extreme); stop above that red candle; first target = the last swing low; leave 20% of the
    quantity for bigger targets; mirror image (green candle, high break, long) at the low line;
  * at most 3 trades a day, stop after 2 full stop-losses; avoid stops of 70-90 pips (gold);
  * his claims: "50% accuracy at 1:10 risk-reward", "my average trade risk-reward is 1:67", "1:3 is my failure trade".

Data (all free, all public):
  * XAUUSD (his instrument; he trades the Vantage CFD feed on TradingView): Dukascopy BID+ASK 1-minute 2015-01 ->
    2026-09 built by tools/fx_data.py into projects/topg-audit/data_raw/XAUUSD_m1.npz (re-used, not copied);
    HistData BID 1-minute as the second source.
  * NIFTY 50 / NIFTY BANK spot 1-minute 2015-01-09 -> 2026-05-15 (Kaggle; built by tools/sb_backtest.py into
    projects/stockburner-audit/data_raw/<SYM>_m1.npz, re-used). He says the method can be used on NIFTY/SENSEX
    "after your own backtest".
  * BTCUSDT / ETHUSDT spot 1-minute 2020-01 -> 2026-09 (data.binance.vision monthly zips -> `binance` command), as a
    proxy for the crypto perpetuals he trades live ("Crypto Gold Live Trading").

Execution model (gold = the topg-audit conventions; spot instruments = the stockburner-audit conventions):
  * Signals on the BID chart (gold) / spot chart (index, crypto), 1-minute candles. "Day" = the New York 17:00 ->
    17:00 day for gold (TradingView's daily candle on broker CFD feeds; his chart is VANTAGE:XAUUSD), the NSE session
    for NIFTY, the UTC day for crypto (Binance/Delta daily candle).
  * Breakout entries are stop orders at the candle's low/high, triggered when the BID trades through the level, filled
    at the level or a worse minute open; longs pay the spread (fill at ASK) + slippage, shorts fill at the BID - slippage.
  * Exits on the executable side (long on BID, short on ASK). Stop first when stop and target share a minute. On the
    entry minute a stop touch is a loss; a target counts only if that minute closed beyond it. An "optimistic
    intrabar" upper bound is reported too.
  * Partial exits per his words: 80% at the first target, stop to entry ("CTC") for the 20% runner, runner to the next
    swing; a trade that hit its original stop with no partial is a "full SL" for his 2-SL daily rule.
  * Positions are closed at the end of the day (gold: 17:00 New York; NIFTY 15:20 IST; crypto 00:00 UTC) — our
    assumption; he never says to hold overnight and draws new lines every day.
  * Costs: gold = the spread on HIS OWN SCREEN (0.28, VANTAGE:XAUUSD, wI9b968AvW8 at 15:05) + $0.05 slippage on stop
    fills, no commission (headline, generous to him); sensitivity = Dukascopy's real BID/ASK + $0.07/oz commission.
    NIFTY = statutory option charges by date + slippage (stockburner model, delta 0.5). Crypto = Delta Exchange taker
    fee + GST + 0.01% slippage per side. Zero-cost run for every headline variant.
  * Money: fixed risk per trade (1% of the starting balance), no compounding.

Usage:
  .venv/bin/python tools/gautam_backtest.py binance --symbols BTCUSDT,ETHUSDT
  .venv/bin/python tools/gautam_backtest.py run --symbols XAUUSD [--tag x]
  .venv/bin/python tools/gautam_backtest.py demo         # his own demo days (19-21 May 2026)
  .venv/bin/python tools/gautam_backtest.py demorank     # how unusual those 3 days were
  .venv/bin/python tools/gautam_backtest.py spot --symbol XAUUSD --variant "<name>" --n 5 --seed 7
  .venv/bin/python tools/gautam_backtest.py premise --symbols XAUUSD,NIFTY
  .venv/bin/python tools/gautam_backtest.py crosscheck   # gold: Dukascopy vs HistData
  .venv/bin/python tools/gautam_backtest.py report
"""
from __future__ import annotations

import argparse
import hashlib
import json
import math
import os
import random
import sys
from dataclasses import dataclass
from pathlib import Path

import numpy as np
import pandas as pd
from numba import njit

os.environ.setdefault("FX_PROJECT", "topg-audit")          # gold minute data lives there (re-used, not copied)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import fx_data  # noqa: E402

ROOT = Path(__file__).resolve().parents[1]
PROJ = ROOT / "projects/gautam-audit"
RAW = PROJ / "data_raw"
RULES = PROJ / "gautam_rules.json"
OUT_DIR = ROOT / "engine/public/projects/gautam-audit/data"
MD = PROJ / "BACKTEST.md"
SB_RAW = ROOT / "projects/stockburner-audit/data_raw"
NY = "America/New_York"
IST = "Asia/Kolkata"
NSE = ("NIFTY", "BANKNIFTY", "FINNIFTY")
CRYPTO = ("BTCUSDT", "ETHUSDT")


# ------------------------------------------------------------------ data ---
@dataclass
class Market:
    symbol: str
    kind: str            # "gold" | "nse" | "crypto"
    t: np.ndarray        # int64 unix s, minute start (UTC)
    o: np.ndarray; h: np.ndarray; l: np.ndarray; c: np.ndarray     # signal side (BID for gold)
    sp: np.ndarray       # spread per minute (ASK - BID); 0 for spot data
    day: np.ndarray      # int64 day id (gold: NY-close day; nse: IST date; crypto: UTC date) as days since epoch
    entry_ok: np.ndarray  # bool: a setup candle may form / an entry may trigger in this minute
    day_last: np.ndarray  # int64: index of the forced-exit minute of this minute's day
    source: str = ""

    def __len__(self) -> int:
        return len(self.t)


def _day_bounds(day: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    brk = np.flatnonzero(np.diff(day)) + 1
    return np.concatenate([[0], brk]), np.concatenate([brk, [len(day)]])


def load_market(sym: str, rules: dict, source: str = "", spread_mode: str = "his_screen",
                start: str | None = None, end: str | None = None, day_mode: str = "ny") -> Market:
    S = rules["session"]
    if sym == "XAUUSD":
        if source == "histdata":
            df = fx_data.load_histdata_m1(sym)
            if start or end:
                df = df.loc[start:end]
            sp = np.zeros(len(df))
        else:
            df = fx_data.load_m1(sym, start, end)
            sp = (df["ac"] - df["bc"]).clip(lower=0).to_numpy()
            source = "dukascopy"
        if spread_mode == "his_screen":
            sp = np.full(len(df), rules["costs"]["XAUUSD"]["spread_his_screen"])
        elif spread_mode == "zero":
            sp = np.zeros(len(df))
        idx = df.index
        if day_mode == "utc":                                           # variant: UTC-midnight daily candles
            ny = idx.tz_convert("UTC").tz_localize(None)
            dayts = ny.normalize()
            mins_to_close = ((dayts + pd.Timedelta(days=1)) - ny).total_seconds().to_numpy() / 60
        else:
            ny = idx.tz_convert(NY).tz_localize(None)
            dayts = (ny + pd.Timedelta(hours=7)).normalize()           # NY 17:00 -> 17:00 day, labelled by end date
            mins_to_close = ((dayts + pd.Timedelta(days=1) - pd.Timedelta(hours=7)) - ny).total_seconds().to_numpy() / 60
        day = (dayts.to_numpy("datetime64[D]").astype(np.int64))
        entry_ok = mins_to_close > S["gold_no_entry_last_min"]
        t = (idx.asi8 // 10**9).astype(np.int64) if idx.asi8.max() > 10**12 else idx.asi8.astype(np.int64)
        m = Market(sym, "gold", t, df["bo"].to_numpy(float), df["bh"].to_numpy(float), df["bl"].to_numpy(float),
                   df["bc"].to_numpy(float), sp.astype(float), day, entry_ok, np.zeros(len(df), np.int64), source)
    elif sym in NSE:
        z = np.load(SB_RAW / (f"{sym}_m1.npz" if source in ("", "kaggle") else f"{sym}_hf_m1.npz"))
        t = z["t"]
        keep = np.ones(len(t), bool)
        if start:
            keep &= t >= int(pd.Timestamp(start, tz=IST).timestamp())
        if end:
            keep &= t < int((pd.Timestamp(end, tz=IST) + pd.Timedelta(days=1)).timestamp())
        t = t[keep]
        loc = t + 19800
        day = loc // 86400
        mod = (loc % 86400) // 60
        cnt = pd.Series(1, index=day).groupby(level=0).size()
        good = np.isin(day, cnt[cnt >= S["nse_min_minutes"]].index.to_numpy())
        entry_ok = good & (mod >= S["nse_first_entry_mod"]) & (mod < S["nse_last_entry_mod"])
        m = Market(sym, "nse", t.astype(np.int64), z["o"][keep], z["h"][keep], z["l"][keep], z["c"][keep],
                   np.zeros(len(t)), day.astype(np.int64), entry_ok, np.zeros(len(t), np.int64),
                   "kaggle" if source in ("", "kaggle") else source)
        # forced exit: last minute before 15:20 IST
        s0, s1 = _day_bounds(m.day)
        for a, b in zip(s0, s1):
            idx = a + np.flatnonzero(mod[a:b] < S["nse_flat_mod"])
            m.day_last[a:b] = idx[-1] if len(idx) else b - 1
        return m
    elif sym in CRYPTO:
        z = np.load(RAW / f"{sym}_m1.npz")
        t = z["t"]
        keep = np.ones(len(t), bool)
        if start:
            keep &= t >= int(pd.Timestamp(start, tz="UTC").timestamp())
        if end:
            keep &= t < int((pd.Timestamp(end, tz="UTC") + pd.Timedelta(days=1)).timestamp())
        t = t[keep]
        day = t // 86400
        mod = (t % 86400) // 60
        entry_ok = mod < (1440 - S["crypto_no_entry_last_min"])
        m = Market(sym, "crypto", t.astype(np.int64), z["o"][keep], z["h"][keep], z["l"][keep], z["c"][keep],
                   np.zeros(len(t)), day.astype(np.int64), entry_ok, np.zeros(len(t), np.int64), "binance")
    else:
        raise SystemExit(f"unknown symbol {sym}")
    s0, s1 = _day_bounds(m.day)
    for a, b in zip(s0, s1):
        m.day_last[a:b] = b - 1
    return m


def cmd_binance(a, rules: dict) -> None:
    """data.binance.vision monthly 1-minute zips in data_raw/binance -> data_raw/<SYM>_m1.npz (spot, UTC)."""
    import zipfile
    for sym in a.symbols.split(","):
        parts = []
        for zf_path in sorted((RAW / "binance").glob(f"{sym}-1m-*.zip")):
            with zipfile.ZipFile(zf_path) as zf:
                with zf.open(zf.namelist()[0]) as fh:
                    df = pd.read_csv(fh, header=None, usecols=[0, 1, 2, 3, 4])
            if not str(df.iat[0, 0]).isdigit():         # some months carry a header row
                df = df.iloc[1:].astype(float)
            ot = df[0].astype(np.int64).to_numpy()
            ot = np.where(ot > 10**14, ot // 1000, ot)      # 2025+ files are in microseconds
            parts.append(pd.DataFrame({"t": ot // 1000, "o": df[1].astype(float), "h": df[2].astype(float),
                                       "l": df[3].astype(float), "c": df[4].astype(float)}))
        d = pd.concat(parts).drop_duplicates("t").sort_values("t")
        np.savez_compressed(RAW / f"{sym}_m1.npz", t=d["t"].to_numpy(np.int64), o=d["o"].to_numpy(float),
                            h=d["h"].to_numpy(float), l=d["l"].to_numpy(float), c=d["c"].to_numpy(float))
        gaps = np.diff(d["t"].to_numpy()) // 60
        print(f"{sym}: {len(d):,} minutes {pd.Timestamp(int(d.t.iat[0]), unit='s')} .. "
              f"{pd.Timestamp(int(d.t.iat[-1]), unit='s')}; gaps > 15 min: {int((gaps > 15).sum())}")


# ------------------------------------------------------------- daily levels ---
def day_levels(m: Market) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Per minute: previous day's high/low (signal side), the day's index and the previous day's range."""
    s0, s1 = _day_bounds(m.day)
    dh = np.maximum.reduceat(m.h, s0)
    dl = np.minimum.reduceat(m.l, s0)
    pdh = np.full(len(m), np.nan)
    pdl = np.full(len(m), np.nan)
    for q in range(1, len(s0)):
        if m.day[s0[q]] - m.day[s0[q - 1]] > 4:       # previous trading day too far away (data gap): no lines
            continue
        pdh[s0[q]:s1[q]] = dh[q - 1]
        pdl[s0[q]:s1[q]] = dl[q - 1]
    return pdh, pdl, s0, s1


def sl_cap(m: Market, v: dict, rules: dict, s0: np.ndarray, s1: np.ndarray) -> np.ndarray:
    """Max allowed stop distance per minute (price units). 'price': his 70 pips at the price he said it, scaled by
    price; 'fixed': 70 pips literally; 'vol': the same share of the trailing 20-day median daily range that 70 pips
    was of gold's median daily range when he said it; 'none'."""
    C = rules["sl_cap"]
    mode = v.get("sl_cap", "price")
    if m.kind != "gold" and mode in ("price", "fixed"):
        mode = "vol"                     # he states 70 pips for gold only
    if mode == "none":
        return np.full(len(m), np.inf)
    if mode == "points":                 # his own number for that market (e.g. Bank Nifty 25-80 points, 2022)
        return np.full(len(m), float(v["sl_max_points"]))
    if mode == "fixed":
        if m.kind != "gold":
            raise SystemExit("fixed pip cap is gold-only")
        return np.full(len(m), C["pips"] * C["pip_gold"])
    if mode == "price":
        frac = C["pips"] * C["pip_gold"] / C["gold_price_when_said"]
        dclose = m.c[s1 - 1]
        prev = np.concatenate([[dclose[0]], dclose[:-1]])
        cap = np.empty(len(m))
        for q, (a, b) in enumerate(zip(s0, s1)):
            cap[a:b] = frac * prev[q]
        return cap
    if mode == "vol":
        rng = np.maximum.reduceat(m.h, s0) - np.minimum.reduceat(m.l, s0)
        med = pd.Series(rng).rolling(20, min_periods=5).median().shift(1).bfill().to_numpy()
        cap = np.empty(len(m))
        for q, (a, b) in enumerate(zip(s0, s1)):
            cap[a:b] = C["share_of_gold_daily_range"] * med[q]
        return cap
    raise SystemExit(f"bad sl_cap {mode}")


def pivots(h: np.ndarray, l: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Swing points on the 1-minute chart: pivot high = strictly higher than the k candles before it and not lower
    than the k after it (mirror for lows). Returned as (index, price) arrays; a pivot is known at index + k."""
    n = len(h)
    ph, pl = np.ones(n, bool), np.ones(n, bool)
    ph[:k] = ph[n - k:] = False
    pl[:k] = pl[n - k:] = False
    for i in range(1, k + 1):
        ph[k:n - k] &= (h[k:n - k] > h[k - i:n - k - i]) & (h[k:n - k] >= h[k + i:n - k + i])
        pl[k:n - k] &= (l[k:n - k] < l[k - i:n - k - i]) & (l[k:n - k] <= l[k + i:n - k + i])
    hi, li = np.flatnonzero(ph), np.flatnonzero(pl)
    return hi.astype(np.int64), h[hi].astype(float), li.astype(np.int64), l[li].astype(float)


# ------------------------------------------------------------ simulation ---
@njit(cache=True)
def _find_pivot(pidx, ppx, k, j, beyond, d, lookback_start):
    """Most recent pivot known before minute j (pivot index + k <= j - 1), at or after lookback_start, strictly
    beyond `beyond` in direction d (d=-1: below, for short targets; d=+1: above). Returns price or nan."""
    lo, hi = 0, len(pidx)
    # binary search: last pivot with index <= j - 1 - k
    lim = j - 1 - k
    while lo < hi:
        mid = (lo + hi) // 2
        if pidx[mid] <= lim:
            lo = mid + 1
        else:
            hi = mid
    q = lo - 1
    while q >= 0 and pidx[q] >= lookback_start:
        p = ppx[q]
        if (d == -1 and p < beyond) or (d == 1 and p > beyond):
            return p
        q -= 1
    return np.nan


@njit(cache=True)
def simulate(o, h, l, c, sp, day, entry_ok, day_last, pdh, pdl, cap, day_start_prev,
             phi, php, pli, plp, kpiv,
             fresh_mode, body_min, next_only, max_trades, max_full_sl, sides,
             tg_mode, fixed_R, tg1_frac, one_r_frac, runner, fallback_R,
             slip, optimistic, need_beyond_line, sl_min):
    """Walk all minutes once. Returns trade rows as a 2-D float array:
    [setup_i, entry_i, exit_i, dir, entry_px, stop, risk, tg1, tg2, R_gross, reason, full_sl, mfe_R, fallback]
    reason: 0 stop (full), 1 target(s)/runner done, 2 breakeven after partial, 3 time exit."""
    n = len(o)
    out = np.empty((max(16, n // 50), 14))
    nt = 0
    i = 1
    cur_day = -1
    up_cross = False; dn_cross = False
    ext_hi = -1e18; ext_lo = 1e18
    pend_s = -1; pend_l = -1              # pending setup candle index (short / long)
    trades_today = 0; full_sl_today = 0
    while i < n:
        if day[i] != cur_day:
            cur_day = day[i]
            up_cross = False; dn_cross = False
            ext_hi = -1e18; ext_lo = 1e18
            pend_s = -1; pend_l = -1
            trades_today = 0; full_sl_today = 0
        if np.isnan(pdh[i]):
            i += 1
            continue
        # ---- 1) trigger check on minute i for a pending setup formed at i-1 (or earlier if not next_only)
        done_today = trades_today >= max_trades or full_sl_today >= max_full_sl
        trig = 0
        if not done_today and entry_ok[i]:
            if pend_s >= 0 and (sides & 1) and l[i] < l[pend_s]:
                trig = -1
            elif pend_l >= 0 and (sides & 2) and h[i] > h[pend_l]:
                trig = 1
        if trig != 0:
            k = pend_s if trig == -1 else pend_l
            j = i
            if trig == -1:
                lvl = l[k]; stop = h[k]
                entry = min(lvl, o[j]) - slip
            else:
                lvl = h[k]; stop = l[k]
                entry = max(lvl, o[j]) + sp[j] + slip
            risk = abs(entry - stop)
            dist = abs(stop - lvl)
            pend_s = -1; pend_l = -1
            if risk <= 0 or dist > cap[j] or dist < sl_min:
                # avoided: stop too big (his rule). The setup is consumed.
                i += 1
                continue
            d = trig
            # targets
            tg1 = np.nan; tg2 = np.nan; fb = 0.0
            if tg_mode == 1:
                tg1 = entry + d * fixed_R * risk
            else:
                if d == -1:
                    tg1 = _find_pivot(pli, plp, kpiv, j, entry, -1, day_start_prev[j])
                else:
                    tg1 = _find_pivot(phi, php, kpiv, j, entry, 1, day_start_prev[j])
                if np.isnan(tg1):
                    tg1 = entry + d * fallback_R * risk
                    fb = 1.0
                if runner:
                    if d == -1:
                        tg2 = _find_pivot(pli, plp, kpiv, j, tg1, -1, day_start_prev[j])
                    else:
                        tg2 = _find_pivot(phi, php, kpiv, j, tg1, 1, day_start_prev[j])
            # legs: (level, fraction); a fraction left without a level rides to the time exit
            lv = np.full(3, np.nan)
            fr = np.zeros(3)
            nl = 0
            if tg_mode == 1:
                lv[0] = tg1; fr[0] = 1.0; nl = 1
            else:
                run_f = (1.0 - tg1_frac) if runner else 0.0
                if one_r_frac > 0 and abs(tg1 - entry) > risk:
                    lv[0] = entry + d * risk; fr[0] = one_r_frac
                    lv[1] = tg1; fr[1] = 1.0 - one_r_frac - run_f
                    nl = 2
                else:
                    lv[0] = tg1; fr[0] = 1.0 - run_f
                    nl = 1
                if runner:
                    lv[nl] = tg2; fr[nl] = run_f; nl += 1
            last = day_last[j]
            rem = 1.0
            pnl = 0.0
            cur_stop = stop
            got_partial = False
            move_be = False
            reason = 3
            exit_i = last
            leg = 0
            # max favourable excursion before the ORIGINAL stop is touched (same day), in R
            mfe = 0.0
            for jj in range(j, last + 1):
                if d == 1:
                    lo_x = l[jj]; hi_x = h[jj]; cl_x = c[jj]
                else:
                    lo_x = l[jj] + sp[jj]; hi_x = h[jj] + sp[jj]; cl_x = c[jj] + sp[jj]
                if (d == 1 and lo_x <= stop) or (d == -1 and hi_x >= stop):
                    break
                fav = ((cl_x - entry) if d == 1 else (entry - cl_x)) if jj == j else ((hi_x - entry) if d == 1 else (entry - lo_x))
                if fav / risk > mfe:
                    mfe = fav / risk
            for jj in range(j, last + 1):
                if move_be:
                    cur_stop = entry
                    move_be = False
                if d == 1:
                    lo_x = l[jj]; hi_x = h[jj]; op_x = o[jj]; cl_x = c[jj]
                else:
                    lo_x = l[jj] + sp[jj]; hi_x = h[jj] + sp[jj]; op_x = o[jj] + sp[jj]; cl_x = c[jj] + sp[jj]
                st = (lo_x <= cur_stop) if d == 1 else (hi_x >= cur_stop)
                # which target legs does this minute reach?
                reach = leg
                while reach < nl and not np.isnan(lv[reach]):
                    tl = lv[reach]
                    if jj == j:
                        ok = (cl_x >= tl) if d == 1 else (cl_x <= tl)
                    else:
                        ok = (hi_x >= tl) if d == 1 else (lo_x <= tl)
                    if not ok:
                        break
                    reach += 1
                if st and (reach == leg or not optimistic):
                    if jj > j:
                        px = (min(cur_stop, op_x) - slip) if d == 1 else (max(cur_stop, op_x) + slip)
                    else:
                        px = (cur_stop - slip) if d == 1 else (cur_stop + slip)
                    pnl += rem * d * (px - entry)
                    rem = 0.0
                    reason = 2 if got_partial else 0
                    exit_i = jj
                    break
                while leg < reach:
                    f = min(fr[leg], rem)
                    pnl += f * d * (lv[leg] - entry)
                    rem -= f
                    leg += 1
                    got_partial = True
                    move_be = True
                if rem <= 1e-9:
                    rem = 0.0
                    reason = 1
                    exit_i = jj
                    break
            if rem > 1e-9:          # time exit at the day's last minute close
                px = c[last] if d == 1 else c[last] + sp[last]
                pnl += rem * d * (px - entry)
                rem = 0.0
                reason = 1 if got_partial else 3
                exit_i = last
            full_sl = 1.0 if (reason == 0) else 0.0
            if nt >= out.shape[0]:
                big = np.empty((out.shape[0] * 2, 14))
                big[:nt] = out[:nt]
                out = big
            out[nt, 0] = k; out[nt, 1] = j; out[nt, 2] = exit_i; out[nt, 3] = d
            out[nt, 4] = entry; out[nt, 5] = stop; out[nt, 6] = risk; out[nt, 7] = tg1; out[nt, 8] = tg2
            out[nt, 9] = pnl / risk; out[nt, 10] = reason; out[nt, 11] = full_sl; out[nt, 12] = mfe; out[nt, 13] = fb
            nt += 1
            trades_today += 1
            full_sl_today += int(full_sl)
            # update extremes over the minutes the position was open, then resume after the exit minute
            for q in range(i, exit_i + 1):
                if day[q] != cur_day:
                    break
                if h[q] > pdh[q]:
                    up_cross = True
                if l[q] < pdl[q]:
                    dn_cross = True
                if up_cross and h[q] > ext_hi:
                    ext_hi = h[q]
                if dn_cross and l[q] < ext_lo:
                    ext_lo = l[q]
            i = exit_i + 1
            continue
        # ---- 2) update state with minute i
        if h[i] > pdh[i]:
            up_cross = True
        if l[i] < pdl[i]:
            dn_cross = True
        prev_ext_hi = ext_hi
        prev_ext_lo = ext_lo
        if up_cross and h[i] > ext_hi:
            ext_hi = h[i]
        if dn_cross and l[i] < ext_lo:
            ext_lo = l[i]
        # invalidate pending setups (generous mode keeps them until price goes beyond the candle's extreme)
        if pend_s >= 0 and (next_only or h[i] > h[pend_s]):
            pend_s = -1
        if pend_l >= 0 and (next_only or l[i] < l[pend_l]):
            pend_l = -1
        # ---- 3) is minute i a setup candle?
        rng = h[i] - l[i]
        body = abs(c[i] - o[i])
        okbody = rng > 0 and body >= body_min * rng
        if entry_ok[i] and okbody and day[i - 1] == cur_day:
            if up_cross and c[i] < o[i] and (sides & 1):
                fresh = False
                if fresh_mode == 0:
                    fresh = (h[i] > pdh[i]) or not need_beyond_line
                elif fresh_mode == 1:
                    fresh = max(h[i], h[i - 1]) >= ext_hi
                else:
                    fresh = h[i] >= ext_hi
                if need_beyond_line and h[i] <= pdh[i] and h[i - 1] <= pdh[i]:
                    fresh = False
                if fresh:
                    pend_s = i
            if dn_cross and c[i] > o[i] and (sides & 2):
                fresh = False
                if fresh_mode == 0:
                    fresh = (l[i] < pdl[i]) or not need_beyond_line
                elif fresh_mode == 1:
                    fresh = min(l[i], l[i - 1]) <= ext_lo
                else:
                    fresh = l[i] <= ext_lo
                if need_beyond_line and l[i] >= pdl[i] and l[i - 1] >= pdl[i]:
                    fresh = False
                if fresh:
                    pend_l = i
        i += 1
    return out[:nt]


COLS = ["setup_i", "entry_i", "exit_i", "dir", "entry_px", "stop", "risk", "tg1", "tg2", "R_gross", "reason_code",
        "full_sl", "mfe_R", "tg1_fallback"]
REASONS = {0: "stop", 1: "target", 2: "breakeven_after_partial", 3: "time"}


# ------------------------------------------------------------- costs ---
def _sched(table: list, date: str) -> float:
    v = table[0][1]
    for d, x in table:
        if date >= d:
            v = x
    return v


def cost_points(rules: dict, m: Market, date: str, price: float, zero: bool, mult: float = 1.0) -> float:
    """Extra round-trip cost per unit in price points that is NOT already in the fills (gold: commission;
    NSE: statutory option charges + slippage in spot points; crypto: fees + slippage)."""
    if zero:
        return 0.0
    if m.kind == "gold":
        return rules["costs"]["XAUUSD"]["commission_round_trip"] if m.source == "dukascopy_real_spread" else 0.0
    if m.kind == "crypto":
        K = rules["costs"]["crypto"]
        return price * 2 * (K["taker_fee"] * (1 + K["gst"]) + K["slippage_pct_per_side"] * mult)
    C = rules["costs"]["nse"]
    gst = C["gst"]
    prem = C["atm_premium_pct_of_spot"][m.symbol] * price
    pct = (_sched(C["stt_option_sell"], date) + 2 * _sched(C["nse_txn_option"], date) * (1 + gst)
           + C["stamp_buy"] + 2 * C["sebi_fee"] * (1 + gst))
    slip = 2 * C["slippage_premium_pts_per_side"][m.symbol] * mult
    return (prem * pct + slip) / C["delta"]


def brokerage_R(rules: dict, m: Market, zero: bool) -> float:
    if zero or m.kind != "nse":
        return 0.0
    C = rules["costs"]["nse"]
    return 2 * C["brokerage_per_order_inr"] * (1 + C["gst"]) / rules["money"]["nse"]["fixed_risk"]


# ------------------------------------------------------------- running ---
def _ts(m: Market, i: int) -> str:
    tz = {"gold": NY, "nse": IST, "crypto": "UTC"}[m.kind]
    return pd.Timestamp(int(m.t[i]), unit="s", tz="UTC").tz_convert(tz).strftime("%Y-%m-%d %H:%M")


def run_variant(m: Market, v: dict, rules: dict, ctx: dict, zero: bool = False, optimistic: bool = False,
                slip_mult: float = 1.0) -> pd.DataFrame:
    pdh, pdl, s0, s1 = ctx["levels"]
    k = v.get("pivot_k", rules["twoline"]["pivot_k"])
    if k not in ctx["piv"]:
        ctx["piv"][k] = pivots(m.h, m.l, k)
    phi, php, pli, plp = ctx["piv"][k]
    capkey = v.get("sl_cap", "price")
    if m.kind != "gold" and capkey in ("price", "fixed"):
        capkey = "vol"
    if capkey == "points":
        capkey = f"points{v['sl_max_points']}"
    if capkey not in ctx["cap"]:
        ctx["cap"][capkey] = sl_cap(m, v, rules, s0, s1)
    cap = ctx["cap"][capkey]
    slip = 0.0
    if m.kind == "gold" and not zero:
        slip = rules["costs"]["XAUUSD"]["slippage_stop_fill"] * slip_mult
    sp = m.sp if not zero else np.zeros(len(m))
    tgm = 1 if v.get("target") == "fixed" else 0
    eok = m.entry_ok
    if v.get("ist_window"):                       # only setups/entries inside an IST clock window [from, to) minutes
        a0, b0 = v["ist_window"]
        mod = ((m.t + 19800) % 86400) // 60
        inwin = (mod >= a0) & (mod < b0) if a0 < b0 else ((mod >= a0) | (mod < b0))
        eok = m.entry_ok & inwin
    arr = simulate(m.o, m.h, m.l, m.c, sp, m.day, eok, m.day_last, pdh, pdl, cap, ctx["prevstart"],
                   phi, php, pli, plp, k,
                   {"generous": 0, "exact": 1, "strict": 2}[v.get("fresh", "exact")], float(v.get("body_min", 0.0)),
                   bool(v.get("next_only", True)), int(v.get("max_trades", 3)), int(v.get("max_full_sl", 2)),
                   int(v.get("sides", 3)), tgm, float(v.get("fixed_R", 0.0)), float(v.get("tg1_frac", 0.8)),
                   float(v.get("one_r_frac", 0.0)), bool(v.get("runner", True)), float(v.get("fallback_R", 2.0)),
                   slip, optimistic, bool(v.get("need_beyond_line", True)), float(v.get("sl_min_points", 0.0)))
    df = pd.DataFrame(arr, columns=COLS)
    if not len(df):
        return df
    for col in ("setup_i", "entry_i", "exit_i"):
        df[col] = df[col].astype(np.int64)
    df["dir"] = df["dir"].astype(int)
    df["reason"] = df["reason_code"].astype(int).map(REASONS)
    df["setup_t"] = [_ts(m, i) for i in df["setup_i"]]
    df["entry_t"] = [_ts(m, i) for i in df["entry_i"]]
    df["exit_t"] = [_ts(m, i) for i in df["exit_i"]]
    df["day"] = [str(np.datetime64(int(m.day[i]), "D")) for i in df["entry_i"]]
    df["pdh"] = pdh[df["entry_i"]]
    df["pdl"] = pdl[df["entry_i"]]
    cp = np.array([cost_points(rules, m, d, p, zero, slip_mult) for d, p in zip(df["day"], df["entry_px"])])
    df["cost_pts"] = cp
    df["brokerage_R"] = brokerage_R(rules, m, zero)
    df["R"] = df["R_gross"] - cp / df["risk"] - df["brokerage_R"]
    # costs already inside the fills (gold: spread on the entry/exit side + slippage on stop fills), per unit
    df["fill_cost_price"] = (0.0 if zero else 1.0) * (sp[df["entry_i"].to_numpy()] + 2 * slip)
    df["cost_share_of_risk"] = (df["fill_cost_price"] + df["cost_pts"]) / df["risk"] + df["brokerage_R"]
    df["risk_pct_of_price"] = 100 * df["risk"] / df["entry_px"]
    df["tg1_R"] = (df["tg1"] - df["entry_px"]).abs() / df["risk"]
    return df


def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
    if n == 0:
        return (float("nan"), float("nan"))
    p = k / n
    den = 1 + z * z / n
    centre = (p + z * z / (2 * n)) / den
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / den
    return centre - half, centre + half


PERIODS = {"since_he_recorded_2026-05-22": "2026-05-22", "since_upload_2026-06-07": "2026-06-07",
           "last_12_months_2025-10-01": "2025-10-01"}


def _period(tr: pd.DataFrame, d0: str) -> dict:
    g = tr[tr["day"] >= d0]
    if not len(g):
        return {"trades": 0}
    r = g["R"].to_numpy()
    return {"trades": int(len(g)), "win_rate_pct": round(100 * float((r > 0).mean()), 1),
            "reach_10R_pct": round(100 * float((g["mfe_R"] >= 10).mean()), 1),
            "avg_R": round(float(r.mean()), 3), "total_R": round(float(r.sum()), 1),
            "green_days_pct": round(100 * float((g.groupby("day")["R"].sum() > 0).mean()), 1)}


def _quartiles(tr: pd.DataFrame) -> list:
    """Results by stop size (quartiles of the stop distance), to answer 'nobody uses such tiny stops'."""
    out = []
    try:
        q = pd.qcut(tr["risk"], 4, duplicates="drop")
    except ValueError:
        return out
    for iv, g in tr.groupby(q, observed=True):
        r = g["R"].to_numpy()
        out.append({"risk_from": round(float(iv.left), 3), "risk_to": round(float(iv.right), 3), "trades": int(len(g)),
                    "win_rate_pct": round(100 * float((r > 0).mean()), 1),
                    "reach_10R_pct": round(100 * float((g["mfe_R"] >= 10).mean()), 1),
                    "avg_R": round(float(r.mean()), 3),
                    "median_cost_share_of_risk_pct": round(100 * float(g["cost_share_of_risk"].median()), 1)})
    return out


def summarize(tr: pd.DataFrame, weeks: float, money: dict) -> dict:
    n = len(tr)
    if n == 0:
        return {"trades": 0}
    tr = tr.sort_values("entry_t")
    R = tr["R"].to_numpy()
    wins = R > 0
    cum = np.cumsum(R)
    dd_r = float(np.max(np.maximum.accumulate(np.concatenate([[0], cum]))[1:] - cum))
    cap, risk = money["start"], money["fixed_risk"]
    bal = cap + risk * cum
    blown = np.flatnonzero(bal <= 0)
    ruin_i = int(blown[0]) if len(blown) else None
    if ruin_i is not None:
        bal = bal.copy()
        bal[ruin_i:] = 0.0
    peak = np.maximum.accumulate(np.concatenate([[cap], bal]))[1:]
    gp, gl = R[R > 0].sum(), -R[R < 0].sum()
    lo, hi = wilson(int(wins.sum()), n)
    t1 = (tr["mfe_R"] >= tr["tg1_R"]).to_numpy()
    r10 = (tr["mfe_R"] >= 10).to_numpy()
    l10, h10 = wilson(int(r10.sum()), n)
    se = R.std(ddof=1) / math.sqrt(n) if n > 1 else float("nan")
    years = []
    for y, g in tr.groupby(tr["entry_t"].str[:4]):
        r = g["R"].to_numpy()
        years.append({"year": int(y), "trades": int(len(r)), "win_rate": round(100 * (r > 0).mean(), 1),
                      "reach_10R_pct": round(100 * (g["mfe_R"] >= 10).mean(), 1),
                      "total_R": round(float(r.sum()), 1), "avg_R": round(float(r.mean()), 3)})
    streak = best = 0
    for w in wins:
        streak = 0 if w else streak + 1
        best = max(best, streak)
    # days
    per_day = tr.groupby("day")["R"].sum()
    months = max(weeks / 4.348, 1e-9)
    return {
        "trades": n, "trades_per_week": round(n / weeks, 2), "days_with_trades": int(len(per_day)),
        "win_rate_pct": round(100 * wins.mean(), 1), "win_rate_95ci_pct": [round(100 * lo, 1), round(100 * hi, 1)],
        "full_sl_pct": round(100 * tr["full_sl"].mean(), 1),
        "reached_tg1_pct": round(100 * t1.mean(), 1),
        "reach_10R_pct": round(100 * r10.mean(), 1), "reach_10R_95ci_pct": [round(100 * l10, 1), round(100 * h10, 1)],
        "reach_3R_pct": round(100 * (tr["mfe_R"] >= 3).mean(), 1),
        "reach_67R_pct": round(100 * (tr["mfe_R"] >= 67).mean(), 2),
        "median_tg1_R": round(float(tr["tg1_R"].median()), 2),
        "tg1_fallback_pct": round(100 * tr["tg1_fallback"].mean(), 1),
        "exit_reasons_pct": {k: round(100 * float(v), 1) for k, v in tr["reason"].value_counts(normalize=True).items()},
        "avg_R": round(float(R.mean()), 3), "median_R": round(float(np.median(R)), 3),
        "avg_R_95ci": [round(float(R.mean() - 1.96 * se), 3), round(float(R.mean() + 1.96 * se), 3)],
        "avg_win_R": round(float(R[wins].mean()), 3) if wins.any() else 0.0,
        "avg_loss_R": round(float(-R[~wins].mean()), 3) if (~wins).any() else 0.0,
        "profit_factor": round(float(gp / gl), 3) if gl > 0 else None,
        "total_R": round(float(R.sum()), 1), "max_drawdown_R": round(dd_r, 1),
        "longest_losing_streak": int(best),
        "green_days_pct": round(100 * float((per_day > 0).mean()), 1),
        "fixed_final_balance": round(float(bal[-1]), 0),
        "fixed_account_blown_at_trade": (ruin_i + 1) if ruin_i is not None else None,
        "fixed_account_blown_date": (str(tr["entry_t"].iloc[ruin_i])[:10]) if ruin_i is not None else None,
        "fixed_total_if_refilled": round(float(risk * R.sum()), 0),
        "fixed_profit_per_month": round(float(risk * R.sum() / months), 0),
        "fixed_max_drawdown": round(float(np.max(peak - bal)), 0),
        "median_risk_price": round(float(tr["risk"].median()), 3),
        "median_cost_share_of_risk_pct": round(float(tr["cost_share_of_risk"].median() * 100), 1),
        "median_risk_pct_of_price": round(float(tr["risk_pct_of_price"].median()), 4),
        "risk_quartiles": _quartiles(tr),
        "periods": {k: _period(tr, d0) for k, d0 in PERIODS.items()},
        "years": years,
        "years_win_ge_50": [y["year"] for y in years if y["win_rate"] >= 50],
        "years_reach10_ge_50": [y["year"] for y in years if y["reach_10R_pct"] >= 50],
        "years_positive": [y["year"] for y in years if y["total_R"] > 0],
    }


def context(m: Market) -> dict:
    pdh, pdl, s0, s1 = day_levels(m)
    prevstart = np.zeros(len(m), np.int64)
    for q, (a, b) in enumerate(zip(s0, s1)):
        prevstart[a:b] = s0[q - 1] if q > 0 else a
    return {"levels": (pdh, pdl, s0, s1), "piv": {}, "cap": {}, "prevstart": prevstart}


def money_for(m: Market, rules: dict) -> dict:
    return rules["money"]["nse" if m.kind == "nse" else "usd"]


KEEP = ("trades", "win_rate_pct", "win_rate_95ci_pct", "reach_10R_pct", "reached_tg1_pct", "full_sl_pct", "avg_R",
        "avg_R_95ci", "profit_factor", "total_R", "years_positive", "years_win_ge_50", "longest_losing_streak",
        "fixed_final_balance", "fixed_account_blown_date", "risk_quartiles", "periods", "years",
        "median_cost_share_of_risk_pct")


def cmd_run(a, rules: dict) -> None:
    syms = a.symbols.split(",")
    tag = a.tag or "_".join(syms)
    out = {"title": "Gautam Jha 'two-line' liquidity strategy - backtest of the rules as taught",
           "preliminary": bool(rules.get("_preliminary", True)), "claims": rules["claims"], "symbols": {},
           "generated_utc": pd.Timestamp.now(tz="UTC").isoformat(),
           "rules_sha1": hashlib.sha1(RULES.read_bytes()).hexdigest()}
    tdir = RAW / "trades" / tag
    tdir.mkdir(parents=True, exist_ok=True)
    for sym in syms:
        mode = "his_screen" if sym == "XAUUSD" else "none"
        m = load_market(sym, rules, spread_mode=mode, start=a.start, end=a.end)
        ctx = context(m)
        weeks = (m.t[-1] - m.t[0]) / (7 * 86400)
        span = f"{_ts(m, 0)[:10]} .. {_ts(m, len(m) - 1)[:10]}"
        print(f"{sym}: {len(m):,} minutes {span} source={m.source}", flush=True)
        S = {"source": m.source, "span": span, "minutes": len(m), "variants": {}, "no_cost": {}, "optimistic": {},
             "most_generous": {}, "real_spread": {}, "slippage_x2": {}}
        money = money_for(m, rules)
        alt = {}
        for v in rules["twoline"]["variants"]:
            if m.kind not in v.get("markets", ["gold", "nse", "crypto"]):
                continue
            if v.get("symbols") and sym not in v["symbols"]:
                continue
            if v.get("gold_day") == "utc" and m.kind == "gold":
                if "utc" not in alt:
                    mu = load_market(sym, rules, spread_mode=mode, start=a.start, end=a.end, day_mode="utc")
                    alt["utc"] = (mu, context(mu))
                df = run_variant(alt["utc"][0], v, rules, alt["utc"][1])
            else:
                df = run_variant(m, v, rules, ctx)
            s = summarize(df, weeks, money) if len(df) else {"trades": 0}
            s["variant"] = v
            if len(df):
                slug = f"{sym}_{hashlib.md5(v['name'].encode()).hexdigest()[:8]}"
                df.assign(variant=v["name"], symbol=sym).to_csv(tdir / f"{slug}.csv.gz", index=False)
                s["trades_file"] = f"data_raw/trades/{tag}/{slug}.csv.gz"
                print(f"  [{sym}] {v['name'][:58]:58s} n={s['trades']:5d} win={s['win_rate_pct']:5.1f}% "
                      f"10R={s['reach_10R_pct']:5.1f}% fullSL={s['full_sl_pct']:5.1f}% avgR={s['avg_R']:+.3f} "
                      f"PF={s['profit_factor']} totR={s['total_R']:+.1f}", flush=True)
            S["variants"][v["name"]] = s
            if v["name"] in rules["twoline"]["headline"] and not a.fast:
                z = run_variant(m, v, rules, ctx, zero=True)
                o = run_variant(m, v, rules, ctx, optimistic=True)
                g = run_variant(m, v, rules, ctx, zero=True, optimistic=True)
                x2 = run_variant(m, v, rules, ctx, slip_mult=2.0)
                for key, d2 in (("no_cost", z), ("optimistic", o), ("most_generous", g), ("slippage_x2", x2)):
                    s2 = summarize(d2, weeks, money) if len(d2) else {"trades": 0}
                    S[key][v["name"]] = {x: s2.get(x) for x in KEEP}
                if m.kind == "gold":
                    mr = load_market(sym, rules, spread_mode="real", start=a.start, end=a.end)
                    mr.source = "dukascopy_real_spread"
                    d3 = run_variant(mr, v, rules, context(mr))
                    s3 = summarize(d3, weeks, money) if len(d3) else {"trades": 0}
                    S["real_spread"][v["name"]] = {x: s3.get(x) for x in KEEP}
                    del mr
                print(f"     zero-cost avgR={S['no_cost'][v['name']].get('avg_R')} win={S['no_cost'][v['name']].get('win_rate_pct')}"
                      f" | most-generous avgR={S['most_generous'][v['name']].get('avg_R')}"
                      f" | real-spread avgR={S['real_spread'].get(v['name'], {}).get('avg_R')}", flush=True)
        out["symbols"][sym] = S
        del m
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    path = OUT_DIR / f"gautam_results_{tag}.json"
    path.write_text(json.dumps(out, default=lambda x: x.item() if hasattr(x, "item") else str(x), separators=(",", ":")))
    print(f"wrote {path.relative_to(ROOT)}")


# ------------------------------------------------------------- his demo days ---
def cmd_demo(a, rules: dict) -> None:
    """His own 3 demo days (wI9b968AvW8 15:04-21:36: 21, 20, 19 May 2026, gold): print the lines and every trade the
    exact rules take, minute by minute around each, to compare with what he says on camera."""
    v = next(x for x in rules["twoline"]["variants"] if x["name"] == rules["twoline"]["headline"][0])
    m = load_market("XAUUSD", rules, spread_mode="his_screen", start="2026-05-12", end="2026-05-23")
    ctx = context(m)
    df = run_variant(m, v, rules, ctx)
    pdh, pdl, s0, s1 = ctx["levels"]
    res = {"variant": v["name"], "days": {}}
    for dstr in ("2026-05-19", "2026-05-20", "2026-05-21"):
        q = [i for i, a0 in enumerate(s0) if str(np.datetime64(int(m.day[a0]), "D")) == dstr][0]
        a0, b0 = s0[q], s1[q]
        g = df[df["day"] == dstr]
        res["days"][dstr] = {"pdh": float(pdh[a0]), "pdl": float(pdl[a0]), "day_high": float(m.h[a0:b0].max()),
                             "day_low": float(m.l[a0:b0].min()),
                             "first_cross_above_pdh": next((_ts(m, i) for i in range(a0, b0) if m.h[i] > pdh[a0]), None),
                             "first_cross_below_pdl": next((_ts(m, i) for i in range(a0, b0) if m.l[i] < pdl[a0]), None),
                             "trades": g[["setup_t", "entry_t", "exit_t", "dir", "entry_px", "stop", "risk", "tg1", "tg2",
                                          "R", "reason", "mfe_R"]].round(3).to_dict("records")}
        print(dstr, json.dumps(res["days"][dstr], indent=1, default=str))
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    (OUT_DIR / "gautam_demo_days.json").write_text(json.dumps(res, indent=1, default=str))


def cmd_demorank(a, rules: dict) -> None:
    """How unusual were his three demo days? Daily R of the exact gold rules (every NY trading day, no-trade days = 0R),
    rolling 3-day sums, share of windows at least as good as 19-21 May 2026."""
    v = next(x for x in rules["twoline"]["variants"] if x["name"] == rules["twoline"]["headline"][0])
    m = load_market("XAUUSD", rules, spread_mode="his_screen")
    df = run_variant(m, v, rules, context(m))
    days = pd.Series(sorted({str(np.datetime64(int(x), "D")) for x in np.unique(m.day)}))
    s = df.groupby("day")["R"].sum().reindex(days).fillna(0.0)
    s.index = days
    demo = float(s.loc[["2026-05-19", "2026-05-20", "2026-05-21"]].sum())
    roll = s.rolling(3).sum().dropna()
    r26 = roll[roll.index.str[:4] == "2026"]
    out = {"demo_3day_R": round(demo, 2), "windows": int(len(roll)),
           "share_3day_windows_at_least_as_good_pct": round(100 * float((roll >= demo).mean()), 2),
           "share_2026_pct": round(100 * float((r26 >= demo).mean()), 2),
           "share_3day_windows_positive_pct": round(100 * float((roll > 0).mean()), 1),
           "median_3day_R": round(float(roll.median()), 2)}
    print(out)
    (OUT_DIR / "gautam_demo_rank.json").write_text(json.dumps(out, indent=1))


# ------------------------------------------------------------- spot check ---
def cmd_spot(a, rules: dict) -> None:
    v = next(x for x in rules["twoline"]["variants"] if x["name"] == a.variant)
    mode = "his_screen" if a.symbol == "XAUUSD" else "none"
    m = load_market(a.symbol, rules, spread_mode=mode, start=a.start, end=a.end)
    ctx = context(m)
    df = run_variant(m, v, rules, ctx)
    pdh, pdl, s0, s1 = ctx["levels"]
    rng = random.Random(a.seed)
    pick = sorted(rng.sample(range(len(df)), min(a.n, len(df))))
    print(f"{a.symbol} '{v['name']}' trades={len(df)} (minutes shown: time o h l c [spread])")
    for ix in pick:
        t = df.iloc[ix]
        print("=" * 110)
        print({k: (round(x, 3) if isinstance(x, float) else x) for k, x in t.to_dict().items()
               if k not in ("setup_i", "entry_i", "exit_i", "reason_code")})
        k0, j, e = int(t.setup_i), int(t.entry_i), int(t.exit_i)
        a0 = s0[np.searchsorted(s0, k0, side="right") - 1]
        cross = next((i for i in range(a0, k0 + 1) if (m.h[i] > pdh[k0] if t.dir == -1 else m.l[i] < pdl[k0])), None)
        print(f"  PDH {pdh[k0]} PDL {pdl[k0]}; first cross of the line at {_ts(m, cross) if cross is not None else None}; "
              f"extreme since cross up to the setup: {(m.h[cross:k0 + 1].max() if t.dir == -1 else m.l[cross:k0 + 1].min()) if cross is not None else None}")
        for i in list(range(max(k0 - 6, 0), min(j + 3, len(m)))) + [-1] + list(range(max(e - 2, j + 3), min(e + 2, len(m)))):
            if i == -1:
                print("   ...")
                continue
            tagx = " <setup" if i == k0 else (" <entry" if i == j else (" <exit" if i == e else ""))
            print(f"   {_ts(m, i)} {m.o[i]:10.3f} {m.h[i]:10.3f} {m.l[i]:10.3f} {m.c[i]:10.3f} [{m.sp[i]:.2f}]{tagx}")
        print(f"  check: risk = |entry {t.entry_px:.3f} - stop {t.stop:.3f}| = {t.risk:.3f}; tg1 {t.tg1:.3f} "
              f"({t.tg1_R:.2f}R), tg2 {t.tg2}; R_gross {t.R_gross:+.3f}, cost {t.cost_pts:.3f} -> R {t.R:+.3f}")


# ---------------------------------------------------------------- premises ---
def cmd_premise(a, rules: dict) -> None:
    """P1 'the line is liquidity: after it is taken, price reverses': after the first trade through the previous
    day's high (low), does price come back to the previous day's MIDPOINT before it extends by the same distance
    beyond the line (same day)? Random walk ~ coin flip in distance terms; reported with the reverse question
    ('extends first'). P2 his '50% of trades give 1:10': share of every entry the exact rules take whose favourable
    move reaches 10x the risk before the stop (in the trades files: mfe_R)."""
    out = {}
    for sym in a.symbols.split(","):
        mode = "his_screen" if sym == "XAUUSD" else "none"
        m = load_market(sym, rules, spread_mode=mode)
        pdh, pdl, s0, s1 = day_levels(m)
        rows = []
        for a0, b0 in zip(s0, s1):
            if np.isnan(pdh[a0]):
                continue
            H, L = pdh[a0], pdl[a0]
            mid = (H + L) / 2
            dist = H - mid
            for side in (1, -1):
                hit = np.flatnonzero(m.h[a0:b0] > H) if side == 1 else np.flatnonzero(m.l[a0:b0] < L)
                if not len(hit):
                    continue
                i0 = a0 + hit[0]
                if i0 == a0:          # day opened beyond the line (gap) - skip
                    continue
                lvl = H if side == 1 else L
                back = mid
                ext = lvl + side * dist
                res = "neither"
                for i in range(i0, b0):
                    rb = (m.l[i] <= back) if side == 1 else (m.h[i] >= back)
                    ex = (m.h[i] >= ext) if side == 1 else (m.l[i] <= ext)
                    if rb and ex:
                        res = "same_minute"
                        break
                    if rb:
                        res = "reversed_to_mid"
                        break
                    if ex:
                        res = "extended"
                        break
                rows.append({"day": int(m.day[a0]), "side": side, "res": res,
                             "close_back_inside": bool((m.c[b0 - 1] < H) if side == 1 else (m.c[b0 - 1] > L))})
        df = pd.DataFrame(rows)
        decided = df[df["res"].isin(["reversed_to_mid", "extended"])]
        out[sym] = {"days_with_a_cross": int(len(df)),
                    "reversed_to_prev_mid_first_pct": round(100 * float((decided["res"] == "reversed_to_mid").mean()), 1),
                    "extended_same_distance_first_pct": round(100 * float((decided["res"] == "extended").mean()), 1),
                    "neither_by_day_end_pct": round(100 * float((df["res"] == "neither").mean()), 1),
                    "day_closed_back_inside_pct": round(100 * float(df["close_back_inside"].mean()), 1),
                    "definition": "first trade beyond the previous day's high (low) -> which comes first the same day: "
                                  "back to the previous day's midpoint, or the same distance further beyond the line"}
        print(sym, out[sym])
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    (OUT_DIR / f"gautam_premises_{'_'.join(a.symbols.split(','))}.json").write_text(json.dumps(out, indent=1))


# ------------------------------------------------------- second data source ---
def cmd_crosscheck(a, rules: dict) -> None:
    """Gold, headline variants, zero cost: Dukascopy BID vs HistData BID, trade-level agreement."""
    out = {}
    md = load_market("XAUUSD", rules, spread_mode="zero")
    mh = load_market("XAUUSD", rules, source="histdata", spread_mode="zero")
    cd, ch = context(md), context(mh)
    weeks = (md.t[-1] - md.t[0]) / (7 * 86400)
    for v in rules["twoline"]["variants"]:
        if v["name"] not in rules["twoline"]["headline"] or "gold" not in v.get("markets", ["gold"]):
            continue
        d1 = run_variant(md, v, rules, cd, zero=True)
        d2 = run_variant(mh, v, rules, ch, zero=True)
        s1_, s2_ = summarize(d1, weeks, rules["money"]["usd"]), summarize(d2, weeks, rules["money"]["usd"])
        same = len(set(d1["entry_t"]) & set(d2["entry_t"]))
        out[v["name"]] = {"dukascopy": {x: s1_.get(x) for x in KEEP}, "histdata": {x: s2_.get(x) for x in KEEP},
                          "same_entry_minute": same}
        print(v["name"], json.dumps(out[v["name"]]))
    (OUT_DIR / "gautam_crosscheck_XAUUSD.json").write_text(json.dumps(out, indent=1, default=str))


# ------------------------------------------------------------- proof export ---
def cmd_export(a, rules: dict) -> None:
    """Copy the headline trade CSVs into engine/public/projects/gautam-audit/data/trades/ (for the proof page)."""
    tdir = OUT_DIR / "trades"
    tdir.mkdir(parents=True, exist_ok=True)
    for old in tdir.glob("*.csv"):
        old.unlink()
    short = {rules["twoline"]["headline"][0]: "exact", rules["twoline"]["headline"][1]: "exact",
             rules["twoline"]["headline"][2]: "claim_1to10"}          # GENEROUS is too big for the page (in data_raw)
    for f in sorted((RAW / "trades").glob("*/*.csv.gz")):
        df = pd.read_csv(f)
        if "variant" not in df or df["variant"].iloc[0] not in short:
            continue
        cols = ["symbol", "variant", "day", "setup_t", "entry_t", "exit_t", "dir", "pdh", "pdl", "entry_px", "stop",
                "risk", "tg1", "tg1_R", "tg2", "reason", "full_sl", "mfe_R", "R_gross", "cost_pts", "R"]
        name = f"{df['symbol'].iloc[0]}_{short[df['variant'].iloc[0]]}.csv"
        df[cols].round(4).to_csv(tdir / name, index=False)
        print("wrote", (tdir / name).relative_to(ROOT), len(df))


# --------------------------------------------------------------- swing (2023) ---
def _swing_one(df: pd.DataFrame, v: dict, rules: dict, optimistic: bool = False, zero: bool = False) -> list[dict]:
    """His 2023 daily swing rules on one stock (see gautam_rules.json 'swing'). Daily OHLC, stop first on a shared day."""
    C = rules["swing"]["costs"]
    o, h, l, c = (df[x].to_numpy(float) for x in ("Open", "High", "Low", "Close"))
    dates = df.index.strftime("%Y-%m-%d").to_numpy()
    n = len(df)
    tr = np.maximum(h[1:], c[:-1]) - np.minimum(l[1:], c[:-1])
    atr = pd.Series(np.concatenate([[h[0] - l[0]], tr])).rolling(14).mean().to_numpy()
    sma = pd.Series(c).rolling(50).mean().to_numpy()
    k = int(v["k"])
    piv = [p for p in range(k, n - k) if l[p] < l[p - k:p].min() and l[p] <= l[p + 1:p + k + 1].min()]
    out = []
    i = k + 1
    used = -1
    qi = 0
    while i < n - 1:
        # most recent pivot low known by day i-1 (pivot + k <= i - 1), not used, not yet broken before day i
        cand = [p for p in piv if p > used and p + k <= i - 1]
        if not cand:
            i += 1
            continue
        p = cand[-1]
        L = l[p]
        if l[p + 1:i].min(initial=np.inf) < L if v["break"] == "wick" else c[p + 1:i].min(initial=np.inf) < L:
            used = p          # already broken earlier (before we could watch it): skip this low
            continue
        brk = (l[i] < L) if v["break"] == "wick" else (c[i] < L)
        if not brk:
            i += 1
            continue
        b = i
        used = p
        H = h[p + 1:b].max() if b > p + 1 else np.nan
        if v.get("trend") and not (b >= 60 and c[b - 1] > sma[b - 1] and sma[b - 1] > sma[b - 11]):
            i = b + 1
            continue
        e = None
        for j in range(b + 1, min(b + 1 + v["window"], n - 1)):
            if c[j] > o[j] and not (atr[j] > 0 and (h[j] - l[j]) > v["big_atr"] * atr[j]):
                e = j
                break
        if e is None:
            i = b + 1
            continue
        slip = 0.0 if zero else C["slippage_pct_per_side"] / 100
        if v["entry"] == "next_open":
            px = o[e + 1] * (1 + slip)
            start = e + 1
        else:
            px = c[e] * (1 + slip)
            start = e + 1
        stop = l[e] - (v["buffer_abs"] if "buffer_abs" in v else l[e] * v["buffer_pct"] / 100)
        risk = px - stop
        if risk <= 0 or not (H > px):
            i = e + 1
            continue
        t3 = px * 1.03
        legs = [(t3, 0.5), (H, 0.5)] if H > t3 else [(H, 1.0)]
        rem, pnl, got3, reachH, reason, x = 1.0, 0.0, False, False, "time", min(start + 59, n - 1)
        for d in range(start, min(start + 60, n)):
            st = l[d] <= stop
            hits = [lv for lv, f in legs if h[d] >= lv]
            if st and (not hits or not optimistic):
                fill = min(stop, o[d]) * (1 - slip)
                pnl += rem * (fill - px)
                rem, reason, x = 0.0, ("stop" if not got3 else "stop_after_partial"), d
                break
            while legs and h[d] >= legs[0][0]:
                lv, f = legs.pop(0)
                pnl += f * (max(lv, o[d]) - px)
                rem -= f
                got3 = got3 or lv == t3
                reachH = reachH or lv == H
            if rem <= 1e-9:
                reason, x = "target", d
                break
        if rem > 1e-9:
            pnl += rem * (c[x] * (1 - slip) - px)
        cost = 0.0 if zero else C["round_trip_pct"] / 100 * px
        R = (pnl - cost) / risk
        fav = h[start:x + 1].max() if x >= start else px
        out.append({"setup_low_date": dates[p], "break_date": dates[b], "entry_date": dates[e if v["entry"] == "close" else e + 1],
                    "exit_date": dates[x], "entry_px": round(px, 2), "stop": round(stop, 2), "risk_pct": round(100 * risk / px, 2),
                    "prev_high": round(H, 2), "reached_3pct": bool(got3 or (h[start:x + 1].max(initial=0) >= t3 and reason != "stop")),
                    "reached_prev_high": bool(reachH), "reason": reason, "ret_pct": round(100 * (pnl - cost) / px, 3), "R": round(R, 3)})
        i = x + 1
    return out


def cmd_swingbuild(a, rules: dict) -> None:
    """Kaggle debashis74017 Nifty 100 stocks 15-minute files -> daily OHLC CSVs in data_raw/n100_daily/ (NSE session)."""
    src = RAW / "kaggle_n100_15m" / "files"
    dst = RAW / "n100_daily"
    dst.mkdir(parents=True, exist_ok=True)
    for f in sorted(src.rglob("*_15minute.csv")):
        df = pd.read_csv(f)
        df.columns = [c.lower() for c in df.columns]
        ts = pd.to_datetime(df["date"])
        if ts.dt.tz is not None:
            ts = ts.dt.tz_convert(IST).dt.tz_localize(None)
        df = df.assign(_t=ts).dropna(subset=["open", "high", "low", "close"]).sort_values("_t")
        hm = df["_t"].dt.hour * 60 + df["_t"].dt.minute
        df = df[(hm >= 555) & (hm < 930)]
        d = df.groupby(df["_t"].dt.date).agg(Open=("open", "first"), High=("high", "max"), Low=("low", "min"),
                                             Close=("close", "last"), n=("close", "size"))
        d = d[d["n"] >= 10].drop(columns="n")
        d.index = pd.to_datetime(d.index)
        d.index.name = "Date"
        d.to_csv(dst / f.name.replace("_15minute.csv", ".csv"))
    print(len(list(dst.glob("*.csv"))), "daily files in", dst.relative_to(ROOT))


def cmd_swing(a, rules: dict) -> None:
    """His 2023 swing strategy on daily data. --source kaggle: today's NIFTY 100 (Nifty 50 + Next 50, his watchlists),
    daily bars built from Kaggle 15-minute data; --source yahoo: today's NIFTY 50 + his named stocks (Yahoo .NS)."""
    folder = RAW / ("n100_daily" if a.source in ("", "kaggle") else "yahoo_stocks")
    files = sorted(folder.glob("*.csv"))
    res = {"claim": rules["swing"]["claim"], "stocks": [f.stem for f in files], "variants": {}}
    for v in rules["swing"]["variants"]:
        for tag, zero, opt in (("", False, False), ("zero_cost", True, False), ("most_generous", True, True)):
            if tag and v["name"] not in rules["swing"]["headline"]:
                continue
            rows = []
            for f in files:
                df = pd.read_csv(f, index_col=0, parse_dates=True)
                df = df[["Open", "High", "Low", "Close"]].apply(pd.to_numeric, errors="coerce").dropna()
                df = df[(df.index >= "2015-01-01") & (df["Low"] > 0)]
                for t in _swing_one(df, v, rules, optimistic=opt, zero=zero):
                    t["stock"] = f.stem
                    rows.append(t)
            tr = pd.DataFrame(rows)
            if not len(tr):
                continue
            R = tr["R"].to_numpy()
            lo3, hi3 = wilson(int(tr["reached_3pct"].sum()), len(tr))
            low, hiw = wilson(int((R > 0).sum()), len(tr))
            se = R.std(ddof=1) / math.sqrt(len(R))
            yrs = []
            for y, g in tr.groupby(tr["entry_date"].str[:4]):
                yrs.append({"year": int(y), "trades": int(len(g)), "reached_3pct": round(100 * g["reached_3pct"].mean(), 1),
                            "win_rate": round(100 * (g["R"] > 0).mean(), 1), "avg_R": round(float(g["R"].mean()), 3),
                            "avg_ret_pct": round(float(g["ret_pct"].mean()), 3)})
            s = {"trades": int(len(tr)), "stocks_with_trades": int(tr["stock"].nunique()),
                 "reached_3pct_before_stop_pct": round(100 * tr["reached_3pct"].mean(), 1), "reached_3pct_95ci": [round(100 * lo3, 1), round(100 * hi3, 1)],
                 "win_rate_pct": round(100 * (R > 0).mean(), 1), "win_rate_95ci": [round(100 * low, 1), round(100 * hiw, 1)],
                 "reached_prev_high_pct": round(100 * tr["reached_prev_high"].mean(), 1),
                 "avg_R": round(float(R.mean()), 3), "avg_R_95ci": [round(float(R.mean() - 1.96 * se), 3), round(float(R.mean() + 1.96 * se), 3)],
                 "avg_ret_pct_per_trade": round(float(tr["ret_pct"].mean()), 3), "median_risk_pct": round(float(tr["risk_pct"].median()), 2),
                 "exit_reasons_pct": {k2: round(100 * float(x2), 1) for k2, x2 in tr["reason"].value_counts(normalize=True).items()},
                 "years": yrs, "years_reached3_ge_80": [y["year"] for y in yrs if y["reached_3pct"] >= 80],
                 "years_win_ge_80": [y["year"] for y in yrs if y["win_rate"] >= 80]}
            key = v["name"] + (f" [{tag}]" if tag else "")
            if not tag:
                named = {}
                for st_, g in tr.groupby("stock"):
                    if st_ in ("EXIDEIND", "APTUS", "M_MFIN", "MANAPPURAM", "BHARTIARTL", "INFY"):
                        named[st_] = {"trades": int(len(g)), "reached_3pct": round(100 * g["reached_3pct"].mean(), 1),
                                      "win_rate": round(100 * (g["R"] > 0).mean(), 1), "avg_R": round(float(g["R"].mean()), 3)}
                s["his_named_stocks"] = named
            res["variants"][key] = s
            print(f"{key[:80]:80s} n={s['trades']} +3%={s['reached_3pct_before_stop_pct']}% win={s['win_rate_pct']}% "
                  f"prevHigh={s['reached_prev_high_pct']}% avgR={s['avg_R']:+.3f} avgRet={s['avg_ret_pct_per_trade']:+.3f}%")
            if not tag and v["name"] in rules["swing"]["headline"] and a.source in ("", "kaggle"):
                tr.to_csv(RAW / "trades" / "swing_exact.csv.gz", index=False)
                (OUT_DIR / "trades").mkdir(parents=True, exist_ok=True)
                tr.to_csv(OUT_DIR / "trades" / "NSE_stocks_swing_exact.csv", index=False)
    res["source"] = str(folder.relative_to(ROOT))
    suffix = "" if a.source in ("", "kaggle") else f"_{a.source}"
    (OUT_DIR / f"gautam_swing_results{suffix}.json").write_text(json.dumps(res, indent=1))
    print("wrote gautam_swing_results.json")

# ----------------------------------------------------------------- report ---
def _pct(x):
    return "—" if x is None else f"{x:.1f}%"


def _ci(x):
    return "—" if not x else f"{x[0]:.1f}–{x[1]:.1f}"


def _r(x):
    return "—" if x is None else f"{x:+.3f}R"


def cmd_report(a, rules: dict) -> None:
    """BACKTEST.md from the result JSONs in engine/public/projects/gautam-audit/data/ (numbers only from there)."""
    L = []
    files = sorted(OUT_DIR.glob("gautam_results_*.json"))
    res = {}
    for f in files:
        d = json.loads(f.read_text())
        for sym, S in d["symbols"].items():
            res[sym] = (S, f.name, d.get("rules_sha1"), d.get("generated_utc"))
    H = rules["twoline"]["headline"]
    exact_name = {True: H[0], False: H[1]}
    L.append("# Backtest: Gautam Jha's \"two-line\" liquidity strategy (rules as he taught them)")
    L.append("")
    L.append("Generated by `tools/gautam_backtest.py report` from `engine/public/projects/gautam-audit/data/*.json` "
             f"({pd.Timestamp.now(tz='UTC'):%Y-%m-%d %H:%M} UTC). Rules, quotes and every assumption: "
             "`projects/gautam-audit/BACKTEST_PLAN.md` and `gautam_rules.json` (pre-registered copy in `prereg/`). "
             + ("**PRELIMINARY.**" if any(json.loads(f.read_text()).get("preliminary", True) for f in files) else
                "**FINAL** (marked 11 Oct 2026 after the cross-checks below; no numbers changed)."))
    L.append("")
    L.append("His claim (wI9b968AvW8, 14:22–14:40): out of 10 trades \"four, five, six\" work; \"this 50% accuracy is at 1:10 "
             "risk-reward\". If that were true the strategy would make about +4.5R per trade (0.5 × 10 − 0.5 × 1).")
    L.append("")
    if "XAUUSD" in res:
        G = res["XAUUSD"][0]["variants"]
        ex, cl = G.get(H[0], {}), G.get(H[2], {})
        L.append("### His numbers vs what his rules do (gold, 1-minute, 2015 → Sep 2026)")
        L.append("")
        L.append("| His claim | Where | Measured: share of trades that EVER reach 1:10 before the stop | Measured: trades that win at all |")
        L.append("|---|---|---|---|")
        L.append(f"| \"yeh 50% ka accuracy 1:10 ke risk reward pe hai\" (this strategy) | wI9b968AvW8 14:33 (verified 3-way) | **{ex.get('reach_10R_pct')}%** [{_ci(ex.get('reach_10R_95ci_pct'))}] of {ex.get('trades', 0):,} | {ex.get('win_rate_pct')}% (full position to 10R: {cl.get('win_rate_pct')}%) |")
        L.append(f"| \"dono mere Telegram ka accuracy more than 50% hai with 1:10\" (his Telegram calls) | live 14 Sep 2026, 0stizzV7D1Y 4344.5–4375 (agent B/D) | {ex.get('reach_10R_pct')}% for the strategy he teaches publicly; his Telegram calls themselves: agent D | — |")
        L.append(f"| \"group ka accuracy more than 60–70% hai with 1:10… phenomenal\" (his group) | live 17 Sep 2026, e4lZ5QeeZ6E 37:19 (agent B/D) | same | — |")
        L.append("")
        L.append("Reading his 1:10 claim generously: \"accuracy at 1:10\" = the trade's favourable move reaches ten times the stop "
                 "distance before the stop is hit, measured on every minute until the day's close, ignoring costs in the fills "
                 "except his own screen's spread. Even with ZERO costs it is about 9% (cost table below).")
        L.append("")
    L.append("## 1. Headline: his exact rules, his market (gold, 1-minute), 2015 → Sep 2026")
    L.append("")
    L.append("| Market | Data | Trades | Win rate (net R > 0) [95% CI] | Trades that ever reach 1:10 before the stop [95% CI] | Avg R after costs [95% CI] | Avg R, the quarter of trades with the BIGGEST stops | Avg R at ZERO cost (all / biggest-stop quarter) | Years with a profit (after costs) |")
    L.append("|---|---|---|---|---|---|---|---|---|")
    for sym in ("XAUUSD", "NIFTY", "BANKNIFTY", "BTCUSDT", "ETHUSDT"):
        if sym not in res:
            continue
        S = res[sym][0]
        nm = exact_name[sym == "XAUUSD"]
        v = S["variants"].get(nm)
        if not v or not v.get("trades"):
            continue
        z = S["no_cost"].get(nm, {})
        yrs = len(v["years"])
        bq = v["risk_quartiles"][-1]["avg_R"] if v.get("risk_quartiles") else None
        zq = z.get("risk_quartiles") or [{}]
        L.append(f"| {sym} | {S['source']} {S['span']} | {v['trades']:,} | {_pct(v['win_rate_pct'])} [{_ci(v['win_rate_95ci_pct'])}] | "
                 f"{_pct(v['reach_10R_pct'])} [{_ci(v['reach_10R_95ci_pct'])}] | {_r(v['avg_R'])} [{v['avg_R_95ci'][0]:+.3f}, {v['avg_R_95ci'][1]:+.3f}] | "
                 f"{_r(bq)} | {_r(z.get('avg_R'))} / {_r(zq[-1].get('avg_R'))} | {len(v['years_positive'])} of {yrs} |")
    L.append("")
    L.append("Costs: gold = the 0.28 spread on HIS OWN SCREEN + $0.05 slippage on stop fills, no commission; NIFTY/BANK NIFTY = "
             "statutory option charges by date + 0.5/1.0 premium-point slippage (delta 0.5); crypto = Delta Exchange taker fee + GST + "
             "0.01% slippage. Exact rules for indices/crypto use a stop cap of 7% of the 20-day median daily range (his 70 pips "
             "was 7% of gold's daily range when he said it).")
    L.append("")
    # gold details
    if "XAUUSD" in res:
        S = res["XAUUSD"][0]
        L.append("## 2. Gold (XAUUSD) — every reading of his rules")
        L.append("")
        L.append("| Variant | Trades | /week | Win rate | Reach TG1 | Reach 1:10 | Full SL | Avg R | PF | Total R | Longest losing run | $10k at $100/trade |")
        L.append("|---|---|---|---|---|---|---|---|---|---|---|---|")
        for nm, v in S["variants"].items():
            if not v.get("trades"):
                continue
            bal = "blown " + v["fixed_account_blown_date"] if v.get("fixed_account_blown_date") else f"${v['fixed_final_balance']:,.0f}"
            L.append(f"| {nm} | {v['trades']:,} | {v['trades_per_week']} | {_pct(v['win_rate_pct'])} | {_pct(v['reached_tg1_pct'])} | "
                     f"{_pct(v['reach_10R_pct'])} | {_pct(v['full_sl_pct'])} | {_r(v['avg_R'])} | {v['profit_factor']} | {v['total_R']:+,.1f} | "
                     f"{v['longest_losing_streak']} | {bal} |")
        L.append("")
        L.append("Cost sensitivity on the headline readings (same trades logic, different cost / fill assumptions):")
        L.append("")
        L.append("| Variant | Costs as on his screen | Zero cost | Zero cost + optimistic intrabar (most generous) | Dukascopy real spread + $0.07 commission | Slippage ×2 |")
        L.append("|---|---|---|---|---|---|")
        for nm in H:
            if nm not in S["variants"] or not S["variants"][nm].get("trades"):
                continue
            row = [S["variants"][nm]] + [S[k].get(nm, {}) for k in ("no_cost", "most_generous", "real_spread", "slippage_x2")]
            L.append(f"| {nm[:60]} | " + " | ".join(f"win {_pct(x.get('win_rate_pct'))}, 1:10 {_pct(x.get('reach_10R_pct'))}, {_r(x.get('avg_R'))}" for x in row) + " |")
        L.append("")
        v = S["variants"][H[0]]
        L.append(f"Headline per year ({H[0][:40]}…):")
        L.append("")
        L.append("| Year | Trades | Win rate | Reach 1:10 | Avg R | Total R |")
        L.append("|---|---|---|---|---|---|")
        for y in v["years"]:
            L.append(f"| {y['year']} | {y['trades']} | {y['win_rate']}% | {y['reach_10R_pct']}% | {y['avg_R']:+.3f} | {y['total_R']:+.1f} |")
        L.append("")
        L.append(f"Years with win rate ≥ 50%: {v['years_win_ge_50'] or 'none'}; years where ≥ 50% of trades reach 1:10: "
                 f"{v['years_reach10_ge_50'] or 'none'}; profitable years after costs: {v['years_positive'] or 'none'}.")
        L.append("")
        L.append("**Costs in plain pips (answers Trading With Sidhant's \"1:7 bana hai lekin 81 pips hai… brokerage to poora lagega\", "
                 "UdGXYHp1nYA 1743–1752):** gold pip = $0.10. The stops these rules give are small because they sit on one 1-minute "
                 f"candle: median {S['variants'][H[0]]['median_risk_price']*10:.1f} pips. Every trade pays the spread (2.8 pips on his "
                 "own screen) plus slippage on the entry and on a stop exit (0.5 pip each) — about 3.8 pips per round trip, a median "
                 f"{S['variants'][H[0]]['median_cost_share_of_risk_pct']}% of the stop. So a 1:7 that is only ~80 pips loses a big slice to "
                 "costs, and a full stop costs more than 1R. With Dukascopy's real 2025–26 spreads (5.8–6.9 pips) it is worse; with "
                 "Indian-exchange crypto fees (Delta: 0.05% + GST per side) BTC costs are ~96% of the stop (BTC section).")
        L.append("")
        L.append("By stop size (quartiles of the stop distance in $/oz) — answers \"nobody uses such tiny stops\":")
        L.append("")
        L.append("| Stop size | Trades | Win rate | Reach 1:10 | Avg R | Costs as % of the stop (median) | Avg R at zero cost (same quartile cut, zero-cost run) |")
        L.append("|---|---|---|---|---|---|---|")
        zq = S["no_cost"].get(H[0], {}).get("risk_quartiles", [])
        for i, q in enumerate(v["risk_quartiles"]):
            zz = zq[i]["avg_R"] if i < len(zq) else None
            L.append(f"| ${q['risk_from']:.2f}–{q['risk_to']:.2f} | {q['trades']} | {q['win_rate_pct']}% | {q['reach_10R_pct']}% | "
                     f"{q['avg_R']:+.3f} | {q['median_cost_share_of_risk_pct']}% | {_r(zz)} |")
        L.append("")
        L.append("After he recorded / published the video (out-of-sample for him):")
        L.append("")
        L.append("| Period | Trades | Win rate | Reach 1:10 | Avg R | Total R | Green days |")
        L.append("|---|---|---|---|---|---|---|")
        for nm in (H[0], H[2]):
            for k, p in S["variants"][nm]["periods"].items():
                if p.get("trades"):
                    L.append(f"| {nm[:28]}… {k} | {p['trades']} | {p['win_rate_pct']}% | {p['reach_10R_pct']}% | {p['avg_R']:+.3f} | {p['total_R']:+.1f} | {p['green_days_pct']}% |")
        L.append("")
    for sym in ("NIFTY", "BANKNIFTY", "BTCUSDT", "ETHUSDT"):
        if sym not in res:
            continue
        S = res[sym][0]
        L.append(f"## {sym} ({S['source']}, {S['span']}) — he says use it here only after your own backtest")
        L.append("")
        L.append("| Variant | Trades | Win rate | Reach 1:10 | Full SL | Avg R | Avg R zero cost | PF | Total R | Profitable years |")
        L.append("|---|---|---|---|---|---|---|---|---|---|")
        for nm, v in S["variants"].items():
            if not v.get("trades"):
                continue
            z = S["no_cost"].get(nm, {})
            L.append(f"| {nm} | {v['trades']:,} | {_pct(v['win_rate_pct'])} | {_pct(v['reach_10R_pct'])} | {_pct(v['full_sl_pct'])} | "
                     f"{_r(v['avg_R'])} | {_r(z.get('avg_R'))} | {v['profit_factor']} | {v['total_R']:+,.1f} | {len(v['years_positive'])}/{len(v['years'])} |")
        L.append("")
        nm = exact_name[False]
        v = S["variants"].get(nm, {})
        if v.get("risk_quartiles"):
            zq = S["no_cost"].get(nm, {}).get("risk_quartiles", [])
            L.append(f"Exact rules by stop size (median stop = {v['median_risk_pct_of_price']:.3f}% of price; median costs = "
                     f"{v['median_cost_share_of_risk_pct']}% of the stop):")
            L.append("")
            L.append("| Stop size (price units) | Trades | Win rate | Reach 1:10 | Avg R | Costs as % of the stop | Avg R at zero cost |")
            L.append("|---|---|---|---|---|---|---|")
            for i, q in enumerate(v["risk_quartiles"]):
                zz = zq[i]["avg_R"] if i < len(zq) else None
                L.append(f"| {q['risk_from']:.3f}–{q['risk_to']:.3f} | {q['trades']} | {q['win_rate_pct']}% | {q['reach_10R_pct']}% | "
                         f"{q['avg_R']:+.3f} | {q['median_cost_share_of_risk_pct']}% | {_r(zz)} |")
            L.append("")
            for k, p in v["periods"].items():
                if p.get("trades"):
                    L.append(f"- {k}: {p['trades']} trades, win {p['win_rate_pct']}%, reach 1:10 {p['reach_10R_pct']}%, avg {p['avg_R']:+.3f}R, green days {p['green_days_pct']}%")
            L.append("")
    # premises
    L.append("## Premises (his ideas measured directly)")
    L.append("")
    for f in sorted(OUT_DIR.glob("gautam_premises_*.json")):
        P = json.loads(f.read_text())
        L.append("P1 \"the previous day's high/low is liquidity: once it is taken, big players reverse the market\". "
                 "After the first trade beyond the line, which comes first the same day — back to the previous day's midpoint, "
                 "or the same distance further beyond the line? (A coin flip would be ~50/50.)")
        L.append("")
        L.append("| Market | Days with a line taken | Reversed to the midpoint first | Extended first | Neither by day end | Day closed back inside the lines |")
        L.append("|---|---|---|---|---|---|")
        for sym, x in P.items():
            L.append(f"| {sym} | {x['days_with_a_cross']} | {x['reversed_to_prev_mid_first_pct']}% | {x['extended_same_distance_first_pct']}% | "
                     f"{x['neither_by_day_end_pct']}% | {x['day_closed_back_inside_pct']}% |")
        L.append("")
    if "XAUUSD" in res:
        v = res["XAUUSD"][0]["variants"][H[0]]
        L.append(f"P2 \"50% accuracy at 1:10\" / \"my average trade is 1:6–7\" / \"1:3 is my failure trade\": share of ALL entries his exact "
                 f"rules take (gold) whose favourable move EVER reaches the multiple before the stop, same day: 3R {v['reach_3R_pct']}%, "
                 f"10R {v['reach_10R_pct']}%, 67R {v['reach_67R_pct']}%. Median first target (last swing) = {v['median_tg1_R']}R.")
        L.append("")
    demo = OUT_DIR / "gautam_demo_days.json"
    if demo.exists():
        D = json.loads(demo.read_text())
        L.append("## His own demo days (19–21 May 2026, gold), what the exact rules do")
        L.append("")
        L.append("| Day (NY 17:00 day) | Prev-day high / low | Trades (entry NY time, dir, stop size, result) |")
        L.append("|---|---|---|")
        for dstr, x in D["days"].items():
            tr = "; ".join(f"{t['entry_t'][11:]} {'short' if t['dir'] == -1 else 'long'} risk ${t['risk']:.2f} → {t['R']:+.2f}R ({t['reason']}, max {t['mfe_R']:.1f}R)" for t in x["trades"]) or "none"
            L.append(f"| {dstr} | {x['pdh']:.2f} / {x['pdl']:.2f} | {tr} |")
        L.append("")
        rk = OUT_DIR / "gautam_demo_rank.json"
        if rk.exists():
            K = json.loads(rk.read_text())
            L.append(f"How unusual were his demo days? Under the exact rules the three days total {K['demo_3day_R']:+.1f}R. Of all "
                     f"{K['windows']:,} three-consecutive-day windows 2015 → Sep 2026 only {K['share_3day_windows_at_least_as_good_pct']}% "
                     f"were as good or better ({K['share_2026_pct']}% of 2026 windows); only {K['share_3day_windows_positive_pct']}% of "
                     "three-day windows made any profit at all (`gautam_backtest.py demorank`; no-trade days count as 0R).")
            L.append("")
    cc = OUT_DIR / "gautam_crosscheck_XAUUSD.json"
    if cc.exists():
        C = json.loads(cc.read_text())
        L.append("## Second data source (gold, zero cost): Dukascopy BID vs HistData BID")
        L.append("")
        L.append("| Variant | Dukascopy trades / win / avg R | HistData trades / win / avg R | Same entry minute |")
        L.append("|---|---|---|---|")
        for nm, x in C.items():
            d1, d2 = x["dukascopy"], x["histdata"]
            L.append(f"| {nm[:60]} | {d1['trades']} / {d1['win_rate_pct']}% / {d1['avg_R']:+.3f} | {d2['trades']} / {d2['win_rate_pct']}% / {d2['avg_R']:+.3f} | {x['same_entry_minute']} |")
        L.append("")
    if "XAUUSD" in res:
        G = res["XAUUSD"][0]["variants"]
        sn = next((n for n in G if n.startswith("SIDHANT'S")), None)
        cn = next((n for n in G if n.startswith("COMMENTER'S")), None)
        if sn and cn:
            a1, b1, c1 = G[H[0]], G[sn], G[cn]
            L.append("## The disputed rule: Trading With Sidhant's reading vs his commenter's (both tested)")
            L.append("")
            L.append("Sidhant (\"I Backtested Gautam Jha's Gold Strategy\", UdGXYHp1nYA, 31 Aug 2026, 248K) reads the rule as \"the red "
                     "candle has to be THE highest candle\" and asks whether the long side breaking it is \"a rule, a mistake or an "
                     "oversight?\" (1447.7–1452.9). A commenter (@kr4T0X) says that is a misreading: any first red/green candle, broken "
                     "by the immediate next candle. Sidhant shows days (10–11 Aug, 17–18 Aug, his re-run of 19–21 May) but no total "
                     "win rate. All three readings, same everything else:")
            L.append("")
            L.append("| Reading | Trades | Win rate | Reach 1:10 | Avg R | Avg R zero cost |")
            L.append("|---|---|---|---|---|---|")
            Z = res["XAUUSD"][0]["no_cost"]
            for lab, n_, x in (("Ours (exact): the candle or the one before it printed the extreme", H[0], a1),
                               ("Sidhant: the candle itself is the extreme", sn, b1),
                               ("Commenter: any candle beyond the line, immediate break", cn, c1)):
                L.append(f"| {lab} | {x['trades']:,} | {x['win_rate_pct']}% | {x['reach_10R_pct']}% | {x['avg_R']:+.3f} | {_r(Z.get(n_, {}).get('avg_R'))} |")
            L.append("")
            L.append("The disputed rule does not change the answer. Where Sidhant and we differ: he exits at the previous swing or day "
                     "level by eye on a few days; we code every day from 2015 and keep Gautam's 80/20 split.")
            L.append("")
    sw = OUT_DIR / "gautam_swing_results.json"     # Kaggle Nifty 100 (headline); _yahoo = cross-check
    if sw.exists():
        W = json.loads(sw.read_text())
        L.append("## Second strategy: his 2023 daily swing setup (aBaJD8Bnx6w, \"80–90% accuracy\") — SECONDARY test")
        L.append("")
        L.append(f"Daily bars built from the Kaggle Nifty 100 15-minute set (debashis74017), Feb 2015 → Aug 2025, {len(W['stocks'])} stocks = "
                 "today's Nifty 50 + Next 50 (two of his watchlists; survivorship bias in his favour). Several definitions are ours "
                 "(pivot size, 'big candle', buffer, 60-day time exit): see "
                 "`gautam_rules.json` → `swing.interpreted_by_us`. Costs: 0.25% round trip (delivery) + 0.05% slippage per side.")
        L.append("")
        L.append("| Variant | Trades | Reached +3% before the stop [95% CI] | Closed in profit [95% CI] | Reached the previous high | Avg R [95% CI] | Avg return per trade | Years where +3% came first ≥ 80% |")
        L.append("|---|---|---|---|---|---|---|---|")
        for nm, x in W["variants"].items():
            L.append(f"| {nm} | {x['trades']:,} | {x['reached_3pct_before_stop_pct']}% [{_ci(x['reached_3pct_95ci'])}] | {x['win_rate_pct']}% [{_ci(x['win_rate_95ci'])}] | "
                     f"{x['reached_prev_high_pct']}% | {x['avg_R']:+.3f} [{x['avg_R_95ci'][0]:+.3f}, {x['avg_R_95ci'][1]:+.3f}] | {x['avg_ret_pct_per_trade']:+.3f}% | "
                     f"{len(x['years_reached3_ge_80'])} of {len(x['years'])} |")
        L.append("")
        swy = OUT_DIR / "gautam_swing_results_yahoo.json"
        if swy.exists():
            Y = json.loads(swy.read_text())
            hn = rules["swing"]["headline"][0]
            y0 = Y["variants"].get(hn, {})
            k0 = W["variants"].get(hn, {})
            L.append(f"Cross-check on a second source (Yahoo daily, {len(Y['stocks'])} stocks incl. his named Exide, Aptus, M&M Finance; "
                     f"to Sep 2026): exact rules {y0.get('trades')} trades, +3% first {y0.get('reached_3pct_before_stop_pct')}%, "
                     f"in profit {y0.get('win_rate_pct')}%, avg {y0.get('avg_R', 0):+.3f}R.")
            nm_ = y0.get("his_named_stocks", {}) or {}
            nm_.update({k: v for k, v in (k0.get("his_named_stocks") or {}).items() if k not in nm_})
            if nm_:
                L.append("His own example stocks: " + "; ".join(f"{k}: {v['trades']} trades, +3% first {v['reached_3pct']}%, "
                                                               f"in profit {v['win_rate']}%, avg {v['avg_R']:+.3f}R" for k, v in nm_.items()) + ".")
            L.append("")
        L.append("Notes: the Kaggle 15-minute set ends 6 Aug 2025 for most stocks; daily bars built from it use the last 15-minute close "
                 "(official closes differ by a median ₹0.95 on Infosys). Prices are split-adjusted, so the ₹2.5 flat-buffer variant is "
                 "approximate for split stocks; the 1% buffer is scale-free.")
        L.append("")
    L.append("## Files")
    L.append("")
    for sym, (S, fn, sha, gen) in res.items():
        L.append(f"- {sym}: `engine/public/projects/gautam-audit/data/{fn}` (rules sha1 {sha[:10]}, generated {gen[:19]})")
    L.append("- Trades (every trade, every variant): `projects/gautam-audit/data_raw/trades/<tag>/*.csv.gz`; headline copies for the proof "
             "page: `engine/public/projects/gautam-audit/data/trades/*.csv`.")
    MD.write_text("\n".join(L) + "\n")
    print("wrote", MD.relative_to(ROOT), len(L), "lines")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("cmd", choices=["binance", "run", "demo", "demorank", "spot", "premise", "crosscheck", "export", "report", "swing", "swingbuild"])
    ap.add_argument("--source", default="")
    ap.add_argument("--symbols", default="XAUUSD")
    ap.add_argument("--symbol", default="XAUUSD")
    ap.add_argument("--variant", default="")
    ap.add_argument("--tag", default="")
    ap.add_argument("--start", default=None)
    ap.add_argument("--end", default=None)
    ap.add_argument("--n", type=int, default=5)
    ap.add_argument("--seed", type=int, default=7)
    ap.add_argument("--fast", action="store_true")
    a = ap.parse_args()
    rules = json.loads(RULES.read_text())
    {"binance": cmd_binance, "run": cmd_run, "demo": cmd_demo, "spot": cmd_spot, "premise": cmd_premise,
     "crosscheck": cmd_crosscheck, "export": cmd_export, "report": cmd_report, "demorank": cmd_demorank, "swing": cmd_swing, "swingbuild": cmd_swingbuild}[a.cmd](a, rules)


if __name__ == "__main__":
    main()
