Add insider/sharp detector (insider.py) — the one real edge signal
Replicates the Bubblemaps / 60 Minutes per-wallet insider methodology on the public data API: - Improbability z-score / p-value: wins vs the wins entry odds imply (beating the market's own pricing) — the rigorous edge metric the project was after, unlike biased win-rate or variance-driven PnL - Pre-resolution timing, fresh-wallet (/traded count), sizing signals - Scoring GATED by improbability so losing sports bettors (entering <24h before a game is normal) no longer false-flag - Modes: --scan leaderboard, --market <conditionId|slug> (score a market's traders, the Bubblemaps approach), --wallet deep profile Findings: leaderboard has no extreme insiders (max z~2.3, high-vol sharps); scanning a market's traders surfaces real edges (e.g. arimnestos z=4.0 p~3e-5 over 2205 bets). Funding-cluster linking needs a Polygonscan/Alchemy key (public RPC getLogs capped at 10k blocks). README documents methodology + the project-wide conclusion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ live), and backtest the strategy. Zero dependencies — Python 3 stdlib only
|
||||
| `lp_screener.py` | Rank reward-eligible markets by risk-adjusted LP yield (pool ÷ competition, penalized by volatility). |
|
||||
| `lp_paper.py` | Paper liquidity-provision loop — simulate quoting on the live book, track **net = rewards − adverse selection**. |
|
||||
| `xarb.py` | Cross-venue scanner — match the same event on Polymarket vs Kalshi and flag price gaps. |
|
||||
| `insider.py` | Insider/sharp detector — flag wallets winning *above their entry odds* (z-score / p-value), with timing, freshness, and sizing signals. |
|
||||
|
||||
## Run the dashboard
|
||||
|
||||
@@ -269,6 +270,46 @@ different sub-question), illiquid wide-spread markets (exact-score, props), or
|
||||
stale snapshot timing. Matches the documented reality that real gaps last
|
||||
~seconds and are taken by bots watching 10k+ markets.
|
||||
|
||||
## Insider / sharp detection (`insider.py`) — the one real signal
|
||||
|
||||
After the 2026 *60 Minutes* / WSJ coverage of Polymarket insider trading (a firm,
|
||||
Bubblemaps, found 9 anonymous wallets that won ~$2.4M at a 98% rate on Iran-war
|
||||
dates), `insider.py` replicates the *per-wallet* detection methodology on the
|
||||
public data API:
|
||||
|
||||
- **Improbability (the core signal):** each bet entered at price `p` has an
|
||||
odds-implied win prob `p`. Winning far more than `Σp` is a z-score and
|
||||
one-sided p-value — the rigorous "luck can't explain this." This is the
|
||||
*correct* version of the edge metric the whole project was chasing: beating
|
||||
the market's own pricing, not raw win-rate (biased) or PnL (variance).
|
||||
- **Pre-resolution timing** — median hours before resolution they entered; share
|
||||
of wins entered <24h out (advance-knowledge tell).
|
||||
- **Fresh wallet** (`/traded` count) and **sizing** — the insider fingerprint.
|
||||
- **Scoring is gated by improbability:** a wallet winning at/below its odds
|
||||
scores 0 no matter how it's timed or sized (kills the sports-bettor confound,
|
||||
where entering <24h before a game is normal, not suspicious).
|
||||
|
||||
```bash
|
||||
python3 insider.py --scan 40 # score top leaderboard wallets
|
||||
python3 insider.py --market <conditionId|slug># score everyone who traded a market (Bubblemaps approach)
|
||||
python3 insider.py --wallet 0xABC… # deep-profile one wallet
|
||||
```
|
||||
|
||||
**Findings:** the all-time leaderboard holds *no* extreme insiders (max z≈2.3) —
|
||||
those are high-volume sharps, not info-traders. Scanning a *market's* traders
|
||||
surfaces the real signal: e.g. `arimnestos` at **z=4.0, p≈3e-5** over 2,205 bets
|
||||
— a demonstrable edge. Distinguishing **sharp** (high z, normal timing) from
|
||||
**insider** (high z + late entry + fresh wallet) is the timing/freshness combo.
|
||||
The Bubblemaps **funding-cluster** step (linking an operator's wallets via
|
||||
who-funded-whom) needs a Polygonscan/Alchemy key — public RPC caps `getLogs` at
|
||||
10k blocks.
|
||||
|
||||
**Why this matters:** the z-score over many bets is the first metric in this
|
||||
project that identifies a *real, hard-to-fake* edge. A high-z wallet has beaten
|
||||
the market's own prices repeatedly — a far better "who to study/follow" signal
|
||||
than the leaderboard. (Caveat: *trading* on material nonpublic info is illegal —
|
||||
detecting it is fine; blindly following a suspected insider is not a free pass.)
|
||||
|
||||
### The bottom line across the whole project
|
||||
|
||||
Six systematic, public-data edges tested — copy-trading, win-rate ranking, LP
|
||||
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Polymarket insider-pattern detector.
|
||||
|
||||
Replicates the per-wallet methodology behind the Bubblemaps / 60 Minutes work:
|
||||
flag wallets whose results are too good to be luck and whose behavior fits the
|
||||
insider fingerprint. All signals come from Polymarket's public data API.
|
||||
|
||||
Signals per wallet (over resolved bets in a recent window):
|
||||
1. IMPROBABILITY — wins vs the wins their entry odds imply. Each bet entered
|
||||
at price p has expected win prob p; observed wins far above Σp is the
|
||||
"luck alone can't explain this" z-score (and one-sided p-value).
|
||||
2. PRE-RESOLUTION TIMING — how long before a market resolved they entered.
|
||||
Entering minutes/hours before resolution is the classic advance-knowledge
|
||||
tell. We report median lead time and the share of wins entered <24h out.
|
||||
3. FRESH WALLET — account age (first observed trade). New account + big
|
||||
improbable wins is a strong flag.
|
||||
4. SIZING — average / max bet size.
|
||||
|
||||
Composite suspicion 0-10. Funding-cluster linking (the Bubblemaps "who funded
|
||||
whom" step) needs a Polygonscan/Alchemy key — see cluster_stub().
|
||||
|
||||
python3 insider.py --scan 40 # score top-40 leaderboard wallets
|
||||
python3 insider.py --wallet 0xABC… # deep profile one wallet
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import smart_money as sm
|
||||
|
||||
WINDOW_DAYS = 120
|
||||
WEEK = 7 * 86400
|
||||
|
||||
|
||||
def _parse_end(end):
|
||||
if not end:
|
||||
return 0
|
||||
end = end.replace("Z", "")
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
return time.mktime(time.strptime(end, fmt))
|
||||
except ValueError:
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
def resolved_bets(wallet, cutoff, max_pages=40):
|
||||
"""Resolved bets with entry price, conditionId, resolution time, size."""
|
||||
now = time.time()
|
||||
out = []
|
||||
for endpoint in ("/closed-positions", "/positions"):
|
||||
off = 0
|
||||
while off < max_pages * 50:
|
||||
params = {"user": wallet, "limit": 50, "offset": off}
|
||||
if endpoint == "/closed-positions":
|
||||
params.update(sortBy="TIMESTAMP", sortDirection="DESC")
|
||||
else:
|
||||
params["sizeThreshold"] = 0.0
|
||||
page = sm.get_json(endpoint, params)
|
||||
if not page:
|
||||
break
|
||||
for p in page:
|
||||
end = _parse_end(p.get("endDate"))
|
||||
if endpoint == "/closed-positions":
|
||||
ts = p.get("timestamp", 0)
|
||||
if ts < cutoff:
|
||||
continue
|
||||
res_t = end or ts
|
||||
else:
|
||||
if not (cutoff <= end < now):
|
||||
continue
|
||||
res_t = end
|
||||
out.append({
|
||||
"won": p.get("curPrice", 0) >= 0.5,
|
||||
"p": max(0.001, min(0.999, p.get("avgPrice", 0) or 0)),
|
||||
"cond": p.get("conditionId"),
|
||||
"res_t": res_t,
|
||||
"size": p.get("initialValue") or
|
||||
(p.get("avgPrice", 0) * p.get("totalBought", 0)),
|
||||
})
|
||||
off += 50
|
||||
if len(page) < 50:
|
||||
break
|
||||
if endpoint == "/closed-positions" and page[-1].get("timestamp", 0) < cutoff:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def entry_times(wallet, max_pages=20):
|
||||
"""conditionId -> earliest BUY timestamp; plus account-age (first trade)."""
|
||||
first_buy = {}
|
||||
earliest = time.time()
|
||||
off = 0
|
||||
while off < max_pages * 500:
|
||||
page = sm.get_json("/activity",
|
||||
{"user": wallet, "type": "TRADE", "limit": 500, "offset": off})
|
||||
if not page:
|
||||
break
|
||||
for t in page:
|
||||
ts = t.get("timestamp", 0)
|
||||
earliest = min(earliest, ts) if ts else earliest
|
||||
if t.get("side") == "BUY" and t.get("conditionId"):
|
||||
c = t["conditionId"]
|
||||
if c not in first_buy or ts < first_buy[c]:
|
||||
first_buy[c] = ts
|
||||
off += 500
|
||||
if len(page) < 500:
|
||||
break
|
||||
return first_buy, earliest
|
||||
|
||||
|
||||
def norm_sf(z):
|
||||
"""One-sided normal survival P(Z>z) — the 'probability this was luck'."""
|
||||
return 0.5 * math.erfc(z / math.sqrt(2))
|
||||
|
||||
|
||||
def analyze(cand):
|
||||
wallet = cand["wallet"]
|
||||
cutoff = time.time() - WINDOW_DAYS * 86400
|
||||
bets = resolved_bets(wallet, cutoff)
|
||||
if len(bets) < 15:
|
||||
return None
|
||||
first_buy, _ = entry_times(wallet)
|
||||
total_trades = (sm.get_json("/traded", {"user": wallet}) or {}).get("traded", 0)
|
||||
|
||||
n = len(bets)
|
||||
wins = sum(1 for b in bets if b["won"])
|
||||
exp = sum(b["p"] for b in bets) # expected wins by odds
|
||||
var = sum(b["p"] * (1 - b["p"]) for b in bets) or 1e-9
|
||||
z = (wins - exp) / math.sqrt(var) # wins above odds-implied
|
||||
pval = norm_sf(z)
|
||||
|
||||
# pre-resolution timing on WINNING bets we can time
|
||||
leads = []
|
||||
for b in bets:
|
||||
if b["won"] and b["cond"] in first_buy and b["res_t"]:
|
||||
lead_h = (b["res_t"] - first_buy[b["cond"]]) / 3600
|
||||
if lead_h >= 0:
|
||||
leads.append(lead_h)
|
||||
median_lead = sorted(leads)[len(leads) // 2] if leads else None
|
||||
pre24 = (sum(1 for l in leads if l < 24) / len(leads)) if leads else 0
|
||||
|
||||
avg_size = sum(b["size"] for b in bets) / n
|
||||
max_size = max(b["size"] for b in bets)
|
||||
|
||||
# IMPROBABILITY GATES the score: a wallet winning at/below its odds cannot
|
||||
# be an insider regardless of timing or size. Only when wins clearly exceed
|
||||
# what the entry odds imply (z high) do the other signals amplify.
|
||||
improb = 0.0 if z < 1 else min(7, (z - 1) * 2.0) # z=1→0, 2→2, 4.5→7
|
||||
score = improb
|
||||
if improb > 0: # amplifiers, gated
|
||||
score += 1.5 if total_trades < 20 else (0.7 if total_trades < 60 else 0)
|
||||
score += 1.0 if avg_size > 5000 else (0.5 if avg_size > 1000 else 0)
|
||||
score += 1.0 if pre24 > 0.5 else 0 # late entry + improbable
|
||||
score = round(min(10, score), 1)
|
||||
|
||||
return {
|
||||
"username": cand["username"], "wallet": wallet,
|
||||
"n": n, "wins": wins, "exp_wins": round(exp, 1),
|
||||
"z": round(z, 1), "pval": pval,
|
||||
"med_lead_h": round(median_lead, 1) if median_lead is not None else None,
|
||||
"pre24_pct": round(pre24 * 100),
|
||||
"trades": total_trades, "avg_size": round(avg_size),
|
||||
"max_size": round(max_size), "score": score,
|
||||
}
|
||||
|
||||
|
||||
def market_traders(market, top=40):
|
||||
"""Wallets who traded a market, ranked by notional in it. Start from a
|
||||
suspicious market and score everyone — the Bubblemaps approach."""
|
||||
cond = market
|
||||
if not market.startswith("0x"): # treat as slug
|
||||
g = sm.get_json("/markets" if False else None) or None
|
||||
import urllib.request, json as _j, ssl as _ssl
|
||||
c = _ssl._create_unverified_context()
|
||||
req = urllib.request.Request(
|
||||
f"https://gamma-api.polymarket.com/markets?slug={market}",
|
||||
headers={"User-Agent": "Mozilla/5.0"})
|
||||
gm = _j.loads(urllib.request.urlopen(req, timeout=20, context=c).read())
|
||||
cond = gm[0]["conditionId"] if gm else market
|
||||
notional = {}
|
||||
names = {}
|
||||
off = 0
|
||||
while off < 8000:
|
||||
page = sm.get_json("/trades", {"market": cond, "limit": 500, "offset": off})
|
||||
if not page:
|
||||
break
|
||||
for t in page:
|
||||
w = t.get("proxyWallet")
|
||||
if not w:
|
||||
continue
|
||||
notional[w] = notional.get(w, 0) + t.get("usdcSize", 0)
|
||||
names.setdefault(w, t.get("name") or w[:10] + "…")
|
||||
off += 500
|
||||
if len(page) < 500:
|
||||
break
|
||||
ranked = sorted(notional, key=notional.get, reverse=True)[:top]
|
||||
return [{"wallet": w, "username": names[w]} for w in ranked], cond
|
||||
|
||||
|
||||
def cluster_stub():
|
||||
return ("funding-cluster linking (who-funded-whom, the Bubblemaps step) "
|
||||
"needs a Polygonscan/Alchemy API key — public RPC caps getLogs at "
|
||||
"10k blocks. Add a key to enable.")
|
||||
|
||||
|
||||
def fmt_p(p):
|
||||
if p <= 0:
|
||||
return "<1e-12"
|
||||
if p < 0.001:
|
||||
return f"{p:.1e}"
|
||||
return f"{p:.3f}"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--scan", type=int, default=40, help="score top-N leaderboard wallets")
|
||||
ap.add_argument("--wallet", help="deep-profile a single wallet")
|
||||
ap.add_argument("--market", help="score the traders of a market (conditionId or slug)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.wallet:
|
||||
cands = [{"wallet": args.wallet, "username": args.wallet[:12] + "…"}]
|
||||
elif args.market:
|
||||
cands, cond = market_traders(args.market)
|
||||
print(f"market {cond[:20]}… · scoring {len(cands)} top traders by notional\n")
|
||||
else:
|
||||
cands = sm.leaderboard_candidates(args.scan)
|
||||
|
||||
rows = []
|
||||
with ThreadPoolExecutor(max_workers=10) as ex:
|
||||
futs = {ex.submit(analyze, c): c for c in cands}
|
||||
for f in as_completed(futs):
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception:
|
||||
r = None
|
||||
if r:
|
||||
rows.append(r)
|
||||
rows.sort(key=lambda r: r["score"], reverse=True)
|
||||
|
||||
h = (f"{'susp':>5}{'z':>6}{'p(luck)':>9}{'rec':>11}{'medLead':>8}"
|
||||
f"{'pre24':>6}{'trades':>7}{'avgSz':>8} trader")
|
||||
print(h)
|
||||
print("-" * len(h))
|
||||
for r in rows:
|
||||
rec = f"{r['wins']}/{r['n']}(E{r['exp_wins']:.0f})"
|
||||
lead = "n/a" if r["med_lead_h"] is None else f"{r['med_lead_h']:.0f}h"
|
||||
print(f"{r['score']:>5.1f}{r['z']:>6.1f}{fmt_p(r['pval']):>9}{rec:>11}"
|
||||
f"{lead:>8}{r['pre24_pct']:>5}%{r['trades']:>7}{'$'+format(r['avg_size'],','):>8}"
|
||||
f" {r['username'][:22]}")
|
||||
print("-" * len(h))
|
||||
print("susp gated by improbability: a wallet must win ABOVE its odds (z>1) to score at all.")
|
||||
print("z=wins above odds-implied · p(luck)=prob it was chance · pre24=% wins entered <24h out")
|
||||
print("\nNOTE:", cluster_stub())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user