#!/usr/bin/env python3
"""Did any Alpha Arena season 1.5 wallet beat 2,048 coin-flip monkeys?

Served at https://oaia.fun/recompute_alpha_arena_s15.py. Python 3, standard library only.

THE METHOD, fixed in this text before any percentile was computed (written 2026-11-10):

Data. Season 1.5 of Nof1's Alpha Arena had eight models trade US stock perpetuals on
Hyperliquid (trade.xyz's dex) in four modes, 32 accounts of about $10,000, from
2025-11-19 to 2025-12-03. We found no published wallet list. The 32 candidate wallets below were traced on
chain: season 1's master wallet funded four new masters on 2025-11-17, and exactly 32 of
their sub-accounts trade the season's seven instruments during its dates, with some small earlier trades (the
trace is in docs/research/ALPHA_ARENA_S15_WALLETS_2026-11-08.md). They are named by their
on-chain sub-account names, sub_N. Which model and mode each one is, is NOT part of this
script; wallet membership and every model label remain inferred, not organizer-confirmed.

Window. From 2025-11-19 00:00Z to the bell, 2025-12-03 22:00Z (5 p.m. EST, the same hour as
season 1's bell; we found no published exact season 1.5 time). This is our chosen cutoff,
not a confirmed organizer bell. Fills after it are ignored.

Price at the bell. This scored run uses each instrument's last price any of the 32 wallets traded it at, at
or before the bell. The age of that print is recorded per instrument.
Our initial explanation that historical candles were unavailable was wrong: review on
2026-11-10 found that the API still serves 2h/4h/1d candles. The original marks and scores
remain unchanged; these are last-fill marks, not necessarily the price at the cutoff.

Position. Per wallet and instrument, one position runs from flat to flat, with its adds and
partial closes inside it. Its result r is its realized profit (the exchange's closedPnl),
plus its funding, plus, if still open at the bell, the move from its average entry to the
price at the bell. Fees stay out of r, because a monkey pays the same fees.
Sixteen positions were already open at the window start. The code dates their opening
to T0 and uses the exchange's closedPnl on their in-window closing fills (which includes
the earlier entry basis). All sixteen close within the window; their result magnitudes
are at most $4.35. This is not a perfectly isolated in-window-return experiment.

Monkeys. Each wallet gets 2,048 monkeys. A monkey holds every one of the wallet's positions,
same moments and same sizes, and flips a fair coin for the side. The other side of a
position makes -r. The coins are dealt in the order the positions opened, from a generator
seeded with sha256('OAIA|alpha-arena-s15|<sub_N>'). Percentile: the share of monkeys the
wallet finished above, ties counted half. 50 is a coin flip.

The bar, the same as season 1 and /ai-traders: beating all but at most one of the 2,048
(one-sided rank threshold about 0.001), or losing to all but at most one. This is a
per-wallet two-sided rule, not a family-wide multiple-testing correction. Inside the
bar a wallet cannot be told from luck at this sample size, which is
never the same as having no skill.

Second reading, fixed now too: positions opened on the same UTC day share one coin, seeded
with sha256('OAIA|alpha-arena-s15-day|<sub_N>'). The verdict rests on the first reading;
both are printed.

Control. Start (USDC moved into the wallet before the bell) + sum(r) - fees + funding not
matched to a position rebuilds each account at the bell. It is compared with the exchange's
own account value from its `portfolio` endpoint, the reading nearest the bell (its gap in
minutes is printed). A row more than 5% off says so on the page.

AMENDED 2026-11-10 before this version's first scored run, which stopped on its own check
before scoring anything: season 1.5 has fills that take a position through zero in one fill
(season 1 had none). Such a fill is now split into a close of the old side, carrying all of
its closedPnl and its share of the fee by size, and an open of the new side with the rest.
The same run then stopped on a millisecond holding both a buy and a sell on one instrument,
which "buys climb, sells descend" cannot order. Fills in one millisecond are now chained by
startPosition: each follows the fill that ends where it starts, searched depth first so
that every fill of the millisecond is used.

CORRECTED 2026-11-10 after the first scored run: the start read only subAccountTransfer rows
and missed USDC `send` rows, which carried most of each deposit, so every start read 0 and
every control row failed. The start enters only the control, never r, so no percentile can
move; the scored rows of that run are kept in alpha_arena_s15_first_run.json.

DOCUMENTATION CORRECTED 2026-11-10 after review: qualified wallet and cutoff inference,
corrected the candle-availability and multiple-testing explanations, and documented
carried positions. No executable scoring code, input, mark or score changed. The JSON
"fills" fields count position pieces, including both halves of zero-crossing fills;
there are 169,232 raw fills and 169,261 pieces. The page now counts raw input rows.

Usage, in a folder holding alpha_arena_s15_input.json and alpha_arena_s15.json:
  python3 recompute_alpha_arena_s15.py          rebuild every row; exit code 0 means all match
  python3 recompute_alpha_arena_s15.py --fetch  replace alpha_arena_s15_input.json with a fresh download
  python3 recompute_alpha_arena_s15.py --demo   the controls, on made-up trades
"""
import hashlib
import json
import random
import statistics
import sys
import time
import urllib.error
import urllib.request

