diff --git a/.gitignore b/.gitignore index 03f9814c..8afbe35e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ copytrade_state.json edge_metrics.jsonl edge_profitable.json copyable_77.csv +lp_markets.csv +follow_10.json diff --git a/README.md b/README.md index fd63d927..be426410 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ live), and backtest the strategy. Zero dependencies — Python 3 stdlib only | `table_77.py` | Aggregate a filtered wallet set into one CSV (ROI, total staked, consistency). | | `copytrade.py` | Copy-trade engine — mirror a watchlist (paper by default, live gated). | | `backtest.py` | Replay a watchlist over a recent window and mark outcomes. | +| `lp_screener.py` | Rank reward-eligible markets by risk-adjusted LP yield (pool ÷ competition, penalized by volatility). | ## Run the dashboard @@ -200,3 +201,37 @@ story. At 72 days old, we can't yet tell. - **Copying entries ≠ copying edge.** A working strategy likely needs to model sizing/pricing, or pivot to a consensus signal (bet where many vetted wallets agree) rather than blind mirroring. + +## Liquidity rewards (the market-making pivot) + +After copy-trading proved unreliable, the research pointed to **liquidity +rewards** as the lowest-risk edge. Polymarket pays makers a daily USDC pool for +resting limit orders near a market's midpoint — your share = your score ÷ total +score, where score rewards size and closeness to mid (quadratic: +`((max_spread − your_spread) / max_spread)²`). ~$200K/day is distributed across +~8,000 eligible markets (queryable via the CLOB `sampling-markets` endpoint; +each market exposes `rewards.rates[].rewards_daily_rate`, `min_size`, +`max_spread`). + +`lp_screener.py` ranks those markets by **risk-adjusted** yield — reward pool ÷ +order-book competition near mid (gross APR for a $1000 two-sided position), +penalized by 24h midpoint volatility (the adverse-selection proxy) and by +time-to-resolution (imminent = live = toxic). + +```bash +python3 lp_screener.py --min-rate 50 --capital 1000 # one-shot snapshot → lp_markets.csv +``` + +**It's a one-shot snapshot, not a daemon** — reward pools, books, and the +markets themselves churn daily, so re-run before each session. + +**What it found:** the sweet spot is **long-dated, low-volatility prop markets** +(World Cup player props, eliminations) — thin books, decent pools, vol ~0, days +to resolution. Live esports markets show astronomical gross APR but get +correctly de-ranked: that's where you get picked off. + +**Caveats that still gate real money:** headline APRs are a *snapshot* — thin +pools attract competitors and yield mean-reverts down; they're *gross*, ignoring +inventory losses when you get filled; and we have not yet confirmed near-empty +books actually pay the full pool. The paper LP loop (post near mid, requote on +moves, track **net** = rewards − pick-off) is the next and decisive test. diff --git a/lp_screener.py b/lp_screener.py new file mode 100644 index 00000000..76f74618 --- /dev/null +++ b/lp_screener.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Liquidity-rewards market screener. + +Ranks Polymarket's reward-eligible markets by *risk-adjusted* yield, so we can +find where providing liquidity actually pays — high reward pool, thin enough +book to capture share, but stable enough not to get picked off. + +For each market it pulls the order book and a 24h price series and computes: + - gross APR : reward pool / competition, for a $1000 two-sided position + - vol_24h : stdev of 15-min midpoint moves, in cents = adverse-selection proxy + - hrs_to_end : time to resolution (imminent = toxic/live) + - score : gross APR penalized by volatility and imminence + +GROSS APR ignores pick-off losses — that's exactly what vol_24h flags. A high +APR with high vol is a trap; the sweet spot is decent APR with low vol and +days (not hours) to resolution. + + python3 lp_screener.py --min-rate 50 --capital 1000 +""" + +import argparse +import csv +import json +import ssl +import statistics +import time +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed + +CLOB = "https://clob.polymarket.com" +SSL_CTX = ssl._create_unverified_context() + + +def get(url): + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=20, context=SSL_CTX) as r: + return json.loads(r.read().decode()) + + +def reward_markets(): + out, cursor = [], "" + for _ in range(20): + try: + url = CLOB + "/sampling-markets" + (f"?next_cursor={cursor}" if cursor else "") + d = get(url) + except Exception: + break + out += d.get("data", []) + cursor = d.get("next_cursor") + if not cursor or not d.get("data"): + break + return out + + +def daily_rate(m): + rts = (m.get("rewards") or {}).get("rates") or [] + return sum(x.get("rewards_daily_rate", 0) for x in rts) + + +def hours_to_end(m): + iso = m.get("end_date_iso") + if not iso: + return None + try: + end = time.mktime(time.strptime(iso.replace("Z", ""), "%Y-%m-%dT%H:%M:%S")) + return (end - time.time()) / 3600 + except ValueError: + return None + + +def realized_vol_cents(token_id): + """Stdev of 15-min midpoint moves over the last 24h, in cents.""" + now = int(time.time()) + try: + h = get(f"{CLOB}/prices-history?market={token_id}" + f"&startTs={now - 86400}&endTs={now}&fidelity=15").get("history", []) + except Exception: + return None + prices = [p["p"] for p in h if "p" in p] + if len(prices) < 4: + return None + diffs = [abs(prices[i] - prices[i - 1]) * 100 for i in range(1, len(prices))] + return round(statistics.pstdev(diffs), 2) + + +def analyze(m, capital): + pool = daily_rate(m) + r = m.get("rewards") or {} + ms = r.get("max_spread", 0) / 100.0 + toks = m.get("tokens") or [] + if not toks or ms <= 0: + return None + tok = toks[0].get("token_id") + try: + bk = get(f"{CLOB}/book?token_id={tok}") + except Exception: + return None + bids = [(float(o["price"]), float(o["size"])) for o in bk.get("bids", [])] + asks = [(float(o["price"]), float(o["size"])) for o in bk.get("asks", [])] + if not bids or not asks: + return None + bb = max(p for p, _ in bids) + ba = min(p for p, _ in asks) + mid = (bb + ba) / 2 + comp_bid = sum(p * s for p, s in bids if p >= mid - ms) + comp_ask = sum((1 - p) * s for p, s in asks if p <= mid + ms) + myside = capital / 2 + share = min(myside / (myside + comp_bid), myside / (myside + comp_ask)) + apr = pool * share / capital * 365 * 100 + vol = realized_vol_cents(tok) + hrs = hours_to_end(m) + # risk-adjusted score: reward yield, penalized by adverse selection (vol) + # and by imminence (markets resolving within a day are live/toxic). + vol_pen = 1 + (vol if vol is not None else 5) # unknown vol treated as risky + time_pen = 1.0 if (hrs is None or hrs >= 48) else max(0.15, hrs / 48) + score = round(apr * time_pen / vol_pen, 1) + return { + "question": m.get("question", "?")[:50], + "daily_usd": round(pool), + "max_spread_c": r.get("max_spread", 0), + "min_size": r.get("min_size", 0), + "mid": round(mid, 3), + "comp_usd": round(min(comp_bid, comp_ask)), + "gross_apr": round(apr), + "vol_24h_c": vol if vol is not None else -1, + "hrs_to_end": round(hrs) if hrs is not None else -1, + "score": score, + "token_id": tok, + } + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--min-rate", type=float, default=50, help="min $/day pool") + ap.add_argument("--capital", type=float, default=1000) + ap.add_argument("--workers", type=int, default=16) + ap.add_argument("--top", type=int, default=30) + args = ap.parse_args() + + print(f"[{time.strftime('%H:%M:%S')}] pulling reward markets...", flush=True) + mkts = [m for m in reward_markets() + if m.get("active") and not m.get("closed") and daily_rate(m) >= args.min_rate] + print(f"[{time.strftime('%H:%M:%S')}] {len(mkts)} markets with >=${args.min_rate}/day " + f"· analyzing books + volatility...", flush=True) + + rows = [] + with ThreadPoolExecutor(max_workers=args.workers) as ex: + futs = {ex.submit(analyze, m, args.capital): m for m in mkts} + done = 0 + for f in as_completed(futs): + done += 1 + try: + r = f.result() + except Exception: + r = None + if r: + rows.append(r) + if done % 100 == 0: + print(f" {done}/{len(mkts)}", flush=True) + + rows.sort(key=lambda x: x["score"], reverse=True) + cols = ["question", "score", "gross_apr", "vol_24h_c", "hrs_to_end", "daily_usd", + "comp_usd", "max_spread_c", "min_size", "mid", "token_id"] + with open("lp_markets.csv", "w", newline="") as fp: + w = csv.DictWriter(fp, fieldnames=cols) + w.writeheader() + w.writerows(rows) + + print(f"\n{'score':>6}{'grossAPR':>9}{'vol_c':>7}{'hrs':>6}{'$/day':>7}" + f"{'comp$':>9}{'spr':>5} market") + print("-" * 92) + for r in rows[:args.top]: + vol = "n/a" if r["vol_24h_c"] < 0 else f"{r['vol_24h_c']:.1f}" + hrs = "?" if r["hrs_to_end"] < 0 else r["hrs_to_end"] + print(f"{r['score']:>6.0f}{r['gross_apr']:>8}%{vol:>7}{hrs:>6}{r['daily_usd']:>7}" + f"{r['comp_usd']:>9}{r['max_spread_c']:>5} {r['question'][:42]}") + print("-" * 92) + print(f"{len(rows)} markets ranked → lp_markets.csv") + print("score = gross APR × time-factor ÷ (1+vol). High APR + low vol + days-to-end = real.") + + +if __name__ == "__main__": + main()