#!/usr/bin/env python3
"""Backtest of the strategies taught by "Top G Traders" (@topg_traders), for the topg-audit reel.

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

Data: Dukascopy BID+ASK 1-minute candles built by tools/fx_data.py (`fetch`, `build`) into
projects/topg-audit/data_raw/<SYM>_m1.npz; HistData BID-only 1-minute bars as the second source
(`fx_data.py histdata`).

Execution model (same conventions as tools/ib_backtest.py, the umar-audit reference):
  * Signals are read off the BID chart (what MT4/MT5/TradingView show for FX and gold).
  * Breakout ("stop") entries fire when the BID trades through the level and fill at the ASK for longs
    (level + that minute's spread, or the minute's open if it gapped through) plus slippage; shorts
    fill at the BID minus slippage.
  * Limit entries at a level fill only when the executable side trades through it (buy limit: ASK low
    <= level; sell limit: BID high >= level), at the level (no price improvement).
  * Market entries at a confirmation candle's close fill at the ASK close (long) / BID close (short)
    plus slippage.
  * Exits on the executable side: long stop/target on the BID, short stop/target on the ASK. Stops fill
    at the level or a worse minute open (gaps, weekends) minus slippage; targets fill exactly at the level.
  * Stop and target in the same minute -> stop first. On the entry minute a stop touch is a loss and a
    target counts only if that minute closed beyond it. An "optimistic intrabar" upper bound is also run.
  * Breakeven (if a variant uses it): once the favourable excursion reaches the trigger, the stop moves to
    the entry price from the NEXT minute on.
  * Commission per round trip and slippage per stop/market fill are set per symbol in the rules file.
  * Money maths: FIXED capital. $10,000 account, a fixed $100 (1% of the starting balance) risked on every
    trade, no compounding. A compounded line is reported separately and labelled as such.

Usage:
  .venv/bin/python tools/topg_backtest.py run [--symbols XAUUSD,EURUSD] [--strategies london,topg]
  .venv/bin/python tools/topg_backtest.py spot --strategy london --symbol XAUUSD --n 5
  .venv/bin/python tools/topg_backtest.py crosscheck --symbol XAUUSD      # HistData vs Dukascopy
"""
from __future__ import annotations

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

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

sys.path.insert(0, str(Path(__file__).resolve().parent))
import fx_data  # noqa: E402

ROOT = Path(__file__).resolve().parents[1]
PROJ = ROOT / "projects/topg-audit"
RAW = PROJ / "data_raw"
RULES = PROJ / "topg_rules.json"
OUT_JSON = ROOT / "engine/public/projects/topg-audit/data/topg_results.json"
MD = PROJ / "BACKTEST.md"
IST = "Asia/Kolkata"
FALLBACK_SPREAD = {"XAUUSD": 0.30, "USDJPY": 0.012}
NY = "America/New_York"


# ------------------------------------------------------------------ data ---
@dataclass
class Market:
    symbol: str
    t: np.ndarray       # int64 unix seconds, minute start (UTC), active minutes only
    bo: np.ndarray; bh: np.ndarray; bl: np.ndarray; bc: np.ndarray
    ao: np.ndarray; ah: np.ndarray; al: np.ndarray; ac: np.ndarray
    source: str = "dukascopy"

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


