mirror of
https://github.com/jaxperro/winning-wallet-finder.git
synced 2026-07-27 15:57:47 +00:00
Archive dead-end strategies; add FINDINGS.md write-up
Moved the 8 tested-and-failed strategy tools into archive/ (copytrade, backtest, edge_research, lookback, table_77, lp_screener, lp_paper, xarb) with an archive/README explaining each. Root now holds the keepers: insider.py (made self-sufficient — dropped the copytrade load_json dependency) and smart_money.py (data foundation). New FINDINGS.md is the honest scorecard: six systematic public-data edges all efficient/illusory, the win-rate survivorship-bias finding, and the one real signal (z-score improbability + funding clustering). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Archive — strategies that didn't work
|
||||
|
||||
These tools were built and tested during the research in
|
||||
[`../FINDINGS.md`](../FINDINGS.md). They all proved to be dead ends (the market
|
||||
is efficient / the metric was biased), so they're archived here for reference
|
||||
rather than deleted. Each one *works* as written — it's the *strategy* that
|
||||
didn't clear. They import `smart_money`/`copytrade` from the repo root, so to
|
||||
run one you'd adjust the import path.
|
||||
|
||||
| File | What it did | Why it's here |
|
||||
|------|-------------|---------------|
|
||||
| `copytrade.py` | Paper/live copy-trade engine — mirror a watchlist's entries/exits, % -of-bankroll sizing, price guard, per-position cap, Discord alerts. | Copying entries is −EV; followed wallets win ~50%. Backtested −48%. |
|
||||
| `backtest.py` | Replay a watchlist over a window, mark outcomes from resolution. | The tool that proved copy-trading loses. |
|
||||
| `edge_research.py` | Scan ~2000 wallets for reliable weekly consistency (% green weeks, profit factor, Sharpe). | "Consistent" wallets were mostly young accounts (survivorship); no durable edge. |
|
||||
| `lookback.py` | Deep-dive a wallet list over a long window, split into halves for out-of-sample reads. | Showed the "best" wallets had <90 days of history. |
|
||||
| `table_77.py` | Aggregate a wallet set to CSV (ROI, total staked, consistency). | Supported the above; ROI inversely related to size. |
|
||||
| `lp_screener.py` | Rank reward-eligible markets by risk-adjusted LP yield. | The high APRs were illusory — see `lp_paper`. |
|
||||
| `lp_paper.py` | Paper liquidity-provision loop: simulate quoting, track net = rewards − adverse selection. | Polymarket refunds unearned reward pool; thin-book "jackpots" don't pay. |
|
||||
| `xarb.py` | Cross-venue scanner: match the same event on Polymarket vs Kalshi, flag price gaps. | Venues priced efficiently (~1¢); both legs cost >$1 after fees. |
|
||||
|
||||
The keeper that came out of all this lives at the repo root: `insider.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()
|
||||
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Polymarket copy-trade engine.
|
||||
|
||||
Watches a list of wallets and mirrors their trades onto your own account:
|
||||
- sizing: a fixed % of your configured bankroll per new entry
|
||||
- mirror: entries AND exits (sells are mirrored proportionally)
|
||||
- guard: skip a copy if the market has moved >5% from their fill price
|
||||
|
||||
SAFETY
|
||||
------
|
||||
Runs in PAPER mode by default — it logs exactly what it would do and places
|
||||
nothing. Live trading requires ALL of:
|
||||
1. "mode": "live" in the config,
|
||||
2. the --live command-line flag,
|
||||
3. typing the confirmation phrase when prompted,
|
||||
4. py-clob-client installed and valid credentials in the config.
|
||||
Hard risk caps (per-trade, daily spend, total exposure, open positions, price
|
||||
bounds) apply in both modes. This is real money in live mode — you are
|
||||
responsible for the configuration and the outcomes.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 copytrade.py --init # write config.example.json
|
||||
python3 copytrade.py # paper mode (safe)
|
||||
python3 copytrade.py --once # one polling pass, then exit
|
||||
python3 copytrade.py --live # live mode (requires config + confirm)
|
||||
python3 copytrade.py --config my.json # custom config path
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# reuse the scanner's hardened HTTP helper (SSL fallback, retries)
|
||||
from smart_money import get_json, SSL_CTX # noqa: E402
|
||||
|
||||
DATA_API = "https://data-api.polymarket.com"
|
||||
CLOB_API = "https://clob.polymarket.com"
|
||||
POLYGON_CHAIN_ID = 137
|
||||
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,
|
||||
"min_price": 0.05, # don't open longshots/near-certainties
|
||||
"max_price": 0.95,
|
||||
"min_order_usd": 5.0, # Polymarket min order size
|
||||
},
|
||||
# live credentials — only read in live mode
|
||||
"live": {
|
||||
"private_key": "", # EOA key that controls the funds
|
||||
"funder_address": "", # proxy wallet holding USDC (sig type 1/2)
|
||||
"signature_type": 1, # 0 EOA · 1 email/magic proxy · 2 browser proxy
|
||||
},
|
||||
}
|
||||
|
||||
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):
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path, data):
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def new_state():
|
||||
return {
|
||||
"started_at": time.time(),
|
||||
"seen_tx": [], # transactionHashes already processed
|
||||
"their_pos": {}, # wallet -> {token_id: shares}, live-tracked
|
||||
"seed_tokens": {}, # wallet -> [token_id] held when we started
|
||||
"my_pos": {}, # token_id -> {"shares", "cost", "title", "outcome"}
|
||||
"spend": {"date": "", "usd": 0.0},
|
||||
"seeded": [], # wallets whose starting positions we loaded
|
||||
}
|
||||
|
||||
|
||||
# ── market data ─────────────────────────────────────────────────────────────
|
||||
|
||||
def clob_price(token_id, side):
|
||||
"""Best price to trade `side` ('buy'/'sell') on this token, or None."""
|
||||
try:
|
||||
url = f"{CLOB_API}/price?token_id={token_id}&side={side}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=10, context=SSL_CTX) as r:
|
||||
return float(json.loads(r.read().decode())["price"])
|
||||
except (urllib.error.URLError, KeyError, ValueError, TimeoutError):
|
||||
return None
|
||||
|
||||
|
||||
def their_positions(wallet):
|
||||
"""Current open positions -> {token_id: shares}, for exit-fraction math."""
|
||||
pos = {}
|
||||
offset = 0
|
||||
while offset < 500:
|
||||
page = get_json("/positions",
|
||||
{"user": wallet, "limit": 50, "offset": offset,
|
||||
"sizeThreshold": 0.1})
|
||||
if not page:
|
||||
break
|
||||
for p in page:
|
||||
if p.get("asset"):
|
||||
pos[p["asset"]] = pos.get(p["asset"], 0) + p.get("size", 0)
|
||||
offset += 50
|
||||
if len(page) < 50:
|
||||
break
|
||||
return pos
|
||||
|
||||
|
||||
def recent_trades(wallet, limit=100):
|
||||
"""Newest-first TRADE activity for a wallet."""
|
||||
return get_json("/activity",
|
||||
{"user": wallet, "type": "TRADE", "limit": limit}) or []
|
||||
|
||||
|
||||
# ── execution ────────────────────────────────────────────────────────────────
|
||||
|
||||
class PaperExecutor:
|
||||
"""Simulates fills at the current best price. Places nothing."""
|
||||
live = False
|
||||
|
||||
def buy(self, token_id, shares, price, meta):
|
||||
return {"ok": True, "filled_shares": shares, "price": price, "paper": True}
|
||||
|
||||
def sell(self, token_id, shares, price, meta):
|
||||
return {"ok": True, "filled_shares": shares, "price": price, "paper": True}
|
||||
|
||||
|
||||
class LiveExecutor:
|
||||
"""Places real orders via py-clob-client. Imported lazily."""
|
||||
live = True
|
||||
|
||||
def __init__(self, cfg):
|
||||
try:
|
||||
from py_clob_client.client import ClobClient
|
||||
from py_clob_client.clob_types import OrderArgs, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY, SELL
|
||||
except ImportError:
|
||||
sys.exit("Live mode needs py-clob-client: pip install py-clob-client")
|
||||
self._OrderArgs, self._OrderType = OrderArgs, OrderType
|
||||
self._BUY, self._SELL = BUY, SELL
|
||||
live = cfg["live"]
|
||||
if not live.get("private_key"):
|
||||
sys.exit("Live mode needs live.private_key in the config.")
|
||||
self.client = ClobClient(
|
||||
host=CLOB_API,
|
||||
key=live["private_key"],
|
||||
chain_id=POLYGON_CHAIN_ID,
|
||||
signature_type=live.get("signature_type", 1),
|
||||
funder=live.get("funder_address") or None,
|
||||
)
|
||||
self.client.set_api_creds(self.client.create_or_derive_api_creds())
|
||||
|
||||
def _order(self, token_id, shares, price, side):
|
||||
args = self._OrderArgs(price=round(price, 3), size=round(shares, 2),
|
||||
side=side, token_id=token_id)
|
||||
signed = self.client.create_order(args)
|
||||
resp = self.client.post_order(signed, self._OrderType.GTC)
|
||||
ok = bool(resp and resp.get("success", True))
|
||||
return {"ok": ok, "filled_shares": shares, "price": price,
|
||||
"resp": resp, "paper": False}
|
||||
|
||||
def buy(self, token_id, shares, price, meta):
|
||||
return self._order(token_id, shares, price, self._BUY)
|
||||
|
||||
def sell(self, token_id, shares, price, meta):
|
||||
return self._order(token_id, shares, price, self._SELL)
|
||||
|
||||
|
||||
# ── engine ────────────────────────────────────────────────────────────────
|
||||
|
||||
class CopyTrader:
|
||||
def __init__(self, cfg, state, executor, state_path):
|
||||
self.cfg = cfg
|
||||
self.state = state
|
||||
self.ex = executor
|
||||
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:
|
||||
self.state["spend"] = {"date": today, "usd": 0.0}
|
||||
|
||||
def open_exposure(self):
|
||||
return sum(p["cost"] for p in self.state["my_pos"].values())
|
||||
|
||||
def persist(self):
|
||||
self.state["seen_tx"] = list(self.seen)[-5000:]
|
||||
save_json(self.state_path, self.state)
|
||||
|
||||
# -- risk gate: returns (allowed_usd, reason_if_blocked) --
|
||||
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']}]"
|
||||
if len(self.state["my_pos"]) >= r["max_open_positions"]:
|
||||
return 0.0, f"max open positions ({r['max_open_positions']}) reached"
|
||||
self.reset_daily_if_needed()
|
||||
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(),
|
||||
]
|
||||
allowed = min(caps)
|
||||
if allowed < r["min_order_usd"]:
|
||||
return 0.0, (f"capped to ${allowed:.2f} < min order "
|
||||
f"${r['min_order_usd']:.2f} (daily/exposure caps)")
|
||||
return allowed, None
|
||||
|
||||
# -- process one of their trades --
|
||||
def handle_trade(self, wallet, t):
|
||||
tx = t.get("transactionHash")
|
||||
if not tx or tx in self.seen:
|
||||
return
|
||||
token = t.get("asset")
|
||||
side = t.get("side") # BUY / SELL
|
||||
their_size = t.get("size", 0)
|
||||
their_price = t.get("price", 0)
|
||||
title = t.get("title", "?")
|
||||
outcome = t.get("outcome", "?")
|
||||
label = f"{outcome} · {title[:42]}"
|
||||
|
||||
their_book = self.state["their_pos"].setdefault(wallet, {})
|
||||
their_prev = their_book.get(token, 0)
|
||||
|
||||
if side == "BUY":
|
||||
self._handle_their_buy(wallet, token, their_size, their_price,
|
||||
label, title, outcome)
|
||||
their_book[token] = their_prev + their_size
|
||||
elif side == "SELL":
|
||||
self._handle_their_sell(token, their_size, their_prev, label)
|
||||
their_book[token] = max(0.0, their_prev - their_size)
|
||||
|
||||
self.seen.add(tx)
|
||||
self.persist()
|
||||
|
||||
def _live_price(self, token, side):
|
||||
p = clob_price(token, side)
|
||||
if p is None:
|
||||
self.log(f" ⚠ no live price for token, skipping")
|
||||
return p
|
||||
|
||||
def _price_guard_ok(self, current, their_price):
|
||||
if their_price <= 0:
|
||||
return True
|
||||
drift = abs(current - their_price) / their_price
|
||||
return drift <= self.cfg["price_guard_pct"]
|
||||
|
||||
def _handle_their_buy(self, wallet, token, their_size, their_price,
|
||||
label, title, outcome):
|
||||
mine = self.state["my_pos"].get(token)
|
||||
is_add = mine is not None
|
||||
# don't backfill: never open a position they already held when we
|
||||
# started watching. (A position we built during the run is an ADD;
|
||||
# a brand-new position they opened after start is a fresh OPEN.)
|
||||
if not is_add and token in self.state["seed_tokens"].get(wallet, []):
|
||||
self.log(f"BUY {label} — skip (held before we started, no backfill)")
|
||||
return
|
||||
|
||||
price = self._live_price(token, "buy")
|
||||
if price is None:
|
||||
return
|
||||
if not self._price_guard_ok(price, their_price):
|
||||
self.log(f"BUY {label} — skip (price {price:.3f} vs their "
|
||||
f"{their_price:.3f}, >{self.cfg['price_guard_pct']:.0%})")
|
||||
return
|
||||
|
||||
if is_add:
|
||||
# proportional add: grow my position by the same fraction they did
|
||||
their_prev = self.state["their_pos"].get(wallet, {}).get(token, 0)
|
||||
frac = their_size / their_prev if their_prev > 0 else 0
|
||||
want_shares = mine["shares"] * frac
|
||||
want_usd = want_shares * price
|
||||
kind = "ADD "
|
||||
else:
|
||||
want_usd = self.cfg["bankroll_usd"] * self.cfg["bankroll_pct"]
|
||||
kind = "OPEN"
|
||||
|
||||
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
|
||||
shares = allowed / price
|
||||
res = self.ex.buy(token, shares, price, {"title": title})
|
||||
if not res["ok"]:
|
||||
self.log(f"{kind} {label} — ORDER FAILED: {res.get('resp')}")
|
||||
return
|
||||
spent = res["filled_shares"] * res["price"]
|
||||
self.state["spend"]["usd"] += spent
|
||||
if is_add:
|
||||
mine["shares"] += res["filled_shares"]
|
||||
mine["cost"] += spent
|
||||
else:
|
||||
self.state["my_pos"][token] = {
|
||||
"shares": res["filled_shares"], "cost": spent,
|
||||
"title": title, "outcome": outcome}
|
||||
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
|
||||
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)
|
||||
if not mine:
|
||||
return # we don't hold it
|
||||
frac = 1.0 if their_prev <= 0 else min(1.0, their_size / their_prev)
|
||||
sell_shares = min(mine["shares"], mine["shares"] * frac)
|
||||
if sell_shares <= 0:
|
||||
return
|
||||
price = self._live_price(token, "sell")
|
||||
if price is None:
|
||||
return
|
||||
res = self.ex.sell(token, sell_shares, price, {})
|
||||
if not res["ok"]:
|
||||
self.log(f"EXIT {label} — ORDER FAILED: {res.get('resp')}")
|
||||
return
|
||||
proceeds = res["filled_shares"] * res["price"]
|
||||
# reduce position; release cost proportionally
|
||||
sold_frac = res["filled_shares"] / mine["shares"] if mine["shares"] else 1
|
||||
mine["cost"] *= (1 - sold_frac)
|
||||
mine["shares"] -= res["filled_shares"]
|
||||
tag = "[PAPER]" if not self.ex.live else "[LIVE]"
|
||||
verb = "EXIT" if frac >= 0.999 else "TRIM"
|
||||
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]
|
||||
|
||||
# -- seed their current positions so exits mirror proportionally --
|
||||
def seed_wallet(self, wallet):
|
||||
if wallet in self.state["seeded"]:
|
||||
return
|
||||
self.state["their_pos"][wallet] = their_positions(wallet)
|
||||
self.state["seed_tokens"][wallet] = list(self.state["their_pos"][wallet])
|
||||
self.state["seeded"].append(wallet)
|
||||
n = len(self.state["their_pos"][wallet])
|
||||
self.log(f"seeded {wallet[:10]}… with {n} existing positions "
|
||||
f"(won't be copied as new entries)")
|
||||
|
||||
# -- one polling pass over every watched wallet --
|
||||
def poll_once(self, first_pass):
|
||||
started = self.state["started_at"]
|
||||
for wallet in self.cfg["watchlist"]:
|
||||
self.seed_wallet(wallet)
|
||||
trades = recent_trades(wallet)
|
||||
# oldest-first so position math is causal
|
||||
for t in sorted(trades, key=lambda x: x.get("timestamp", 0)):
|
||||
# on the very first pass, ignore anything from before we started
|
||||
if first_pass and t.get("timestamp", 0) < started:
|
||||
self.seen.add(t.get("transactionHash"))
|
||||
continue
|
||||
self.handle_trade(wallet, t)
|
||||
self.persist()
|
||||
|
||||
def run(self, once):
|
||||
mode = "LIVE — REAL MONEY" if self.ex.live else "PAPER (no orders placed)"
|
||||
self.log(f"copy-trader started · mode: {mode}")
|
||||
self.log(f"watching {len(self.cfg['watchlist'])} wallets · "
|
||||
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.)")
|
||||
return
|
||||
first = True
|
||||
try:
|
||||
while True:
|
||||
self.poll_once(first_pass=first)
|
||||
first = False
|
||||
if once:
|
||||
break
|
||||
time.sleep(self.cfg["poll_seconds"])
|
||||
except KeyboardInterrupt:
|
||||
self.log("stopped.")
|
||||
|
||||
|
||||
# ── cli ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def confirm_live(cfg):
|
||||
print("\n" + "=" * 64)
|
||||
print(" LIVE MODE — this will place REAL orders with REAL money.")
|
||||
print(f" Bankroll ${cfg['bankroll_usd']:.0f} · {cfg['bankroll_pct']:.1%}/entry"
|
||||
f" · max ${cfg['risk']['max_trade_usd']:.0f}/trade"
|
||||
f" · daily cap ${cfg['risk']['daily_spend_cap_usd']:.0f}")
|
||||
print(f" Watching {len(cfg['watchlist'])} wallets.")
|
||||
print("=" * 64)
|
||||
typed = input(f'Type "{CONFIRM_PHRASE}" to proceed (anything else aborts): ')
|
||||
if typed.strip() != CONFIRM_PHRASE:
|
||||
sys.exit("Aborted — not confirmed.")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--config", default="config.json")
|
||||
ap.add_argument("--state", default=STATE_PATH_DEFAULT)
|
||||
ap.add_argument("--live", action="store_true",
|
||||
help="enable live trading (also needs mode:live in config)")
|
||||
ap.add_argument("--once", action="store_true", help="one pass, then exit")
|
||||
ap.add_argument("--init", action="store_true",
|
||||
help="write config.example.json and exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.init:
|
||||
save_json("config.example.json", DEFAULT_CONFIG)
|
||||
print("Wrote config.example.json — copy to config.json and edit.")
|
||||
return
|
||||
|
||||
if not os.path.exists(args.config):
|
||||
sys.exit(f"No config at {args.config}. Run --init to create a template.")
|
||||
cfg = {**DEFAULT_CONFIG, **load_json(args.config, {})}
|
||||
cfg["risk"] = {**DEFAULT_CONFIG["risk"], **cfg.get("risk", {})}
|
||||
cfg["live"] = {**DEFAULT_CONFIG["live"], **cfg.get("live", {})}
|
||||
|
||||
want_live = args.live and cfg.get("mode") == "live"
|
||||
if args.live and cfg.get("mode") != "live":
|
||||
sys.exit('--live given but config "mode" is not "live". Refusing to trade.')
|
||||
|
||||
state = load_json(args.state, new_state())
|
||||
if want_live:
|
||||
confirm_live(cfg)
|
||||
executor = LiveExecutor(cfg)
|
||||
else:
|
||||
executor = PaperExecutor()
|
||||
|
||||
CopyTrader(cfg, state, executor, args.state).run(once=args.once)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan many wallets for a RELIABLE, COPYABLE weekly edge.
|
||||
|
||||
Two passes:
|
||||
1. metrics — for every candidate, bucket resolved-bet PnL by week over the
|
||||
window and compute consistency (% green weeks, profit factor, Sharpe, ROI).
|
||||
Results stream to a JSONL file so a long run is crash-safe.
|
||||
2. copyability — for the wallets that look profitable, pull /activity and
|
||||
measure how much they hold to resolution (mirrorable) vs trade around
|
||||
(not mirrorable by copying entries).
|
||||
|
||||
python3 edge_research.py --pool 1500 --days 120
|
||||
|
||||
Outputs: edge_metrics.jsonl (raw, all wallets)
|
||||
edge_profitable.json (filtered + copyability, ranked)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import smart_money as sm
|
||||
|
||||
WEEK = 7 * 86400
|
||||
MAX_PAGES = 40 # per endpoint, bounds runtime on hyperactive wallets
|
||||
|
||||
|
||||
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 candidates(pool):
|
||||
seen = {}
|
||||
for window in ("7d", "30d", "all"):
|
||||
offset = 0
|
||||
while offset < pool and offset < 2000:
|
||||
page = sm.get_json("/v1/leaderboard",
|
||||
{"window": window, "limit": 50, "offset": offset})
|
||||
if not page:
|
||||
break
|
||||
for u in page:
|
||||
w = u.get("proxyWallet")
|
||||
if w and w not in seen:
|
||||
seen[w] = {"wallet": w,
|
||||
"username": u.get("userName") or w[:10] + "...",
|
||||
"lb_pnl": u.get("pnl", 0)}
|
||||
offset += 50
|
||||
if len(page) < 50:
|
||||
break
|
||||
if len(seen) >= pool:
|
||||
break
|
||||
return list(seen.values())[:pool]
|
||||
|
||||
|
||||
def resolved_with_stake(wallet, cutoff):
|
||||
now = time.time()
|
||||
out = []
|
||||
off = 0
|
||||
while off < MAX_PAGES * 50:
|
||||
page = sm.get_json("/closed-positions",
|
||||
{"user": wallet, "limit": 50, "offset": off,
|
||||
"sortBy": "TIMESTAMP", "sortDirection": "DESC"})
|
||||
if not page:
|
||||
break
|
||||
for p in page:
|
||||
if p.get("timestamp", 0) >= cutoff:
|
||||
out.append({"ts": p["timestamp"], "pnl": p.get("realizedPnl", 0),
|
||||
"stake": p.get("avgPrice", 0) * p.get("totalBought", 0)})
|
||||
off += 50
|
||||
if len(page) < 50 or page[-1].get("timestamp", 0) < cutoff:
|
||||
break
|
||||
off = 0
|
||||
while off < MAX_PAGES * 50:
|
||||
page = sm.get_json("/positions",
|
||||
{"user": wallet, "limit": 50, "offset": off,
|
||||
"sizeThreshold": 0.0})
|
||||
if not page:
|
||||
break
|
||||
for p in page:
|
||||
end = _parse_end(p.get("endDate"))
|
||||
if cutoff <= end < now:
|
||||
out.append({"ts": end, "pnl": p.get("cashPnl", 0),
|
||||
"stake": p.get("initialValue", 0)})
|
||||
off += 50
|
||||
if len(page) < 50:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def metrics(cand, cutoff):
|
||||
bets = resolved_with_stake(cand["wallet"], cutoff)
|
||||
if len(bets) < 20:
|
||||
return None
|
||||
by_week = defaultdict(lambda: [0.0, 0.0])
|
||||
for b in bets:
|
||||
wk = int(b["ts"] // WEEK)
|
||||
by_week[wk][0] += b["pnl"]
|
||||
by_week[wk][1] += b["stake"]
|
||||
weeks = sorted(by_week)
|
||||
wpnl = [by_week[w][0] for w in weeks]
|
||||
wroi = [by_week[w][0] / by_week[w][1] if by_week[w][1] else 0 for w in weeks]
|
||||
total_pnl = sum(wpnl)
|
||||
total_stake = sum(by_week[w][1] for w in weeks)
|
||||
gw = sum(p for p in wpnl if p > 0)
|
||||
gl = abs(sum(p for p in wpnl if p < 0))
|
||||
mean_roi = statistics.mean(wroi)
|
||||
std_roi = statistics.pstdev(wroi) if len(wroi) > 1 else 0
|
||||
return {
|
||||
"wallet": cand["wallet"], "username": cand["username"],
|
||||
"lb_pnl": round(cand["lb_pnl"]),
|
||||
"n_weeks": len(weeks), "n_bets": len(bets),
|
||||
"pct_weeks_pos": round(sum(1 for p in wpnl if p > 0) / len(weeks) * 100),
|
||||
"mean_weekly_roi": round(mean_roi * 100, 1),
|
||||
"weekly_sharpe": round(mean_roi / std_roi, 2) if std_roi else 0,
|
||||
"profit_factor": round(gw / gl, 2) if gl else 999,
|
||||
"total_pnl": round(total_pnl),
|
||||
"total_roi": round(total_pnl / total_stake * 100, 1) if total_stake else 0,
|
||||
}
|
||||
|
||||
|
||||
def copyability(wallet):
|
||||
trades, off = [], 0
|
||||
while off < 2000: # cap fills for speed
|
||||
p = sm.get_json("/activity",
|
||||
{"user": wallet, "type": "TRADE", "limit": 500, "offset": off})
|
||||
if not p:
|
||||
break
|
||||
trades += p
|
||||
off += 500
|
||||
if len(p) < 500:
|
||||
break
|
||||
by_mkt = defaultdict(lambda: {"buy_usd": 0.0, "sell_usd": 0.0, "sold": False})
|
||||
for t in trades:
|
||||
m = by_mkt[t.get("conditionId")]
|
||||
if t.get("side") == "BUY":
|
||||
m["buy_usd"] += t.get("usdcSize", 0)
|
||||
else:
|
||||
m["sell_usd"] += t.get("usdcSize", 0)
|
||||
m["sold"] = True
|
||||
n = len(by_mkt) or 1
|
||||
hold = sum(1 for m in by_mkt.values() if not m["sold"])
|
||||
return {"markets": len(by_mkt), "hold_pct": round(hold / n * 100),
|
||||
"fills": len(trades)}
|
||||
|
||||
|
||||
def run(pool, days, workers):
|
||||
cutoff = time.time() - days * 86400
|
||||
out_path, prof_path = "edge_metrics.jsonl", "edge_profitable.json"
|
||||
print(f"[{time.strftime('%H:%M:%S')}] pulling up to {pool} candidates...", flush=True)
|
||||
cands = candidates(pool)
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {len(cands)} candidates · "
|
||||
f"window {days}d · analyzing (workers={workers})", flush=True)
|
||||
|
||||
done = kept = 0
|
||||
with open(out_path, "w") as fout, ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futs = {ex.submit(metrics, c, cutoff): c for c in cands}
|
||||
for f in as_completed(futs):
|
||||
done += 1
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception:
|
||||
r = None
|
||||
if r:
|
||||
kept += 1
|
||||
fout.write(json.dumps(r) + "\n")
|
||||
fout.flush()
|
||||
if done % 50 == 0 or done == len(cands):
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {done}/{len(cands)} analyzed "
|
||||
f"· {kept} with enough history", flush=True)
|
||||
|
||||
rows = [json.loads(l) for l in open(out_path)]
|
||||
# "looks profitable" screen
|
||||
prof = [r for r in rows if r["n_weeks"] >= max(4, days // 7 * 0.4)
|
||||
and r["n_bets"] >= 30 and r["total_pnl"] > 0 and r["total_roi"] > 0
|
||||
and r["pct_weeks_pos"] >= 60 and r["profit_factor"] >= 1.3]
|
||||
print(f"\n[{time.strftime('%H:%M:%S')}] {len(prof)} wallets pass the profitable "
|
||||
f"screen · checking copyability...", flush=True)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futs = {ex.submit(copyability, r["wallet"]): r for r in prof}
|
||||
for f in as_completed(futs):
|
||||
r = futs[f]
|
||||
try:
|
||||
r["copy"] = f.result()
|
||||
except Exception:
|
||||
r["copy"] = {"markets": 0, "hold_pct": 0, "fills": 0}
|
||||
|
||||
for r in prof:
|
||||
r["copyable"] = r["copy"]["hold_pct"] >= 70
|
||||
# composite: reward consistency, profit factor, and ROI
|
||||
r["score"] = round(r["pct_weeks_pos"] / 100 * r["profit_factor"]
|
||||
* (1 + r["total_roi"] / 100), 2)
|
||||
prof.sort(key=lambda r: (r["copyable"], r["score"]), reverse=True)
|
||||
json.dump(prof, open(prof_path, "w"), indent=2)
|
||||
|
||||
print(f"\n{'='*94}")
|
||||
print(f" PROFITABLE & COPYABLE wallets (window {days}d, pool {len(cands)})")
|
||||
print(f"{'='*94}")
|
||||
h = (f"{'Trader':<20}{'wks':>4}{'bets':>6}{'%wk+':>6}{'PF':>6}"
|
||||
f"{'Sharpe':>7}{'totROI':>8}{'hold%':>7}{'copy':>6}{'90d PnL':>13}")
|
||||
print(h)
|
||||
print("-" * len(h))
|
||||
for r in prof:
|
||||
print(f"{r['username'][:20]:<20}{r['n_weeks']:>4}{r['n_bets']:>6}"
|
||||
f"{r['pct_weeks_pos']:>5}%{r['profit_factor']:>6.2f}"
|
||||
f"{r['weekly_sharpe']:>7.2f}{r['total_roi']:>7}%"
|
||||
f"{r['copy']['hold_pct']:>6}%{'yes' if r['copyable'] else 'no':>6}"
|
||||
f"{'$'+format(r['total_pnl'], ','):>13}")
|
||||
print("-" * len(h))
|
||||
cop = sum(1 for r in prof if r["copyable"])
|
||||
print(f"{len(prof)} profitable · {cop} of them copyable (hold-to-resolution ≥70%)")
|
||||
print(f"Full detail: {prof_path}\n")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--pool", type=int, default=1500)
|
||||
ap.add_argument("--days", type=int, default=120)
|
||||
ap.add_argument("--workers", type=int, default=12)
|
||||
args = ap.parse_args()
|
||||
run(args.pool, args.days, args.workers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""240-day lookback on a short list of wallets, split into halves.
|
||||
|
||||
We selected these wallets on their last 120 days. The *older* half (240->120
|
||||
days ago) is data that played no part in selection — so consistency there is
|
||||
backward out-of-sample evidence the edge is real, not a lucky recent stretch.
|
||||
"""
|
||||
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
import smart_money as sm
|
||||
|
||||
WEEK = 7 * 86400
|
||||
PAGES = 160 # generous for this focused 5-wallet run
|
||||
|
||||
|
||||
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(wallet, cutoff):
|
||||
now = time.time()
|
||||
out = []
|
||||
off = 0
|
||||
while off < PAGES * 50:
|
||||
page = sm.get_json("/closed-positions",
|
||||
{"user": wallet, "limit": 50, "offset": off,
|
||||
"sortBy": "TIMESTAMP", "sortDirection": "DESC"})
|
||||
if not page:
|
||||
break
|
||||
for p in page:
|
||||
if p.get("timestamp", 0) >= cutoff:
|
||||
out.append({"ts": p["timestamp"], "pnl": p.get("realizedPnl", 0),
|
||||
"stake": p.get("avgPrice", 0) * p.get("totalBought", 0)})
|
||||
off += 50
|
||||
if len(page) < 50 or page[-1].get("timestamp", 0) < cutoff:
|
||||
break
|
||||
off = 0
|
||||
while off < PAGES * 50:
|
||||
page = sm.get_json("/positions",
|
||||
{"user": wallet, "limit": 50, "offset": off,
|
||||
"sizeThreshold": 0.0})
|
||||
if not page:
|
||||
break
|
||||
for p in page:
|
||||
end = parse_end(p.get("endDate"))
|
||||
if cutoff <= end < now:
|
||||
out.append({"ts": end, "pnl": p.get("cashPnl", 0),
|
||||
"stake": p.get("initialValue", 0)})
|
||||
off += 50
|
||||
if len(page) < 50:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def stats(bets):
|
||||
if not bets:
|
||||
return None
|
||||
by_week = defaultdict(lambda: [0.0, 0.0])
|
||||
for b in bets:
|
||||
wk = int(b["ts"] // WEEK)
|
||||
by_week[wk][0] += b["pnl"]
|
||||
by_week[wk][1] += b["stake"]
|
||||
weeks = sorted(by_week)
|
||||
wpnl = [by_week[w][0] for w in weeks]
|
||||
wroi = [by_week[w][0] / by_week[w][1] if by_week[w][1] else 0 for w in weeks]
|
||||
tot_pnl = sum(wpnl)
|
||||
tot_stake = sum(by_week[w][1] for w in weeks)
|
||||
gw = sum(p for p in wpnl if p > 0)
|
||||
gl = abs(sum(p for p in wpnl if p < 0))
|
||||
mean = statistics.mean(wroi)
|
||||
std = statistics.pstdev(wroi) if len(wroi) > 1 else 0
|
||||
return {
|
||||
"weeks": len(weeks), "bets": len(bets),
|
||||
"green": round(sum(1 for p in wpnl if p > 0) / len(weeks) * 100),
|
||||
"pf": round(gw / gl, 2) if gl else 999,
|
||||
"sharpe": round(mean / std, 2) if std else 0,
|
||||
"roi": round(tot_pnl / tot_stake * 100, 1) if tot_stake else 0,
|
||||
"pnl": round(tot_pnl),
|
||||
}
|
||||
|
||||
|
||||
def line(label, s):
|
||||
if not s:
|
||||
print(f" {label:<8} (no resolved bets in this period)")
|
||||
return
|
||||
print(f" {label:<8} {s['weeks']:>2}wk {s['bets']:>5}bets "
|
||||
f"{s['green']:>3}%grn PF {s['pf']:>6} Sharpe {s['sharpe']:>5} "
|
||||
f"ROI {s['roi']:>6}% ${s['pnl']:>12,}")
|
||||
|
||||
|
||||
def main(wallets):
|
||||
now = time.time()
|
||||
mid = now - 120 * 86400
|
||||
for name, w in wallets:
|
||||
bets = resolved(w, now - 240 * 86400)
|
||||
older = [b for b in bets if b["ts"] < mid] # 240->120d (not used to select)
|
||||
recent = [b for b in bets if b["ts"] >= mid] # 120->0d (selection window)
|
||||
print(f"\n{name} ({w[:16]}…)")
|
||||
line("240d all", stats(bets))
|
||||
line("older½", stats(older)) # out-of-sample
|
||||
line("recent½", stats(recent)) # in-sample
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# name, wallet — passed as alternating argv or hardcoded by caller
|
||||
pairs = [(sys.argv[i], sys.argv[i + 1]) for i in range(1, len(sys.argv), 2)]
|
||||
main(pairs)
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Paper liquidity-provision loop — measures NET reward yield without real money.
|
||||
|
||||
It simulates posting two-sided limit orders near the midpoint on the screener's
|
||||
top markets, against the LIVE order book, and tracks:
|
||||
|
||||
net P&L = rewards accrued - adverse-selection / inventory P&L
|
||||
|
||||
Reward model (per poll, per market):
|
||||
your_share = your_notional / (your_notional + competing_notional_near_mid)
|
||||
rewards += daily_pool * your_share * (seconds_elapsed / 86400)
|
||||
(matches Polymarket's score-share mechanic; assumes ~equal price-quality, which
|
||||
is conservative since we quote tight to mid.)
|
||||
|
||||
Fill / adverse-selection model:
|
||||
We rest a bid at mid-tick and an ask at mid+tick. Between polls, if the
|
||||
midpoint crosses a quote, that quote is assumed FILLED at its price, and we
|
||||
take on inventory marked at the NEW mid — so a price that runs through us
|
||||
books an immediate loss. This is the bleed that must stay below rewards.
|
||||
Each poll we "cancel and re-quote" around the new mid (a bot requoting every
|
||||
poll interval). Shorter --poll = less bleed, fewer missed requotes.
|
||||
|
||||
This is an APPROXIMATION (ignores queue position, partial fills, requote
|
||||
latency), deliberately a bit pessimistic on fills. Good enough to answer the
|
||||
one question that gates real money: is net positive, and how big?
|
||||
|
||||
python3 lp_paper.py --capital 1000 --markets 6 --poll 20
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from lp_screener import get, reward_markets, daily_rate, hours_to_end, realized_vol_cents, CLOB
|
||||
from copytrade import post_discord, load_json
|
||||
|
||||
STATE_PATH = "lp_paper_state.json"
|
||||
|
||||
|
||||
def screen_targets(n, min_rate, per_market, max_vol):
|
||||
"""Pick the top-N low-vol reward markets we can actually qualify in.
|
||||
|
||||
Filters out markets where our per-side size would fall below the market's
|
||||
min_size (we'd earn nothing) and markets priced outside 0.10-0.90 (the
|
||||
double-sided-only regime, easy to get adversely filled at the extremes).
|
||||
"""
|
||||
mkts = [m for m in reward_markets()
|
||||
if m.get("active") and not m.get("closed") and daily_rate(m) >= min_rate]
|
||||
scored = []
|
||||
def assess(m):
|
||||
r = m.get("rewards") or {}
|
||||
ms = r.get("max_spread", 0) / 100.0
|
||||
min_size = r.get("min_size", 0)
|
||||
toks = m.get("tokens") or []
|
||||
if not toks or ms <= 0:
|
||||
return None
|
||||
tok = toks[0]["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
|
||||
mid = (max(p for p, _ in bids) + min(p for p, _ in asks)) / 2
|
||||
if not (0.10 <= mid <= 0.90): # extreme regime: skip
|
||||
return None
|
||||
if mid <= 0 or (per_market / 2) / mid < min_size: # can't meet min_size
|
||||
return None
|
||||
comp = min(sum(p * s for p, s in bids if p >= mid - ms),
|
||||
sum((1 - p) * s for p, s in asks if p <= mid + ms))
|
||||
vol = realized_vol_cents(tok)
|
||||
hrs = hours_to_end(m)
|
||||
if vol is None or vol > max_vol: # skip toxic / unknown-vol
|
||||
return None
|
||||
if hrs is not None and hrs < 24: # skip imminent/live
|
||||
return None
|
||||
return {
|
||||
"token": tok, "question": m.get("question", "?")[:50],
|
||||
"pool": daily_rate(m), "max_spread": ms, "min_size": min_size,
|
||||
"tick": float(bk.get("tick_size", 0.01)),
|
||||
"comp": comp, "mid": mid, "vol": vol,
|
||||
}
|
||||
with ThreadPoolExecutor(max_workers=16) as ex:
|
||||
for res in ex.map(assess, mkts):
|
||||
if res:
|
||||
scored.append(res)
|
||||
# rank by reward-yield / competition, low vol
|
||||
scored.sort(key=lambda x: x["pool"] / (x["comp"] + x["pool"]) / (1 + x["vol"]),
|
||||
reverse=True)
|
||||
return scored[:n]
|
||||
|
||||
|
||||
def fresh_market_state(t, per_market):
|
||||
# cash = cumulative cash flow from simulated trades (buys negative, sells
|
||||
# positive); inv = signed share position. Net trading P&L = cash + inv*mid.
|
||||
return {**t, "notional": per_market / 2, "bid": None, "ask": None,
|
||||
"inv": 0.0, "cash": 0.0, "rewards": 0.0, "fills": 0,
|
||||
"last_t": time.time()}
|
||||
|
||||
|
||||
def poll_market(s, max_inv_mult, max_dt):
|
||||
"""One observe-fill-accrue-requote step against the live book."""
|
||||
try:
|
||||
bk = get(f"{CLOB}/book?token_id={s['token']}")
|
||||
except Exception:
|
||||
return
|
||||
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
|
||||
mid = (max(p for p, _ in bids) + min(p for p, _ in asks)) / 2
|
||||
if mid <= 0:
|
||||
return
|
||||
now = time.time()
|
||||
dt = min(now - s["last_t"], max_dt) # cap dt so a stall/sleep can't over-credit
|
||||
s["last_t"] = now
|
||||
size = s["notional"] / mid # intended per-side size (shares)
|
||||
cap = max_inv_mult * size # price-aware inventory cap
|
||||
|
||||
# 1) fills: did the mid cross our resting quotes? cap the fill to remaining
|
||||
# inventory room so one fill can't overshoot the intended position.
|
||||
if s["bid"] is not None and mid <= s["bid"]: # bought at our bid
|
||||
f = min(size, max(0.0, cap - s["inv"]))
|
||||
if f > 0:
|
||||
s["inv"] += f
|
||||
s["cash"] -= f * s["bid"]
|
||||
s["fills"] += 1
|
||||
if s["ask"] is not None and mid >= s["ask"]: # sold at our ask
|
||||
f = min(size, max(0.0, cap + s["inv"]))
|
||||
if f > 0:
|
||||
s["inv"] -= f
|
||||
s["cash"] += f * s["ask"]
|
||||
s["fills"] += 1
|
||||
|
||||
# 2) accrue rewards — only if we'd actually qualify (min_size, price regime),
|
||||
# and at 1/3 share when only one side is live (Polymarket's Q_min penalty).
|
||||
comp = s["comp"]
|
||||
base = s["notional"] / (s["notional"] + comp) if (s["notional"] + comp) > 0 else 0
|
||||
both_live = (s["inv"] < cap) and (s["inv"] > -cap)
|
||||
qualifies = size >= s["min_size"] and 0.10 <= mid <= 0.90
|
||||
eff = 0.0 if not qualifies else (base if both_live else base / 3.0)
|
||||
s["rewards"] += s["pool"] * eff * (dt / 86400.0)
|
||||
|
||||
# 3) re-quote around the new mid (within max_spread), respecting inventory cap
|
||||
s["mid"] = mid
|
||||
s["bid"] = mid - s["tick"] if s["inv"] < cap else None
|
||||
s["ask"] = mid + s["tick"] if s["inv"] > -cap else None
|
||||
ms = s["max_spread"]
|
||||
s["comp"] = min(sum(p * sz for p, sz in bids if p >= mid - ms),
|
||||
sum((1 - p) * sz for p, sz in asks if p <= mid + ms))
|
||||
|
||||
|
||||
def net_pnl(s):
|
||||
return s["rewards"] + s["cash"] + s["inv"] * s["mid"]
|
||||
|
||||
|
||||
def summary(states, started, capital, retired):
|
||||
rew = retired["rewards"] + sum(s["rewards"] for s in states)
|
||||
trading = retired["trading"] + sum(s["cash"] + s["inv"] * s["mid"] for s in states)
|
||||
net = rew + trading
|
||||
hrs = (time.time() - started) / 3600 or 1e-9
|
||||
apr = net / capital / (hrs / 24) * 365 * 100 if capital else 0
|
||||
lines = [
|
||||
f"⏱ {hrs:.1f}h · capital ${capital:,.0f}",
|
||||
f" rewards accrued : +${rew:,.2f}",
|
||||
f" trading/inventory: {trading:+,.2f} (adverse-selection bleed)",
|
||||
f" ── NET : {net:+,.2f} (~{apr:,.0f}% APR if it holds)",
|
||||
]
|
||||
return net, "\n".join(lines)
|
||||
|
||||
|
||||
def run(args):
|
||||
cfg = load_json("config.json", {})
|
||||
webhook = cfg.get("discord_webhook", "")
|
||||
per_market = args.capital / args.markets
|
||||
print(f"[{time.strftime('%H:%M:%S')}] screening for {args.markets} low-vol markets...",
|
||||
flush=True)
|
||||
targets = screen_targets(args.markets, args.min_rate, per_market, args.max_vol)
|
||||
if not targets:
|
||||
print("No suitable markets we can qualify in at this capital/market split.")
|
||||
return
|
||||
states = [fresh_market_state(t, per_market) for t in targets]
|
||||
started = time.time()
|
||||
print(f"[{time.strftime('%H:%M:%S')}] making markets on {len(states)} markets "
|
||||
f"(${per_market:,.0f} each, ${args.capital:,.0f} total):", flush=True)
|
||||
for s in states:
|
||||
print(f" ${s['pool']:>4.0f}/day vol {s['vol']:.1f}c comp ${s['comp']:,.0f}"
|
||||
f" {s['question']}", flush=True)
|
||||
if webhook:
|
||||
post_discord(webhook, f"📊 **Paper LP started** · {len(states)} markets · "
|
||||
f"${args.capital:,.0f} capital. Tracking net = rewards − bleed.")
|
||||
|
||||
max_dt = max(120, args.poll * 5) # cap reward accrual gap (sleep/stall guard)
|
||||
# P&L from markets that have rotated out (resolved/expired) is banked here
|
||||
# so cumulative net survives rotation.
|
||||
retired = {"rewards": 0.0, "trading": 0.0}
|
||||
|
||||
def retire(s):
|
||||
retired["rewards"] += s["rewards"]
|
||||
retired["trading"] += s["cash"] + s["inv"] * s["mid"]
|
||||
|
||||
last_report = started
|
||||
next_rescreen = started + args.refresh
|
||||
try:
|
||||
while True:
|
||||
for s in states:
|
||||
poll_market(s, args.max_inv, max_dt)
|
||||
now = time.time()
|
||||
|
||||
# rotate: drop markets that fell out of the fresh screen (resolved /
|
||||
# vol spiked / out-competed), bank their P&L, add fresh ones.
|
||||
if now >= next_rescreen:
|
||||
next_rescreen = now + args.refresh
|
||||
try:
|
||||
fresh = screen_targets(args.markets, args.min_rate, per_market, args.max_vol)
|
||||
except Exception as e:
|
||||
fresh = None
|
||||
print(f"[{time.strftime('%H:%M:%S')}] re-screen failed ({e}); "
|
||||
f"keeping current markets", flush=True)
|
||||
if fresh:
|
||||
fresh_toks = {t["token"] for t in fresh}
|
||||
kept = []
|
||||
for s in states:
|
||||
if s["token"] in fresh_toks:
|
||||
kept.append(s)
|
||||
else:
|
||||
retire(s)
|
||||
states = kept
|
||||
held = {s["token"] for s in states}
|
||||
for t in fresh:
|
||||
if len(states) >= args.markets:
|
||||
break
|
||||
if t["token"] not in held:
|
||||
states.append(fresh_market_state(t, per_market))
|
||||
print(f"[{time.strftime('%H:%M:%S')}] re-screened · {len(states)} active "
|
||||
f"· banked net so far ${retired['rewards'] + retired['trading']:,.2f}",
|
||||
flush=True)
|
||||
|
||||
save_state(states, started, args.capital, retired)
|
||||
if now - last_report >= args.report:
|
||||
net, txt = summary(states, started, args.capital, retired)
|
||||
print(f"\n[{time.strftime('%H:%M:%S')}]\n{txt}", flush=True)
|
||||
if webhook:
|
||||
post_discord(webhook, "📊 **Paper LP update**\n" + txt)
|
||||
last_report = now
|
||||
if args.duration and (now - started) >= args.duration * 3600:
|
||||
break
|
||||
time.sleep(args.poll)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
net, txt = summary(states, started, args.capital, retired)
|
||||
print(f"\n=== FINAL ===\n{txt}")
|
||||
print("\nPer-market:")
|
||||
for s in sorted(states, key=net_pnl, reverse=True):
|
||||
print(f" net {net_pnl(s):+8.2f} | rew +{s['rewards']:6.2f} | "
|
||||
f"fills {s['fills']:3d} | inv {s['inv']:+8.1f} | {s['question']}")
|
||||
|
||||
|
||||
def save_state(states, started, capital, retired):
|
||||
slim = [{"question": s["question"], "pool": s["pool"],
|
||||
"rewards": round(s["rewards"], 2), "trading": round(s["cash"] + s["inv"] * s["mid"], 2),
|
||||
"inv": round(s["inv"], 1), "fills": s["fills"],
|
||||
"net": round(net_pnl(s), 2)} for s in states]
|
||||
net, _ = summary(states, started, capital, retired)
|
||||
tmp = STATE_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump({"started": started, "capital": capital, "net": round(net, 2),
|
||||
"retired": {k: round(v, 2) for k, v in retired.items()},
|
||||
"markets": slim}, f, indent=2)
|
||||
os.replace(tmp, STATE_PATH)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--capital", type=float, default=1000)
|
||||
ap.add_argument("--markets", type=int, default=6)
|
||||
ap.add_argument("--poll", type=int, default=20, help="seconds between requotes")
|
||||
ap.add_argument("--report", type=int, default=900, help="seconds between summaries")
|
||||
ap.add_argument("--refresh", type=int, default=3600, help="seconds between re-screens")
|
||||
ap.add_argument("--min-rate", type=float, default=50)
|
||||
ap.add_argument("--max-vol", type=float, default=1.5, help="max 24h vol (cents) to qualify")
|
||||
ap.add_argument("--max-inv", type=float, default=1.0, help="inventory cap multiple")
|
||||
ap.add_argument("--duration", type=float, default=0, help="hours to run (0 = until killed)")
|
||||
run(ap.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Aggregate the 77 copyable wallets into one table: total staked, PnL, ROI,
|
||||
consistency. Prints sorted by ROI and writes copyable_77.csv."""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import smart_money as sm
|
||||
from lookback import resolved # reuse the 120d+ resolved-bet puller
|
||||
|
||||
WEEK = 7 * 86400
|
||||
|
||||
|
||||
def compute(r):
|
||||
cutoff = time.time() - 120 * 86400
|
||||
bets = resolved(r["wallet"], cutoff)
|
||||
if not bets:
|
||||
return None
|
||||
by_week = defaultdict(lambda: [0.0, 0.0])
|
||||
for b in bets:
|
||||
wk = int(b["ts"] // WEEK)
|
||||
by_week[wk][0] += b["pnl"]
|
||||
by_week[wk][1] += b["stake"]
|
||||
weeks = sorted(by_week)
|
||||
wpnl = [by_week[w][0] for w in weeks]
|
||||
wroi = [by_week[w][0] / by_week[w][1] if by_week[w][1] else 0 for w in weeks]
|
||||
total_bet = sum(b["stake"] for b in bets)
|
||||
total_pnl = sum(b["pnl"] for b in bets)
|
||||
gw = sum(p for p in wpnl if p > 0)
|
||||
gl = abs(sum(p for p in wpnl if p < 0))
|
||||
mean = statistics.mean(wroi)
|
||||
std = statistics.pstdev(wroi) if len(wroi) > 1 else 0
|
||||
oldest_days = round((time.time() - min(b["ts"] for b in bets)) / 86400)
|
||||
return {
|
||||
"username": r["username"], "wallet": r["wallet"],
|
||||
"weeks": len(weeks), "bets": len(bets),
|
||||
"total_bet": round(total_bet), "total_pnl": round(total_pnl),
|
||||
"roi_pct": round(total_pnl / total_bet * 100, 1) if total_bet else 0,
|
||||
"pct_weeks_green": round(sum(1 for p in wpnl if p > 0) / len(weeks) * 100),
|
||||
"profit_factor": round(gw / gl, 2) if gl else 999,
|
||||
"weekly_sharpe": round(mean / std, 2) if std else 0,
|
||||
"hold_pct": r["copy"]["hold_pct"],
|
||||
"history_days": oldest_days,
|
||||
"avg_bet": round(total_bet / len(bets)) if bets else 0,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
cop = [r for r in json.load(open("edge_profitable.json")) if r.get("copyable")]
|
||||
out = []
|
||||
with ThreadPoolExecutor(max_workers=12) as ex:
|
||||
futs = {ex.submit(compute, r): r for r in cop}
|
||||
for f in as_completed(futs):
|
||||
r = f.result()
|
||||
if r:
|
||||
out.append(r)
|
||||
out.sort(key=lambda r: r["roi_pct"], reverse=True)
|
||||
|
||||
cols = ["username", "roi_pct", "total_bet", "total_pnl", "avg_bet",
|
||||
"pct_weeks_green", "profit_factor", "weekly_sharpe", "weeks",
|
||||
"bets", "hold_pct", "history_days", "wallet"]
|
||||
with open("copyable_77.csv", "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=cols)
|
||||
w.writeheader()
|
||||
w.writerows(out)
|
||||
|
||||
print(f"{'#':>3} {'Trader':<20}{'ROI%':>7}{'TotalBet':>13}{'TotalPnL':>13}"
|
||||
f"{'AvgBet':>9}{'%grn':>6}{'PF':>6}{'Shrp':>6}{'wks':>4}{'hist_d':>7}")
|
||||
print("-" * 100)
|
||||
for i, r in enumerate(out, 1):
|
||||
print(f"{i:>3} {r['username'][:20]:<20}{r['roi_pct']:>6}%"
|
||||
f"{'$'+format(r['total_bet'], ','):>13}{'$'+format(r['total_pnl'], ','):>13}"
|
||||
f"{'$'+format(r['avg_bet'], ','):>9}{r['pct_weeks_green']:>5}%"
|
||||
f"{r['profit_factor']:>6.1f}{r['weekly_sharpe']:>6.2f}{r['weeks']:>4}"
|
||||
f"{r['history_days']:>7}")
|
||||
print("-" * 100)
|
||||
print(f"{len(out)} copyable wallets · saved to copyable_77.csv")
|
||||
print(f" total staked across all: ${sum(r['total_bet'] for r in out):,}")
|
||||
print(f" median history: {statistics.median([r['history_days'] for r in out])} days")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Polymarket <-> Kalshi cross-venue arbitrage scanner.
|
||||
|
||||
Pulls live prices from both venues, matches the same event across them, and
|
||||
flags executable spreads: buy YES on one + NO on the other for < $1 (net of
|
||||
fees) = locked profit regardless of resolution.
|
||||
|
||||
python3 xarb.py # one-shot scan -> xarb_hits.csv
|
||||
python3 xarb.py --min-vol 5000 # only liquid markets
|
||||
|
||||
Matching is conservative (token overlap + same resolution month) to limit
|
||||
false matches — a wrong match isn't an arb, it's two different bets
|
||||
(resolution risk). Treat flagged hits as candidates to eyeball, not gospel.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
|
||||
ctx = ssl._create_unverified_context()
|
||||
K = "https://api.elections.kalshi.com/trade-api/v2"
|
||||
GAMMA = "https://gamma-api.polymarket.com"
|
||||
|
||||
STOP = {"will", "the", "a", "an", "to", "of", "in", "by", "be", "win", "wins",
|
||||
"winner", "2026", "2025", "at", "on", "for", "and", "vs", "game", "match",
|
||||
"who", "what", "during", "this", "next", "before", "after", "his", "her"}
|
||||
|
||||
|
||||
def get(url):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=30, context=ctx) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def get_safe(url):
|
||||
try:
|
||||
return get(url)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fnum(v, d=0.0):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return d
|
||||
|
||||
|
||||
def norm(s):
|
||||
s = re.sub(r"[^a-z0-9 ]", " ", (s or "").lower())
|
||||
return {t for t in s.split() if t not in STOP and len(t) > 1}
|
||||
|
||||
|
||||
def nums(s):
|
||||
"""Numeric tokens (thresholds, scores, dates) that must match exactly for
|
||||
two markets to be the *same* contract, not just the same event."""
|
||||
return set(re.findall(r"\d+", (s or "").lower()))
|
||||
|
||||
|
||||
def kalshi_fee(price):
|
||||
"""Kalshi taker fee per $1 contract ≈ 0.07 * P * (1-P)."""
|
||||
return 0.07 * price * (1 - price)
|
||||
|
||||
|
||||
# ── data pulls ──────────────────────────────────────────────────────────────
|
||||
|
||||
def pull_kalshi(min_vol):
|
||||
evs, cur = [], ""
|
||||
for _ in range(60):
|
||||
d = get(K + "/events?limit=200&status=open&with_nested_markets=true"
|
||||
+ (f"&cursor={cur}" if cur else ""))
|
||||
evs += d.get("events", [])
|
||||
cur = d.get("cursor")
|
||||
if not cur:
|
||||
break
|
||||
out = []
|
||||
for e in evs:
|
||||
for m in e.get("markets", []):
|
||||
ya, na = fnum(m.get("yes_ask_dollars")), fnum(m.get("no_ask_dollars"))
|
||||
if not (0 < ya < 1 and 0 < na < 1):
|
||||
continue
|
||||
vol = fnum(m.get("volume_24h_fp"))
|
||||
if vol < min_vol:
|
||||
continue
|
||||
text = f"{e.get('title','')} {m.get('yes_sub_title','')}"
|
||||
out.append({
|
||||
"venue": "kalshi", "ticker": m["ticker"], "text": text,
|
||||
"tokens": norm(text), "end": (m.get("close_time") or "")[:7],
|
||||
"yes_ask": ya, "no_ask": na,
|
||||
"yes_bid": fnum(m.get("yes_bid_dollars")),
|
||||
"no_bid": fnum(m.get("no_bid_dollars")), "vol": vol,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def pull_polymarket(min_vol):
|
||||
out, offset = [], 0
|
||||
for _ in range(200):
|
||||
g = get_safe(f"{GAMMA}/markets?limit=100&offset={offset}&active=true"
|
||||
f"&closed=false&order=volumeNum&ascending=false")
|
||||
if not g:
|
||||
break
|
||||
for m in g:
|
||||
try:
|
||||
outcomes = json.loads(m.get("outcomes", "[]"))
|
||||
except Exception:
|
||||
outcomes = []
|
||||
if [o.lower() for o in outcomes] != ["yes", "no"]:
|
||||
continue
|
||||
ask = fnum(m.get("bestAsk"))
|
||||
bid = fnum(m.get("bestBid"))
|
||||
if not (0 < ask < 1 and 0 < bid < 1):
|
||||
continue
|
||||
vol = fnum(m.get("volumeNum"))
|
||||
if vol < min_vol:
|
||||
continue
|
||||
q = m.get("question", "")
|
||||
out.append({
|
||||
"venue": "poly", "text": q, "tokens": norm(q),
|
||||
"end": (m.get("endDateIso") or m.get("endDate") or "")[:7],
|
||||
"yes_ask": ask, "no_ask": round(1 - bid, 4), # NO ask ≈ 1 - YES bid
|
||||
"vol": vol,
|
||||
})
|
||||
if len(g) < 100:
|
||||
break
|
||||
offset += 100
|
||||
return out
|
||||
|
||||
|
||||
# ── matching + arb ────────────────────────────────────────────────────────
|
||||
|
||||
def match_and_scan(poly, kalshi, min_sim):
|
||||
# inverted index: token -> kalshi markets containing it
|
||||
idx = defaultdict(list)
|
||||
for k in kalshi:
|
||||
for t in k["tokens"]:
|
||||
idx[t].append(k)
|
||||
hits = []
|
||||
for p in poly:
|
||||
if len(p["tokens"]) < 2:
|
||||
continue
|
||||
p_nums = nums(p["text"])
|
||||
cand = {id(k): k for t in p["tokens"] for k in idx.get(t, [])}
|
||||
best, best_sim = None, 0
|
||||
for k in cand.values():
|
||||
if p["end"] and k["end"] and p["end"] != k["end"]:
|
||||
continue # different resolution month → skip
|
||||
if nums(k["text"]) != p_nums:
|
||||
continue # different thresholds/scores/dates → not the same contract
|
||||
inter = len(p["tokens"] & k["tokens"])
|
||||
sim = inter / len(p["tokens"] | k["tokens"])
|
||||
if sim > best_sim:
|
||||
best, best_sim = k, sim
|
||||
if not best or best_sim < min_sim:
|
||||
continue
|
||||
# two arb directions
|
||||
# A: poly YES + kalshi NO
|
||||
a_cost = p["yes_ask"] + best["no_ask"]
|
||||
a_edge = 1 - a_cost - kalshi_fee(best["no_ask"])
|
||||
# B: kalshi YES + poly NO
|
||||
b_cost = best["yes_ask"] + p["no_ask"]
|
||||
b_edge = 1 - b_cost - kalshi_fee(best["yes_ask"])
|
||||
if a_edge >= b_edge:
|
||||
edge, leg = a_edge, "poly YES + kalshi NO"
|
||||
else:
|
||||
edge, leg = b_edge, "kalshi YES + poly NO"
|
||||
hits.append({
|
||||
"edge_c": round(edge * 100, 2), "sim": round(best_sim, 2),
|
||||
"leg": leg, "poly": p["text"][:46], "kalshi": best["text"][:46],
|
||||
"p_yes": p["yes_ask"], "p_no": p["no_ask"],
|
||||
"k_yes": best["yes_ask"], "k_no": best["no_ask"],
|
||||
"min_vol": round(min(p["vol"], best["vol"])),
|
||||
})
|
||||
hits.sort(key=lambda h: h["edge_c"], reverse=True)
|
||||
return hits
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--min-vol", type=float, default=2000)
|
||||
ap.add_argument("--min-sim", type=float, default=0.5, help="token-overlap threshold")
|
||||
ap.add_argument("--top", type=int, default=30)
|
||||
args = ap.parse_args()
|
||||
|
||||
print("pulling Kalshi...", flush=True)
|
||||
kalshi = pull_kalshi(args.min_vol)
|
||||
print(f" {len(kalshi)} liquid Kalshi markets", flush=True)
|
||||
print("pulling Polymarket...", flush=True)
|
||||
poly = pull_polymarket(args.min_vol)
|
||||
print(f" {len(poly)} liquid Polymarket markets", flush=True)
|
||||
|
||||
hits = match_and_scan(poly, kalshi, args.min_sim)
|
||||
with open("xarb_hits.csv", "w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(hits[0].keys()) if hits else
|
||||
["edge_c", "sim", "leg", "poly", "kalshi"])
|
||||
w.writeheader()
|
||||
w.writerows(hits)
|
||||
|
||||
arbs = [h for h in hits if h["edge_c"] > 0]
|
||||
print(f"\nmatched pairs: {len(hits)} · positive-edge (after fees): {len(arbs)}")
|
||||
print(f"\n{'edge¢':>6}{'sim':>5}{'minVol':>9} match (poly ↔ kalshi)")
|
||||
print("-" * 92)
|
||||
for h in hits[:args.top]:
|
||||
print(f"{h['edge_c']:>6.1f}{h['sim']:>5.2f}{h['min_vol']:>9} "
|
||||
f"{h['poly'][:34]:34} ↔ {h['kalshi'][:30]}")
|
||||
print("-" * 92)
|
||||
print(f"saved {len(hits)} matched pairs → xarb_hits.csv (edge>0 = arb after fees)")
|
||||
return arbs, hits
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user