#!/usr/bin/env python3
"""
Backtest of the Werracle curator stress model against the 12–18 Oct 2026 Base sell-off.

Question: if, at 2026-10-12 00:00 UTC, we had applied the model with the collateral price drop
that actually happened in the following 7 days, which positions / how much debt would it have
flagged as liquidatable, and how does that compare with the Morpho Blue liquidations that
actually occurred?

Data (all on-chain, Base, historical eth_call at fixed blocks):
  * positions at T0:  Morpho.position(id, user) + Morpho.market(id) for every borrower address
                       the Werracle shadow has ever seen in the market (plus liquidated borrowers)
  * price path:       market oracle price() every 450 blocks (15 min) from T0 to T1
  * actual:           Morpho `Liquidate` events in [T0, T1] for the same markets
Model (identical to curator_stress.py, but in loan-token units with the market's own oracle):
  liquidatable at shock d  <=>  borrow > collateral * P0 * (1 - d) * LLTV
  d = realised maximum drawdown of the oracle price inside the window (1 - Pmin / P0)
"""
import json, os, sys, time, urllib.request
from concurrent.futures import ThreadPoolExecutor
from eth_abi import encode, decode

RPCS = ["https://base-rpc.publicnode.com", "https://mainnet.base.org", "https://base.drpc.org"]
MORPHO = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"
MC3 = "0xcA11bde05977b3631167028862bE2a173976CA11"
T_LIQ = "0xa4946ede45d0c6f06a0f5ce92c9ad3b4751452d2fe0e25010783bcab57a67e41"
B0, B1 = 52486927, 52789327          # 2026-10-12 00:00:01 UTC, 2026-10-19 00:00:01 UTC
STEP = 450                            # 15 minutes
OUT = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(OUT, "cache"); os.makedirs(CACHE, exist_ok=True)


def rpc(method, params, tries=8):
    key = os.path.join(CACHE, str(abs(hash(json.dumps([method, params])))) + ".json")
    if method != "eth_blockNumber" and os.path.exists(key):
        return json.load(open(key))
    last = None
    for i in range(tries):
        url = RPCS[i % len(RPCS)]
        try:
            req = urllib.request.Request(url, json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(),
                                         {"content-type": "application/json", "user-agent": "werracle-backtest"})
            j = json.load(urllib.request.urlopen(req, timeout=60))
            if "error" in j:
                last = j["error"]; time.sleep(1 + i); continue
            json.dump(j["result"], open(key, "w"))
            return j["result"]
        except Exception as e:
            last = e; time.sleep(1 + i)
    raise RuntimeError(f"{method} failed: {last}")


def multicall(calls, block):
    """calls: list of (target, calldata bytes). Returns list of (success, returndata bytes)."""
    data = "0x82ad56cb" + encode(["(address,bool,bytes)[]"], [[(t, True, d) for t, d in calls]]).hex()
    res = rpc("eth_call", [{"to": MC3, "data": data}, hex(block)])
    return decode(["(bool,bytes)[]"], bytes.fromhex(res[2:]))[0]


def sel(sig_hex, *args_types_vals):
    types = [t for t, _ in args_types_vals]; vals = [v for _, v in args_types_vals]
    return bytes.fromhex(sig_hex[2:]) + (encode(types, vals) if types else b"")


def market_params(mid):
    r = rpc("eth_call", [{"to": MORPHO, "data": "0x2c3c9157" + mid[2:]}, hex(B0)])
    loan, coll, oracle, irm, lltv = decode(["address", "address", "address", "address", "uint256"], bytes.fromhex(r[2:]))
    return {"loan": loan, "collateral": coll, "oracle": oracle, "lltv": lltv / 1e18}


def market_state(mid, block):
    r = rpc("eth_call", [{"to": MORPHO, "data": "0x5c60e39a" + mid[2:]}, hex(block)])
    w = decode(["uint128"] * 6, bytes.fromhex(r[2:]))
    return {"totalSupplyAssets": w[0], "totalSupplyShares": w[1], "totalBorrowAssets": w[2], "totalBorrowShares": w[3]}


def decimals(token):
    r = rpc("eth_call", [{"to": token, "data": "0x313ce567"}, "latest"])
    return int(r, 16)


def symbol(token):
    try:
        r = rpc("eth_call", [{"to": token, "data": "0x95d89b41"}, "latest"])
        return decode(["string"], bytes.fromhex(r[2:]))[0]
    except Exception:
        return token[:8]


