"""hdam_sim.py - pessimistic M1 execution simulator for Hypothesis-Driven Alpha Mining (HDAM).

Built by: Claude (2026-10-17), on the owner's order after STRETCH-AUDIT-20261017. Strategy code never reads a parquet file: quotes come only
from `prop_gpu_miner/data_loader.load_true_clock` (true clock, UTC start of minute, Validation / Holdout sealed until unlocked).

Execution invariants (all on the file's own bid/ask, never on mid):
  * Stop-entry: a long triggers when ask_high >= trigger, fill = max(ask_open, trigger) + slip; a short triggers when bid_low <= trigger,
    fill = min(bid_open, trigger) - slip. A price that jumps over the trigger is filled at the jump, not at the trigger.
  * Bar-close entry ('market'): the decision is taken at the close of a bar; the fill is the ask_open (long) / bid_open (short) of the first
    minute at or after t_signal, +/- slip. No entry-minute ambiguity; the whole entry minute is after the fill.
  * Stops: a long stop triggers when bid_low <= stop, fill = min(bid_open, stop) - slip (shorts mirrored on the ask).
  * Entry minute of a stop-entry: the order of prices inside one minute is unknown. ambiguity='pessimistic' books the stop if the entry minute
    also reaches it; 'optimistic' ignores it. Report both; decide on the pessimistic one. A fill at the minute's open (gap) is not ambiguous:
    the whole minute is after the fill, so its stop is checked in both modes.
  * Both triggers of a bracket inside one minute with no position = a loss: the order filled first and was stopped by the move to the other side
    (booked at the worse side's stop, -1R minus cost). Nothing is skipped with a silent break.
  * Stop and target in the same minute: the stop wins.
  * Forced flat: at the open of the first minute >= t_flat if it starts within 15 minutes of t_flat (bid for longs, ask for shorts, -/+ slip);
    otherwise at the close of the last minute before t_flat.
  * R unit = the planned risk in points (|reference entry - stop| known when the order is placed). The cost of a trade in R is
    (0.5*S_in + 0.5*S_out + 2*slip + comm) / plan_risk; R at a cost multiple m = R_1x - (m - 1) * cost_r. `cost_r_ex_ante` uses the spread of
    the minute before the order and is the only cost a filter may use.
EMPIRICAL RESEARCH TOOL (NOT A STRATEGY).
"""
from __future__ import annotations

import os
import sys
from dataclasses import dataclass, field

import numpy as np
import pandas as pd

HERE = os.path.dirname(os.path.abspath(__file__))
LAB = os.path.dirname(HERE)
sys.path.insert(0, os.path.join(LAB, "prop_gpu_miner"))
sys.path.insert(0, LAB)
import data_loader as DL  # noqa: E402
from lab_utc import TZ_MAP  # noqa: E402


# ----------------------------------------------------------------------------------------------------------------- data
@dataclass
class Book:
    """Flat numpy view of one symbol's minutes (UTC start of minute)."""
    symbol: str
    t: np.ndarray                      # int64 ns UTC
    bo: np.ndarray; bh: np.ndarray; bl: np.ndarray; bc: np.ndarray
    ao: np.ndarray; ah: np.ndarray; al: np.ndarray; ac: np.ndarray
    attrs: dict = field(default_factory=dict)

    @property
    def spread(self) -> np.ndarray:
        return self.ac - self.bc

    def mid_frame(self) -> pd.DataFrame:
        idx = pd.to_datetime(self.t, unit="ns", utc=True)
        return pd.DataFrame({"open": (self.bo + self.ao) / 2, "high": (self.bh + self.ah) / 2, "low": (self.bl + self.al) / 2,
                             "close": (self.bc + self.ac) / 2, "spread": self.ac - self.bc}, index=idx)


def load_book(symbol: str, stage: str = "discovery") -> Book:
    q = DL.load_true_clock(symbol, stage).dropna(subset=["bid_open", "ask_open", "bid_close", "ask_close"])
    a = {k: q[k].to_numpy(float) for k in q.columns}
    return Book(symbol, pd.DatetimeIndex(q.index).as_unit("ns").asi8.copy(), a["bid_open"], a["bid_high"], a["bid_low"], a["bid_close"],
                a["ask_open"], a["ask_high"], a["ask_low"], a["ask_close"], attrs={"stage": stage, "tz": TZ_MAP[symbol][1]})


def book_from_frame(symbol: str, df: pd.DataFrame) -> Book:
    """For self-tests: a frame with bid_*/ask_* OHLC and a UTC DatetimeIndex."""
    a = {k: df[k].to_numpy(float) for k in df.columns}
    return Book(symbol, pd.DatetimeIndex(df.index).tz_convert("UTC").as_unit("ns").asi8.copy(), a["bid_open"], a["bid_high"], a["bid_low"], a["bid_close"],
                a["ask_open"], a["ask_high"], a["ask_low"], a["ask_close"], attrs={"stage": "synthetic"})


