"""panel.py - intraday decision panel for the ML forecaster (alpha_v3). DISCOVERY data only unless a stage is unlocked in data_loader.

Written by: Claude (2026-10-19). Every price comes from data_loader.load_true_clock (UTC start of minute, true clock).
Clock rule: a decision at time t (ET wall clock, e.g. 10:30) may use minutes that START before t (their close is known at t). The entry is the open of
the minute starting at t (ask for a long, bid for a short); the exit is the open of the minute starting at t + H (bid for a long, ask for a short).
Feature returns are mid log returns in bp, divided by the asset's own trailing 20-day RTH volatility per 30 minutes (so assets and years are comparable).
"""
from __future__ import annotations

import os
import sys

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

NY = "America/New_York"
GEX_FILE = os.path.join(LAB, "data", "squeezemetrics", "DIX_2026-10-19.csv")
TRADE = ("nas100usd", "spx500usd", "us30usd", "us2000usd")                     # cost-eligible US index CFDs
CROSS = ("nas100usd", "spx500usd", "us30usd", "us2000usd", "de30eur", "usb10yusd", "eurusd", "usdjpy", "xauusd", "wticousd")
SLOTS = [600 + 30 * i for i in range(11)]                                        # 10:00 .. 15:00 ET (minutes since midnight)
HORIZONS = {"h30": 30, "h60": 60, "close": None}                                 # close = exit at the 15:55 minute open


def minute_grid(symbol: str, stage: str) -> pd.DataFrame:
    q = DL.load_true_clock(symbol, stage)
    m = pd.DataFrame({"mo": 0.5 * (q.bid_open + q.ask_open), "mc": 0.5 * (q.bid_close + q.ask_close),
                      "bo": q.bid_open, "ao": q.ask_open}, index=q.index)
    return m[(m.mo > 0) & (m.mc > 0)]


def _asof(idx_ns: np.ndarray, vals: np.ndarray, t_ns: np.ndarray, strict_before: bool) -> np.ndarray:
    """value of the last minute starting before t (strict) or at/after t (first minute >= t) - here: last minute < t."""
    side = "left" if strict_before else "left"
    pos = np.searchsorted(idx_ns, t_ns, side=side) - (1 if strict_before else 0)
    out = np.full(len(t_ns), np.nan)
    ok = (pos >= 0) & (pos < len(idx_ns))
    out[ok] = vals[pos[ok]]
    return out, pos