def positions_at(mid, users, block, mstate):
    out = {}
    chunks = [users[i:i + 150] for i in range(0, len(users), 150)]
    def run(ch):
        res = multicall([(MORPHO, sel("0x93c52062", ("bytes32", bytes.fromhex(mid[2:])), ("address", u))) for u in ch], block)
        r = {}
        for u, (ok, data) in zip(ch, res):
            if ok and data:
                ss, bs, col = decode(["uint256", "uint128", "uint128"], data)
                if bs > 0:
                    borrow = -(-bs * (mstate["totalBorrowAssets"] + 1) // (mstate["totalBorrowShares"] + 10 ** 6))  # toAssetsUp
                    r[u.lower()] = (col, borrow)
        return r
    with ThreadPoolExecutor(6) as ex:
        for r in ex.map(run, chunks):
            out.update(r)
    return out


def price_path(oracles, b0, b1, step, extra_blocks=()):
    blocks = sorted(set(range(b0, b1 + 1, step)) | {b for b in extra_blocks if b0 <= b <= b1})
    def run(b):
        res = multicall([(o, bytes.fromhex("a035b1fe")) for o in oracles], b)
        return b, [int.from_bytes(d, "big") if ok and d else None for ok, d in res]
    with ThreadPoolExecutor(6) as ex:
        return dict(ex.map(run, blocks))


def liquidations(mids, b0, b1):
    ev = []
    for s in range(b0, b1 + 1, 49000):
        e = min(b1, s + 48999)
        logs = rpc("eth_getLogs", [{"fromBlock": hex(s), "toBlock": hex(e), "address": MORPHO,
                                    "topics": [T_LIQ, [m for m in mids]]}])
        for lg in logs:
            w = decode(["uint256"] * 5, bytes.fromhex(lg["data"][2:]))
            ev.append({"market": lg["topics"][1], "borrower": "0x" + lg["topics"][3][-40:], "block": int(lg["blockNumber"], 16),
                       "tx": lg["transactionHash"], "repaid": w[0], "seized": w[2], "bad_debt": w[3]})
    return ev


def lif(l):
    return min(1.15, 1 / (0.3 * l + 0.7))


def evaluate(mids, params, bor, liq_all, path_all, b0, b1, diagnose=False):
    """Positions at b0, realised drawdown in (b0, b1], liquidations in (b0, b1]."""
    rows, tot = [], {"pred_pos": 0, "pred_debt": 0.0, "act_pos": 0, "act_debt_t0": 0.0, "act_repaid": 0.0,
                     "hit_pos": 0, "hit_debt": 0.0, "new_after_t0_pos": 0, "new_after_t0_repaid": 0.0,
                     "pred_bad": 0.0, "act_bad": 0.0, "pred_inc": 0.0, "miss_leverage_up": 0, "miss_other": 0}
    for i, m in enumerate(mids):
        p = params[m]
        liq = [e for e in liq_all if e["market"] == m and b0 < e["block"] <= b1]
        users = sorted(set(u.lower() for u in bor[m]["borrowers"]) | {e["borrower"].lower() for e in liq_all if e["market"] == m})
        ms0 = market_state(m, b0)
        pos = positions_at(m, users, b0, ms0)
        prices = [v[i] for b, v in sorted(path_all.items()) if b0 <= b <= b1 and v[i]]
        p0, pmin = prices[0], min(prices)
        dd = max(0.0, 1 - pmin / p0)
        scale = 10 ** p["loan_dec"]
        L, F = p["lltv"], lif(p["lltv"])
        pred = {}
        for u, (c, b) in pos.items():
            cval = c * p0 / 1e36
            if b > cval * (1 - dd) * L:
                c2 = cval * (1 - dd); r = min(b, c2 / F)
                pred[u] = {"debt": b, "incentive": r * (F - 1), "bad": b - r}
        act = {}
        for e in liq:
            a = act.setdefault(e["borrower"].lower(), {"repaid": 0, "bad": 0, "first_block": e["block"]})
            a["repaid"] += e["repaid"]; a["bad"] += e["bad_debt"]; a["first_block"] = min(a["first_block"], e["block"])
        at_t0 = {u for u in act if u in pos}; new_after = set(act) - at_t0; hits = at_t0 & set(pred)
        misses = at_t0 - hits
        lev_up = 0
        if diagnose and misses:
            for u in misses:
                bl = act[u]["first_block"] - 1
                msb = market_state(m, bl)
                pb = positions_at(m, [u], bl, msb).get(u)
                c0, d0 = pos[u]
                if pb and (pb[1] > d0 * 1.01 or pb[0] < c0 * 0.99):
                    lev_up += 1
        row = {"market_id": m, "pair": f'{p["coll_sym"]}/{p["loan_sym"]}', "lltv": L, "candidates_checked": len(users),
               "borrow_positions_at_t0": len(pos), "debt_at_t0_usd": round(sum(b for _, b in pos.values()) / scale, 2),
               "oracle_drawdown_pct": round(100 * dd, 2), "predicted_liquidatable_positions": len(pred),
               "predicted_liquidatable_debt_usd": round(sum(v["debt"] for v in pred.values()) / scale, 2),
               "predicted_incentive_usd": round(sum(v["incentive"] for v in pred.values()) / scale, 2),
               "predicted_bad_debt_usd": round(sum(v["bad"] for v in pred.values()) / scale, 2),
               "actual_liquidated_borrowers": len(act), "actual_existing_at_t0": len(at_t0), "actual_opened_after_t0": len(new_after),
               "actual_repaid_usd": round(sum(a["repaid"] for a in act.values()) / scale, 2),
               "actual_bad_debt_usd": round(sum(a["bad"] for a in act.values()) / scale, 2),
               "actual_t0_debt_of_liquidated_usd": round(sum(pos[u][1] for u in at_t0) / scale, 2),
               "hits": len(hits), "misses": len(misses), "misses_with_leverage_increase_after_t0": lev_up if diagnose else None}
        rows.append(row)
        tot["pred_pos"] += len(pred); tot["pred_debt"] += row["predicted_liquidatable_debt_usd"]
        tot["act_pos"] += len(at_t0); tot["act_debt_t0"] += row["actual_t0_debt_of_liquidated_usd"]
        tot["act_repaid"] += row["actual_repaid_usd"]; tot["hit_pos"] += len(hits)
        tot["hit_debt"] += sum(pos[u][1] for u in hits) / scale
        tot["new_after_t0_pos"] += len(new_after); tot["new_after_t0_repaid"] += sum(act[u]["repaid"] for u in new_after) / scale
        tot["pred_bad"] += row["predicted_bad_debt_usd"]; tot["act_bad"] += row["actual_bad_debt_usd"]; tot["pred_inc"] += row["predicted_incentive_usd"]
        tot["miss_leverage_up"] += lev_up; tot["miss_other"] += (len(misses) - lev_up) if diagnose else 0
    t = {k: round(v, 2) if isinstance(v, float) else v for k, v in tot.items()}
    t["recall_positions"] = round(tot["hit_pos"] / tot["act_pos"], 4) if tot["act_pos"] else None
    t["recall_debt"] = round(tot["hit_debt"] / tot["act_debt_t0"], 4) if tot["act_debt_t0"] else None
    t["precision_positions"] = round(tot["hit_pos"] / tot["pred_pos"], 4) if tot["pred_pos"] else None
    t["precision_debt"] = round(tot["hit_debt"] / tot["pred_debt"], 4) if tot["pred_debt"] else None
    return rows, t


def main():
    bor = json.load(open(sys.argv[1]))
    mids = list(bor)
    params = {m: market_params(m) for m in mids}
    for m, p in params.items():
        p["loan_dec"] = decimals(p["loan"]); p["coll_sym"] = symbol(p["collateral"]); p["loan_sym"] = symbol(p["loan"])
    print("markets:", [(p["coll_sym"] + "/" + p["loan_sym"], p["lltv"], len(bor[m]["borrowers"])) for m, p in params.items()], flush=True)
    liq = liquidations(mids, B0, B1)
    print("actual liquidation events:", len(liq), flush=True)
    # price path: every 15 min plus every block that contains a liquidation in these markets
    path = price_path([params[m]["oracle"] for m in mids], B0, B1, STEP, extra_blocks={e["block"] for e in liq})
    print("price samples:", len(path), flush=True)
    report = {"window": {"from_block": B0, "to_block": B1, "from_utc": "2026-10-12T00:00:01Z", "to_utc": "2026-10-19T00:00:01Z",
                         "price_sampling": "every 450 blocks (15 min) plus every block containing a liquidation in the tested markets"}}
    rows, tot = evaluate(mids, params, bor, liq, path, B0, B1, diagnose=True)
    report["static_7d"] = {"markets": rows, "totals": tot}
    print("STATIC", json.dumps(tot), flush=True)
    day = 43200
    daily = []
    agg = None
    for k in range(7):
        b0, b1 = B0 + k * day, B0 + (k + 1) * day
        rws, t = evaluate(mids, params, bor, liq, path, b0, b1)
        daily.append({"day": k, "from_block": b0, "to_block": b1, "markets": rws, "totals": t})
        print("DAY", k, json.dumps(t), flush=True)
        if agg is None:
            agg = {kk: 0 for kk in ("pred_pos", "pred_debt", "act_pos", "act_debt_t0", "act_repaid", "hit_pos", "hit_debt", "new_after_t0_pos", "new_after_t0_repaid", "pred_bad", "act_bad", "pred_inc")}
        for kk in agg:
            agg[kk] += t[kk]
    agg = {k: round(v, 2) for k, v in agg.items()}
    agg["recall_positions"] = round(agg["hit_pos"] / agg["act_pos"], 4) if agg["act_pos"] else None
    agg["recall_debt"] = round(agg["hit_debt"] / agg["act_debt_t0"], 4) if agg["act_debt_t0"] else None
    agg["precision_positions"] = round(agg["hit_pos"] / agg["pred_pos"], 4) if agg["pred_pos"] else None
    agg["precision_debt"] = round(agg["hit_debt"] / agg["pred_debt"], 4) if agg["pred_debt"] else None
    report["rolling_daily"] = {"days": daily, "totals": agg}
    print("ROLLING", json.dumps(agg), flush=True)
    json.dump(report, open(os.path.join(OUT, "backtest_oct12_18_result.json"), "w"), indent=1)


if __name__ == "__main__":
    main()