def load_market(symbol: str, source: str = "dukascopy", no_spread: bool = False,
                start: str | None = None, end: str | None = None) -> Market:
    df = fx_data.load_m1(symbol, start, end) if source == "dukascopy" else fx_data.load_histdata_m1(symbol)
    if source != "dukascopy":
        if start:
            df = df[df.index >= pd.Timestamp(start, tz="UTC")]
        if end:
            df = df[df.index < pd.Timestamp(end, tz="UTC") + pd.Timedelta(days=1)]
    t = ((df.index - pd.Timestamp("1970-01-01", tz="UTC")) // pd.Timedelta(seconds=1)).to_numpy().astype(np.int64)
    b = [df[f"b{k}"].to_numpy(np.float64) for k in "ohlc"]
    if no_spread:
        a = b
    elif source == "dukascopy":
        a = [df[f"a{k}"].to_numpy(np.float64) for k in "ohlc"]
    else:
        # BID-only source: ASK = BID + Dukascopy's median spread for that (year, UTC hour), from the
        # hourly candles (fx_data.py hourly). Labelled "modelled ASK" wherever it is used.
        try:
            prof = fx_data.spread_profile(symbol)
            key = pd.MultiIndex.from_arrays([df.index.year, df.index.hour])
            sp = prof.set_index(["year", "hour"])["spread"].reindex(key).to_numpy()
            sp = np.where(np.isnan(sp), np.nanmedian(prof["spread"]), sp)
            source = source + "+modelled_ask"
        except FileNotFoundError:   # smoke tests only: a flat spread until the hourly profile exists
            sp = FALLBACK_SPREAD.get(symbol, 0.00012)
            source = source + "+flat_spread_SMOKE_TEST_ONLY"
        a = [x + sp for x in b]
    return Market(symbol, t, *b, *a, source=source)


def candles(m: Market, minutes: int, offset_min: int = 0) -> pd.DataFrame:
    """BID (and ASK) candles of `minutes` length from active minutes, aligned to the UTC clock
    (+offset). Returns start time, OHLC, and [i0, i1) minute-index span. Candles with no active
    minute do not exist (like a broker chart)."""
    key = (m.t - offset_min * 60) // (minutes * 60)
    brk = np.flatnonzero(np.diff(key)) + 1
    i0 = np.concatenate([[0], brk])
    i1 = np.concatenate([brk, [len(m.t)]])
    out = pd.DataFrame({
        "t0": key[i0] * minutes * 60 + offset_min * 60,
        "i0": i0, "i1": i1,
        "o": m.bo[i0], "c": m.bc[i1 - 1],
        "h": np.maximum.reduceat(m.bh, i0), "l": np.minimum.reduceat(m.bl, i0),
        "ao": m.ao[i0], "ac": m.ac[i1 - 1],
        "ah": np.maximum.reduceat(m.ah, i0), "al": np.minimum.reduceat(m.al, i0),
    })
    return out


# ------------------------------------------------------------ execution ---
@njit(cache=True)
def _first_hit(mask_start: int, last: int, arr: np.ndarray, level: float, above: bool) -> int:
    for j in range(mask_start, last + 1):
        if above:
            if arr[j] >= level:
                return j
        else:
            if arr[j] <= level:
                return j
    return -1


@njit(cache=True)
def manage_nb(bo, bh, bl, bc, ao, ah, al, ac, i0, d, fill, stop, target, slip, comm,
              be_trigger, last_i, optimistic):
    """Follow one position from entry minute i0. d=+1 long / -1 short. be_trigger: favourable
    excursion (price units) that moves the stop to entry from the next minute (<=0: off).
    Returns (exit_i, exit_px, reason) with reason 0=stop 1=target 2=breakeven-stop 3=time."""
    cur_stop = stop
    be_on = False
    for j in range(i0, last_i + 1):
        if d == 1:
            st = bl[j] <= cur_stop
            tg = bh[j] >= target
            st_close = bc[j] <= cur_stop
            tg_close = bc[j] >= target
        else:
            st = ah[j] >= cur_stop
            tg = al[j] <= target
            st_close = ac[j] >= cur_stop
            tg_close = ac[j] <= target
        if j == i0:
            stop_now = st_close if optimistic else st
            if stop_now:
                px = cur_stop - slip if d == 1 else cur_stop + slip
                return j, px, 2 if be_on else 0
            if tg_close or (optimistic and tg):
                return j, target, 1
        else:
            if st and (not tg or not optimistic):
                if d == 1:
                    px = min(cur_stop, bo[j]) - slip
                else:
                    px = max(cur_stop, ao[j]) + slip
                return j, px, 2 if be_on else 0
            if tg:
                return j, target, 1
        if be_trigger > 0 and not be_on:
            fav = (bh[j] - fill) if d == 1 else (fill - al[j])
            if fav >= be_trigger:
                cur_stop = fill
                be_on = True
    px = bc[last_i] - slip if d == 1 else ac[last_i] + slip
    return last_i, px, 3


REASONS = {0: "stop", 1: "target", 2: "breakeven", 3: "time"}


def fill_entry(m: Market, kind: str, d: int, level: float, i_from: int, i_to: int, slip: float) -> tuple[int, float]:
    """Find the entry minute in [i_from, i_to] and the fill price. kind: 'stop' | 'limit' | 'market'."""
    if i_from > i_to or i_from >= len(m):
        return -1, float("nan")
    if kind == "market":
        i = i_from
        return i, (m.ao[i] + slip) if d == 1 else (m.bo[i] - slip)
    if kind == "stop":
        if d == 1:
            hits = np.flatnonzero(m.bh[i_from:i_to + 1] > level)
            if not len(hits):
                return -1, float("nan")
            i = i_from + int(hits[0])
            spread = max(m.ao[i] - m.bo[i], m.ac[i] - m.bc[i])
            return i, max(level, m.bo[i]) + spread + slip
        hits = np.flatnonzero(m.bl[i_from:i_to + 1] < level)
        if not len(hits):
            return -1, float("nan")
        i = i_from + int(hits[0])
        return i, min(level, m.bo[i]) - slip
    if kind == "limit":
        if d == 1:
            hits = np.flatnonzero(m.al[i_from:i_to + 1] <= level)
        else:
            hits = np.flatnonzero(m.bh[i_from:i_to + 1] >= level)
        if not len(hits):
            return -1, float("nan")
        i = i_from + int(hits[0])
        return i, level
    raise ValueError(kind)


def run_trade(m: Market, d: int, i: int, fill: float, stop: float, target: float, c: dict,
              last_i: int, be_trigger: float = 0.0) -> dict:
    j, px, reason = manage_nb(m.bo, m.bh, m.bl, m.bc, m.ao, m.ah, m.al, m.ac, i, d, fill, stop, target,
                              c["slip"], c["comm"], be_trigger, last_i, c.get("optimistic", False))
    risk = (fill - stop) * d
    pts = (px - fill) * d - c["comm"]
    return {"entry_i": int(i), "exit_i": int(j), "entry_px": fill, "exit_px": float(px), "stop": stop,
            "target": target, "risk": risk, "pts": pts, "R": pts / risk, "reason": REASONS[int(reason)],
            "dir": d, "entry_t": pd.Timestamp(int(m.t[i]), unit="s", tz="UTC").isoformat(),
            "exit_t": pd.Timestamp(int(m.t[j]), unit="s", tz="UTC").isoformat()}


def costs_for(rules: dict, symbol: str, zero: bool = False, optimistic: bool = False) -> dict:
    if zero:
        return {"slip": 0.0, "comm": 0.0, "optimistic": optimistic}
    cs = rules["costs"][symbol] if symbol in rules["costs"] else rules["costs"]["_default_fx"]
    return {"slip": cs["slippage_price"], "comm": cs["commission_price_round_trip"], "optimistic": optimistic}


# ------------------------------------------------------------- statistics ---
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


def summarize(tr: pd.DataFrame, weeks: float, rules: dict, claim_wr: float = 70.0) -> 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_usd = rules["money"]["start_equity"], rules["money"]["fixed_risk_usd"]
    bal = cap + risk_usd * cum
    # fixed $100 a trade cannot continue below zero: the account is blown at the first trade that takes it there
    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:]
    eq_c = cap * np.cumprod(1 + rules["money"]["compound_risk_pct"] / 100 * R)
    gp, gl = R[R > 0].sum(), -R[R < 0].sum()
    avg_win = float(R[wins].mean()) if wins.any() else 0.0
    avg_loss = float(-R[~wins].mean()) if (~wins).any() else 0.0
    lo, hi = wilson(int(wins.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),
                      "target_hit_rate": round(100 * (g["reason"] == "target").mean(), 1),
                      "total_R": round(float(r.sum()), 1), "avg_R": round(float(r.mean()), 3)})
    months_tab = []
    for mo, g in tr.groupby(tr["entry_t"].str[5:7]):
        r = g["R"].to_numpy()
        months_tab.append({"month": int(mo), "trades": int(len(r)), "win_rate": round(100 * (r > 0).mean(), 1),
                           "total_R": round(float(r.sum()), 1)})
    streak = best = 0
    for w in wins:
        streak = 0 if w else streak + 1
        best = max(best, streak)
    # consecutive STOP-LOSS exits (his "25 SL in a row"): a target, breakeven or time exit ends the run
    sl_run, sl_best, runs25, first25 = 0, 0, 0, None
    reasons = tr["reason"].to_numpy()
    days = tr["entry_t"].str[:10].to_numpy()
    for ix, rsn in enumerate(reasons):
        if rsn == "stop":
            sl_run += 1
            if sl_run == 25:
                runs25 += 1
                if first25 is None:
                    first25 = str(days[ix])
        else:
            sl_run = 0
        sl_best = max(sl_best, sl_run)
    months = max(weeks / 4.348, 1e-9)
    return {
        "trades": n, "trades_per_week": round(n / weeks, 2),
        "win_rate_pct": round(100 * wins.mean(), 1),
        "win_rate_95ci_pct": [round(100 * lo, 1), round(100 * hi, 1)],
        "target_hit_rate_pct": round(100 * (tr["reason"] == "target").mean(), 1),
        "stop_rate_pct": round(100 * (tr["reason"] == "stop").mean(), 1),
        "breakeven_exit_rate_pct": round(100 * (tr["reason"] == "breakeven").mean(), 1),
        "time_exit_rate_pct": round(100 * (tr["reason"] == "time").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_win_R": round(avg_win, 3), "avg_loss_R": round(avg_loss, 3),
        "breakeven_win_rate_pct": round(100 * avg_loss / (avg_win + avg_loss), 1) if (avg_win + avg_loss) else None,
        "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),
        "longest_stop_streak": int(sl_best),
        "stop_streaks_of_25_or_more": int(runs25),
        "first_25_stop_streak_completed": first25,
        "fixed_final_balance_usd": 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_usd_if_refilled": round(float(risk_usd * R.sum()), 0),
        "fixed_profit_per_month_usd": round(float(risk_usd * R.sum() / months), 0),
        "fixed_max_drawdown_usd": round(float(np.max(peak - bal)), 0),
        "fixed_max_drawdown_pct_of_start": round(float(np.max(peak - bal)) / cap * 100, 1),
        "compounded_final_balance_usd": round(float(eq_c[-1]), 0),
        "median_risk_price": round(float(tr["risk"].median()), 5),
        "years": years,
        "calendar_months": months_tab,
        "years_at_or_above_claim": [y["year"] for y in years if y["win_rate"] >= claim_wr],
    }


# ------------------------------------------------------------ indicators ---
def wilder_atr(c: pd.DataFrame, n: int = 14) -> np.ndarray:
    h, l, cl = c["h"].to_numpy(), c["l"].to_numpy(), c["c"].to_numpy()
    prev = np.concatenate([[cl[0]], cl[:-1]])
    tr = np.maximum(h - l, np.maximum(np.abs(h - prev), np.abs(l - prev)))
    out = np.empty_like(tr)
    out[:n] = np.nan
    out[n - 1] = tr[:n].mean()
    for k in range(n, len(tr)):
        out[k] = (out[k - 1] * (n - 1) + tr[k]) / n
    return out


def ema(x: np.ndarray, n: int) -> np.ndarray:
    return pd.Series(x).ewm(span=n, adjust=False).mean().to_numpy()


def htf_trend_at(m: Market, htf_min: int, ema_n: int, times: np.ndarray) -> np.ndarray:
    """+1/-1: last COMPLETED higher-timeframe candle closed above/below its EMA(ema_n) at each time.
    (Our objective proxy for 'trade with the trend'; he does not define the trend in numbers.)"""
    h = candles(m, htf_min, offset_min=120 if htf_min == 240 else 0)
    e = ema(h["c"].to_numpy(), ema_n)
    sign = np.where(h["c"].to_numpy() > e, 1, -1)
    sign[: ema_n] = 0
    end_t = h["t0"].to_numpy() + htf_min * 60
    k = np.searchsorted(end_t, times, side="right") - 1      # last candle that has closed by `times`
    out = np.where(k >= 0, sign[np.clip(k, 0, None)], 0)
    return out


# ------------------------------------------------------- Fibonacci module ---
def fib_impulses(c: pd.DataFrame, min_candles: int, atr_mult: float, atr: np.ndarray) -> list[dict]:
    """Runs of consecutive same-colour candles ('first red candle to last red candle, until a green
    candle forms'), big enough to count as momentum. The run ends at the first candle that is not of
    the run's colour; the setup becomes known when that candle closes."""
    o, cl, h, l = c["o"].to_numpy(), c["c"].to_numpy(), c["h"].to_numpy(), c["l"].to_numpy()
    col = np.sign(cl - o).astype(int)
    out = []
    n = len(c)
    a = 0
    while a < n - 1:
        s = col[a]
        if s == 0:
            a += 1
            continue
        b = a
        while b + 1 < n and col[b + 1] == s:
            b += 1
        if b + 1 >= n:
            break
        length = b - a + 1
        hi, lo = h[a:b + 1].max(), l[a:b + 1].min()
        if length >= min_candles and not np.isnan(atr[b]) and (hi - lo) >= atr_mult * atr[b]:
            out.append({"a": a, "b": b, "dir": int(s), "hi": float(hi), "lo": float(lo), "len": int(length),
                        "range": float(hi - lo), "atr": float(atr[b])})
        a = b + 1
    return out


def fib_level(imp: dict, x: float) -> float:
    """Retracement level x of the impulse (0 = the impulse's end/extreme, 1 = its origin)."""
    if imp["dir"] == 1:   # bullish impulse: low -> high, buy the pullback
        return imp["hi"] - x * (imp["hi"] - imp["lo"])
    return imp["lo"] + x * (imp["hi"] - imp["lo"])


def strat_fib(m: Market, v: dict, rules: dict, c: dict) -> tuple[list[dict], dict]:
    """His 'HOW TO USE FIBONACCI RETRACEMENT' video (24 Feb 2026), see topg_rules.json -> fib."""
    R = rules["fib"]
    tf, ltf = v["tf_min"], v["ltf_min"]
    cnd = candles(m, tf)
    atr = wilder_atr(cnd, 14)
    imps = fib_impulses(cnd, v["min_candles"], v["atr_mult"], atr)
    flags = {"impulses": len(imps)}
    t_end = cnd["t0"].to_numpy() + tf * 60
    i0s, i1s = cnd["i0"].to_numpy(), cnd["i1"].to_numpy()
    act_times = np.array([t_end[min(imp["b"] + 1, len(cnd) - 1)] for imp in imps], dtype=np.int64)
    trend = htf_trend_at(m, v["htf_min"], R["trend_ema"], act_times) if v["trend_filter"] else np.zeros(len(imps), int)
    ltf_c = candles(m, ltf) if v["entry"] == "confirm" else None
    trades = []
    for k, imp in enumerate(imps):
        d = imp["dir"]
        brk = imp["b"] + 1
        if v["trend_filter"] and trend[k] != d:
            flags["skip_against_trend"] = flags.get("skip_against_trend", 0) + 1
            continue
        i_act = int(i1s[brk])                     # first minute after the breaker candle closed
        if i_act >= len(m):
            continue
        # the breaker candle itself must not have erased the whole impulse
        if (d == 1 and cnd["l"].iat[brk] < imp["lo"]) or (d == -1 and cnd["h"].iat[brk] > imp["hi"]):
            flags["skip_breaker_beyond_origin"] = flags.get("skip_breaker_beyond_origin", 0) + 1
            continue
        i_exp = int(i1s[min(brk + v["expiry_bars"], len(cnd) - 1)]) - 1
        extreme = imp["hi"] if d == 1 else imp["lo"]
        origin = imp["lo"] if d == 1 else imp["hi"]
        seg = slice(i_act, i_exp + 1)
        # invalidation: a new extreme beyond the impulse end before entry (the move simply continued)
        beyond = (m.bh[seg] > extreme) if d == 1 else (m.bl[seg] < extreme)
        j_inv = i_act + int(np.argmax(beyond)) if beyond.any() else i_exp + 1
        if v["entry"] == "limit618":
            lvl = fib_level(imp, R["entry_level"])
            stop = fib_level(imp, R["limit_stop_level"])
            j, fill = fill_entry(m, "limit", d, lvl, i_act, min(j_inv, i_exp + 1) - 1, c["slip"])
            if j < 0:
                continue
        else:  # confirm: tap of the golden zone, then the first lower-timeframe candle in our direction
            tap_lvl = fib_level(imp, R["zone"][0])
            tap = (m.bl[seg] <= tap_lvl) if d == 1 else (m.bh[seg] >= tap_lvl)
            if not tap.any():
                continue
            j_tap = i_act + int(np.argmax(tap))
            if j_tap >= j_inv:
                continue
            # lower-timeframe candles that close after the tap and before invalidation/expiry
            lt = ltf_c[(ltf_c["i1"] - 1 >= j_tap) & (ltf_c["i1"] - 1 < min(j_inv, i_exp + 1))]
            if len(lt) == 0:
                continue
            lo_, hi_ = lt["l"].to_numpy(), lt["h"].to_numpy()
            # the origin (1.0) broken before confirmation kills the setup
            org_broken = (lo_ < origin) if d == 1 else (hi_ > origin)
            col = np.sign(lt["c"].to_numpy() - lt["o"].to_numpy())
            if v["confirm"] == "colour":
                ok = col == d
            else:  # engulfing: our-colour candle whose body engulfs the previous (opposite) body
                po = np.concatenate([[np.nan], lt["o"].to_numpy()[:-1]])
                pc = np.concatenate([[np.nan], lt["c"].to_numpy()[:-1]])
                oo, cc = lt["o"].to_numpy(), lt["c"].to_numpy()
                if d == 1:
                    ok = (cc > oo) & (pc < po) & (cc >= po) & (oo <= pc)
                else:
                    ok = (cc < oo) & (pc > po) & (cc <= po) & (oo >= pc)
            first_org = int(np.argmax(org_broken)) if org_broken.any() else len(lt)
            cand = np.flatnonzero(ok[:first_org + 1] if first_org < len(lt) else ok)
            cand = cand[cand < first_org] if first_org < len(lt) else cand
            if not len(cand):
                continue
            q = lt.iloc[int(cand[0])]
            if (d == 1 and q["c"] >= extreme) or (d == -1 and q["c"] <= extreme):
                continue                              # no room left to the target
            stop = float(q["l"]) if d == 1 else float(q["h"])
            j, fill = fill_entry(m, "market", d, float("nan"), int(q["i1"]), int(q["i1"]), c["slip"])
            if j < 0:
                continue
        risk = (fill - stop) * d
        if risk <= 0 or risk < R["min_risk_frac_of_impulse"] * imp["range"]:
            flags["skip_tiny_or_negative_risk"] = flags.get("skip_tiny_or_negative_risk", 0) + 1
            continue
        target = extreme if v["target"] == "extreme" else fill + d * float(v["target"]) * risk
        if (target - fill) * d <= 0:
            flags["skip_target_behind_entry"] = flags.get("skip_target_behind_entry", 0) + 1
            continue
        last_i = int(np.searchsorted(m.t, m.t[j] + v["max_hold_hours"] * 3600, side="right") - 1)
        tr = run_trade(m, d, j, fill, stop, target, c, max(last_i, j))
        tr.update({"setup_t": pd.Timestamp(int(m.t[i_act]), unit="s", tz="UTC").isoformat(),
                   "impulse_len": imp["len"], "impulse_range": imp["range"], "impulse_atr": imp["atr"],
                   "rr_planned": abs(target - fill) / risk})
        trades.append(tr)
    return trades, flags


# ------------------------------------------------------ TopG structure ---
PIP = {"XAUUSD": 0.1}  # his gold pip is $0.10 (D2 14:51 "4660 -> 4650 = 100 pips"); FX below


def pip_of(sym: str) -> float:
    return PIP.get(sym, 0.01 if sym.endswith("JPY") else 0.0001)


def topg_structure(c: pd.DataFrame, retr: str = "strict", warmup: int = 200) -> tuple[list[dict], np.ndarray, np.ndarray, np.ndarray]:
    """His market structure on one timeframe (see topg_rules.json -> topg.structure for every source).

    Bullish state: the high is CONFIRMED by a two-candle retracement (two consecutive red candles, the 2nd
    closing below the 1st's low; retr='loose': any two consecutive red candles); the low is confirmed when
    ONE candle closes above that high = the lowest point between the high and that close. At that moment
    TJL1 (top wick of the confirmed-high candle) and TJL2 = A+ (bottom wick of the confirmed-low candle)
    become active buy zones. Two consecutive closes below the last confirmed low = bearish CHoCH; the broken
    low's wick zone becomes an SBR sell zone (our zone geometry; he never states it). Mirror for bearish.
    Returns (levels, trend[k], tp_long[k], tp_short[k]): trend after candle k closed; tp_* = his target,
    the last confirmed high/low (the tentative extreme if none is confirmed yet), known at candle k's close.
    """
    o, h, l, cl = (c[k].to_numpy() for k in ("o", "h", "l", "c"))
    n = len(c)
    trend = np.zeros(n, dtype=int)
    tpl = np.full(n, np.nan)
    tps = np.full(n, np.nan)
    levels: list[dict] = []
    red = cl < o
    grn = cl > o

    def zone(i: int, side: str) -> tuple[float, float]:
        if side == "top":     # top wick: body top .. high
            return float(max(o[i], cl[i])), float(h[i])
        return float(l[i]), float(min(o[i], cl[i]))   # bottom wick: low .. body bottom

    def retr_down(k: int) -> bool:   # two-candle retracement in a bullish leg (confirms a high)
        if k < 1:
            return False
        if retr == "loose":
            return red[k - 1] and red[k]
        return red[k - 1] and red[k] and cl[k] < l[k - 1]

    def retr_up(k: int) -> bool:
        if k < 1:
            return False
        if retr == "loose":
            return grn[k - 1] and grn[k]
        return grn[k - 1] and grn[k] and cl[k] > h[k - 1]

    d = 1
    ext, ext_i = h[0], 0            # running extreme of the current leg (tentative high / low)
    conf, conf_i = None, -1          # confirmed high (bull) / confirmed low (bear) waiting for a break
    run, run_i = None, -1            # running opposite extreme after `conf` (candidate confirmed low/high)
    ref, ref_i = l[0], 0             # CHoCH reference: last confirmed low (bull) / high (bear)
    l1_i = -1                        # candle of the last level 1 (confirmed high in bull / low in bear)
    for k in range(1, n):
        if d == 1:
            # bearish CHoCH: two consecutive closes below the last confirmed low
            if k - 1 > ref_i and cl[k] < ref and cl[k - 1] < ref:
                if k >= warmup:
                    zt = zone(ref_i, "bot")
                    levels.append({"kind": "SBR", "dir": -1, "bot": zt[0], "top": zt[1], "src_i": ref_i, "born_k": k})
                    if l1_i >= 0:   # QML / post-CHoCH 'A+' = zone of level 1 (inferred mapping, C1 09:40, P2 18:47)
                        zq = zone(l1_i, "top")
                        levels.append({"kind": "QML", "dir": -1, "bot": zq[0], "top": zq[1], "src_i": l1_i, "born_k": k})
                l1_i = -1
                top_i = ref_i + int(np.argmax(h[ref_i:k + 1]))
                d, ext, ext_i = -1, l[k], k
                ext = float(l[top_i:k + 1].min()); ext_i = top_i + int(np.argmin(l[top_i:k + 1]))
                conf, run = None, None
                ref, ref_i = float(h[top_i]), top_i
                trend[k] = d
                tps[k] = ext
                continue
            if conf is None:
                if h[k] > ext:
                    ext, ext_i = h[k], k
                if retr_down(k) and ext_i <= k - 1:
                    conf, conf_i = ext, ext_i
                    seg = slice(conf_i, k + 1)
                    run = float(l[seg].min()); run_i = conf_i + int(np.argmin(l[seg]))
            else:
                if l[k] < run:
                    run, run_i = l[k], k
                if cl[k] > conf:                       # one close above the confirmed high
                    if k >= warmup:
                        z1, z2 = zone(conf_i, "top"), zone(run_i, "bot")
                        levels.append({"kind": "TJL1", "dir": 1, "bot": z1[0], "top": z1[1], "src_i": conf_i, "born_k": k})
                        levels.append({"kind": "TJL2", "dir": 1, "bot": z2[0], "top": z2[1], "src_i": run_i, "born_k": k})
                    ref, ref_i = run, run_i
                    l1_i = conf_i
                    seg = slice(run_i, k + 1)
                    ext = float(h[seg].max()); ext_i = run_i + int(np.argmax(h[seg]))
                    conf, run = None, None
            tpl[k] = conf if conf is not None else ext
        else:
            if k - 1 > ref_i and cl[k] > ref and cl[k - 1] > ref:
                if k >= warmup:
                    zt = zone(ref_i, "top")
                    levels.append({"kind": "RBS", "dir": 1, "bot": zt[0], "top": zt[1], "src_i": ref_i, "born_k": k})
                    if l1_i >= 0:
                        zq = zone(l1_i, "bot")
                        levels.append({"kind": "QML", "dir": 1, "bot": zq[0], "top": zq[1], "src_i": l1_i, "born_k": k})
                l1_i = -1
                bot_i = ref_i + int(np.argmin(l[ref_i:k + 1]))
                d = 1
                ext = float(h[bot_i:k + 1].max()); ext_i = bot_i + int(np.argmax(h[bot_i:k + 1]))
                conf, run = None, None
                ref, ref_i = float(l[bot_i]), bot_i
                trend[k] = d
                tpl[k] = ext
                continue
            if conf is None:
                if l[k] < ext:
                    ext, ext_i = l[k], k
                if retr_up(k) and ext_i <= k - 1:
                    conf, conf_i = ext, ext_i
                    seg = slice(conf_i, k + 1)
                    run = float(h[seg].max()); run_i = conf_i + int(np.argmax(h[seg]))
            else:
                if h[k] > run:
                    run, run_i = h[k], k
                if cl[k] < conf:
                    if k >= warmup:
                        z1, z2 = zone(conf_i, "bot"), zone(run_i, "top")
                        levels.append({"kind": "TJL1", "dir": -1, "bot": z1[0], "top": z1[1], "src_i": conf_i, "born_k": k})
                        levels.append({"kind": "TJL2", "dir": -1, "bot": z2[0], "top": z2[1], "src_i": run_i, "born_k": k})
                    ref, ref_i = run, run_i
                    l1_i = conf_i
                    seg = slice(run_i, k + 1)
                    ext = float(l[seg].min()); ext_i = run_i + int(np.argmin(l[seg]))
                    conf, run = None, None
            tps[k] = conf if conf is not None else ext
        trend[k] = d
    return levels, trend, tpl, tps


def breach_index(c: pd.DataFrame, lv: dict) -> int:
    """First TF candle index at which two consecutive closes went beyond the zone's far edge (MTF 56:37)."""
    cl = c["c"].to_numpy()
    k0 = lv["born_k"] + 1
    if lv["dir"] == 1:
        beyond = cl[k0:] < lv["bot"]
    else:
        beyond = cl[k0:] > lv["top"]
    both = beyond[1:] & beyond[:-1]
    idx = np.flatnonzero(both)
    return k0 + int(idx[0]) + 1 if len(idx) else len(cl)


def in_session(t: int, windows: list | None) -> bool:
    if not windows:
        return True
    mins = ((t // 60) + 330) % 1440           # minutes since 00:00 IST (UTC+5:30)
    return any(a <= mins < b for a, b in windows)


def strat_topg(m: Market, v: dict, rules: dict, c: dict) -> tuple[list[dict], dict]:
    """TopG trend-joining levels (TJL2 = A+, optionally TJL1, SBR/RBS), see topg_rules.json -> topg."""
    R = rules["topg"]
    sym = m.symbol
    pip = pip_of(sym)
    tf, ltf = v["tf_min"], v.get("ltf_min")
    cnd = candles(m, tf)
    levels, trend, tpl, tps = topg_structure(cnd, v.get("retr", "strict"))
    kinds = set(v["levels"])
    levels = [lv for lv in levels if lv["kind"] in kinds]
    flags = {"levels": len(levels)}
    t_end = cnd["t0"].to_numpy() + tf * 60
    i1s = cnd["i1"].to_numpy()
    co, ch, clo, cc = (cnd[k].to_numpy() for k in ("o", "h", "l", "c"))
    lt = candles(m, ltf) if v["confirm"] in ("ltf_candle", "ltf_engulf") else None
    if lt is not None:
        lt_end_i = lt["i1"].to_numpy() - 1
        lo_, lh_, ll_, lc_ = (lt[k].to_numpy() for k in ("o", "h", "l", "c"))
    # higher-timeframe bias (e.g. H1 structure for M15 levels)
    htf = None
    if v.get("htf_bias_min"):
        hc = candles(m, v["htf_bias_min"])
        _, htrend, _, _ = topg_structure(hc, v.get("retr", "strict"))
        htf = (hc["t0"].to_numpy() + v["htf_bias_min"] * 60, htrend)
    sessions = R["sessions"].get(v.get("session", "all"))
    trades = []
    for lv in levels:
        d = lv["dir"]
        kb = breach_index(cnd, lv)
        i_act = int(i1s[lv["born_k"]])                 # first minute after the activation candle closed
        i_end = int(i1s[kb]) - 1 if kb < len(cnd) else len(m) - 1
        if v.get("expiry_bars"):
            i_end = min(i_end, int(i1s[min(lv["born_k"] + v["expiry_bars"], len(cnd) - 1)]) - 1)
        if i_act >= i_end:
            continue
        seg = slice(i_act, i_end + 1)
        tap = (m.bl[seg] <= lv["top"]) if d == 1 else (m.bh[seg] >= lv["bot"])
        if not tap.any():
            continue
        j_tap = i_act + int(np.argmax(tap))
        entry = None
        if v["confirm"] == "direct":       # 'direct execution' with a limit at the zone (livestream 8 Oct 2026, 11:53-12:32)
            if v.get("max_zone_pips") and (lv["top"] - lv["bot"]) / pip > v["max_zone_pips"]:
                flags["skip_zone_too_wide"] = flags.get("skip_zone_too_wide", 0) + 1
                continue
            edge = lv["top"] if d == 1 else lv["bot"]
            jf, fill_px = fill_entry(m, "limit", d, edge, i_act, i_end, c["slip"])
            if jf < 0:
                continue
            k_tf = int(np.searchsorted(i1s, jf, side="right")) - 1
            if v.get("require_trend", True) and trend[max(k_tf, 0)] != d:
                flags["skip_trend_flipped"] = flags.get("skip_trend_flipped", 0) + 1
                continue
            if not in_session(int(m.t[jf]), sessions):
                flags["skip_outside_session"] = flags.get("skip_outside_session", 0) + 1
                continue
            sl = fill_px - d * v["sl_fixed_pips"] * pip
            risk = v["sl_fixed_pips"] * pip
            tp_ref = tpl[max(k_tf, 0)] if d == 1 else tps[max(k_tf, 0)]
            if v["target"] == "struct":
                target = float(tp_ref)
                if np.isnan(target) or (target - fill_px) * d <= 0:
                    flags["skip_no_room_to_target"] = flags.get("skip_no_room_to_target", 0) + 1
                    continue
            else:
                target = fill_px + d * float(v["target"]) * risk
            last_i = int(np.searchsorted(m.t, m.t[jf] + v["max_hold_hours"] * 3600, side="right") - 1)
            tr = run_trade(m, d, jf, fill_px, sl, target, c, max(last_i, jf))
            tr.update({"level": lv["kind"], "zone_bot": lv["bot"], "zone_top": lv["top"],
                       "level_born_t": pd.Timestamp(int(t_end[lv["born_k"]]), unit="s", tz="UTC").isoformat(),
                       "tap_t": tr["entry_t"], "sl_pips": v["sl_fixed_pips"], "rr_planned": round(abs(target - fill_px) / risk, 2)})
            trades.append(tr)
            continue
        if v["confirm"] == "tf_candle":     # his H1 backtest (D2 27:39): a candle of our colour at the level
            k0 = int(np.searchsorted(i1s, j_tap, side="right"))      # TF candle containing the tap
            for k in range(k0, min(kb, len(cnd))):
                touch = (clo[k] <= lv["top"]) if d == 1 else (ch[k] >= lv["bot"])
                colour_ok = (cc[k] > co[k]) if d == 1 else (cc[k] < co[k])
                if touch and colour_ok:
                    entry = (int(i1s[k]), float(clo[k]) if d == 1 else float(ch[k]), k)
                    break
                if not touch and k > k0 + v.get("max_wait_bars", 1_000_000):
                    break
        else:
            q0 = int(np.searchsorted(lt_end_i, j_tap))               # LTF candle containing the tap
            for q in range(q0, len(lt)):
                if lt_end_i[q] > i_end:
                    break
                if v.get("max_wait_ltf") and q - q0 >= v["max_wait_ltf"]:
                    break
                touch = (ll_[q] <= lv["top"]) if d == 1 else (lh_[q] >= lv["bot"])
                if v["confirm"] == "ltf_candle":
                    ok = (lc_[q] > lo_[q]) if d == 1 else (lc_[q] < lo_[q])
                else:  # engulfing: our colour and a close beyond the previous candle's high/low (D1 7.6)
                    ok = ((lc_[q] > lo_[q]) and lc_[q] > lh_[q - 1]) if d == 1 else ((lc_[q] < lo_[q]) and lc_[q] < ll_[q - 1])
                    touch = touch or ((ll_[q - 1] <= lv["top"]) if d == 1 else (lh_[q - 1] >= lv["bot"]))
                if touch and ok:
                    seg2 = slice(q0, q + 1)
                    if v.get("sl_rule", "lowest_since_tap") == "confirm_candle":
                        sl = float(ll_[q]) if d == 1 else float(lh_[q])
                    else:
                        sl = float(ll_[seg2].min()) if d == 1 else float(lh_[seg2].max())
                    k_tf = int(np.searchsorted(i1s, lt_end_i[q], side="right")) - 1
                    entry = (int(lt_end_i[q]) + 1, sl, max(k_tf, 0))
                    break
        if entry is None:
            continue
        j, sl, k_tf = entry
        if j >= len(m) or j > i_end + 1:
            continue
        if not in_session(int(m.t[j]), sessions):
            flags["skip_outside_session"] = flags.get("skip_outside_session", 0) + 1
            continue
        # his bias rule: trade a level only while the level timeframe's structure is still in that direction
        # ("as soon as the fifth point is confirmed ... you become bearish ... only bearish entries", ISS 11:53;
        # "never trade against the H1 bias", D1). k_tf = last COMPLETED level-timeframe candle at entry.
        if v.get("require_trend", True) and trend[k_tf] != d:
            flags["skip_trend_flipped"] = flags.get("skip_trend_flipped", 0) + 1
            continue
        if htf is not None:
            hk = int(np.searchsorted(htf[0], m.t[j], side="right")) - 1
            if hk < 0 or htf[1][hk] != d:
                flags["skip_htf_bias"] = flags.get("skip_htf_bias", 0) + 1
                continue
        jj, fill = fill_entry(m, "market", d, float("nan"), j, j, c["slip"])
        if v.get("sl_buffer_pips"):        # 'give the SL a bit of breathing space ... 10 pips extra' (MTF 56:00-56:55)
            sl = sl - d * v["sl_buffer_pips"] * pip
        if v.get("sl_fixed_pips"):
            fixed = fill - d * v["sl_fixed_pips"] * pip
            sl = max(sl, fixed) if d == 1 else min(sl, fixed)     # min(30 pips, below the candle) (MTF 55:43-56:55)
        risk = (fill - sl) * d
        if risk <= 0:
            flags["skip_nonpositive_risk"] = flags.get("skip_nonpositive_risk", 0) + 1
            continue
        if v.get("max_sl_pips") and risk / pip > v["max_sl_pips"]:
            flags["skip_sl_too_big"] = flags.get("skip_sl_too_big", 0) + 1
            continue
        tp_ref = tpl[k_tf] if d == 1 else tps[k_tf]
        tgt = v["target"]
        if tgt == "struct":
            target = float(tp_ref)
            if np.isnan(target) or (target - fill) * d <= 0:
                flags["skip_no_room_to_target"] = flags.get("skip_no_room_to_target", 0) + 1
                continue
            if v.get("min_rr") and (target - fill) * d / risk < v["min_rr"]:
                flags["skip_rr_below_min"] = flags.get("skip_rr_below_min", 0) + 1
                continue
        elif tgt == "partial":
            target = None
        else:
            target = fill + d * float(tgt) * risk
        last_i = int(np.searchsorted(m.t, m.t[jj] + v["max_hold_hours"] * 3600, side="right") - 1)
        be = v.get("be_at_R", 0) * risk if v.get("be_at_R") else 0.0
        if target is None:   # DS1/MTF: half off at 1R (stop unchanged), rest at +100 pips
            t1 = run_trade(m, d, jj, fill, sl, fill + d * risk, c, max(last_i, jj))
            t2 = run_trade(m, d, jj, fill, sl, fill + d * v["tp2_pips"] * pip, c, max(last_i, jj))
            tr = dict(t1)
            tr["R"] = 0.5 * t1["R"] + 0.5 * t2["R"]
            tr["pts"] = 0.5 * t1["pts"] + 0.5 * t2["pts"]
            tr["reason"] = t2["reason"] if t1["reason"] == "target" else t1["reason"]
            tr["exit_t"] = max(t1["exit_t"], t2["exit_t"])
            tr["target"] = fill + d * v["tp2_pips"] * pip
        else:
            tr = run_trade(m, d, jj, fill, sl, target, c, max(last_i, jj), be)
        tr.update({"level": lv["kind"], "zone_bot": lv["bot"], "zone_top": lv["top"],
                   "level_born_t": pd.Timestamp(int(t_end[lv["born_k"]]), unit="s", tz="UTC").isoformat(),
                   "tap_t": pd.Timestamp(int(m.t[j_tap]), unit="s", tz="UTC").isoformat(),
                   "sl_pips": round(risk / pip, 1), "rr_planned": round(abs(tr["target"] - fill) / risk, 2)})
        trades.append(tr)
    # two levels confirmed by the same candle are one trade, not two
    seen, out = set(), []
    for tr in sorted(trades, key=lambda x: x["entry_t"]):
        key = (tr["entry_t"], tr["dir"])
        if key in seen:
            flags["dup_same_entry"] = flags.get("dup_same_entry", 0) + 1
            continue
        seen.add(key)
        out.append(tr)
    return out, flags


# ------------------------------------------------- London session module ---
def fractal_levels(c: pd.DataFrame, n: int = 2) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Swing highs/lows (n candles lower/higher on each side) and the index at which each is KNOWN
    (n candles after the pivot closed). Returns (pivot_high, pivot_low, known_at) arrays per candle."""
    h, l = c["h"].to_numpy(), c["l"].to_numpy()
    N = len(c)
    ph = np.full(N, np.nan)
    pl = np.full(N, np.nan)
    for k in range(n, N - n):
        if h[k] == h[k - n:k + n + 1].max() and (h[k] > h[k - n:k]).all() and (h[k] > h[k + 1:k + n + 1]).all():
            ph[k] = h[k]
        if l[k] == l[k - n:k + n + 1].min() and (l[k] < l[k - n:k]).all() and (l[k] < l[k + 1:k + n + 1]).all():
            pl[k] = l[k]
    return ph, pl, np.arange(N) + n


def strat_london(m: Market, v: dict, rules: dict, c: dict) -> tuple[list[dict], dict]:
    """His 'London Session Strategy' (own channel, 26 Mar 2024, yGIL5BdtGmA); see topg_rules.json -> london."""
    pip = pip_of(m.symbol)
    c30 = candles(m, 30)
    c15 = candles(m, 15)
    t30 = c30["t0"].to_numpy()
    o30, h30, l30, cl30 = (c30[k].to_numpy() for k in ("o", "h", "l", "c"))
    i0_30, i1_30 = c30["i0"].to_numpy(), c30["i1"].to_numpy()
    ph30, pl30, known30 = fractal_levels(c30, 2)
    ph15, pl15, known15 = fractal_levels(c15, 2)
    t15 = c15["t0"].to_numpy()
    flags = {}
    trades = []
    day0 = (m.t[0] // 86400 + 1) * 86400
    box_start = 5 * 3600                        # 10:30 IST = 05:00 UTC (IST has no DST)
    box_end = box_start + v["box_min"] * 60
    first_bo = ((box_end + 1799) // 1800) * 1800
    last_entry = 9 * 3600                       # 14:30 IST
    flat_at = 15 * 3600                          # 20:30 IST
    for day in range(int(day0), int(m.t[-1]), 86400):
        wd = pd.Timestamp(day, unit="s").weekday()
        if wd >= 5:
            continue
        a, b = np.searchsorted(m.t, [day + box_start, day + box_end])
        if b - a < 0.8 * v["box_min"]:
            continue
        hi, lo = float(m.bh[a:b].max()), float(m.bl[a:b].min())
        if v.get("clean_filter"):
            pa, pb = np.searchsorted(m.t, [day + 0, day + box_start])
            if pb - pa < 60:
                continue
            pre_hi, pre_lo = float(m.bh[pa:pb].max()), float(m.bl[pa:pb].min())
        k = int(np.searchsorted(t30, day + first_bo))
        i_flat = int(np.searchsorted(m.t, day + flat_at)) - 1
        taken = 0
        while k < len(c30) and t30[k] + 1800 <= day + last_entry and taken < v.get("max_trades", 1):
            if cl30[k] > hi:
                d = 1
            elif cl30[k] < lo:
                d = -1
            else:
                k += 1
                continue
            if v.get("clean_filter") and ((d == 1 and pre_hi > hi) or (d == -1 and pre_lo < lo)):
                flags["skip_not_clean_side"] = flags.get("skip_not_clean_side", 0) + 1
                k += 1
                continue
            lvl = float(h30[k]) if d == 1 else float(l30[k])            # break of the breakout candle's extreme
            sl_ref = float(l30[k]) if d == 1 else float(h30[k])
            i_from = int(i1_30[k])
            if v.get("confirm_second_close"):
                if k + 1 >= len(c30):
                    break
                inside = lo <= cl30[k + 1] <= hi
                if inside:
                    flags["fakeout_second_close_inside"] = flags.get("fakeout_second_close_inside", 0) + 1
                    k += 2
                    continue
                i_from = int(i1_30[k + 1])
            # the order lives until the first 30-min close back inside the box (fake-out) or the entry cut-off
            kk = int(np.searchsorted(t30, m.t[i_from], side="right")) - 1 if i_from < len(m) else len(c30) - 1
            cancel_i = int(np.searchsorted(m.t, day + last_entry)) - 1
            for q in range(max(kk, k + 1), len(c30)):
                if t30[q] + 1800 > day + last_entry:
                    break
                if lo <= cl30[q] <= hi:
                    cancel_i = min(cancel_i, int(i1_30[q]) - 1)
                    break
            if v.get("confirm_second_close") and ((d == 1 and m.bh[i_from - 1] > lvl) or (d == -1 and m.bl[i_from - 1] < lvl)):
                j, fill = fill_entry(m, "market", d, float("nan"), i_from, i_from, c["slip"])
            else:
                j, fill = fill_entry(m, "stop", d, lvl, i_from, cancel_i, c["slip"])
            if j < 0:
                flags["no_fill_fakeout_or_cutoff"] = flags.get("no_fill_fakeout_or_cutoff", 0) + 1
                k += 1
                continue
            # stop-loss
            if v["sl"] == "swing15":
                kq = int(np.searchsorted(t15, m.t[j], side="right")) - 1
                piv = ph15 if d == -1 else pl15
                cand = [piv[x] for x in range(max(0, kq - 96), kq + 1)
                        if not np.isnan(piv[x]) and known15[x] <= kq - 1 and (piv[x] - fill) * -d > 0]
                if not cand:
                    flags["skip_no_15m_swing"] = flags.get("skip_no_15m_swing", 0) + 1
                    k += 1
                    continue
                sl = float(cand[-1])
            else:
                sl = sl_ref
            risk = (fill - sl) * d
            if risk <= 0:
                flags["skip_nonpositive_risk"] = flags.get("skip_nonpositive_risk", 0) + 1
                k += 1
                continue
            if v.get("max_sl_pips") and risk / pip > v["max_sl_pips"]:
                flags["skip_sl_too_big"] = flags.get("skip_sl_too_big", 0) + 1
                taken += 1                      # he would skip the day's setup
                k += 1
                continue
            if v["target"] == "last_level":       # 'last support / resistance in the market' = nearest 30-min swing
                kq = int(np.searchsorted(t30, m.t[j], side="right")) - 1
                piv = pl30 if d == -1 else ph30
                cand = [piv[x] for x in range(max(0, kq - 96), kq + 1)
                        if not np.isnan(piv[x]) and known30[x] <= kq - 1 and (piv[x] - fill) * d > 0]
                if not cand:
                    flags["skip_no_last_level"] = flags.get("skip_no_last_level", 0) + 1
                    k += 1
                    continue
                target = float(min(cand) if d == 1 else max(cand))      # the nearest one beyond entry
            else:
                target = fill + d * float(v["target"]) * risk
            tr = run_trade(m, d, j, fill, sl, target, c, max(i_flat, j))
            tr.update({"box_hi": hi, "box_lo": lo, "sl_pips": round(risk / pip, 1),
                       "rr_planned": round(abs(target - fill) / risk, 2)})
            trades.append(tr)
            taken += 1
            k = int(np.searchsorted(t30, m.t[tr["exit_i"]], side="right"))
    return trades, flags


STRATEGIES = {"fib": strat_fib, "topg": strat_topg, "london": strat_london}


def apply_day_cap(trades: list[dict], cap: int | None, tz: str = IST) -> list[dict]:
    if not cap or not trades:
        return trades
    df = pd.DataFrame(trades).sort_values("entry_t")
    day = pd.to_datetime(df["entry_t"]).dt.tz_convert(tz).dt.date
    df = df[df.groupby(day).cumcount() < cap]
    return df.to_dict("records")


def variant_grid(rules: dict, strategy: str) -> list[dict]:
    g = rules[strategy]["grid"]
    keys = list(g.keys())
    out = [{}]
    for k in keys:
        out = [{**o, k: val} for o in out for val in g[k]]
    return [v for v in out if all(f(v) for f in [])]


def run_strategy(strategy: str, sym: str, m: Market, rules: dict, zero=False, optimistic=False,
                 variants: list[dict] | None = None) -> dict:
    res = {}
    c = costs_for(rules, sym, zero, optimistic)
    weeks = (m.t[-1] - m.t[0]) / (7 * 86400)
    for v in variants or rules[strategy]["variants"]:
        tr, fl = STRATEGIES[strategy](m, v, rules, c)
        tr = apply_day_cap(tr, v.get("max_trades_per_day"))
        df = pd.DataFrame(tr)
        s = summarize(df, weeks, rules, rules["claims"]["headline_win_rate_pct"]) if len(df) else {"trades": 0}
        s.update({"variant": v, "flags": fl})
        res[v["name"]] = {"summary": s, "trades": df}
        if len(df):
            print(f"  [{sym}] {strategy}:{v['name']:42s} n={s['trades']:5d} win={s['win_rate_pct']:5.1f}% "
                  f"tgt={s['target_hit_rate_pct']:5.1f}% avgR={s['avg_R']:+.3f} PF={s['profit_factor']} "
                  f"totR={s['total_R']:+.1f}", flush=True)
        else:
            print(f"  [{sym}] {strategy}:{v['name']:42s} no trades {fl}", flush=True)
    return res


# -------------------------------------------------------------------- run ---
LABELS = {"topg": "TopG levels (TJL2 = A+)", "fib": "Fibonacci golden zone", "london": "London session"}


def cmd_run(a, rules: dict) -> None:
    syms = a.symbols.split(",")
    strategies = a.strategies.split(",")
    final = a.source == "dukascopy" and not a.start and not a.end
    out = {"title": "Top G Traders strategies - backtest of the rules as taught" + ("" if final else " (PRELIMINARY / cross-check)"),
           "preliminary": not final, "source": a.source, "claims": rules["claims"], "symbols": {}, "generated_utc":
           pd.Timestamp.now(tz="UTC").isoformat()}
    tag = "_".join(syms) if a.tag == "" else a.tag
    tdir = RAW / "trades" / tag
    tdir.mkdir(parents=True, exist_ok=True)
    for sym in syms:
        m = load_market(sym, a.source, start=a.start, end=a.end)
        span = f"{pd.Timestamp(int(m.t[0]), unit='s').date()} .. {pd.Timestamp(int(m.t[-1]), unit='s').date()}"
        print(f"{sym}: {len(m):,} minutes {span} source={m.source}", flush=True)
        S = {"source": m.source, "span": span, "minutes": len(m), "strategies": {}}
        for st in strategies:
            res = run_strategy(st, sym, m, rules)
            block = {"variants": {}, "no_cost": {}, "optimistic": {}}
            for name, r in res.items():
                block["variants"][name] = r["summary"]
                if len(r["trades"]):
                    slug = f"{sym}_{st}_{hashlib.md5(name.encode()).hexdigest()[:8]}"
                    r["trades"].assign(variant=name).to_csv(tdir / f"{slug}.csv.gz", index=False)
                    block["variants"][name]["trades_file"] = f"data_raw/trades/{tag}/{slug}.csv.gz"
            heads = [v for v in rules[st]["variants"] if v["name"] in rules[st].get("headline", [])]
            if heads and not a.fast:
                m0 = load_market(sym, a.source, no_spread=True, start=a.start, end=a.end)
                z = run_strategy(st, sym, m0, rules, zero=True, variants=heads)
                o = run_strategy(st, sym, m, rules, optimistic=True, variants=heads)
                g = run_strategy(st, sym, m0, rules, zero=True, optimistic=True, variants=heads)
                del m0
                keep = ("trades", "win_rate_pct", "win_rate_95ci_pct", "target_hit_rate_pct", "avg_R", "profit_factor",
                        "total_R", "breakeven_win_rate_pct", "longest_stop_streak", "years_at_or_above_claim", "years")
                block["no_cost"] = {k: {x: r["summary"].get(x) for x in keep} for k, r in z.items()}
                block["optimistic"] = {k: {x: r["summary"].get(x) for x in keep} for k, r in o.items()}
                block["most_generous"] = {k: {x: r["summary"].get(x) for x in keep} for k, r in g.items()}
            S["strategies"][st] = block
        out["symbols"][sym] = S
        del m
    OUT_JSON.parent.mkdir(parents=True, exist_ok=True)
    path = OUT_JSON.with_name(f"topg_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)}")


# ---------------------------------------------------------------- premises ---
def level_reaction(m: Market, lv: dict, j_tap: int, pip: float, sl_pips: float, rr: float, horizon_h: float) -> int:
    """+1 if, after the first touch, price moves rr*sl_pips in the level's favour (from the zone edge) before going
    sl_pips beyond the zone's far edge; -1 if the adverse move comes first; 0 if neither within the horizon."""
    d = lv["dir"]
    edge = lv["top"] if d == 1 else lv["bot"]
    far = lv["bot"] if d == 1 else lv["top"]
    good = edge + d * rr * sl_pips * pip
    bad = far - d * sl_pips * pip
    end = int(np.searchsorted(m.t, m.t[j_tap] + horizon_h * 3600))
    hi, lo = m.bh[j_tap:end], m.bl[j_tap:end]
    if d == 1:
        g, b = np.flatnonzero(hi >= good), np.flatnonzero(lo <= bad)
    else:
        g, b = np.flatnonzero(lo <= good), np.flatnonzero(hi >= bad)
    gi = g[0] if len(g) else 10**12
    bi = b[0] if len(b) else 10**12
    if gi == bi == 10**12:
        return 0
    return 1 if gi < bi else -1          # same minute -> adverse first (conservative)


def cmd_premise(a, rules: dict) -> None:
    """P1 level types, P3 'first level tapped works best 90% of the time' (Crash D2 33:13), on his H1 structure."""
    out = {}
    for sym in a.symbols.split(","):
        m = load_market(sym, a.source, start=a.start, end=a.end)
        pip = pip_of(sym)
        cnd = candles(m, a.tf)
        levels, trend, _, _ = topg_structure(cnd, "strict")
        i1s = cnd["i1"].to_numpy()
        info = []
        for lv in levels:
            kb = breach_index(cnd, lv)
            i_act = int(i1s[lv["born_k"]])
            i_end = int(i1s[kb]) - 1 if kb < len(cnd) else len(m) - 1
            if i_act >= i_end:
                continue
            seg = slice(i_act, i_end + 1)
            tap = (m.bl[seg] <= lv["top"]) if lv["dir"] == 1 else (m.bh[seg] >= lv["bot"])
            j_tap = i_act + int(np.argmax(tap)) if tap.any() else None
            res = level_reaction(m, lv, j_tap, pip, 30, 2, 72) if j_tap is not None else None
            info.append({**lv, "i_act": i_act, "i_end": i_end, "j_tap": j_tap, "works": res})
        # P1: reaction rate by level type (all tapped levels)
        df = pd.DataFrame([x for x in info if x["works"] is not None])
        p1 = {k: {"tapped": int(len(g)), "works_pct": round(100 * (g.works == 1).mean(), 1),
                  "fails_pct": round(100 * (g.works == -1).mean(), 1)} for k, g in df.groupby("kind")}
        # P1 baseline: the same reaction test on RANDOM zones (same width and direction, random time, zone edge at
        # the price of that moment) - does a TopG level react better than any random price?
        rng = np.random.default_rng(7)
        base = []
        for x in info:
            if x["works"] is None:
                continue
            hi_j = len(m) - 60 * 72
            if x["i_act"] >= hi_j:
                continue
            j = int(rng.integers(x["i_act"], hi_j))
            w_ = x["top"] - x["bot"]
            px = m.bc[j]
            fake = {"dir": x["dir"], "top": px if x["dir"] == 1 else px + w_, "bot": px - w_ if x["dir"] == 1 else px}
            base.append(level_reaction(m, fake, j, pip, 30, 2, 72))
        p1["RANDOM_ZONE_BASELINE"] = {"tapped": len(base), "works_pct": round(100 * float(np.mean([b == 1 for b in base])), 1),
                                      "fails_pct": round(100 * float(np.mean([b == -1 for b in base])), 1)}
        # P3: a buy and a sell level live and untouched at the same time; per LEVEL: was it tapped before its
        # co-live opposite level(s) ("first") or after one of them ("second")?
        firsts, seconds = {}, {}
        tapped = [x for x in info if x["j_tap"] is not None]
        for ix, x in enumerate(tapped):
            for y in tapped:
                if y["dir"] == x["dir"]:
                    continue
                t0 = max(x["i_act"], y["i_act"])
                if t0 >= min(x["j_tap"], y["j_tap"]) or t0 > min(x["i_end"], y["i_end"]):
                    continue                                     # never both live and untouched together
                if x["j_tap"] < y["j_tap"]:
                    firsts[ix] = x["works"]
                else:
                    seconds[ix] = x["works"]
        only_second = {k: v for k, v in seconds.items() if k not in firsts}
        w = lambda d: round(100 * float(np.mean([v == 1 for v in d.values()])), 1) if d else None
        out[sym] = {"source": m.source, "timeframe_min": a.tf, "P1_reaction_by_level": p1,
                    "P3_levels_tapped_first": len(firsts), "P3_first_tapped_works_pct": w(firsts),
                    "P3_levels_tapped_second": len(only_second), "P3_second_tapped_works_pct": w(only_second),
                    "_definition": "works = from the first touch, price moves 2 x 30 pips in the level's favour (from the zone edge) "
                                   "before trading 30 pips beyond the zone's far edge, within 72 h (his 30-pip stop, 1:2). "
                                   "First = tapped before an opposite level that was live and untouched at the same time."}
        print(sym, json.dumps(out[sym], indent=1), flush=True)
    path = OUT_JSON.with_name(f"topg_premises_{'_'.join(a.symbols.split(','))}_{a.source}_tf{a.tf}.json")
    path.write_text(json.dumps(out, indent=1))
    print("wrote", path.relative_to(ROOT))


# ------------------------------------------------------------- spot check ---
def cmd_spot(a, rules: dict) -> None:
    """Print random trades of one variant bar by bar so they can be recomputed by hand."""
    st, name = a.strategies.split(",")[0], a.variant
    v = [x for x in rules[st]["variants"] if x["name"] == name][0]
    m = load_market(a.symbols.split(",")[0], a.source, start=a.start, end=a.end)
    c = costs_for(rules, m.symbol)
    tr, fl = STRATEGIES[st](m, v, rules, c)
    rng = random.Random(a.seed)
    pick = sorted(rng.sample(range(len(tr)), min(a.n, len(tr))))
    tf = v.get("tf_min", 60)
    big = candles(m, tf if st != "london" else 30)
    small = candles(m, v.get("ltf_min") or 5)
    print(f"{m.symbol} {st} '{name}' source={m.source} trades={len(tr)} flags={fl} costs={c}")
    for ix in pick:
        t = tr[ix]
        print("=" * 110)
        print({k: (round(x, 5) if isinstance(x, float) else x) for k, x in t.items()})
        e = int(pd.Timestamp(t["entry_t"]).timestamp())
        s0 = int(pd.Timestamp(t.get("tap_t") or t.get("setup_t") or t["entry_t"]).timestamp())
        sel = (big["t0"] >= s0 - 12 * tf * 60) & (big["t0"] <= e)
        print(f"  {tf}-min BID candles before entry (o h l c):")
        for _, r in big[sel].tail(14).iterrows():
            print(f"   {pd.Timestamp(int(r.t0), unit='s'):%Y-%m-%d %H:%M} {r.o:.5g} {r.h:.5g} {r.l:.5g} {r.c:.5g}")
        sel = (small["t0"] >= s0 - 600) & (small["t0"] <= e)
        print(f"  lower-timeframe BID candles tap->entry (o h l c | ask close):")
        for _, r in small[sel].tail(16).iterrows():
            print(f"   {pd.Timestamp(int(r.t0), unit='s'):%Y-%m-%d %H:%M} {r.o:.5g} {r.h:.5g} {r.l:.5g} {r.c:.5g} | {r.ac:.5g}")
        pts = (t["exit_px"] - t["entry_px"]) * t["dir"] - c["comm"]
        print(f"  check: R = ((exit {t['exit_px']:.5g} - entry {t['entry_px']:.5g}) x dir {t['dir']} - comm {c['comm']}) / risk {t['risk']:.5g} = {pts / t['risk']:+.3f}")


def cmd_crosscheck(a, rules: dict) -> None:
    """Same headline variants, no costs, on Dukascopy BID and HistData BID over the same span: trade-level agreement."""
    sym = a.symbols.split(",")[0]
    md = load_market(sym, "dukascopy", no_spread=True, start=a.start, end=a.end)
    mh = load_market(sym, "histdata", no_spread=True, start=a.start, end=a.end)
    lo, hi = max(md.t[0], mh.t[0]), min(md.t[-1], mh.t[-1])
    out = {}
    for st in a.strategies.split(","):
        for v in [x for x in rules[st]["variants"] if x["name"] in rules[st].get("headline", [])]:
            c = costs_for(rules, sym, zero=True)
            ta = pd.DataFrame(STRATEGIES[st](md, v, rules, c)[0])
            tb_ = pd.DataFrame(STRATEGIES[st](mh, v, rules, c)[0])
            if len(ta) == 0 or len(tb_) == 0:
                continue
            sec = lambda col: (pd.to_datetime(col) - pd.Timestamp("1970-01-01", tz="UTC")) // pd.Timedelta(seconds=1)
            ta = ta[(sec(ta.entry_t) >= lo) & (sec(ta.entry_t) <= hi)]
            tb_ = tb_[(sec(tb_.entry_t) >= lo) & (sec(tb_.entry_t) <= hi)]
            ka = set(zip(ta.entry_t.str[:16], ta.dir))
            kb = set(zip(tb_.entry_t.str[:16], tb_.dir))
            both = ka & kb
            ma = ta.set_index(ta.entry_t.str[:16])
            mb = tb_.set_index(tb_.entry_t.str[:16])
            same_exit = sum(1 for k, _ in both if ma.loc[k, "reason"] == mb.loc[k, "reason"] if not isinstance(ma.loc[k, "reason"], pd.Series))
            res = {"dukascopy_trades": len(ta), "histdata_trades": len(tb_), "same_entry_minute": len(both),
                   "same_entry_and_exit_type": same_exit,
                   "dukascopy_win_rate": round(100 * (ta.R > 0).mean(), 1), "histdata_win_rate": round(100 * (tb_.R > 0).mean(), 1),
                   "dukascopy_avg_R": round(ta.R.mean(), 3), "histdata_avg_R": round(tb_.R.mean(), 3)}
            out[f"{st}:{v['name']}"] = res
            print(st, v["name"][:60], res, flush=True)
    path = OUT_JSON.with_name(f"topg_crosscheck_{sym}.json")
    path.write_text(json.dumps(out, indent=1))
    print("wrote", path.relative_to(ROOT))


# ----------------------------------------------------------------- report ---
def cmd_report(a, rules: dict) -> None:
    """Write projects/topg-audit/BACKTEST.md from one or more results files (--tag a,b,c)."""
    data = OUT_JSON.parent
    nm = lambda k: k.replace("|", "·")
    runs = [json.loads((data / f"topg_results_{t}.json").read_text()) for t in a.tag.split(",")]
    claim = rules["claims"]["headline_win_rate_pct"]
    L = []
    L.append("# Top G Traders strategies: backtest results")
    L.append("")
    L.append("_Generated by `tools/topg_backtest.py report`. Rules, sources and method: `BACKTEST_PLAN.md` and `topg_rules.json`. "
             "Win = trade closed with net R > 0 after costs. Money = fixed $100 risk per trade on a $10,000 account, no compounding._")
    L.append("")
    for r in runs:
        for sym, S in r["symbols"].items():
            kind = ("real Dukascopy BID+ASK 1-minute (headline)" if S["source"] == "dukascopy"
                    else "HistData BID 1-minute + ASK modelled from Dukascopy's median spread by year and hour")
            L.append(f"- **{sym}**: {kind}; {S['span']}, {S['minutes']:,} active minutes.")
    for st in ("topg", "fib", "london"):
        L.append("")
        L.append(f"## {LABELS[st]}")
        heads = rules[st].get("headline", [])
        L.append("")
        L.append("| Symbol | Variant | Trades | /week | Win rate (95% CI) | Full target | Avg R | PF | Break-even WR | Total R | Max DD (R) | Longest losing run | $10k fixed $100/trade | Years ≥70% |")
        L.append("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|")
        for r in runs:
            for sym, S in r["symbols"].items():
                B = S["strategies"].get(st)
                if not B:
                    continue
                for name in heads:
                    x = B["variants"].get(name)
                    if not x or not x.get("trades"):
                        continue
                    money = (f"blown {x['fixed_account_blown_date']}" if x.get("fixed_account_blown_date")
                             else f"${x['fixed_final_balance_usd']:,.0f}")
                    L.append(f"| {sym} | {nm(name)} | {x['trades']} | {x['trades_per_week']} | {x['win_rate_pct']}% ({x['win_rate_95ci_pct'][0]}–{x['win_rate_95ci_pct'][1]}) "
                             f"| {x['target_hit_rate_pct']}% | {x['avg_R']:+.3f} | {x['profit_factor']} | {x['breakeven_win_rate_pct']}% | {x['total_R']:+.0f} "
                             f"| {x['max_drawdown_R']} | {x['longest_losing_streak']} | {money} | {len(x['years_at_or_above_claim'])} of {len(x['years'])} |")
        # grid summary
        for r in runs:
            for sym, S in r["symbols"].items():
                B = S["strategies"].get(st)
                if not B:
                    continue
                V = {k: x for k, x in B["variants"].items() if x.get("trades")}
                if not V:
                    continue
                best = max(V.items(), key=lambda kv: kv[1]["win_rate_pct"])
                bestR = max(V.items(), key=lambda kv: kv[1]["avg_R"])
                vy = sum(len(x["years_at_or_above_claim"]) for x in V.values())
                ny = sum(len(x["years"]) for x in V.values())
                L.append("")
                L.append(f"**{sym}, all {len(B['variants'])} variants:** highest win rate {best[1]['win_rate_pct']}% ({nm(best[0])}); "
                         f"best average {bestR[1]['avg_R']:+.3f}R ({nm(bestR[0])}); variant-years at ≥{claim}%: {vy} of {ny}; "
                         f"variants with a positive average R after costs: {sum(1 for x in V.values() if x['avg_R'] > 0)}.")
                L.append("")
                L.append("<details><summary>Full grid</summary>\n")
                L.append("| Variant | Trades | Win | Target | Stop | BE | Time | Avg win R | Avg loss R | BE WR | Avg R | PF | Total R | Median SL | Flags |")
                L.append("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|")
                for k, x in B["variants"].items():
                    if not x.get("trades"):
                        L.append(f"| {nm(k)} | 0 | | | | | | | | | | | | | {x.get('flags')} |")
                        continue
                    L.append(f"| {nm(k)} | {x['trades']} | {x['win_rate_pct']}% | {x['target_hit_rate_pct']}% | {x['stop_rate_pct']}% | {x['breakeven_exit_rate_pct']}% | "
                             f"{x['time_exit_rate_pct']}% | {x['avg_win_R']} | {x['avg_loss_R']} | {x['breakeven_win_rate_pct']}% | {x['avg_R']:+.3f} | {x['profit_factor']} | "
                             f"{x['total_R']:+.0f} | {x['median_risk_price']} | {x.get('flags')} |")
                L.append("\n</details>")
                zc = B.get("no_cost") or {}
                op = B.get("optimistic") or {}
                if zc:
                    L.append("")
                    L.append(f"{sym}, headline variants with **zero costs** (no spread, slippage, commission) and with the **most generous intrabar order** (full costs):")
                    L.append("")
                    L.append("| Variant | Win (costs) | Avg R (costs) | Win (zero cost) | Avg R (zero cost) | PF (zero cost) | Win (generous intrabar) | Avg R (generous) |")
                    L.append("|---|---|---|---|---|---|---|---|")
                    for k in heads:
                        x, z, o = B["variants"].get(k, {}), zc.get(k, {}), op.get(k, {})
                        if not x.get("trades"):
                            continue
                        L.append(f"| {nm(k)} | {x['win_rate_pct']}% | {x['avg_R']:+.3f} | {z.get('win_rate_pct')}% | {z.get('avg_R')} | {z.get('profit_factor')} | {o.get('win_rate_pct')}% | {o.get('avg_R')} |")
                # yearly
                L.append("")
                L.append(f"{sym}, win rate by year, headline variants (cell = win rate, trades, total R):")
                L.append("")
                hv = [k for k in heads if B["variants"].get(k, {}).get("trades")]
                yrs = sorted({y["year"] for k in hv for y in B["variants"][k]["years"]})
                L.append("| Year | " + " | ".join(nm(k) for k in hv) + " |")
                L.append("|---|" + "---|" * len(hv))
                for yv in yrs:
                    cells = []
                    for k in hv:
                        row = next((y for y in B["variants"][k]["years"] if y["year"] == yv), None)
                        cells.append(f"{row['win_rate']}% ({row['trades']}, {row['total_R']:+.0f}R)" if row else "–")
                    L.append(f"| {yv} | " + " | ".join(cells) + " |")
    # robustness: his exact reading vs all conditions vs the most generous reading, costs vs zero cost, per year
    T = rules["topg"]
    rd = T.get("readings", {})
    L.append("")
    L.append("## TopG levels: three readings side by side (for critics)")
    L.append("")
    L.append(f"- **Exact** = {nm(rd.get('exact', ''))}")
    L.append(f"- **All conditions** = {nm(rd.get('all_conditions', ''))}")
    L.append(f"- **Most generous** = {nm(rd.get('generous', ''))}; also run with zero costs AND the most favourable intrabar order.")
    L.append(f"- {rd.get('_note', '')}")
    for r in runs:
        for sym, S in r["symbols"].items():
            B = S["strategies"].get("topg")
            if not B:
                continue
            cols = []
            for key, label_ in (("exact", "Exact"), ("all_conditions", "All conditions"), ("generous", "Most generous")):
                k = rd.get(key)
                x = B["variants"].get(k, {})
                z = (B.get("no_cost") or {}).get(k, {})
                g = (B.get("most_generous") or {}).get(k, {}) if key == "generous" else {}
                cols.append((label_, x, z, g))
            L.append("")
            L.append(f"**{sym}**")
            L.append("")
            L.append("| Reading | Trades | Win rate after costs (95% CI) | Avg R after costs | PF | Win rate zero cost | Avg R zero cost | Zero cost + generous intrabar |")
            L.append("|---|---|---|---|---|---|---|---|")
            for label_, x, z, g in cols:
                if not x.get("trades"):
                    continue
                gg = f"{g.get('win_rate_pct')}% / {g.get('avg_R')}R" if g else "–"
                L.append(f"| {label_} | {x['trades']} | {x['win_rate_pct']}% ({x['win_rate_95ci_pct'][0]}–{x['win_rate_95ci_pct'][1]}) | {x['avg_R']:+.3f} | {x['profit_factor']} | "
                         f"{z.get('win_rate_pct')}% | {z.get('avg_R')} | {gg} |")
            yrs = sorted({y["year"] for _, x, _, _ in cols for y in x.get("years", [])})
            L.append("")
            L.append("| Year | " + " | ".join(f"{c[0]} (costs / zero cost)" for c in cols) + " |")
            L.append("|---|" + "---|" * len(cols))
            for yv in yrs:
                cells = []
                for label_, x, z, g in cols:
                    a_ = next((y for y in x.get("years", []) if y["year"] == yv), None)
                    b_ = next((y for y in z.get("years", []) if y["year"] == yv), None)
                    cells.append((f"{a_['win_rate']}% ({a_['trades']}, {a_['total_R']:+.0f}R)" if a_ else "–") + " / " +
                                 (f"{b_['win_rate']}% ({b_['total_R']:+.0f}R)" if b_ else "–"))
                L.append(f"| {yv} | " + " | ".join(cells) + " |")
    # his '25 stop-losses in a row is impossible' (8 Oct 2026 livestream), framed as he said it
    L.append("")
    L.append("## \"25 SL in a row is impossible\" (his livestream, 8 Oct 2026): M15 A+ only, direct execution")
    L.append("")
    L.append(T.get("direct_entry", ""))
    L.append("")
    L.append("| Symbol | Variant | Trades | Win rate | Longest run of consecutive stop-losses | Runs of 25+ stops | First 25th stop in a row | Longest losing run (any loss) |")
    L.append("|---|---|---|---|---|---|---|---|")
    for r in runs:
        for sym, S in r["symbols"].items():
            B = S["strategies"].get("topg")
            if not B:
                continue
            for k in T.get("streak_tests", []):
                x = B["variants"].get(k, {})
                if not x.get("trades"):
                    continue
                L.append(f"| {sym} | {nm(k)} | {x['trades']} | {x['win_rate_pct']}% | {x.get('longest_stop_streak')} | {x.get('stop_streaks_of_25_or_more')} | "
                         f"{x.get('first_25_stop_streak_completed') or '–'} | {x['longest_losing_streak']} |")
    if T.get("_streak_note"):
        L.append("")
        L.append(T["_streak_note"])
    flags = T.get("interpretation_flags", {})
    if flags:
        L.append("")
        L.append("## What is his definition vs our interpretation")
        L.append("")
        L.append("**Defined explicitly in his words (safe to show as 'his rule'):** " + "; ".join(flags["explicit_in_his_words"]) + ".")
        L.append("")
        L.append("**Our interpretation (he names it but never defines it; label it on screen or leave it out):** " + "; ".join(flags["our_interpretation"]) + ".")
        L.append("")
        L.append("**Not coded (pure discretion or undefined):** " + "; ".join(flags["not_coded"]) + ".")
    # premises / cross-checks if present
    for f in sorted(data.glob("topg_premises_*.json")):
        P = json.loads(f.read_text())
        L.append("")
        L.append(f"## His premises ({f.name})")
        for sym, x in P.items():
            L.append("")
            L.append(f"**{sym}** ({x['source']}, {x.get('timeframe_min', 60)}-minute levels). {x['_definition']}")
            L.append("")
            L.append("| Level type | Touched | Works | Fails |")
            L.append("|---|---|---|---|")
            for k, y in x["P1_reaction_by_level"].items():
                L.append(f"| {k} | {y['tapped']} | {y['works_pct']}% | {y['fails_pct']}% |")
            L.append("")
            L.append(f"'First level tapped works best, 90% of the time': first-tapped levels work {x['P3_first_tapped_works_pct']}% "
                     f"(n={x['P3_levels_tapped_first']}); levels tapped second {x['P3_second_tapped_works_pct']}% (n={x['P3_levels_tapped_second']}).")
    for f in sorted(data.glob("topg_crosscheck_*.json")):
        C = json.loads(f.read_text())
        L.append("")
        L.append(f"## Second data source ({f.name}): Dukascopy BID vs HistData BID, same rules, zero costs")
        L.append("")
        L.append("| Variant | Dukascopy trades | HistData trades | Same entry minute | Same entry + exit type | Win D / H | Avg R D / H |")
        L.append("|---|---|---|---|---|---|---|")
        for k, y in C.items():
            L.append(f"| {nm(k)} | {y['dukascopy_trades']} | {y['histdata_trades']} | {y['same_entry_minute']} | {y['same_entry_and_exit_type']} | "
                     f"{y['dukascopy_win_rate']}% / {y['histdata_win_rate']}% | {y['dukascopy_avg_R']} / {y['histdata_avg_R']} |")
    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=["run", "spot", "crosscheck", "premise", "report"])
    ap.add_argument("--symbols", default="XAUUSD")
    ap.add_argument("--strategies", default="topg,fib,london")
    ap.add_argument("--source", default="dukascopy")
    ap.add_argument("--start", default=None)
    ap.add_argument("--end", default=None)
    ap.add_argument("--tag", default="")
    ap.add_argument("--fast", action="store_true", help="skip the zero-cost / optimistic re-runs")
    ap.add_argument("--variant", default="")
    ap.add_argument("--n", type=int, default=5)
    ap.add_argument("--seed", type=int, default=7)
    ap.add_argument("--tf", type=int, default=60, help="premise: level timeframe in minutes")
    a = ap.parse_args()
    rules = json.loads(RULES.read_text())
    {"run": cmd_run, "spot": cmd_spot, "crosscheck": cmd_crosscheck, "premise": cmd_premise,
     "report": cmd_report}[a.cmd](a, rules)


if __name__ == "__main__":
    sys.exit(main())