def build(stage: str = "discovery", tol_min: int = 3) -> pd.DataFrame:
    """One row per (trade symbol, day, slot). Columns: features f_*, targets y_<h> (mid bp), executed long/short net bp per horizon, cost bp."""
    grids = {s: minute_grid(s, stage) for s in sorted(set(TRADE) | set(CROSS))}
    # trading days = NY weekdays where nas100usd has the 09:30 minute
    nq = grids["nas100usd"]
    loc = nq.index.tz_convert(NY)
    days = pd.DatetimeIndex(sorted(set(loc[(loc.hour == 9) & (loc.minute == 30) & (loc.dayofweek < 5)].normalize().tz_localize(None))))
    tol = tol_min * 60 * 10**9

    def at(s: str, t_utc: pd.DatetimeIndex, col: str, before: bool):
        g = grids[s]
        idx = g.index.asi8
        tn = t_utc.asi8
        if before:                                   # last minute starting < t: use its close
            pos = np.searchsorted(idx, tn, "left") - 1
            ok = (pos >= 0) & (tn - idx[np.maximum(pos, 0)] <= tol + 60 * 10**9)
        else:                                        # first minute starting >= t: use its open (or bid/ask open)
            pos = np.searchsorted(idx, tn, "left")
            ok = (pos < len(idx)) & (idx[np.minimum(pos, len(idx) - 1)] - tn <= tol)
        v = np.full(len(tn), np.nan)
        v[ok] = g[col].to_numpy()[pos[ok]]
        return v

    def et(day_idx: pd.DatetimeIndex, minute: int) -> pd.DatetimeIndex:
        return (day_idx + pd.Timedelta(minutes=minute)).tz_localize(NY, ambiguous="NaT", nonexistent="NaT").tz_convert("UTC")

    # per-asset daily anchors (mid): previous 15:59 close, 09:30 open, and 30-min vol scale
    px = {}
    for s in grids:
        a = {}
        a["open930"] = at(s, et(days, 570), "mo", False)
        a["close1559"] = at(s, et(days, 960), "mc", True)            # last minute starting before 16:00
        a["prev_close"] = np.r_[np.nan, a["close1559"][:-1]]
        a["prev2_close"] = np.r_[np.nan, np.nan, a["close1559"][:-2]]
        # realised 30-min vol from 30-min RTH returns over the previous 20 days (known before the day starts)
        r30 = []
        for k in range(13):
            p0 = at(s, et(days, 570 + 30 * k), "mo", False)
            p1 = at(s, et(days, 600 + 30 * k), "mo", False)
            r30.append(np.log(p1 / p0) * 1e4)
        r30 = np.vstack(r30).T
        dvar = np.nanmean(r30 ** 2, axis=1)
        vs = pd.Series(dvar).shift(1).rolling(20, min_periods=10).mean() ** 0.5
        a["vol30"] = vs.to_numpy()
        a["rv_prev"] = pd.Series(dvar ** 0.5).shift(1).to_numpy()
        px[s] = a

    g = pd.read_csv(GEX_FILE, parse_dates=["date"]).set_index("date").sort_index()
    rank = lambda x: (x[:-1] < x[-1]).mean()
    g["gex_rank"] = g["gex"].rolling(252, min_periods=126).apply(rank, raw=True)
    g["dix_rank"] = g["dix"].rolling(252, min_periods=126).apply(rank, raw=True)
    gpos = g.index.searchsorted(days, "left") - 1                       # lag 1: the previous published row
    gok = gpos >= 0
    gex_rank = np.where(gok, g["gex_rank"].to_numpy()[np.maximum(gpos, 0)], np.nan)
    dix_rank = np.where(gok, g["dix_rank"].to_numpy()[np.maximum(gpos, 0)], np.nan)
    gex_neg = np.where(gok, (g["gex"].to_numpy()[np.maximum(gpos, 0)] < 0).astype(float), np.nan)

    rows = []
    for slot in SLOTS:
        t = et(days, slot)
        f = {"slot": np.full(len(days), slot), "dow": days.dayofweek.to_numpy(), "gex_rank": gex_rank, "dix_rank": dix_rank, "gex_neg": gex_neg}
        cur = {}
        for s in CROSS:
            a = px[s]
            p_now = at(s, t, "mc", True)
            p_30 = at(s, t - pd.Timedelta(minutes=30), "mc", True)
            p_60 = at(s, t - pd.Timedelta(minutes=60), "mc", True)
            v = a["vol30"]
            f[f"f_{s}_r30"] = np.log(p_now / p_30) * 1e4 / v
            f[f"f_{s}_r60"] = np.log(p_now / p_60) * 1e4 / v
            f[f"f_{s}_rday"] = np.log(p_now / a["open930"]) * 1e4 / v
            f[f"f_{s}_ron"] = np.log(a["open930"] / a["prev_close"]) * 1e4 / v
            f[f"f_{s}_rprev"] = np.log(a["prev_close"] / a["prev2_close"]) * 1e4 / v
            f[f"f_{s}_rvrel"] = a["rv_prev"] / v
            cur[s] = p_now
        base = pd.DataFrame(f, index=days)
        for s in TRADE:
            d = base.copy()
            d["sym"] = s
            d["day"] = days
            a = px[s]
            d["own_vol30"] = a["vol30"]
            for c in ("r30", "r60", "rday", "ron", "rprev", "rvrel"):
                d[f"own_{c}"] = d[f"f_{s}_{c}"]
            ent_mid = at(s, t, "mo", False)
            ent_ask, ent_bid = at(s, t, "ao", False), at(s, t, "bo", False)
            for h, hm in HORIZONS.items():
                tx = t + pd.Timedelta(minutes=hm) if hm is not None else et(days, 955)
                ex_mid, ex_ask, ex_bid = at(s, tx, "mo", False), at(s, tx, "ao", False), at(s, tx, "bo", False)
                d[f"y_{h}"] = np.log(ex_mid / ent_mid) * 1e4
                d[f"long_{h}"] = np.log(ex_bid / ent_ask) * 1e4
                d[f"short_{h}"] = -np.log(ex_ask / ent_bid) * 1e4
            rows.append(d)
    P = pd.concat(rows, ignore_index=True)
    P = P[np.isfinite(P["own_vol30"]) & np.isfinite(P["y_h30"])]
    return P.reset_index(drop=True)


if __name__ == "__main__":
    import time
    t0 = time.time()
    P = build("discovery")
    out = os.path.join(HERE, "panel_discovery.parquet")
    P.to_parquet(out)
    print(P.shape, round(time.time() - t0, 1), "s")
    print(P.groupby("sym").day.agg(["min", "max", "count"]))
    print(P[["y_h30", "long_h30", "short_h30", "y_close"]].describe().round(2))