# ----------------------------------------------------------------------------------------------------------------- staleness guard
def stale_share(book: Book, tz: str, start_hm: str, end_hm: str) -> pd.Series:
    """Share of flat minutes (bid and ask unchanged inside the minute) in a local-time window, per year."""
    loc = pd.to_datetime(book.t, unit="ns", utc=True).tz_convert(tz)
    hm = loc.hour * 60 + loc.minute
    a = int(start_hm[:2]) * 60 + int(start_hm[3:]); b = int(end_hm[:2]) * 60 + int(end_hm[3:])
    m = (hm >= a) & (hm <= b)
    flat = (book.bh == book.bl) & (book.ah == book.al)
    return pd.Series(flat[m]).groupby(loc.year[m]).mean()


def assert_live_anchor(book: Book, tz: str, start_hm: str, end_hm: str, max_flat: float = 0.10) -> pd.Series:
    """Refuse an anchor window whose flat-minute share exceeds max_flat in any year (the DAX / Bund pre-open artefact)."""
    s = stale_share(book, tz, start_hm, end_hm)
    bad = s[s > max_flat]
    if len(bad):
        raise ValueError(f"{book.symbol} {start_hm}-{end_hm} {tz}: stale quotes in {dict(bad.round(3))} (limit {max_flat:.0%})")
    return s


# ----------------------------------------------------------------------------------------------------------------- orders
@dataclass
class Market:
    """Bar-close entry: fill at the open of the first minute >= t_signal."""
    t_signal: pd.Timestamp
    side: int                      # +1 long, -1 short
    stop: float
    t_flat: pd.Timestamp
    ref_px: float                  # price known at the decision (e.g. mid close of the signal bar): defines the planned risk
    target: float | None = None
    tag: str = ""


@dataclass
class Bracket:
    """OCO stop-entry on both sides, live from t_start to t_last_entry (inclusive minute starts)."""
    t_start: pd.Timestamp
    t_last_entry: pd.Timestamp
    long_trigger: float
    short_trigger: float
    long_stop: float
    short_stop: float
    t_flat: pd.Timestamp
    long_target: float | None = None
    short_target: float | None = None
    tag: str = ""


@dataclass
class Costs:
    slip_pts: float = 0.0          # per side, adverse
    comm_pts: float = 0.0          # round trip, in price points


FLAT_TOLERANCE_NS = 15 * 60 * 10**9


def _ts(x) -> int:
    x = pd.Timestamp(x)
    x = x.tz_convert("UTC") if x.tzinfo else x.tz_localize("UTC")
    return x.as_unit("ns").value


def _manage(b: Book, k0: int, k_end: int, side: int, entry: float, stop: float, target, slip: float, check_entry_minute: bool):
    """Walk minutes k0..k_end-1 from the fill. Returns (exit_px, k_exit, reason)."""
    for k in range(k0, k_end):
        if k == k0 and not check_entry_minute:
            continue
        if side == 1:
            if b.bl[k] <= stop:
                return min(b.bo[k], stop) - slip if k != k0 else stop - slip, k, "sl"
            if target is not None and b.bh[k] >= target:
                return target, k, "tp"
        else:
            if b.ah[k] >= stop:
                return max(b.ao[k], stop) + slip if k != k0 else stop + slip, k, "sl"
            if target is not None and b.al[k] <= target:
                return target, k, "tp"
    return None, None, None


def _flat(b: Book, k_flat: int, k_last: int, side: int, slip: float):
    if k_flat is not None:
        return (b.bo[k_flat] - slip) if side == 1 else (b.ao[k_flat] + slip), k_flat, "flat"
    return (b.bc[k_last] - slip) if side == 1 else (b.ac[k_last] + slip), k_last, "flat_lastclose"


def _row(b, tag, side, k_in, k_out, entry, exit_px, plan_risk, reason, costs, k_pre):
    s_in, s_out = b.spread[k_in], b.spread[k_out]
    cost_pts = 0.5 * s_in + 0.5 * s_out + 2 * costs.slip_pts + costs.comm_pts
    s_pre = b.spread[k_pre] if k_pre is not None and k_pre >= 0 else s_in
    gross_exec = (exit_px - entry) * side - costs.comm_pts
    return dict(tag=tag, side=side, t_in=pd.Timestamp(int(b.t[k_in]), unit="ns", tz="UTC"), t_out=pd.Timestamp(int(b.t[k_out]), unit="ns", tz="UTC"),
                entry=entry, exit=exit_px, plan_risk=plan_risk, reason=reason,
                R=gross_exec / plan_risk,                                   # executed on bid/ask with slip and commission = net at 1x
                cost_r=cost_pts / plan_risk,
                cost_r_ex_ante=(s_pre + 2 * costs.slip_pts + costs.comm_pts) / plan_risk)


