"""Basket rule v3 (2 Oct 2026): risk on / risk off for every basket, shared by the live engine and the backtest.

RISK ON (BUY) at a daily close above the basket's highest close of the prior 30 days, but only while
  BTC closes above its 100 day average AND the majors basket is itself risk on (the market agrees).
TAKE HALF the first time the basket runs HOT (20 day z score >= 2 and in the top 20% of its 90 day range).
RISK OFF (SELL) at a daily close below the basket's lowest close of the prior 15 days.
Rotation: of the baskets that are risk on, the 3 strongest over 30 days.

Test (it1 to it3 in /home/claude/chains, raw data published with the handover): 14 point in time CoinMarketCap
baskets, dead coins counted, Jan 2023 to Sep 2026, 1% round trip cost: 115 trades, +8.0% average, median +3.1%,
58% wins, $2.64 won per $1 lost; Sep 2024 to Sep 2026 alone: 76 trades, +4.0%, 57% wins, $1.80 per $1.
Rotation book (3 strongest risk on baskets): +154% (log) Jan 2023 to Sep 2026 vs BTC +161%, with about 25%
of the money in on average and a worst drop of -36% vs BTC -53%."""
import math, statistics as S, datetime as dt
from stages import stage_series

NIN, NOUT, COST, TOPK = 30, 15, 0.01, 3


def sma_on(days, px, n=100):
    """date -> True when the price closes above its n day simple average (needs 90% of the window)."""
    ds = [d for d in days if px.get(d)]
    out = {}
    for i, d in enumerate(ds):
        w = [px[x] for x in ds[max(0, i - n + 1):i + 1]]
        if len(w) >= int(n * 0.9): out[d] = px[d] > sum(w) / len(w)
    return out


def donchian_on(days, L, nin=NIN, nout=NOUT):
    s = False; out = {}
    for i, d in enumerate(days):
        if i >= nin and L[i] > max(L[i - nin:i]): s = True
        elif i >= nout and L[i] < min(L[i - nout:i]): s = False
        out[d] = s
    return out


def to_daily(line, stamp_open=False):
    """[[unix_ts, level], ...] 4 hour bars -> [[YYYY-MM-DD, level]], the last level seen each UTC day, keyed like
    CoinMarketCap: a level seen on day D is the close for the label D+1 only once the day is over. The current
    (unfinished) day is kept as a provisional last point."""
    by = {}
    for t, v in line:
        if not v: continue
        if stamp_open: t += 4 * 3600
        d = dt.datetime.fromtimestamp(t - 1, dt.timezone.utc).date() + dt.timedelta(days=1)
        by[d.isoformat()] = v
    return sorted(by.items())


def run(days, L, btc_on, maj_on, is_majors=False, nin=NIN, nout=NOUT, cost=COST):
    """Replay the rule on one basket. Returns (trades, per-day weight). A trade dict carries entry/exit days,
    levels, trim day, return after costs, and status open/closed."""
    st = stage_series(L) if len(L) >= 26 else [("WARMUP", {})] * len(L)
    trades = []; w = [0.0] * len(L); i = nin
    while i < len(L):
        d = days[i]
        ok = (L[i] > max(L[i - nin:i]) and btc_on.get(d, False) and (is_majors or maj_on.get(d, False)))
        if not ok: i += 1; continue
        ent = L[i]; cur = 1.0; trim = None; acc = 0.0; ex = None
        w[i] = 1.0
        for j in range(i + 1, len(L)):
            if L[j] < min(L[j - nout:j]): ex = j; break
            if cur == 1.0 and st[j][0] == "HOT":
                trim = j; acc += 0.5 * (L[j] / ent - 1); cur = 0.5
            w[j] = cur
        last = ex if ex is not None else len(L) - 1
        ret = acc + cur * (L[last] / ent - 1) - (cost if ex is not None else cost / 2)
        trades.append(dict(sig=days[i], entry=days[i], entry_lvl=ent, trim=days[trim] if trim is not None else None,
                           trim_lvl=L[trim] if trim is not None else None, exit=days[ex] if ex is not None else None,
                           last=days[last], last_lvl=L[last], ret=ret, bars=last - i, status="closed" if ex is not None else "open",
                           weight=cur))
        if ex is None: break
        i = ex + 1
    return trades, w


def stats(ts):
    xs = [t["ret"] for t in ts if t["status"] == "closed"]
    if not xs: return dict(n=0)
    wn = [x for x in xs if x > 0]; ls = [x for x in xs if x <= 0]
    return dict(n=len(xs), mean=round(S.mean(xs) * 100, 1), med=round(S.median(xs) * 100, 1), win=round(len(wn) / len(xs) * 100),
                pf=round(sum(wn) / -sum(ls), 2) if ls and sum(ls) < 0 else None)


def read(days, L, btc_on, maj_on, is_majors=False, live_start="2026-10-02"):
    """Everything the dashboard needs for one basket on its last close."""
    n = len(L)
    if n < 2: return {"state": "NO DATA", "word": "NO READ"}
    young = n <= NIN
    nin = NIN if not young else max(5, n - 2)
    trades, w = run(days, L, btc_on, maj_on, is_majors, nin=nin)
    last = days[-1]; cur = trades[-1] if trades else None
    hi = max(L[-nin - 1:-1]) if n > nin else max(L[:-1]); lo = min(L[-NOUT - 1:-1]) if n > NOUT else min(L[:-1])
    hi_next = max(L[-nin:]); lo_next = min(L[-NOUT:])   # levels the NEXT close has to beat / hold
    st = stage_series(L)[-1] if n >= 26 else ("WARMUP", {})
    gates = {"btc": bool(btc_on.get(last)), "majors": True if is_majors else bool(maj_on.get(last))}
    o = {"young": young, "window": nin, "gates": gates, "stage": st[0],
         "z": round(st[1].get("z", 0), 2) if st[1] else None,
         "r30": round((L[-1] / L[-31] - 1) * 100, 1) if n > 30 else None,
         "r7": round((L[-1] / L[-8] - 1) * 100, 1) if n > 7 else None,
         "up_at": round((hi_next / L[-1] - 1) * 100, 1), "down_at": round((lo_next / L[-1] - 1) * 100, 1),
         "record": stats([t for t in trades if t["entry"] < live_start]),
         "trades": [dict(t, ret=round(t["ret"] * 100, 1)) for t in trades if t["entry"] >= live_start or t["status"] == "open"]}
    on = bool(cur and cur["status"] == "open")
    o["state"] = "RISK ON" if on else "RISK OFF"
    if on:
        o["open"] = dict(entry=cur["entry"], entry_lvl=cur["entry_lvl"], trim=cur["trim"], weight=cur["weight"],
                         ret=round((L[-1] / cur["entry_lvl"] - 1) * 100, 1), days=cur["bars"])
        if cur["entry"] == last: o["word"] = "BUY"
        elif cur["trim"] == last: o["word"] = "TAKE HALF"
        elif cur["trim"]: o["word"] = "HOLD HALF"
        else: o["word"] = "HOLD"
    else:
        if cur and cur["exit"] == last: o["word"] = "SELL"; o["closed"] = dict(entry=cur["entry"], ret=round(cur["ret"] * 100, 1))
        elif L[-1] > hi and not (gates["btc"] and gates["majors"]): o["word"] = "WAIT: BLOCKED"
        elif o["up_at"] <= 5: o["word"] = "WAIT: CLOSE"
        else: o["word"] = "WAIT"
    return o
