Fix survivorship-biased win rate; add backtest, per-position cap, Discord alerts
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"runtimeExecutable": "python3",
|
||||||
|
"runtimeArgs": ["smart_money.py", "--port", "8899"],
|
||||||
|
"port": 8899
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ __pycache__/
|
|||||||
# never commit live credentials or runtime state
|
# never commit live credentials or runtime state
|
||||||
config.json
|
config.json
|
||||||
copytrade_state.json
|
copytrade_state.json
|
||||||
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|||||||
@@ -28,14 +28,24 @@ python3 smart_money.py --scan --pool 300 # broader sweep
|
|||||||
1. **Candidates** — pulls the 7d, 30d, and all-time leaderboards from
|
1. **Candidates** — pulls the 7d, 30d, and all-time leaderboards from
|
||||||
`data-api.polymarket.com/v1/leaderboard` and dedupes into a candidate pool
|
`data-api.polymarket.com/v1/leaderboard` and dedupes into a candidate pool
|
||||||
(default 150 wallets).
|
(default 150 wallets).
|
||||||
2. **Win rate** — for each wallet, pages through `/closed-positions` (up to
|
2. **True win rate** — over resolved bets in the last 90 days. This is the
|
||||||
300 most recent resolved positions). A *win* is a resolved position with
|
subtle part: Polymarket only redeems *winning* shares, so `/closed-positions`
|
||||||
`realizedPnl > 0`.
|
(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;
|
3. **Frequency** — counts trades from `/activity` over the last 4 weeks;
|
||||||
*bets/week* is the number of **distinct markets** traded per week, so 50
|
*bets/week* is the number of **distinct markets** traded per week, so 50
|
||||||
fills on one order don't count as 50 bets.
|
fills on one order don't count as 50 bets.
|
||||||
4. **Filter** — keeps wallets with win rate ≥ 75%, ≥ 2 bets/week, and ≥ 10
|
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`)
|
## 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
|
- **No backfill** — only copies positions they open *after* you start
|
||||||
watching; positions they already held are tracked (so exits still mirror)
|
watching; positions they already held are tracked (so exits still mirror)
|
||||||
but never opened.
|
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
|
### ⚠️ 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
|
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
|
appear — scanning every wallet on the platform isn't feasible via the
|
||||||
public API.
|
public API.
|
||||||
- Win rate is measured over each wallet's most recent ~300 resolved
|
- Win rate is measured over resolved bets in the last 90 days, not all history.
|
||||||
positions, not their entire history.
|
- **Win rate ≠ EV.** Wallets with positive all-time leaderboard PnL routinely
|
||||||
- High win rate ≠ high EV: someone selling early for +$1 on every position
|
show ~50% true win rates and even negative 90-day realized PnL. Following a
|
||||||
counts as winning. Check the realized PnL column alongside the win rate.
|
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.
|
||||||
|
|||||||
+257
@@ -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()
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"mode": "paper",
|
"mode": "paper",
|
||||||
"poll_seconds": 12,
|
"poll_seconds": 12,
|
||||||
|
"discord_webhook": "",
|
||||||
"watchlist": [],
|
"watchlist": [],
|
||||||
"bankroll_usd": 1000.0,
|
"bankroll_usd": 1000.0,
|
||||||
"bankroll_pct": 0.02,
|
"bankroll_pct": 0.02,
|
||||||
"price_guard_pct": 0.05,
|
"price_guard_pct": 0.05,
|
||||||
"risk": {
|
"risk": {
|
||||||
"max_trade_usd": 50.0,
|
"max_trade_usd": 50.0,
|
||||||
|
"max_position_usd": 40.0,
|
||||||
"daily_spend_cap_usd": 250.0,
|
"daily_spend_cap_usd": 250.0,
|
||||||
"max_total_exposure_usd": 500.0,
|
"max_total_exposure_usd": 500.0,
|
||||||
"max_open_positions": 20,
|
"max_open_positions": 20,
|
||||||
|
|||||||
+53
-6
@@ -47,12 +47,14 @@ CONFIRM_PHRASE = "TRADE LIVE"
|
|||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"mode": "paper", # "paper" or "live"
|
"mode": "paper", # "paper" or "live"
|
||||||
"poll_seconds": 12, # how often to check each wallet
|
"poll_seconds": 12, # how often to check each wallet
|
||||||
|
"discord_webhook": "", # paste a Discord webhook URL to get pings
|
||||||
"watchlist": [], # ["0xwallet1", "0xwallet2", ...]
|
"watchlist": [], # ["0xwallet1", "0xwallet2", ...]
|
||||||
"bankroll_usd": 1000.0, # your stake pool
|
"bankroll_usd": 1000.0, # your stake pool
|
||||||
"bankroll_pct": 0.02, # 2% of bankroll per new entry
|
"bankroll_pct": 0.02, # 2% of bankroll per new entry
|
||||||
"price_guard_pct": 0.05, # skip if price moved >5% from their fill
|
"price_guard_pct": 0.05, # skip if price moved >5% from their fill
|
||||||
"risk": {
|
"risk": {
|
||||||
"max_trade_usd": 50.0, # hard ceiling on any single copy
|
"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,
|
"daily_spend_cap_usd": 250.0,
|
||||||
"max_total_exposure_usd": 500.0,
|
"max_total_exposure_usd": 500.0,
|
||||||
"max_open_positions": 20,
|
"max_open_positions": 20,
|
||||||
@@ -71,6 +73,22 @@ DEFAULT_CONFIG = {
|
|||||||
STATE_PATH_DEFAULT = "copytrade_state.json"
|
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 ─────────────────────────────────────────────────────────────────
|
# ── state ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_json(path, default):
|
def load_json(path, default):
|
||||||
@@ -201,11 +219,22 @@ class CopyTrader:
|
|||||||
self.state_path = state_path
|
self.state_path = state_path
|
||||||
self.risk = cfg["risk"]
|
self.risk = cfg["risk"]
|
||||||
self.seen = set(state["seen_tx"])
|
self.seen = set(state["seen_tx"])
|
||||||
|
self.webhook = cfg.get("discord_webhook", "")
|
||||||
|
self._discord_warned = False
|
||||||
|
|
||||||
# -- helpers --
|
# -- helpers --
|
||||||
def log(self, msg):
|
def log(self, msg):
|
||||||
print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True)
|
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):
|
def reset_daily_if_needed(self):
|
||||||
today = time.strftime("%Y-%m-%d")
|
today = time.strftime("%Y-%m-%d")
|
||||||
if self.state["spend"]["date"] != today:
|
if self.state["spend"]["date"] != today:
|
||||||
@@ -219,7 +248,7 @@ class CopyTrader:
|
|||||||
save_json(self.state_path, self.state)
|
save_json(self.state_path, self.state)
|
||||||
|
|
||||||
# -- risk gate: returns (allowed_usd, reason_if_blocked) --
|
# -- 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
|
r = self.risk
|
||||||
if not (r["min_price"] <= price <= r["max_price"]):
|
if not (r["min_price"] <= price <= r["max_price"]):
|
||||||
return 0.0, f"price {price:.3f} outside [{r['min_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 = [
|
caps = [
|
||||||
want_usd,
|
want_usd,
|
||||||
r["max_trade_usd"],
|
r["max_trade_usd"],
|
||||||
|
r.get("max_position_usd", float("inf")) - pos_cost,
|
||||||
r["daily_spend_cap_usd"] - self.state["spend"]["usd"],
|
r["daily_spend_cap_usd"] - self.state["spend"]["usd"],
|
||||||
r["max_total_exposure_usd"] - self.open_exposure(),
|
r["max_total_exposure_usd"] - self.open_exposure(),
|
||||||
]
|
]
|
||||||
@@ -307,7 +337,8 @@ class CopyTrader:
|
|||||||
want_usd = self.cfg["bankroll_usd"] * self.cfg["bankroll_pct"]
|
want_usd = self.cfg["bankroll_usd"] * self.cfg["bankroll_pct"]
|
||||||
kind = "OPEN"
|
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:
|
if reason:
|
||||||
self.log(f"{kind} {label} — skip ({reason})")
|
self.log(f"{kind} {label} — skip ({reason})")
|
||||||
return
|
return
|
||||||
@@ -326,8 +357,12 @@ class CopyTrader:
|
|||||||
"shares": res["filled_shares"], "cost": spent,
|
"shares": res["filled_shares"], "cost": spent,
|
||||||
"title": title, "outcome": outcome}
|
"title": title, "outcome": outcome}
|
||||||
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
|
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
|
||||||
self.log(f"{kind} {label} — {tag} buy {res['filled_shares']:.1f} "
|
self.alert(
|
||||||
f"@ {res['price']:.3f} (${spent:.2f})")
|
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):
|
def _handle_their_sell(self, token, their_size, their_prev, label):
|
||||||
mine = self.state["my_pos"].get(token)
|
mine = self.state["my_pos"].get(token)
|
||||||
@@ -351,8 +386,12 @@ class CopyTrader:
|
|||||||
mine["shares"] -= res["filled_shares"]
|
mine["shares"] -= res["filled_shares"]
|
||||||
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
|
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
|
||||||
verb = "EXIT" if frac >= 0.999 else "TRIM"
|
verb = "EXIT" if frac >= 0.999 else "TRIM"
|
||||||
self.log(f"{verb} {label} — {tag} sell {res['filled_shares']:.1f} "
|
self.alert(
|
||||||
f"@ {res['price']:.3f} (${proceeds:.2f})")
|
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:
|
if mine["shares"] <= 0.01:
|
||||||
del self.state["my_pos"][token]
|
del self.state["my_pos"][token]
|
||||||
|
|
||||||
@@ -389,6 +428,14 @@ class CopyTrader:
|
|||||||
f"bankroll ${self.cfg['bankroll_usd']:.0f} @ "
|
f"bankroll ${self.cfg['bankroll_usd']:.0f} @ "
|
||||||
f"{self.cfg['bankroll_pct']:.1%}/entry · "
|
f"{self.cfg['bankroll_pct']:.1%}/entry · "
|
||||||
f"guard {self.cfg['price_guard_pct']:.0%}")
|
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"]:
|
if not self.cfg["watchlist"]:
|
||||||
self.log("watchlist is empty — add wallets to the config. "
|
self.log("watchlist is empty — add wallets to the config. "
|
||||||
"(Run smart_money.py to find them.)")
|
"(Run smart_money.py to find them.)")
|
||||||
|
|||||||
+70
-25
@@ -26,10 +26,10 @@ PORT = 8899
|
|||||||
# Scan defaults — adjustable in the UI
|
# Scan defaults — adjustable in the UI
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"pool": 150, # candidate wallets pulled from the leaderboard
|
"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_bets_week": 2.0, # distinct markets traded per week, recent 4 weeks
|
||||||
"min_resolved": 10, # resolved bets required (filters 3-for-3 flukes)
|
"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
|
FREQ_WEEKS = 4 # window for the bets-per-week measurement
|
||||||
|
|
||||||
@@ -102,23 +102,72 @@ def leaderboard_candidates(pool):
|
|||||||
return ranked[:pool]
|
return ranked[:pool]
|
||||||
|
|
||||||
|
|
||||||
def closed_positions(wallet, max_positions):
|
WIN_WINDOW_DAYS = 90 # measure win rate over resolved bets in this window
|
||||||
"""Most recent resolved positions, newest first.
|
|
||||||
|
|
||||||
The API defaults to sorting by realizedPnl descending — without an
|
|
||||||
explicit TIMESTAMP sort you get a wallet's biggest *wins* first, which
|
def _parse_end(end):
|
||||||
inflates every win rate toward 100%. Sort by time so we sample the
|
"""Parse an endDate ('2026-06-11T00:00:00Z' or '2026-06-09') to epoch."""
|
||||||
actual recent record.
|
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 = []
|
out = []
|
||||||
|
|
||||||
|
# redeemed / sold winners (and losers sold before resolution), time-sorted
|
||||||
offset = 0
|
offset = 0
|
||||||
while offset < max_positions:
|
while offset < max_pages * 50:
|
||||||
page = get_json("/closed-positions",
|
page = get_json("/closed-positions",
|
||||||
{"user": wallet, "limit": 50, "offset": offset,
|
{"user": wallet, "limit": 50, "offset": offset,
|
||||||
"sortBy": "TIMESTAMP", "sortDirection": "DESC"})
|
"sortBy": "TIMESTAMP", "sortDirection": "DESC"})
|
||||||
if not page:
|
if not page:
|
||||||
break
|
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
|
offset += 50
|
||||||
if len(page) < 50:
|
if len(page) < 50:
|
||||||
break
|
break
|
||||||
@@ -149,17 +198,13 @@ def recent_trade_frequency(wallet, weeks=FREQ_WEEKS):
|
|||||||
|
|
||||||
def analyze_wallet(candidate, max_positions):
|
def analyze_wallet(candidate, max_positions):
|
||||||
wallet = candidate["wallet"]
|
wallet = candidate["wallet"]
|
||||||
resolved = closed_positions(wallet, max_positions)
|
resolved = resolved_positions(wallet, max_positions)
|
||||||
if not resolved:
|
if not resolved:
|
||||||
return None
|
return None
|
||||||
# A bet won if the outcome it held resolved YES. For resolved positions
|
wins = sum(1 for p in resolved if p["won"])
|
||||||
# curPrice is binary (1 = won, 0 = lost), so it's a cleaner signal than
|
realized_pnl = sum(p["pnl"] for p in resolved)
|
||||||
# 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)
|
|
||||||
trades, markets = recent_trade_frequency(wallet)
|
trades, markets = recent_trade_frequency(wallet)
|
||||||
|
recent = sorted(resolved, key=lambda p: p["ts"], reverse=True)[:15]
|
||||||
return {
|
return {
|
||||||
**candidate,
|
**candidate,
|
||||||
"resolved": len(resolved),
|
"resolved": len(resolved),
|
||||||
@@ -171,14 +216,14 @@ def analyze_wallet(candidate, max_positions):
|
|||||||
"bets_per_week": round(markets / FREQ_WEEKS, 1),
|
"bets_per_week": round(markets / FREQ_WEEKS, 1),
|
||||||
"recent": [
|
"recent": [
|
||||||
{
|
{
|
||||||
"title": p.get("title", "?"),
|
"title": p["title"],
|
||||||
"outcome": p.get("outcome", "?"),
|
"outcome": p["outcome"],
|
||||||
"avgPrice": p.get("avgPrice", 0),
|
"avgPrice": p["avgPrice"],
|
||||||
"realizedPnl": round(p.get("realizedPnl", 0), 2),
|
"realizedPnl": round(p["pnl"], 2),
|
||||||
"won": won(p),
|
"won": p["won"],
|
||||||
"timestamp": p.get("timestamp", 0),
|
"timestamp": p["ts"],
|
||||||
}
|
}
|
||||||
for p in resolved[:15]
|
for p in recent
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user