def simulate(book: Book, orders: list, costs: Costs = Costs(), ambiguity: str = "pessimistic") -> pd.DataFrame:
    """Run orders (Market / Bracket) on the book. One position per order. Returns one row per filled order."""
    assert ambiguity in ("pessimistic", "optimistic")
    t = book.t
    out = []
    for o in orders:
        tf = _ts(o.t_flat)
        k_flat = int(np.searchsorted(t, tf, "left"))                 # walk stops before the flat minute
        k_flat_ok = k_flat if (k_flat < len(t) and t[k_flat] - tf <= FLAT_TOLERANCE_NS) else None
        k_stop_walk = k_flat
        if isinstance(o, Market):
            k_in = int(np.searchsorted(t, _ts(o.t_signal), "left"))
            if k_in >= k_stop_walk:
                continue
            side, slip = o.side, costs.slip_pts
            entry = (book.ao[k_in] + slip) if side == 1 else (book.bo[k_in] - slip)
            plan_risk = abs(o.ref_px - o.stop)
            if plan_risk <= 0:
                continue
            px, k_out, why = _manage(book, k_in, k_stop_walk, side, entry, o.stop, o.target, slip, check_entry_minute=True)
            if px is None:
                px, k_out, why = _flat(book, k_flat_ok, k_stop_walk - 1, side, slip)
            out.append(_row(book, o.tag, side, k_in, k_out, entry, px, plan_risk, why, costs, k_in - 1))
        elif isinstance(o, Bracket):
            k0 = int(np.searchsorted(t, _ts(o.t_start), "left"))
            k1 = int(np.searchsorted(t, _ts(o.t_last_entry), "right"))
            k1 = min(k1, k_stop_walk)
            slip = costs.slip_pts
            for k in range(k0, k1):
                hl = book.ah[k] >= o.long_trigger
                hs = book.bl[k] <= o.short_trigger
                if not (hl or hs):
                    continue
                if hl and hs:                                        # both sides in one minute: first fill stopped by the move to the other
                    rl = (o.long_trigger - o.long_stop)
                    rs = (o.short_stop - o.short_trigger)
                    if rl >= rs:
                        side, entry, stop, plan_risk = 1, max(book.ao[k], o.long_trigger) + slip, o.long_stop, rl
                    else:
                        side, entry, stop, plan_risk = -1, min(book.bo[k], o.short_trigger) - slip, o.short_stop, rs
                    px = stop - slip if side == 1 else stop + slip
                    out.append(_row(book, o.tag, side, k, k, entry, px, plan_risk, "double_break", costs, k - 1))
                    break
                if hl:
                    side, entry, stop, tgt = 1, max(book.ao[k], o.long_trigger) + slip, o.long_stop, o.long_target
                    plan_risk = o.long_trigger - o.long_stop
                else:
                    side, entry, stop, tgt = -1, min(book.bo[k], o.short_trigger) - slip, o.short_stop, o.short_target
                    plan_risk = o.short_stop - o.short_trigger
                if plan_risk <= 0:
                    break
                px, k_out, why = None, None, None
                gapped = (book.ao[k] > o.long_trigger) if side == 1 else (book.bo[k] < o.short_trigger)
                if ambiguity == "pessimistic" or gapped:              # pessimistic, or filled at the open: the whole minute is after the fill
                    hit = (book.bl[k] <= stop) if side == 1 else (book.ah[k] >= stop)
                    if hit:
                        px, k_out, why = (stop - slip) if side == 1 else (stop + slip), k, "sl_entry_minute"
                if px is None:
                    px, k_out, why = _manage(book, k, k_stop_walk, side, entry, stop, tgt, slip, check_entry_minute=False)
                if px is None:
                    px, k_out, why = _flat(book, k_flat_ok, k_stop_walk - 1, side, slip)
                out.append(_row(book, o.tag, side, k, k_out, entry, px, plan_risk, why, costs, k - 1))
                break
        else:
            raise TypeError(type(o))
    return pd.DataFrame(out)


def at_cost(T: pd.DataFrame, m: float) -> pd.Series:
    """Net R at m times the executed cost (m = 1 is the simulated trade itself)."""
    return T["R"] - (m - 1.0) * T["cost_r"]


def tstat(x) -> float:
    x = np.asarray(x, float)
    return float(x.mean() / x.std(ddof=1) * np.sqrt(len(x))) if len(x) > 2 else float("nan")
