From a426b8bb0597f889add3a733b8e3394896a40d9f Mon Sep 17 00:00:00 2001 From: jaxperro Date: Fri, 12 Jun 2026 23:25:59 -0400 Subject: [PATCH] Fix survivorship-biased win rate; add backtest, per-position cap, Discord alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanner measured win rate over /closed-positions only, but Polymarket only redeems winning shares — losers sit unredeemed in /positions at curPrice 0 and never enter closed-positions. That made win rates wildly inflated (e.g. 90.6% vs a true 48.3%). Win rate now unions both endpoints over a 90-day window. With the honest metric, ~no top wallet exceeds ~60%; true rates cluster near 50%. Also: - backtest.py: replay a watchlist over a recent window, fill at historical price, mark outcomes from resolution. A 7d run of 4 top wallets returned -48%, confirming flat-size entry-copying is -EV at ~50% hit rates. - copytrade.py: add max_position_usd cap (proportional adds could otherwise balloon one position to the whole exposure limit) and Discord webhook alerts on every would-be trade. Co-Authored-By: Claude Fable 5 --- .claude/launch.json | 11 ++ .gitignore | 1 + README.md | 52 +++++++-- backtest.py | 257 ++++++++++++++++++++++++++++++++++++++++++++ config.example.json | 2 + copytrade.py | 59 ++++++++-- smart_money.py | 95 +++++++++++----- 7 files changed, 438 insertions(+), 39 deletions(-) create mode 100644 .claude/launch.json create mode 100644 backtest.py diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..fff2fab7 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dashboard", + "runtimeExecutable": "python3", + "runtimeArgs": ["smart_money.py", "--port", "8899"], + "port": 8899 + } + ] +} diff --git a/.gitignore b/.gitignore index 3897d87e..7e34287f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ __pycache__/ # never commit live credentials or runtime state config.json copytrade_state.json +*.log *.tmp diff --git a/README.md b/README.md index a3ec1bf5..54544265 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,24 @@ python3 smart_money.py --scan --pool 300 # broader sweep 1. **Candidates** — pulls the 7d, 30d, and all-time leaderboards from `data-api.polymarket.com/v1/leaderboard` and dedupes into a candidate pool (default 150 wallets). -2. **Win rate** — for each wallet, pages through `/closed-positions` (up to - 300 most recent resolved positions). A *win* is a resolved position with - `realizedPnl > 0`. +2. **True win rate** — over resolved bets in the last 90 days. This is the + subtle part: Polymarket only redeems *winning* shares, so `/closed-positions` + (redeemed or sold) is heavily **survivorship-biased toward winners** — + losing shares are worth $0 and sit unredeemed in `/positions` at + `curPrice 0` forever. A correct win rate has to **union both** endpoints + over the same window. Counting only `/closed-positions` (the naive approach) + reports ~90% for wallets whose real hit rate is ~50%. 3. **Frequency** — counts trades from `/activity` over the last 4 weeks; *bets/week* is the number of **distinct markets** traded per week, so 50 fills on one order don't count as 50 bets. 4. **Filter** — keeps wallets with win rate ≥ 75%, ≥ 2 bets/week, and ≥ 10 - resolved bets (so a 3-for-3 fluke doesn't rank as a 100% winner). + resolved bets. + +> **Reality check:** with the unbiased metric, essentially **no** top wallet +> wins 75% of its bets — true rates cluster around **49% (a coin flip)**, max +> ~60%. The profitable ones make money through position sizing and entry +> prices, *not* hit rate. Treat a high win rate as a red flag for a bias bug, +> not a green light. See the backtest below. ## Copy-trading (`copytrade.py`) @@ -51,6 +61,27 @@ mirrors their trades onto your own account. - **No backfill** — only copies positions they open *after* you start watching; positions they already held are tracked (so exits still mirror) but never opened. +- **Per-position cap** — `max_position_usd` caps total cost in any one market. + Without it, proportional adds let a single position balloon toward your whole + exposure limit as the whale piles in (a backtest caught exactly this). +- **Discord alerts** — set `discord_webhook` in the config to get a ping on + every trade it would place (entries green, exits red). + +## Backtesting (`backtest.py`) + +Replays a watchlist's real trades over a recent window through the same copy +logic, filling at each wallet's actual historical price and marking outcomes +from how markets resolved. + +```bash +python3 backtest.py --days 7 +``` + +A 7-day backtest of four top wallets returned **−48% on deployed capital** — +not because the engine is broken, but because the wallets' true entry hit rate +is ~50% and flat-size copying pays the spread on every coin flip. Copying a +profitable wallet's *entries* does not reproduce its edge, which lives in +sizing and entry prices. Backtest before you fund anything. ### ⚠️ Real money — read this @@ -86,7 +117,12 @@ and runtime state never get committed. A high-win-rate wallet that has never cracked any leaderboard window won't appear — scanning every wallet on the platform isn't feasible via the public API. -- Win rate is measured over each wallet's most recent ~300 resolved - positions, not their entire history. -- High win rate ≠ high EV: someone selling early for +$1 on every position - counts as winning. Check the realized PnL column alongside the win rate. +- Win rate is measured over resolved bets in the last 90 days, not all history. +- **Win rate ≠ EV.** Wallets with positive all-time leaderboard PnL routinely + show ~50% true win rates and even negative 90-day realized PnL. Following a + wallet profitably is about *how* it sizes and prices entries, not how often + it's right. The `realized_pnl` column is position-level over 90 days and is + **not** comparable to the all-time leaderboard figure. +- Very high-volume / market-maker wallets (thousands of fills) can't be cleanly + backtested via the public API — too many fills, no historical position + snapshot. diff --git a/backtest.py b/backtest.py new file mode 100644 index 00000000..b65ebcb3 --- /dev/null +++ b/backtest.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Backtest the copy-trade strategy over a recent window. + +Replays each watched wallet's real trades through the same copy logic the live +bot uses — % -of-bankroll sizing, no-backfill, proportional adds/exits, risk +caps — but fills at the wallet's actual historical trade price. Outcomes are +marked from how each market resolved (curPrice 1/0 from closed-positions) or, +for still-open positions, the current market price. + + python3 backtest.py # last 7 days, config.json watchlist + python3 backtest.py --days 7 + +This is an approximation. Notably the price guard is a near no-op in backtest +(we fill at their price, with no 12s real-time lag), so results are slightly +optimistic. Wallets whose history doesn't reach before the window are flagged. +""" + +import argparse +import json +import time +from collections import defaultdict + +import smart_money as sm +from copytrade import clob_price, DEFAULT_CONFIG, load_json + +LOOKBACK_DAYS = 21 # how far before the window we try to read, for seed +MAX_TRADES = 4000 # pagination cap per wallet + + +def fetch_trades(wallet, since_ts): + """Newest-first TRADE activity back to ~since_ts (capped).""" + out, off = [], 0 + while off < MAX_TRADES: + page = sm.get_json("/activity", + {"user": wallet, "type": "TRADE", + "limit": 500, "offset": off}) + if not page: + break + out += page + off += 500 + if len(page) < 500 or page[-1].get("timestamp", 0) < since_ts: + break + return out + + +def mark_map(wallets): + """asset(token) -> current/resolved price (curPrice). + + Merges each wallet's open /positions (curPrice = live price, or 0/1 if it + resolved but isn't redeemed yet) and /closed-positions (resolved 1/0). This + is what lets us mark a position we still hold at its true value rather than + falling back to entry price. + """ + res = {} + for w in wallets: + for endpoint in ("/positions", "/closed-positions"): + off = 0 + while off < 1000: + params = {"user": w, "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: + if p.get("asset") is not None: + # closed-positions wins ties (definitively resolved) + if endpoint == "/closed-positions" or p["asset"] not in res: + res[p["asset"]] = p.get("curPrice", 0) + off += 50 + if len(page) < 50: + break + return res + + +def backtest(cfg, days): + wallets = cfg["watchlist"] + now = time.time() + window_start = now - days * 86400 + lookback_start = window_start - LOOKBACK_DAYS * 86400 + stake = cfg["bankroll_usd"] * cfg["bankroll_pct"] + risk = cfg["risk"] + + print(f"Backtesting {len(wallets)} wallets over the last {days} days " + f"· ${stake:.0f}/entry · caps: ${risk['max_trade_usd']:.0f}/trade, " + f"${risk['daily_spend_cap_usd']:.0f}/day, " + f"${risk['max_total_exposure_usd']:.0f} exposure\n") + + # gather every wallet's trades + per-wallet data reach + all_trades, reach = [], {} + for w in wallets: + ts = fetch_trades(w, lookback_start) + for t in ts: + t["_wallet"] = w + all_trades += ts + oldest = min((t["timestamp"] for t in ts), default=now) + reach[w] = (now - oldest) / 86400 + all_trades.sort(key=lambda t: t["timestamp"]) + + res = mark_map(wallets) + + # replay state + their_pos = defaultdict(float) # (wallet, token) -> shares + seed_tokens = set() # (wallet, token) held before window + my = {} # token -> {shares, cost, title, outcome, wallet} + daily_spend = defaultdict(float) # 'YYYY-MM-DD' -> usd + deployed = 0.0 + realized = 0.0 + n_open = n_add = n_exit = n_skip_guard = n_skip_cap = n_skip_backfill = 0 + price_cache = {} + + def cur_price(token, side): + key = (token, side) + if key not in price_cache: + price_cache[key] = clob_price(token, side) + return price_cache[key] + + def exposure(): + return sum(p["cost"] for p in my.values()) + + for t in all_trades: + w, token = t["_wallet"], t.get("asset") + side, size, price = t.get("side"), t.get("size", 0), t.get("price", 0) + key = (w, token) + prev = their_pos[key] + + # pre-window trades only build their position (establish the seed) + if t["timestamp"] < window_start: + seed_tokens.add(key) + their_pos[key] = prev + size if side == "BUY" else max(0.0, prev - size) + continue + + label = f"{t.get('outcome','?')} · {t.get('title','?')[:44]}" + if side == "BUY": + mine = my.get(token) + if mine is None and key in seed_tokens: + n_skip_backfill += 1 + elif mine is None: + # fresh OPEN + if not (risk["min_price"] <= price <= risk["max_price"]): + n_skip_guard += 1 + else: + day = time.strftime("%Y-%m-%d", time.gmtime(t["timestamp"])) + cap = min(stake, risk["max_trade_usd"], + risk.get("max_position_usd", float("inf")), + risk["daily_spend_cap_usd"] - daily_spend[day], + risk["max_total_exposure_usd"] - exposure()) + if cap < risk["min_order_usd"] or len(my) >= risk["max_open_positions"]: + n_skip_cap += 1 + else: + sh = cap / price + my[token] = {"shares": sh, "cost": cap, + "title": t.get("title", "?"), + "outcome": t.get("outcome", "?"), "wallet": w} + deployed += cap + daily_spend[day] += cap + n_open += 1 + else: + # proportional ADD + frac = size / prev if prev > 0 else 0 + add_sh = mine["shares"] * frac + add_usd = add_sh * price + day = time.strftime("%Y-%m-%d", time.gmtime(t["timestamp"])) + cap = min(add_usd, risk["max_trade_usd"], + risk.get("max_position_usd", float("inf")) - mine["cost"], + risk["daily_spend_cap_usd"] - daily_spend[day], + risk["max_total_exposure_usd"] - exposure()) + if cap >= risk["min_order_usd"]: + sh = cap / price + mine["shares"] += sh + mine["cost"] += cap + deployed += cap + daily_spend[day] += cap + n_add += 1 + their_pos[key] = prev + size + elif side == "SELL": + mine = my.get(token) + if mine and mine["shares"] > 0: + frac = 1.0 if prev <= 0 else min(1.0, size / prev) + sell_sh = min(mine["shares"], mine["shares"] * frac) + if sell_sh > 0: + sold_frac = sell_sh / mine["shares"] + cost_out = mine["cost"] * sold_frac + proceeds = sell_sh * price + realized += proceeds - cost_out + mine["shares"] -= sell_sh + mine["cost"] -= cost_out + n_exit += 1 + if mine["shares"] <= 0.01: + del my[token] + their_pos[key] = max(0.0, prev - size) + + # mark remaining open positions to resolution or current price + unrealized = 0.0 + open_rows = [] + for token, p in my.items(): + mark = res.get(token) + if mark is None: + mark = cur_price(token, "sell") + if mark is None: + mark = p["cost"] / p["shares"] # last resort: flat + # curPrice at the extremes means the market has resolved + if mark <= 0.02: + status = "LOST" + elif mark >= 0.98: + status = "WON" + else: + status = "open" + val = p["shares"] * mark + pnl = val - p["cost"] + unrealized += pnl + open_rows.append((p, mark, pnl, status)) + + total_pnl = realized + unrealized + print(f"{'─'*74}") + print(" Per-wallet data reach (how far history extended before today):") + for w in wallets: + flag = "" if reach[w] >= days + 3 else " ⚠ short history — low confidence" + print(f" {w[:12]}… {reach[w]:5.1f} days{flag}") + print(f"{'─'*74}") + print(f" Copies it would have made:") + print(f" {n_open} fresh entries · {n_add} adds · {n_exit} exits/trims") + print(f" skipped: {n_skip_backfill} held-before-start, " + f"{n_skip_guard} price/range, {n_skip_cap} risk-cap") + print(f"{'─'*74}") + print(f" Total deployed (bought): ${deployed:>12,.2f}") + print(f" Realized P&L (closed legs): ${realized:>+12,.2f}") + print(f" Unrealized P&L (still held): ${unrealized:>+12,.2f}") + print(f" ── Net P&L: ${total_pnl:>+12,.2f}" + f" ({(total_pnl/deployed*100) if deployed else 0:+.1f}% on deployed)") + print(f"{'─'*74}") + if open_rows: + won = sum(1 for _, _, _, s in open_rows if s == "WON") + lost = sum(1 for _, _, _, s in open_rows if s == "LOST") + opn = sum(1 for _, _, _, s in open_rows if s == "open") + print(f" Positions still on the book at window end: {len(open_rows)} " + f"({won} won, {lost} lost, {opn} open & marked-to-market)") + for p, mark, pnl, status in sorted(open_rows, key=lambda x: x[2]): + print(f" {status:>5} {pnl:>+9,.2f} {p['outcome']} · {p['title'][:40]}") + print() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--config", default="config.json") + ap.add_argument("--days", type=int, default=7) + args = ap.parse_args() + cfg = {**DEFAULT_CONFIG, **load_json(args.config, {})} + cfg["risk"] = {**DEFAULT_CONFIG["risk"], **cfg.get("risk", {})} + backtest(cfg, args.days) + + +if __name__ == "__main__": + main() diff --git a/config.example.json b/config.example.json index f5a5b464..88026433 100644 --- a/config.example.json +++ b/config.example.json @@ -1,12 +1,14 @@ { "mode": "paper", "poll_seconds": 12, + "discord_webhook": "", "watchlist": [], "bankroll_usd": 1000.0, "bankroll_pct": 0.02, "price_guard_pct": 0.05, "risk": { "max_trade_usd": 50.0, + "max_position_usd": 40.0, "daily_spend_cap_usd": 250.0, "max_total_exposure_usd": 500.0, "max_open_positions": 20, diff --git a/copytrade.py b/copytrade.py index 6b310372..45c00e90 100644 --- a/copytrade.py +++ b/copytrade.py @@ -47,12 +47,14 @@ CONFIRM_PHRASE = "TRADE LIVE" DEFAULT_CONFIG = { "mode": "paper", # "paper" or "live" "poll_seconds": 12, # how often to check each wallet + "discord_webhook": "", # paste a Discord webhook URL to get pings "watchlist": [], # ["0xwallet1", "0xwallet2", ...] "bankroll_usd": 1000.0, # your stake pool "bankroll_pct": 0.02, # 2% of bankroll per new entry "price_guard_pct": 0.05, # skip if price moved >5% from their fill "risk": { "max_trade_usd": 50.0, # hard ceiling on any single copy + "max_position_usd": 40.0, # hard ceiling on total cost in one market "daily_spend_cap_usd": 250.0, "max_total_exposure_usd": 500.0, "max_open_positions": 20, @@ -71,6 +73,22 @@ DEFAULT_CONFIG = { STATE_PATH_DEFAULT = "copytrade_state.json" +def post_discord(webhook, content): + """POST a message to a Discord webhook. Best-effort; never raises.""" + if not webhook: + return False + try: + body = json.dumps({"content": content}).encode() + req = urllib.request.Request( + webhook, data=body, method="POST", + headers={"Content-Type": "application/json", + "User-Agent": "Mozilla/5.0"}) + urllib.request.urlopen(req, timeout=10, context=SSL_CTX).read() + return True + except (urllib.error.URLError, TimeoutError): + return False + + # ── state ───────────────────────────────────────────────────────────────── def load_json(path, default): @@ -201,11 +219,22 @@ class CopyTrader: self.state_path = state_path self.risk = cfg["risk"] self.seen = set(state["seen_tx"]) + self.webhook = cfg.get("discord_webhook", "") + self._discord_warned = False # -- helpers -- def log(self, msg): print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True) + def alert(self, msg, discord_text=None): + """Log to console AND push to Discord (used for actual placements).""" + self.log(msg) + if self.webhook: + ok = post_discord(self.webhook, discord_text or msg) + if not ok and not self._discord_warned: + self.log(" ⚠ Discord webhook post failed (check the URL)") + self._discord_warned = True + def reset_daily_if_needed(self): today = time.strftime("%Y-%m-%d") if self.state["spend"]["date"] != today: @@ -219,7 +248,7 @@ class CopyTrader: save_json(self.state_path, self.state) # -- risk gate: returns (allowed_usd, reason_if_blocked) -- - def gate_buy(self, want_usd, price): + def gate_buy(self, want_usd, price, pos_cost=0.0): r = self.risk if not (r["min_price"] <= price <= r["max_price"]): return 0.0, f"price {price:.3f} outside [{r['min_price']},{r['max_price']}]" @@ -229,6 +258,7 @@ class CopyTrader: caps = [ want_usd, r["max_trade_usd"], + r.get("max_position_usd", float("inf")) - pos_cost, r["daily_spend_cap_usd"] - self.state["spend"]["usd"], r["max_total_exposure_usd"] - self.open_exposure(), ] @@ -307,7 +337,8 @@ class CopyTrader: want_usd = self.cfg["bankroll_usd"] * self.cfg["bankroll_pct"] kind = "OPEN" - allowed, reason = self.gate_buy(want_usd, price) + pos_cost = mine["cost"] if is_add else 0.0 + allowed, reason = self.gate_buy(want_usd, price, pos_cost) if reason: self.log(f"{kind} {label} — skip ({reason})") return @@ -326,8 +357,12 @@ class CopyTrader: "shares": res["filled_shares"], "cost": spent, "title": title, "outcome": outcome} tag = "[PAPER]" if not self.ex.live else "[LIVE]" - self.log(f"{kind} {label} — {tag} buy {res['filled_shares']:.1f} " - f"@ {res['price']:.3f} (${spent:.2f})") + self.alert( + f"{kind} {label} — {tag} buy {res['filled_shares']:.1f} " + f"@ {res['price']:.3f} (${spent:.2f})", + discord_text=(f"🟢 **{kind.strip()}** {tag}\n{label}\n" + f"buy {res['filled_shares']:.0f} @ {res['price']:.3f} " + f"= **${spent:.2f}**")) def _handle_their_sell(self, token, their_size, their_prev, label): mine = self.state["my_pos"].get(token) @@ -351,8 +386,12 @@ class CopyTrader: mine["shares"] -= res["filled_shares"] tag = "[PAPER]" if not self.ex.live else "[LIVE]" verb = "EXIT" if frac >= 0.999 else "TRIM" - self.log(f"{verb} {label} — {tag} sell {res['filled_shares']:.1f} " - f"@ {res['price']:.3f} (${proceeds:.2f})") + self.alert( + f"{verb} {label} — {tag} sell {res['filled_shares']:.1f} " + f"@ {res['price']:.3f} (${proceeds:.2f})", + discord_text=(f"🔴 **{verb}** {tag}\n{label}\n" + f"sell {res['filled_shares']:.0f} @ {res['price']:.3f} " + f"= **${proceeds:.2f}**")) if mine["shares"] <= 0.01: del self.state["my_pos"][token] @@ -389,6 +428,14 @@ class CopyTrader: f"bankroll ${self.cfg['bankroll_usd']:.0f} @ " f"{self.cfg['bankroll_pct']:.1%}/entry · " f"guard {self.cfg['price_guard_pct']:.0%}") + if self.webhook: + post_discord(self.webhook, + f"✅ **Copy-trade tracker connected** ({mode})\n" + f"watching {len(self.cfg['watchlist'])} wallets · " + f"${self.cfg['bankroll_usd']:.0f} bankroll @ " + f"{self.cfg['bankroll_pct']:.1%}/entry · " + f"guard {self.cfg['price_guard_pct']:.0%}\n" + f"You'll get a ping on every trade it would place.") if not self.cfg["watchlist"]: self.log("watchlist is empty — add wallets to the config. " "(Run smart_money.py to find them.)") diff --git a/smart_money.py b/smart_money.py index 321bd530..3b065b2d 100644 --- a/smart_money.py +++ b/smart_money.py @@ -26,10 +26,10 @@ PORT = 8899 # Scan defaults — adjustable in the UI DEFAULTS = { "pool": 150, # candidate wallets pulled from the leaderboard - "min_win_rate": 75.0, # percent of resolved bets with realizedPnl > 0 + "min_win_rate": 75.0, # percent of resolved bets that won (true, unbiased) "min_bets_week": 2.0, # distinct markets traded per week, recent 4 weeks "min_resolved": 10, # resolved bets required (filters 3-for-3 flukes) - "max_positions": 300, # most recent resolved positions sampled per wallet + "max_positions": 80, # page cap per endpoint (50 each) — window is the real limit } FREQ_WEEKS = 4 # window for the bets-per-week measurement @@ -102,23 +102,72 @@ def leaderboard_candidates(pool): return ranked[:pool] -def closed_positions(wallet, max_positions): - """Most recent resolved positions, newest first. +WIN_WINDOW_DAYS = 90 # measure win rate over resolved bets in this window - The API defaults to sorting by realizedPnl descending — without an - explicit TIMESTAMP sort you get a wallet's biggest *wins* first, which - inflates every win rate toward 100%. Sort by time so we sample the - actual recent record. + +def _parse_end(end): + """Parse an endDate ('2026-06-11T00:00:00Z' or '2026-06-09') to epoch.""" + 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_positions(wallet, max_pages): + """Every resolved bet — won or lost — in the last WIN_WINDOW_DAYS. + + Polymarket only redeems *winning* shares; losing shares are worth $0 and + sit unredeemed in the wallet forever. So /closed-positions (redeemed or + sold) is heavily survivorship-biased toward winners — the losers pile up + in /positions at curPrice 0. A true win rate has to union both, over the + same time window so the ratio isn't skewed by truncation. """ + cutoff = time.time() - WIN_WINDOW_DAYS * 86400 + now = time.time() out = [] + + # redeemed / sold winners (and losers sold before resolution), time-sorted offset = 0 - while offset < max_positions: + while offset < max_pages * 50: page = get_json("/closed-positions", {"user": wallet, "limit": 50, "offset": offset, "sortBy": "TIMESTAMP", "sortDirection": "DESC"}) if not page: break - out.extend(page) + for p in page: + if p.get("timestamp", 0) >= cutoff: + out.append({"won": p.get("curPrice", 0) >= 0.5, + "pnl": p.get("realizedPnl", 0), + "title": p.get("title", "?"), + "outcome": p.get("outcome", "?"), + "avgPrice": p.get("avgPrice", 0), + "ts": p.get("timestamp", 0)}) + offset += 50 + if len(page) < 50 or page[-1].get("timestamp", 0) < cutoff: + break + + # currently-held positions whose market already resolved (the hidden losers) + offset = 0 + while offset < max_pages * 50: + page = get_json("/positions", + {"user": wallet, "limit": 50, "offset": offset, + "sizeThreshold": 0.0}) + if not page: + break + for p in page: + end = _parse_end(p.get("endDate")) + if cutoff <= end < now: # resolved, in window, unredeemed + out.append({"won": p.get("curPrice", 0) >= 0.5, + "pnl": p.get("cashPnl", 0), + "title": p.get("title", "?"), + "outcome": p.get("outcome", "?"), + "avgPrice": p.get("avgPrice", 0), + "ts": end}) offset += 50 if len(page) < 50: break @@ -149,17 +198,13 @@ def recent_trade_frequency(wallet, weeks=FREQ_WEEKS): def analyze_wallet(candidate, max_positions): wallet = candidate["wallet"] - resolved = closed_positions(wallet, max_positions) + resolved = resolved_positions(wallet, max_positions) if not resolved: return None - # A bet won if the outcome it held resolved YES. For resolved positions - # curPrice is binary (1 = won, 0 = lost), so it's a cleaner signal than - # the sign of realizedPnl — a hedged position can win yet net $0 PnL. - def won(p): - return p.get("curPrice", 0) >= 0.5 - wins = sum(1 for p in resolved if won(p)) - realized_pnl = sum(p.get("realizedPnl", 0) for p in resolved) + wins = sum(1 for p in resolved if p["won"]) + realized_pnl = sum(p["pnl"] for p in resolved) trades, markets = recent_trade_frequency(wallet) + recent = sorted(resolved, key=lambda p: p["ts"], reverse=True)[:15] return { **candidate, "resolved": len(resolved), @@ -171,14 +216,14 @@ def analyze_wallet(candidate, max_positions): "bets_per_week": round(markets / FREQ_WEEKS, 1), "recent": [ { - "title": p.get("title", "?"), - "outcome": p.get("outcome", "?"), - "avgPrice": p.get("avgPrice", 0), - "realizedPnl": round(p.get("realizedPnl", 0), 2), - "won": won(p), - "timestamp": p.get("timestamp", 0), + "title": p["title"], + "outcome": p["outcome"], + "avgPrice": p["avgPrice"], + "realizedPnl": round(p["pnl"], 2), + "won": p["won"], + "timestamp": p["ts"], } - for p in resolved[:15] + for p in recent ], }