COHORT = 2048
T0 = 1763510400000  # 2025-11-19 00:00Z
BELL = 1764799200000  # 2025-12-03 22:00Z
DAY = 86400000
INFO = "https://api.hyperliquid.xyz/info"
COINS = ("xyz:AMZN", "xyz:GOOGL", "xyz:MSFT", "xyz:NVDA", "xyz:PLTR", "xyz:TSLA", "xyz:XYZ100")
WALLETS = {
    "sub_1": "0xefc0c8f700025554624797fe8b46d36756a5e47c", "sub_2": "0x57c392bc9917eab1b70e0836f0dfed59460e59d3",
    "sub_3": "0x0a2a4ae8c318798bb773b8d1803f553fb25b5635", "sub_4": "0xb158dedfbe2142ba595f082344fed4438b636a3c",
    "sub_5": "0x4b995b73bfd4d6e9cdd98da56e62831897acab70", "sub_6": "0x20f6124a8647944a0ac21d9426c44ebffa99be50",
    "sub_7": "0xe23926551d8e8c66f8869805dcfad05591f0a768", "sub_8": "0x406d9d2534c62c64332134c294e7d9c48f3925a9",
    "sub_9": "0x61f35978eb33bad00644bc9549850e7a7c65186e", "sub_10": "0x8662ee61b17e07213a104305a46e619205abd046",
    "sub_11": "0x628f78337e40129f3a3b7e88e4d7d29bb8dbe9e5", "sub_12": "0xa2730bac5ea13531ae989b727d3e148469ededce",
    "sub_13": "0xb0d63f7dc3beb0d667b601b50f3db25b10b6a212", "sub_14": "0xf180c5145b53b430777f67bded05ddc92650c594",
    "sub_15": "0x3cd9f57464e15afac05b7509fd406e4ad0753af8", "sub_16": "0x2bde65aec178175f0213e46cb9c7a03a9644f7b9",
    "sub_17": "0xcfa311027ad9e5321215b390cbeea8e72edacd88", "sub_18": "0x7c1dd590126f25a991dc3d13cc3ebc0418f6969e",
    "sub_19": "0x2822768f82b41602f3f16537684909d52a95b207", "sub_20": "0xa3ccd92ff5422066465a9fe76b1b9f0a0e038ee5",
    "sub_21": "0xd5b4e395493e1e9a4446f4352663daf66b1fd2d0", "sub_22": "0x7f5269f1514e63989aa8539e6caa5fbe6c1741d7",
    "sub_23": "0x21c96467aaec8395063e94723a17c95e1e26d675", "sub_24": "0x57fcb5584055a9f1ce036c54be3fd9cd4b320efe",
    "sub_25": "0x9a0ad81e55744ebc42c32fb11ed5b407ebb609d8", "sub_26": "0x4a656e7be6e0a0d196decf609b2e86bfa1442e43",
    "sub_27": "0xdf26358c478ca133fef929e804433a46903ca2a8", "sub_28": "0x7ec69baa4a04a73c4e46a80c44cb4e71101ce5e0",
    "sub_31": "0x4856b6fab262b67bf1e12aff1e6a826591b0b9e2", "sub_32": "0x07ab291281569c20577de40a517fd4a74a8729fe",
    "sub_33": "0xe25b8429940abd3572b03f92a8f2d0bf5514970f", "sub_34": "0xcc17ff81fb3adc9e58aed1b375b95c7fc8eed8d6",
}


