#!/usr/bin/env python3
"""an5exits.py - DC Analyst 5, AN5.1 exit map (prereg line '7 Oct 11:10 | A5 | AN5.1 exit map').
Runs on research-box only. Reads the DC Engine tape (read only) and liqlock liq (read only).
Uses twtest v1.1 fills (depth from last 30 swaps, pool fee each way) and dedupe.
usage: an5exits.py <out_dir>
"""
import os, sys, time, sqlite3
os.environ.setdefault("TWDATA", "/opt/tw/engine/data")
os.environ["TW_T250"] = "tape"           # chain clock (A2 v4 fix: launch-file clock was the Lane F artifact)
sys.path.insert(0, "/opt/tw/engine/eng")
import numpy as np, pandas as pd
import twtest as E

OUT = sys.argv[1] if len(sys.argv) > 1 else "."
os.makedirs(OUT, exist_ok=True)
T0 = time.time()
H = 48 * 3600.0; DELAY = 30.0; TICKETS = (100, 1000); SPLIT = "2026-09-28"
SKIP_HFAM = {"5059_7adb2ec7", "25533_3080c1dd"}
LIQ = "file:/opt/pricepredict-data/liqlock.db?mode=ro"
def log(*a): print("[%5.0fs]" % (time.time() - T0), *a, flush=True)

# ---------------------------------------------------------------- tape (filtered, low memory)
class TP: pass
tp = TP()
t = pd.read_pickle(E.D + "/tape.pkl")
tp.end = float(t.t.max())
m = (t.side == -1) & (t.usd >= 100) & (t.cap >= 250e3)
keep = set(t.token[m].unique())
t = t[t.token.isin(keep)]
tp.coins = pd.read_pickle(E.D + "/coins.pkl").set_index("token")
tp.by = {}
for tok, d in t.groupby("token", sort=False):
    tp.by[tok] = {k: d[k].values for k in ("t", "side", "eth", "usd", "px", "cap", "wallet", "ethusd")}
del t
log("tape coins with a $250k sellable print:", len(tp.by), "tape end", E.bkk(tp.end))

# ---------------------------------------------------------------- universes (chain clock)
def build_universes():
    U = {}
    U["C250"] = E.universe(tp, "ALL")
    U["T1c"] = E.universe(tp, "T1")
    t4 = E.universe(tp, "T4")
    ok = []
    for r in t4.itertuples():
        a = tp.by[r.token]
        s = (a["side"] == -1) & (a["usd"] >= 100) & (a["cap"] >= 250e3) & (a["t"] >= r.tdec - 3600) & (a["t"] < r.tdec)
        ok.append(bool(s.any()))
    U["T4c"] = t4[np.array(ok, dtype=bool)].reset_index(drop=True)
    for k in U:
        u = U[k]
        hf = [tp.coins.loc[x].hfam for x in u.token]
        U[k] = u[[h not in SKIP_HFAM for h in hf]].reset_index(drop=True)
    return U

# ---------------------------------------------------------------- LP removals per pool
def lp_first_removal(tokens):
    out = {}
    try:
        con = sqlite3.connect(LIQ, uri=True, timeout=60)
        for tok in tokens:
            pool = tp.coins.loc[tok].pool
            if not isinstance(pool, str): continue
            rows = con.execute("SELECT t, dl, kind, share FROM liq WHERE pool=? AND dl<0 ORDER BY b, li", (pool.lower(),)).fetchall()
            if not rows:
                rows = con.execute("SELECT t, dl, kind, share FROM liq WHERE pool=? AND dl<0 ORDER BY b, li", (pool,)).fetchall()
            out[tok] = [(float(r[0]), r[2], r[3]) for r in rows if r[0] is not None]
        con.close()
    except Exception as e:
        log("liq read failed:", e)
    return out

# ---------------------------------------------------------------- one trade: entry, fill, path
EXITS = ["X3", "X5", "T15", "TR50", "H48", "CR", "INS10", "INS25", "INS50", "NET5", "NET15", "LP"]

def first(mask, lo=0):
    if lo >= len(mask): return -1
    w = np.flatnonzero(mask[lo:])
    return int(w[0]) + lo if len(w) else -1

