|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +polymarket_anomalies.py — find information shocks in a Polymarket market. |
| 4 | +
|
| 5 | +A prediction market's implied probability is usually smooth; a sudden jump is an |
| 6 | +information shock (news, a debate, a resolution). This example pulls a market's |
| 7 | +price history from Polymarket's public APIs (Gamma for discovery, CLOB for the |
| 8 | +series), enriches it with the per-step probability change, runs `anomalyx scan`, |
| 9 | +and maps each finding back to its timestamp — another worked example of consuming |
| 10 | +the `tq1` contract on a real time series. |
| 11 | +
|
| 12 | +What anomalyx surfaces here: |
| 13 | + * point.modz on `prob_change` — the sharp probability jumps (the news days); |
| 14 | + * coll.cusum on `prob` — sustained regime shifts in the odds; |
| 15 | + * the `timestamp` column is auto-classified a sequence and skipped. |
| 16 | +
|
| 17 | +Usage: |
| 18 | + cargo install anomalyx # or set $ANOMALYX |
| 19 | + python3 examples/polymarket_anomalies.py # top market by volume |
| 20 | + python3 examples/polymarket_anomalies.py "bitcoin" # first match by question/slug |
| 21 | + python3 examples/polymarket_anomalies.py "fed" --top 15 --fidelity 60 |
| 22 | +
|
| 23 | +Anything after the known flags passes through to `anomalyx scan`. Read-only, |
| 24 | +public data, no API key. Requires: python3 + the `anomalyx` binary (or $ANOMALYX). |
| 25 | +Exit code mirrors anomalyx: 0 clean, 1 anomalies found, 2 error. |
| 26 | +""" |
| 27 | +from __future__ import annotations |
| 28 | + |
| 29 | +import argparse |
| 30 | +import csv |
| 31 | +import datetime as dt |
| 32 | +import json |
| 33 | +import os |
| 34 | +import shutil |
| 35 | +import subprocess |
| 36 | +import sys |
| 37 | +import tempfile |
| 38 | +import urllib.parse |
| 39 | +import urllib.request |
| 40 | + |
| 41 | +GAMMA = "https://gamma-api.polymarket.com" |
| 42 | +CLOB = "https://clob.polymarket.com" |
| 43 | + |
| 44 | + |
| 45 | +def _get(url: str, timeout: int = 30) -> bytes: |
| 46 | + req = urllib.request.Request(url, headers={"User-Agent": "anomalyx-example/1.0"}) |
| 47 | + return urllib.request.urlopen(req, timeout=timeout).read() |
| 48 | + |
| 49 | + |
| 50 | +def pick_market(search: str | None, candidates: int) -> tuple[str, str]: |
| 51 | + """Return (question, clob_token_id) for the chosen market (YES outcome).""" |
| 52 | + url = ( |
| 53 | + f"{GAMMA}/markets?closed=false&order=volumeNum&ascending=false" |
| 54 | + f"&limit={max(candidates, 1)}" |
| 55 | + ) |
| 56 | + markets = json.loads(_get(url)) |
| 57 | + needle = (search or "").lower() |
| 58 | + for m in markets: |
| 59 | + ids = m.get("clobTokenIds") |
| 60 | + if not ids: |
| 61 | + continue |
| 62 | + text = f"{m.get('question', '')} {m.get('slug', '')}".lower() |
| 63 | + if needle and needle not in text: |
| 64 | + continue |
| 65 | + return m.get("question") or m.get("slug") or "?", json.loads(ids)[0] |
| 66 | + sys.exit(f"no open market with price history matched {search!r}") |
| 67 | + |
| 68 | + |
| 69 | +def fetch_history(token: str, fidelity: int) -> list[tuple[int, float]]: |
| 70 | + url = f"{CLOB}/prices-history?market={urllib.parse.quote(token)}&interval=max&fidelity={fidelity}" |
| 71 | + pts = json.loads(_get(url)).get("history", []) |
| 72 | + if len(pts) < 10: |
| 73 | + sys.exit("not enough price history for that market") |
| 74 | + return [(int(p["t"]), float(p["p"])) for p in pts] |
| 75 | + |
| 76 | + |
| 77 | +def write_csv(points: list[tuple[int, float]], path: str) -> list[str]: |
| 78 | + """Write timestamp/prob/prob_change; return the readable timestamps.""" |
| 79 | + stamps = [] |
| 80 | + with open(path, "w", newline="") as f: |
| 81 | + w = csv.writer(f) |
| 82 | + w.writerow(["timestamp", "prob", "prob_change"]) |
| 83 | + prev = None |
| 84 | + for t, p in points: |
| 85 | + when = dt.datetime.fromtimestamp(t, dt.timezone.utc).strftime("%Y-%m-%d %H:%M") |
| 86 | + stamps.append(when) |
| 87 | + w.writerow([when, f"{p:.6f}", "" if prev is None else f"{p - prev:.6f}"]) |
| 88 | + prev = p |
| 89 | + return stamps[1:] # the first row has an empty prob_change and is dropped on parse |
| 90 | + |
| 91 | + |
| 92 | +def anomalyx_scan(csv_path: str, extra: list[str]) -> dict: |
| 93 | + exe = os.environ.get("ANOMALYX", "anomalyx") |
| 94 | + if shutil.which(exe) is None and not os.path.exists(exe): |
| 95 | + sys.exit(f"`{exe}` not found — run `cargo install anomalyx` or set $ANOMALYX") |
| 96 | + proc = subprocess.run([exe, "scan", *extra, csv_path], capture_output=True, text=True) |
| 97 | + if proc.returncode == 2: |
| 98 | + sys.exit(f"anomalyx error: {proc.stderr.strip()}") |
| 99 | + return json.loads(proc.stdout) |
| 100 | + |
| 101 | + |
| 102 | +def describe_handle(handle: str, dates: list[str]) -> str: |
| 103 | + p = handle.split(":") |
| 104 | + if p[0] == "cell": |
| 105 | + return f"{dates[int(p[2])]} {p[1]}" |
| 106 | + if p[0] == "row": |
| 107 | + return f"{dates[int(p[1])]} (all columns)" |
| 108 | + if p[0] == "range": |
| 109 | + a, b = int(p[2]), min(int(p[3]), len(dates) - 1) |
| 110 | + return f"{p[1]} {dates[a]} -> {dates[b]}" |
| 111 | + if p[0] == "dist": |
| 112 | + return f"{p[1]} (distribution)" |
| 113 | + return handle |
| 114 | + |
| 115 | + |
| 116 | +def report(env: dict, dates: list[str]) -> None: |
| 117 | + dic = env["dict"] |
| 118 | + summ = env["summary"] |
| 119 | + print( |
| 120 | + f"format={env['format']} rows={env['rows_scanned']} exit={env['exit']} " |
| 121 | + f"detected={summ['total']} max_severity={summ.get('max_severity')}" |
| 122 | + ) |
| 123 | + print("roles: " + ", ".join(f"{c['column']}={c['role']}" for c in env.get("roles", []))) |
| 124 | + if scope := env.get("scope"): |
| 125 | + print(f"scope: emitted {scope['emitted']} of {scope['detected']} (dropped {scope['dropped']})") |
| 126 | + print() |
| 127 | + for row in env["rows"]: |
| 128 | + print(f" [{dic[row[4]]:>8}] {dic[row[0]]:<15} {describe_handle(dic[row[2]], dates)}") |
| 129 | + print(f" {dic[row[6]]}") |
| 130 | + if not env["rows"]: |
| 131 | + print(" (no findings)") |
| 132 | + |
| 133 | + |
| 134 | +def main() -> None: |
| 135 | + ap = argparse.ArgumentParser( |
| 136 | + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter |
| 137 | + ) |
| 138 | + ap.add_argument("search", nargs="?", help="match a market by question/slug (else top volume)") |
| 139 | + ap.add_argument("--candidates", type=int, default=50, help="markets to consider when matching") |
| 140 | + ap.add_argument("--fidelity", type=int, default=60, help="price-history resolution in minutes") |
| 141 | + args, scan_args = ap.parse_known_args() |
| 142 | + |
| 143 | + question, token = pick_market(args.search, args.candidates) |
| 144 | + points = fetch_history(token, args.fidelity) |
| 145 | + tmp = tempfile.mkdtemp(prefix="anomalyx-polymarket-") |
| 146 | + csv_path = os.path.join(tmp, "market.csv") |
| 147 | + dates = write_csv(points, csv_path) |
| 148 | + |
| 149 | + span = f"{points[0][0]} .. {points[-1][0]}" |
| 150 | + print(f"# {question}") |
| 151 | + print(f"# {len(points)} points, prob {points[0][1]:.3f} -> {points[-1][1]:.3f}\n") |
| 152 | + env = anomalyx_scan(csv_path, scan_args) |
| 153 | + report(env, dates) |
| 154 | + sys.exit(0 if env["exit"] == 0 else 1) |
| 155 | + |
| 156 | + |
| 157 | +if __name__ == "__main__": |
| 158 | + main() |
0 commit comments