def _info(body):
    for attempt in range(8):
        request = urllib.request.Request(INFO, data=json.dumps(body).encode(),
                                         headers={"Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(request, timeout=60) as reply:
                return json.load(reply)
        except urllib.error.HTTPError as error:
            if error.code != 429:
                raise
            time.sleep(10 * (attempt + 1))
    raise SystemExit("the API kept answering 429")


def _pages(kind, wallet, key, start=T0, end=BELL):
    """Every row of one endpoint in the window; the API answers a page at a time."""
    rows, since = {}, start
    while True:
        page = _info({"type": kind, "user": wallet, "startTime": since, "endTime": end})
        before = len(rows)
        rows.update((key(row), row) for row in page)
        if not page or len(rows) == before:
            return [rows[k] for k in sorted(rows)]
        since = max(row["time"] for row in page)
        time.sleep(1.0)


def fill_row(f):
    return [f["time"], f["tid"], f["coin"], f["side"], float(f["sz"]), float(f["px"]),
            float(f["startPosition"]), float(f["closedPnl"]), float(f["fee"])]


def side_data(wallet):
    """Funding rows, USDC moved in before the bell, and the exchange's reading nearest the bell."""
    funding = _pages("userFunding", wallet, lambda f: (f["time"], f["delta"]["coin"]))
    ledger = _pages("userNonFundingLedgerUpdates", wallet, lambda e: (e["time"], e["hash"]), 0, BELL)
    start = 0.0
    for e in ledger:
        delta = e["delta"]
        if delta.get("type") == "subAccountTransfer":
            amount = float(delta.get("usdc", 0) or 0)
        elif delta.get("type") == "send" and delta.get("token") == "USDC":
            amount = float(delta.get("usdcValue", delta.get("amount", 0)) or 0)
        else:
            continue
        source, destination = delta.get("user", "").lower(), delta.get("destination", "").lower()
        if destination == wallet and source != wallet:
            start += amount
        elif source == wallet and destination != wallet:
            start -= amount
    history = dict(_info({"type": "portfolio", "user": wallet}))["allTime"]["accountValueHistory"]
    t, value = min(history, key=lambda point: abs(point[0] - BELL))
    return ([[f["time"], f["delta"]["coin"], float(f["delta"]["usdc"])] for f in funding],
            round(start, 6), [t, float(value)])


def assemble(fills_by_wallet):
    data = {"t0": T0, "bell": BELL, "wallets": WALLETS, "marks": {}, "mark_age_min": {},
            "fills": {}, "funding": {}, "start": {}, "exchange": {}}
    for name, wallet in WALLETS.items():
        data["fills"][name] = [f for f in fills_by_wallet[name] if T0 <= f[0] <= BELL]
        data["funding"][name], data["start"][name], data["exchange"][name] = side_data(wallet)
        print(f"{name}: {len(data['fills'][name])} fills, {len(data['funding'][name])} funding rows")
    for coin in COINS:
        last = max((f for rows in data["fills"].values() for f in rows if f[2] == coin), key=lambda f: (f[0], f[1]))
        data["marks"][coin] = last[5]
        data["mark_age_min"][coin] = round((BELL - last[0]) / 60000, 1)
    return data


def fetch():
    fills = {name: [fill_row(f) for f in _pages("userFillsByTime", wallet, lambda f: (f["time"], f["tid"]))]
             for name, wallet in WALLETS.items()}
    with open("alpha_arena_s15_input.json", "w", encoding="utf-8") as out:
        json.dump(assemble(fills), out, separators=(",", ":"))


def _chain(fills):
    """Within one millisecond and instrument, put each fill after the one whose end position is
    its startPosition. A block that cannot be chained keeps its order, and the walk stops on it."""
    out, i, walked = [], 0, {}
    while i < len(fills):
        j = i
        while j < len(fills) and fills[j][:1] == fills[i][:1] and fills[j][2] == fills[i][2]:
            j += 1
        block, coin, here = list(fills[i:j]), fills[i][2], walked.get(fills[i][2])
        chained = _walk(block, here) or block
        last = chained[-1]
        walked[coin] = round(last[6] + (last[4] if last[3] == "B" else -last[4]), 8)
        out += chained
        i = j
    return out


def _walk(block, here, budget=None):
    """Depth first: an order using every fill of the block, each starting where the last ended."""
    budget = budget if budget is not None else [100000]
    if not block:
        return []
    for k, f in enumerate(block):
        if here is not None and abs(f[6] - here) >= 1e-6:
            continue
        budget[0] -= 1
        if budget[0] < 0:
            return None
        rest = _walk(block[:k] + block[k + 1:], round(f[6] + (f[4] if f[3] == "B" else -f[4]), 8), budget)
        if rest is not None:
            return [f] + rest
    return None


def _split_flips(fills):
    """A fill that takes a position through zero becomes a close of the old side (all the
    closedPnl, its share of the fee) and an open of the new side (the rest of the fee)."""
    for f in fills:
        t, tid, coin, side, size, price, before, closed, fee = f
        after = round(before + (size if side == "B" else -size), 8)
        if before and after and (before > 0) != (after > 0):
            part = abs(before)
            yield [t, tid, coin, side, part, price, before, closed, fee * part / size]
            yield [t, tid, coin, side, size - part, price, 0.0, 0.0, fee * (size - part) / size]
        else:
            yield f


def positions(fills, funding, marks, t0=T0, bell=BELL):
    """Flat to flat, per instrument, in the order the fills happened."""
    held, done, dropped, walked = {}, [], 0, {}
    # One order's fills share a millisecond and their trade ids are not in fill order, so
    # within a millisecond walk the position: buys climb it, sells descend it.
    ordered = _chain(sorted(fills, key=lambda f: (f[0], f[2], f[6] if f[3] == "B" else -f[6])))
    for t, _, coin, side, size, price, before, closed, fee in _split_flips(ordered):
        if coin not in marks:
            dropped += 1
            continue
        if not t0 <= t <= bell:
            continue
        if abs(walked.get(coin, before) - before) > 1e-6:
            raise ValueError(f"{coin} fill at {t} does not continue the position")
        walked[coin] = round(before + (size if side == "B" else -size), 8)
        p = held.get(coin)
        if p is None:
            p = held[coin] = {"coin": coin, "opened": t0 if before else t, "seen": t, "size": before,
                              "entry": price, "carried": bool(before), "realized": 0.0, "funding": 0.0,
                              "fees": 0.0, "closed": None, "fills": 0}
        after = round(before + (size if side == "B" else -size), 8)
        if abs(after) > abs(before):
            p["entry"] = (p["entry"] * abs(before) + price * size) / abs(after)
        if before and after and (before > 0) != (after > 0):
            raise ValueError(f"{coin} crossed zero inside one fill at {t}")
        p["realized"] += closed
        p["fees"] += fee
        p["fills"] += 1
        p["size"] = after
        if abs(after) < 1e-9:
            p["closed"] = t
            done.append(held.pop(coin))
    still_open = list(held.values())
    for p in still_open:
        p["unrealized"] = (marks[p["coin"]] - p["entry"]) * p["size"]
    every = sorted(done + still_open, key=lambda p: (p["opened"], p["seen"], p["coin"]))
    unassigned = 0.0
    for t, coin, usdc in funding:
        if not t0 <= t <= bell:
            continue
        here = [p for p in every if p["coin"] == coin]
        inside = [p for p in here if p["opened"] <= t <= (p["closed"] or bell)]
        if inside:
            inside[0]["funding"] += usdc
        else:
            unassigned += usdc
    for p in every:
        p["r"] = p["realized"] + p["funding"] + p.get("unrealized", 0.0)
    return every, unassigned, dropped


def _generator(tag, name):
    digest = hashlib.sha256(f"OAIA|{tag}|{name}".encode("utf-8")).digest()
    return random.Random(int.from_bytes(digest[:8], "big"))


def score(name, rows):
    """Both readings for one wallet: below and ties against the cohort, plus the monkeys' median."""
    rets = [p["r"] for p in rows]
    own = sum(rets)
    rng = _generator("alpha-arena-s15", name)
    below = ties = 0
    totals = []
    for _ in range(COHORT):
        monkey = sum((1.0 if rng.random() < 0.5 else -1.0) * r for r in rets)
        totals.append(monkey)
        below += monkey < own
        ties += monkey == own
    days = sorted({p["opened"] // DAY for p in rows})
    rng = _generator("alpha-arena-s15-day", name)
    day_below = day_ties = 0
    for _ in range(COHORT):
        side = {day: (1.0 if rng.random() < 0.5 else -1.0) for day in days}
        monkey = sum(side[p["opened"] // DAY] * p["r"] for p in rows)
        day_below += monkey < own
        day_ties += monkey == own
    return {"below": below, "ties": ties, "percentile": round(100 * (below + 0.5 * ties) / COHORT, 1),
            "day_below": day_below, "day_ties": day_ties,
            "day_percentile": round(100 * (day_below + 0.5 * day_ties) / COHORT, 1),
            "days": len(days), "own": round(own, 2), "median_monkey": round(statistics.median(totals), 2)}


def rebuild(data):
    rows, excluded = [], 0
    for name in data["wallets"]:
        every, unassigned, dropped = positions(data["fills"][name], data["funding"][name], data["marks"],
                                               data["t0"], data["bell"])
        excluded += dropped
        fees = sum(p["fees"] for p in every)
        s = score(name, every)
        start = data["start"][name]
        final = start + s["own"] - fees + unassigned
        t, exchange = data["exchange"][name]
        total_abs = sum(abs(p["r"]) for p in every)
        rows.append({
            "wallet_name": name, "wallet": data["wallets"][name], "start": round(start, 2),
            "rebuilt_final": round(final, 2), "exchange_value": round(exchange, 2),
            "exchange_gap_min": round((t - data["bell"]) / 60000, 1),
            "off_pct": round(100 * (final - exchange) / exchange, 1),
            "return_pct": round(100 * (final - start) / start, 1),
            "positions": len(every), "fills": sum(p["fills"] for p in every),
            "fees": round(fees, 2), "funding": round(sum(p["funding"] for p in every), 2),
            "unassigned_funding": round(unassigned, 2),
            "carried": sum(p["carried"] for p in every),
            "open_at_bell": sum(p["closed"] is None for p in every),
            "top_share": round(max(abs(p["r"]) for p in every) / total_abs, 3),
            "median_monkey_final": round(start + s["median_monkey"] - fees + unassigned, 2),
            **s})
    return {"season": "Alpha Arena season 1.5", "cohort": COHORT, "t0": data["t0"], "bell": data["bell"],
            "bar_monkeys": 1, "marks": data["marks"], "mark_age_min": data["mark_age_min"],
            "dropped_other_fills": excluded, "rows": rows}


def demo():
    """Made-up trades through the same code: random sides sit near 50, planted skill near 100."""
    rng = random.Random(20261110)
    def world(skill):
        fills, price, t = [], 100.0, T0 + 1000
        for i in range(200):
            move = rng.gauss(0, 1.0)
            side = (move > 0) == (rng.random() < 0.5 + skill)
            open_side, close_side = ("B", "A") if side else ("A", "B")
            before = 1.0 if side else -1.0
            fills.append([t, i, "xyz:NVDA", open_side, 1.0, price, 0.0, 0.0, 0.1])
            pnl = move if side else -move
            fills.append([t + 1, 1000 + i, "xyz:NVDA", close_side, 1.0, price + move, before, pnl, 0.1])
            t += 3600000
            price += move
        return positions(fills, [], {"xyz:NVDA": price}, T0, T0 + 300 * 3600000)[0]
    flat = [score(f"scrambled-{i}", world(0.0))["percentile"] for i in range(20)]
    planted = [score(f"planted-{i}", world(0.25))["percentile"] for i in range(5)]
    print("scrambled sides, percentiles:", flat, "mean", round(statistics.mean(flat), 1))
    print("planted skill, percentiles:", planted)
    return 0 if 25 <= statistics.mean(flat) <= 75 and min(planted) > 95 else 1


def main():
    if "--demo" in sys.argv:
        return demo()
    if "--fetch" in sys.argv:
        fetch()
        return 0
    with open("alpha_arena_s15_input.json", encoding="utf-8") as f:
        result = rebuild(json.load(f))
    if "--write" in sys.argv:
        with open(sys.argv[sys.argv.index("--write") + 1], "w", encoding="utf-8") as out:
            json.dump(result, out, indent=1)
            out.write("\n")
    else:
        with open("alpha_arena_s15.json", encoding="utf-8") as f:
            published = json.load(f)
        if result != published:
            print("rebuilt rows differ from alpha_arena_s15.json")
            return 1
    for row in result["rows"]:
        print(f"{row['wallet_name']}: {row['positions']} positions, percentile {row['percentile']}, "
              f"same-day coin {row['day_percentile']}, account {row['rebuilt_final']:,.0f} "
              f"against {row['exchange_value']:,.0f} on the exchange ({row['off_pct']:+}%)")
    print(f"{len(result['rows'])} rows, 0 mismatches")
    return 0


if __name__ == "__main__":
    sys.exit(main())