def trade(tok, tdec, lp):
    a = tp.by[tok]; c = tp.coins.loc[tok]
    fee = float(c.fee_pool) if np.isfinite(c.fee_pool) else E.DYN_FEE
    if fee >= 0.1: fee = E.DYN_FEE
    pull_t = c.pull_t if np.isfinite(c.pull_t) else np.inf
    t = a["t"]; n = len(t)
    i = int(np.searchsorted(t, tdec + DELAY, side="left"))
    if i >= n or t[i] - (tdec + DELAY) > 600 or pull_t <= t[i]: return dict(status="nofill")
    te = t[i]; pe = a["px"][i]; eu = a["ethusd"][i]
    if te + H > tp.end: return dict(status="uncovered")
    R = E.reserve(a, i)
    if not np.isfinite(R) or R <= 0: return dict(status="noreserve")
    px, side, usd, cap, w, eth = a["px"], a["side"], a["usd"], a["cap"], a["wallet"], a["eth"]
    jend = int(np.searchsorted(t, min(te + H, pull_t), side="left"))   # first swap at/after horizon or pull
    if t[min(jend, n - 1)] == te + H and jend < n: jend += 1
    seg = np.arange(i + 1, jend)
    # end case if nothing fires
    if pull_t <= te + H: end = ("pull", 0.0, pull_t)
    elif jend >= n: end = ("trunc", px[n - 1], t[n - 1])
    else: end = ("time", px[jend - 1] if jend - 1 > i else pe, te + H)
    S = seg
    ps, ss, us, ts, ws, es, cs = px[S], side[S], usd[S], t[S], w[S], eth[S], cap[S]
    sellable = (ss == -1) & (us >= 100)
    # first sellable $1M print (new $ split)
    k1m = first(sellable & (cs >= 1e6))
    # insiders: creator + wallets that bought in the first 120 s of trading
    t0 = t[0]
    early = set(w[(side == 1) & (t <= t0 + 120)]) - {""}
    cr = str(c.creator).lower() if isinstance(c.creator, str) else ""
    ins = early | ({cr} if cr else set())
    isin_all = np.fromiter((x in ins for x in w[: jend]), bool, count=min(jend, n))
    tokamt = np.where(px[:i + 1] > 0, eth[:i + 1] / np.where(px[:i + 1] > 0, px[:i + 1], 1), 0) * side[:i + 1]
    stack = float(tokamt[isin_all[:i + 1]].sum())
    isin = isin_all[S] if len(S) else np.zeros(0, bool)
    sig = {}
    tpidx = {k: first(sellable & (ps >= k * pe)) for k in (3, 5)}
    # creator sells
    sig["CR"] = first((ss == -1) & (ws == cr) & (us >= 100)) if cr else -1
    # insider dump share
    ins_sold = np.cumsum(np.where((ss == -1) & isin & (ps > 0), es / np.where(ps > 0, ps, 1), 0))
    for x in (10, 25, 50):
        sig["INS%d" % x] = first(ins_sold >= x / 100 * stack) if stack > 0 else -1
    # outside net buy over trailing N min
    flow = np.where((ss == 1) & ~isin, es, 0.0) - np.where(ss == -1, es, 0.0)
    C = np.concatenate([[0.0], np.cumsum(flow)])
    for N in (5, 15):
        lo = np.searchsorted(ts, ts - N * 60, side="right")
        win = C[np.arange(1, len(ts) + 1)] - C[lo]
        sig["NET%d" % N] = first((ts >= te + N * 60) & (win < 0))
    # LP: first removal of any size after entry (before or at the pull)
    lt = [x[0] for x in lp.get(tok, []) if x[0] > te]
    sig["LP"] = int(np.searchsorted(ts, lt[0], side="left")) if lt and lt[0] < min(te + H, pull_t) else -1
    if sig["LP"] >= len(ts): sig["LP"] = -1

    # ---- resolve each exit to (how, price, time)
    def at(k): return ("sig", ps[k], ts[k])
    def tp_or(kx, j_sig_exit):
        """take profit at kx (5x) or exit at swap index j_sig_exit, whichever first"""
        kt = tpidx[kx]
        cand = []
        if kt >= 0: cand.append((kt, ("tp", kx * pe, ts[kt])))
        if j_sig_exit is not None and j_sig_exit >= 0:
            if j_sig_exit < len(ts): cand.append((j_sig_exit, at(j_sig_exit)))
            else: cand.append((len(ts), None))      # signal on the last swap in the window: sell after it
        if not cand: return end
        j, r = min(cand, key=lambda z: z[0])
        if r is None:   # no swap left after the signal before horizon/pull
            return ("pull", 0.0, pull_t) if end[0] == "pull" else ("sig", ps[-1], ts[-1])
        return r
    X = {}
    X["X3"] = tp_or(3, None)
    X["X5"] = tp_or(5, None)
    k15 = int(np.searchsorted(ts, te + 900, side="left"))
    X["T15"] = tp_or(3, k15 if k15 < len(ts) else -1) if k15 < len(ts) else tp_or(3, None)
    if len(ps):
        peak = np.maximum.accumulate(np.maximum(ps, pe))
        ktr = first((peak >= 1.5 * pe) & (ps <= 0.5 * peak))
        X["TR50"] = at(ktr) if ktr >= 0 else end
    else:
        X["TR50"] = end
    X["H48"] = end
    for k in ("CR", "INS10", "INS25", "INS50", "NET5", "NET15"):
        s = sig[k]
        X[k] = tp_or(5, s + 1 if s >= 0 else None)
    X["LP"] = tp_or(5, sig["LP"] if sig["LP"] >= 0 else None)
    # ---- money
    def sell(price, q):
        if price <= 0: return 0.0
        Rq = R * np.sqrt(price / pe); v = q * price
        return v * Rq / (Rq + v) * (1 - fee) * eu
    res = dict(status="ok", token=tok, te=te, pe=pe, cap_e=cap[i], R=R, fee=fee, stack=stack, n_ins=len(ins),
               pulled=bool(pull_t <= te + H), t1m=ts[k1m] if k1m >= 0 else np.nan, p1m=ps[k1m] if k1m >= 0 else np.nan,
               maxmult=float(ps.max() / pe) if len(ps) else 1.0)
    for tk in TICKETS:
        e = tk / eu * (1 - fee); q = (e / pe) * R / (R + e)
        res["q_%d" % tk] = q
        for k in EXITS:
            how, xp, xt = X[k]
            pnl = sell(xp, q) - tk
            res["%s_pnl_%d" % (k, tk)] = pnl
            if k1m >= 0 and xt > ts[k1m]: res["%s_new_%d" % (k, tk)] = sell(ps[k1m], q) - tk
            else: res["%s_new_%d" % (k, tk)] = pnl
    for k in EXITS:
        how, xp, xt = X[k]
        res[k + "_how"] = how; res[k + "_hold"] = xt - te; res[k + "_mult"] = xp / pe
    # path for the random-exit test: (time offsets, price) of every swap in the window
    res["path"] = (ts - te, ps, (end[0], end[1], end[2] - te))
    return res

# ---------------------------------------------------------------- random exit test
def price_at(path, h):
    off, ps, end = path
    if not len(off):
        return None if (end[0] == "pull" and h < end[2]) or end[0] == "time" else end[1]
    if h > off[-1]:
        if end[0] == "pull" and h < end[2]: return ps[-1]
        return end[1]
    k = int(np.searchsorted(off, h, side="right")) - 1
    return ps[k] if k >= 0 else None

def rand_test(G, k, tk=100, draws=2000, seed=11):
    """shuffle this exit's hold times across the same trades; P(rule $ > random $)"""
    if len(G) < 3: return np.nan
    holds = G[k + "_hold"].values
    m = len(G)
    M = np.zeros((m, m))
    for a_, r in enumerate(G.itertuples()):
        path = r.path
        pe = r.pe; R = r.R; fee = r.fee; eu = None
        q = getattr(r, "q_%d" % tk)
        tok = r.token; eu = tp.by[tok]["ethusd"][int(np.searchsorted(tp.by[tok]["t"], r.te))]
        for b_, h in enumerate(holds):
            p = price_at(path, h)
            if p is None: p = pe
            if p <= 0: M[a_, b_] = -tk; continue
            Rq = R * np.sqrt(p / pe); v = q * p
            M[a_, b_] = v * Rq / (Rq + v) * (1 - fee) * eu - tk
    target = G["%s_pnl_%d" % (k, tk)].sum()
    rng = np.random.default_rng(seed)
    idx = np.arange(m); beat = 0
    for _ in range(draws):
        perm = rng.permutation(m)
        beat += target > M[idx, perm].sum()
    return beat / draws

# ---------------------------------------------------------------- main
def main():
    U = build_universes()
    for k, u in U.items(): log("universe", k, len(u))
    alltok = sorted(set().union(*[set(u.token) for u in U.values()]))
    lp = lp_first_removal(alltok); log("liq rows read for", len(lp), "pools")
    rows = []; lines = []
    for uname, u in U.items():
        T = []
        for r in u.itertuples():
            x = trade(r.token, r.tdec, lp)
            if x.get("status") != "ok":
                T.append(dict(token=r.token, status=x.get("status"))); continue
            c = tp.coins.loc[r.token]
            x.update(sym=c.sym, creator=c.creator, crew=c.crew, day=r.day, tdec=r.tdec)
            T.append(x)
        T = pd.DataFrame(T)
        st = T.status.value_counts().to_dict()
        ok = T[T.status == "ok"].copy()
        ok = E.dedupe(ok)
        ok["half"] = np.where(ok.day < SPLIT, "A", "B")
        d0 = pd.Timestamp(u.day.min()).tz_localize(E.BKK).timestamp(); ds = pd.Timestamp(SPLIT).tz_localize(E.BKK).timestamp()
        last = min(tp.end - H, u.tdec.max())
        days = {"A": (ds - d0) / 86400, "B": max(0.0, (last - ds) / 86400)}
        log(uname, "status", st, "after dedupe", len(ok), "days", days)
        for k in EXITS:
            row = dict(universe=uname, exit=k)
            for h in ("A", "B"):
                G = ok[ok.half == h]
                row["n_" + h] = len(G); row["days_" + h] = round(days[h], 2)
                for tk in TICKETS:
                    row["usd_day_%d_%s" % (tk, h)] = round(G["%s_pnl_%d" % (k, tk)].sum() / days[h], 1) if days[h] > 0 else np.nan
                row["new_day_100_" + h] = round(G["%s_new_100" % k].sum() / days[h], 1) if days[h] > 0 else np.nan
                row["tp_" + h] = int((G[k + "_how"] == "tp").sum())
                row["pull0_" + h] = int((G[k + "_how"] == "pull").sum())
                row["dodged_" + h] = int(((G[k + "_how"] != "pull") & G.pulled).sum())
                row["trunc_" + h] = int((G[k + "_how"] == "trunc").sum())
                row["hold_med_min_" + h] = round(float(np.median(G[k + "_hold"])) / 60, 1) if len(G) else np.nan
                row["beat_rand_" + h] = rand_test(G, k)
            rows.append(row)
        keep = [c for c in ok.columns if c != "path"]
        ok[keep].to_csv(os.path.join(OUT, "an5-trades-%s.csv.gz" % uname), index=False)
    R = pd.DataFrame(rows)
    R.to_csv(os.path.join(OUT, "an5-exitmap.csv"), index=False)
    pd.set_option("display.width", 250); pd.set_option("display.max_columns", 40)
    print(R[["universe", "exit", "n_A", "n_B", "usd_day_100_A", "usd_day_100_B", "usd_day_1000_A", "usd_day_1000_B",
             "new_day_100_A", "new_day_100_B", "tp_A", "tp_B", "pull0_A", "pull0_B", "dodged_A", "dodged_B", "trunc_A", "trunc_B",
             "hold_med_min_A", "hold_med_min_B", "beat_rand_A", "beat_rand_B"]].to_string(index=False))
    log("done")

if __name__ == "__main__":
    main()
