# How to Build a Polymarket Copy-Trading Bot: the Long Version

Build a copy bot, then steer it with chameleons.markets. Polymarket facts checked against Polymarket's docs on 22 Oct 2026. Short version: `/bots`.

## What a copy bot is

1. **Watch**: read a leader's public trades.
2. **Decide**: apply fixed rules. Is this trade copyable? What is the most you pay?
3. **Act**: buy at or under that price, or skip.

> Most leaders are not copyable: `meta.more` shows how many rows the default 3★ filter hides. Copies fill worse than the leader: the trades you can fill tend to pay less than the ones that run away. Taker fees and crowding (other copiers taking the same asks) cost money. Our numbers are past and modelled, and can stop holding. Nothing here promises a profit. You can lose what you put in.

## Before you start

- **Eligibility.** Read Polymarket's terms: who and where you are decides. `GET https://polymarket.com/api/geoblock` checks only your IP. Some regions are close-only on the website while the API takes orders: an accepted order does not make you eligible. Sign-up asks you to confirm you are not a US person and not using a VPN.
- **Wallet.** Use a dedicated account holding only what you can lose. Collateral is pUSD on Polygon. Your wallet address is in your profile menu.
- **Keys.** Read the private key from the environment or a keystore. Never put it in code, a chat, an agent prompt or a web page. A Session Key (beta) can trade but not withdraw; it needs a Deposit Wallet (accounts from 4 May 2026) and a Builder API key Polymarket enables.
- **SDK.** Use the unified SDK (`pip install polymarket-client`, or `@polymarket/client`). `py-clob-client` stopped working on 28 Apr 2026: ignore older tutorials. Set up trading approvals once, as Polymarket's wallets page shows; neg-risk markets need extra ones.
- **Data API.** v1 retires on 24 Oct 2026. Use `/v2`: snake_case fields, cursor paging.

## 1 Pick who to follow

1. Set filters on the screener.
2. Add `.json`, `.tsv` or `.md` to the path and keep the query. Every page's `Link` header gives its data URL.
3. Read `{meta, cols, rows}`: rows are arrays in `cols` order.
4. Lists carry no wallet. Read it from the trader's recipe (`wallet` in the YAML) or `/traders/<name>-<8hex>.json` (`trader.wallet`).
5. Store the wallet or the 8-hex tail, not the whole slug: names change, and old slugs redirect.

Defaults: 3★ and up; 50 rows per machine answer (a page shows all). `limit=0` returns all rows (max 1000) and counts as a heavy read. `stars=all` adds Too few and Can't copy rows.

## 2 The copy loop

The rules the rating assumes; each recipe's YAML carries the live values.

- [ ] **Trigger.** Their buys on one outcome (`token_id`) reach $20; a gap over 600 s starts a new burst.
- [ ] **One copy per burst**: the first buy priced 2¢–97¢ once the total is reached. Its price is the leader price.
- [ ] **Markets.** Their rated category only. Ends within 30 d. No 5- or 15-minute crypto. Outcome rule from `markets.outcomes`.
- [ ] **Sports and esports**: pre-match only. A buy at or after the market's `gameStartTime` is in-play.
- [ ] **Cap**: leader price + 3¢, rounded down to the tick, never above 99¢.
- [ ] **Order**: as soon as you see their buy, send a Fill-and-Kill (FAK) order with `max_price` = cap.
- [ ] **Window**: re-check the lowest ask every ≤ 10 s until 600 s after their buy. The rating assumes you see it ~44 s after their buy (9–120 s): a lag, not a wait; faster or slower changes your fills.
- [ ] **No resting bids.** Our research: resting copy bids fill mostly when the leader was wrong.
- [ ] **Size**: small fixed ticket, per-market and daily caps. You set the amounts.
- [ ] **Exit**: hold to resolution.

On Polymarket:

- **Watch.** Poll `GET https://data-api.polymarket.com/v2/activity?user=<wallet>&type=TRADE&side=BUY` every ≤ 30 s. `/v2/trades` hides maker fills unless `taker_only=false` and has no `usdc_size`. The RTDS websocket `wss://ws-live-data.polymarket.com` (topic `activity/trades`, kept in Polymarket's docs) filters only by event or market slug: match `proxyWallet` yourself; keep polling as a fallback.
- **Feed.** `GET https://chameleons.markets/api/v1/feed?since=<seq>`, public, no key: leader trades ~20–30 s behind, with gate and fill status; 60/min per IP. `recorder_ok: false`: poll v2.
- **Market facts.** `GET https://gamma-api.polymarket.com/markets/slug/<slug>?include_tag=true` gives state, `endDate`, `gameStartTime`, outcomes, tags, tick, minimum size and `version`. The asset id is the outcome's entry in `positionIds` for a `v2` market, else in the parsed `clobTokenIds`.
- **Lowest ask.** SDK `get_price(asset_id=..., side="BUY")`, the CLOB `/book`, or the market websocket.
- **FAK, not FOK.** Fill-or-Kill is all or nothing; Fill-and-Kill takes what is offered at or under the cap now and cancels the rest. `amount` is pre-fee; `max_spend` caps the total with fees.
- **Unclear results.** A `delayed` order (matching-delay markets) is accepted but unfilled: pending. A timeout or 5xx may hide a fill. Never resend blindly: check orders and positions.
- **Restarts.** During a matching-engine restart orders are rejected, then post-only for 2 minutes: skip, never rest bids.
- **Tick and size.** Prices sit on the market's tick. Keep the ticket ≥ `orderMinSize`: the docs differ (shares or USDC); under $1 a share, this covers both.
- **Fees.** Takers pay shares × rate × p × (1 − p): at 60¢ with sports' 0.05 rate, 1.2¢ a share. Each market's schedule is in Gamma.

## 3 Change your bot's criteria

Your criteria are the screener's query string.

| You want | Page | Data |
|---|---|---|
| Stricter | `/?stars=4&copyable=50&ret_lo=0` | `/index.json?stars=4&copyable=50&ret_lo=0` |
| One category, uncrowded | `/categories/geopolitics?uncrowded=1` | `/categories/geopolitics.json?uncrowded=1` |
| Hold <1d | `/?hold_h=24` | `/index.json?hold_h=24` |
| Humans only | `/?kind=human` | `/index.json?kind=human` |
| Combined | `/?uncrowded=1&hold_h=24&kind=human&active_d=7` | `/index.json?uncrowded=1&hold_h=24&kind=human&active_d=7` |

Other filters: `no=crowded,sniper` drops flagged rows, `checked=50` asks for more evidence. `/schema.json` lists every parameter.

Re-sync once a day, after the ~06:00 UTC snapshot (the skeleton: 06:15, then every 10 min until `meta.snapshot` changes; it pauses past 36 h).

1. Fetch your list URL.
2. An unknown parameter answers 400 naming it and the one you likely meant (`suggest`): fix the URL, don't retry.
3. If `meta.stale` is true, pause new copies and retry later.
4. For each row, fetch `/traders/<trader>/copy.md?cat=<cat>` and keep it only when `rules_apply` is true.
5. Diff against yesterday. Added: start watching. Removed: no new copies; held positions ride to resolution.

Stop copying a trader in a category when:

- it rates under 3★, or is Can't copy or Too few;
- its row or recipe says `rules_apply: false` (`why_not` / `reason`: `faded`, `below_3`, `cant_copy` or `too_few`);
- its path answers 404 (unlisted, or no row in that category);
- or you add its 8-hex tail to your own stop list.

## 4 Measure your fills against the model

Run in paper mode first: the skeleton logs a paper fill when the ask is at or under the cap (ignoring depth and your impact), counted against your caps.

Log every copy: leader time and price, when you saw it, the cap, the asks, your fill price and size, and fees. Then compare:

| Yours | Model |
|---|---|
| Copies filled ÷ copies tried | Copyable |
| Paid above leader price | ≤ 3¢ |
| Delay | ~44 s (9–120 s) |
| Profit per copy after resolution, % of stake | ROIC range (`fields=all`: `ret_lo`, `ret_hi`; USD per $10: `copy10_lo`, `copy10_hi`) |

Small samples swing: wait for dozens of resolved copies. If yours run worse, the rating overstates what your bot keeps: change your criteria or stop.

## Parameters

The recipe's parameter block, with placeholders. Each trader's `copy.md` carries the live values; they win.

```yaml
recipe: "chameleons-copy-recipe/1"
trader: "<name>-<8hex>"
wallet: "<wallet>"                       # 0x… address, from copy.md; lists never carry it
category: "<cat>"
stars: 3
state: "rated"
as_of: "<YYYY-MM-DD>"
caveat: "90 d · modelled · not advice"
rules_apply: true                        # false: no rules (reason faded | below_3 | cant_copy | too_few)
status: "<status>"
asof: "<YYYY-MM-DDTHH:MMZ>"
fwd_n: 0
warnings: []
watch:
  feed: "https://data-api.polymarket.com/v2/activity?user=<wallet>&type=TRADE&side=BUY"
  poll_s_max: 30
  websocket: "wss://ws-live-data.polymarket.com"
  websocket_topic: "activity/trades"     # no wallet filter: match proxyWallet yourself
  filter:
    proxy_wallet: "<wallet>"
  fields: ["proxy_wallet", "side", "token_id", "price", "usdc_size", "timestamp"]
  price_units: "decimal"
  trigger:
    side: "BUY"
    same_token: true
    min_usd: 20
    max_gap_s: 600
    once_per_burst: true
    leader_price: [0.02, 0.97]
entry:
  order: "FAK"                           # on Polymarket: FAK (Fill-and-Kill), never FOK
  limit_over_leader: 0.03
  round: "down_to_tick"
  max_limit: 0.99
  assumed_lag_s_median: 44               # the lag the rating assumes, not a wait
  assumed_lag_s_range: [9, 120]
  recheck_s: 10
  window_s: 600
  window_from: "leader_buy"
  resting_bid: false
  example:
    leader: 0.62
    limit: 0.65
    ask: 0.66
    fills: false
markets:
  category: "<cat>"
  outcomes: "any"                        # yes_no | yes_no_or_over_under | any: read it, don't hard-code it
  ends_within_d: 30
  in_play: false                         # sports and esports: before gameStartTime only
  crypto_15m: false                      # no 5- or 15-minute crypto
exit:
  hold_to_resolution: true
size:
  ticket: "small_fixed"                  # you set the amounts
  per_market_cap: true
  daily_cap: true
applies:
  stars_min: 3
  not_states: ["faded", "cant_copy"]
check:
  every_h: 24
  url: "https://chameleons.markets/traders/<name>-<8hex>.json" # the row's rules_apply / why_not say the same daily
  mcp: "get_trader"
```

## Python skeleton

An example to adapt, not a product: daily re-sync, polling, the checks, and capped Fill-and-Kill buys. It does not sell, persist state, use the websocket or follow `delayed` or unclear orders. Paper mode unless `BOT_LIVE=1`. Every Polymarket call is from Polymarket's docs.

```python
"""copybot.py: an example to adapt, not a product. Python 3.11+.
pip install polymarket-client httpx pyyaml
Paper mode unless BOT_LIVE=1. Keys come from your environment or keystore, never from this file."""
import asyncio
import json
import os
import re
import time
from datetime import datetime, timedelta, timezone
from decimal import ROUND_FLOOR, Decimal
from urllib.parse import parse_qsl, urlsplit

import httpx
import yaml
from polymarket import AsyncPublicClient, AsyncSecureClient, PolymarketError

SITE = "https://chameleons.markets"
FOLLOW = SITE + "/index.json?stars=3&uncrowded=1&limit=0"  # your criteria: any screener query
FEED = "https://data-api.polymarket.com/v2/activity"
GAMMA = "https://gamma-api.polymarket.com/markets/slug/{}"
STOP = {"1a2b3c4d"}  # 8-hex tails you never copy, whatever the list says
LIVE = os.environ.get("BOT_LIVE") == "1"
TICKET = Decimal(os.environ["BOT_TICKET"])  # pUSD per copy; you set all three
MARKET_CAP = Decimal(os.environ["BOT_MARKET_CAP"])
DAILY_CAP = Decimal(os.environ["BOT_DAILY_CAP"])
POLL_S, SYNC_UTC = 30, (6, 15)  # first daily re-sync try at 06:15 UTC; the snapshot lands ~06:00-06:20
RETRY_S, MAX_AGE_H = 600, 36  # same snapshot as yesterday: retry every 10 min; older than 36 h: pause new copies
GAME_D, MAX_PAGES = 8, 10  # one game: ends within 8 d of gameStartTime (the rating's rule); feed pages per poll
NET = (httpx.HTTPStatusError, httpx.TransportError)  # 429, 5xx, timeouts: wait, carry on
TAGS = {"politics": "politics", "sports": "sports", "esports": "esports", "crypto": "crypto",
        "finance": "finance", "geopolitics": "geopolitics", "tech": "tech", "pop-culture": "culture",
        "economy": "economics", "economics": "economics", "weather": "weather", "mentions": "mentions",
        "mention-markets": "mentions"}  # Polymarket tag slug -> our category (approximate)
FAST = re.compile(r"updown-(5m|15m)-|up-or-down-.*(5m|15m)")
FAST_TITLE = re.compile(r"\d{1,2}:\d{2}(AM|PM)-\d{1,2}:\d{2}(AM|PM) ET")
OUTCOMES = {"yes_no": [{"Yes", "No"}], "yes_no_or_over_under": [{"Yes", "No"}, {"Over", "Under"}]}


def D(v):
    return Decimal(str(v))


def log(event, **kw):
    with open("copies.jsonl", "a") as f:
        f.write(json.dumps({"ts": int(time.time()), "event": event, **kw}, default=str) + "\n")


def epoch(s):
    if not s:
        return None
    d = datetime.fromisoformat(s.replace("Z", "+00:00"))
    return (d if d.tzinfo else d.replace(tzinfo=timezone.utc)).timestamp()


def next_sync(now):
    """The next SYNC_UTC: today's if it is still ahead, else tomorrow's."""
    t = datetime.fromtimestamp(now, timezone.utc).replace(hour=SYNC_UTC[0], minute=SYNC_UTC[1], second=0,
                                                          microsecond=0)
    return (t if t.timestamp() > now else t + timedelta(days=1)).timestamp()


def after_sync(fresh, sha, snap, now):
    """Stale or old: pause, retry in 1 h. Same snapshot: retry in RETRY_S. New: tomorrow."""
    if fresh is None:
        return {}, snap, now + 3600
    if sha == snap:
        log("wait", why="same snapshot", snapshot=sha)
        return fresh, snap, now + RETRY_S
    return fresh, sha, next_sync(now)


def backoff_s(x):
    """Seconds to wait after a NET error: the server's Retry-After when it sends one."""
    r = getattr(x, "response", None)
    try:
        return float(r.headers["retry-after"]) if r is not None else 30.0
    except (KeyError, ValueError):
        return 30.0


# ---- 1 + 3: who to follow, re-synced daily from chameleons.markets

async def recipe(http, trader, cat):
    """The YAML block of the trader's copy.md, as a dict."""
    r = await http.get(f"{SITE}/traders/{trader}/copy.md", params={"cat": cat})
    if r.status_code == 404:
        return None  # unlisted, or no row in that category
    r.raise_for_status()
    m = re.search(r"^```yaml\n(.*?)^```", r.text, re.S | re.M)
    return yaml.safe_load(m.group(1)) if m else None


async def resync(http, old, last=None):
    """({wallet: {category: recipe}}, snapshot sha); (None, sha) pauses new copies. Same snapshot: keep `old`."""
    r = await http.get(FOLLOW)
    r.raise_for_status()
    d = r.json()
    meta = d["meta"]
    sent = {k for k, _ in parse_qsl(urlsplit(FOLLOW).query)} - {"limit", "offset", "page", "fields"}
    if sent - set(meta["applied"]):  # unknown ones answer 400 (raise_for_status); belt and braces
        raise SystemExit(f"unknown parameters in FOLLOW: {sorted(sent - set(meta['applied']))}")
    pub = epoch(meta.get("published"))
    if meta["stale"] or pub is None or time.time() - pub > MAX_AGE_H * 3600:
        log("pause", why="stale", snapshot=meta.get("snapshot"), published=meta.get("published"))
        return None, meta.get("snapshot")
    if meta["snapshot"] == last and old:
        return old, last
    new = {}
    for row in (dict(zip(d["cols"], x)) for x in d["rows"]):
        if row["trader"].rsplit("-", 1)[-1] in STOP:
            continue
        if row.get("rules_apply") is False:  # Faded, under 3★, Can't copy or Too few
            log("stop", trader=row["trader"], cat=row["cat"], why=row.get("why_not"))
            continue
        rec = await recipe(http, row["trader"], row["cat"])
        await asyncio.sleep(3)  # well inside the site's rate limits
        if rec and rec.get("rules_apply"):  # false: Faded, under 3★, Can't copy or Too few
            new.setdefault(rec["wallet"].lower(), {})[rec["category"]] = rec
        else:
            log("stop", trader=row["trader"], cat=row["cat"], why=(rec or {}).get("reason", "404"))
    pairs = lambda f: {(w, c) for w, cs in (f or {}).items() for c in cs}
    for w, c in pairs(new) - pairs(old):
        log("add", wallet=w, cat=c)
    for w, c in pairs(old) - pairs(new):
        log("remove", wallet=w, cat=c)  # no new copies; held positions ride to resolution
    log("sync", snapshot=meta["snapshot"], published=meta["published"], follow=len(pairs(new)))
    return new, meta["snapshot"]


# ---- 2: the copy loop

async def leader_buys(http, wallet, since):
    """Their BUYs since `since`, every page (v2: follow pagination.next_cursor until null)."""
    params = {"user": wallet, "type": "TRADE", "side": "BUY", "start": since, "sort_direction": "ASC", "limit": 1000}
    rows = []
    for _ in range(MAX_PAGES):
        r = await http.get(FEED, params=params)
        r.raise_for_status()
        d = r.json()
        rows += d["data"]
        cursor = (d.get("pagination") or {}).get("next_cursor")
        if not cursor:
            return rows
        params = dict(params, cursor=cursor)
    log("error", where="leader_buys", wallet=wallet, why="page cap")
    return rows


def burst_copy(bursts, row, trig):
    """One copy per burst: the first in-band buy once their buys on this token reach min_usd."""
    k = (row["proxy_wallet"].lower(), row["token_id"])
    b = bursts.get(k)
    if b is None or row["timestamp"] - b["last"] > trig["max_gap_s"]:
        b = bursts[k] = {"usd": 0.0, "last": row["timestamp"], "done": False}
    b["usd"] += float(row["usdc_size"])
    b["last"] = max(b["last"], row["timestamp"])
    lo, hi = trig["leader_price"]
    if b["done"] or b["usd"] < trig["min_usd"] or not lo <= float(row["price"]) <= hi:
        return False
    b["done"] = True
    return True


async def gate(http, row, recs):
    """(market, recipe) when the market passes the rating's checks, else None."""
    if row.get("is_combo"):
        return None
    r = await http.get(GAMMA.format(row["slug"]), params={"include_tag": "true"})
    if r.status_code != 200:
        return None
    m = r.json()
    tags = {t.get("slug") for t in m.get("tags") or []}
    for e in m.get("events") or []:
        tags |= {t.get("slug") for t in e.get("tags") or []}
    cats = {TAGS[t] for t in tags if t in TAGS}
    cats -= {"sports"} if "esports" in cats else set()
    rec = next((recs[c] for c in cats if c in recs), None)  # only categories this leader is rated in
    if rec is None:
        return None
    mk, end = rec["markets"], epoch(m.get("endDate"))
    labels = set(json.loads(m.get("outcomes") or "[]"))
    ok = (m.get("active") and not m.get("closed") and m.get("acceptingOrders")
          and end is not None and end - time.time() <= mk["ends_within_d"] * 86400
          and len(labels) == 2 and (mk["outcomes"] == "any" or labels in OUTCOMES.get(mk["outcomes"], []))
          and not FAST.search(m.get("slug") or "") and not FAST_TITLE.search(m.get("question") or ""))
    return (m, rec) if ok else None


def game_start(m, rec):
    """(start, known): one game ends within GAME_D of its start; a short market with no start is unknown."""
    if rec["category"] not in ("sports", "esports"):
        return None, True
    start, end = epoch(m.get("gameStartTime")), epoch(m.get("endDate"))
    if start is None:
        return None, end is not None and end - time.time() > GAME_D * 86400
    return (None, True) if end is not None and end - start > GAME_D * 86400 else (start, True)


class Spend:
    """Per-market and per-UTC-day caps. reserve() checks and counts in one step (no await between), so concurrent
    copies can't overshoot. In memory only: persist it before you rely on it across restarts."""
    def __init__(self):
        self.day, self.today, self.market = time.gmtime().tm_yday, Decimal(0), {}

    def room(self, cond):
        if time.gmtime().tm_yday != self.day:
            self.day, self.today = time.gmtime().tm_yday, Decimal(0)
        return (self.today + TICKET <= DAILY_CAP
                and self.market.get(cond, Decimal(0)) + TICKET <= MARKET_CAP)

    def reserve(self, cond):
        if not self.room(cond):
            return False
        self.today += TICKET
        self.market[cond] = self.market.get(cond, Decimal(0)) + TICKET
        return True

    def release(self, cond):  # only after a clean refusal: nothing traded
        self.today = max(Decimal(0), self.today - TICKET)
        self.market[cond] -= TICKET


async def try_copy(pm, row, m, rec, spent):
    """Re-check the lowest ask until the window closes; buy FAK only at or under the cap. Never a resting bid."""
    e = rec["entry"]
    tick = D(m["orderPriceMinTickSize"])
    cap = min(D(e["max_limit"]),
              ((D(row["price"]) + D(e["limit_over_leader"])) / tick).to_integral_value(ROUND_FLOOR) * tick)
    ids = m["positionIds"] if m.get("version") == "v2" else json.loads(m["clobTokenIds"])
    asset = ids[row["outcome_index"]]
    base = dict(trader=rec["trader"], slug=m["slug"], leader_price=row["price"], cap=cap,
                lag_s=round(time.time() - row["timestamp"]))
    if m.get("version") != "v2" and asset != row["token_id"]:
        return log("skip", why="token mismatch", **base)
    if TICKET < D(m.get("orderMinSize") or 0):  # shares or USDC (docs differ): this covers both
        return log("skip", why="ticket under the market minimum", **base)
    cond = m["conditionId"]
    start, known = game_start(m, rec)
    if not known:
        return log("skip", why="no gameStartTime: in-play unknown", **base)
    while time.time() < row["timestamp"] + e["window_s"]:
        if start is not None and time.time() >= start:
            return log("skip", why="in-play", **base)
        if not spent.room(cond):
            return log("skip", why="cap reached", **base)
        try:
            ask = await pm.get_price(asset_id=asset, side="BUY")  # the lowest ask
        except PolymarketError as x:
            log("error", error=str(x), **base)
            ask = None
        if ask is not None and ask <= cap and spent.reserve(cond):  # counted before the order goes out
            if not LIVE:
                return log("paper_fill", ask=ask, **base)
            try:
                res = await pm.place_market_order(asset_id=asset, side="BUY", amount=str(TICKET), max_price=cap,
                                                  max_spend=str(TICKET), order_type="FAK")
            except Exception as x:  # a timeout or 5xx may hide a fill: check, never resend blindly
                return log("unknown", error=repr(x), **base)
            if res.ok:  # "matched", or "delayed" (pending): never resend it
                return log(res.status, ask=ask, trade_ids=res.trade_ids, **base)
            spent.release(cond)
            log("rejected", code=res.code, message=res.message, **base)  # e.g. an engine restart
        await asyncio.sleep(e["recheck_s"])
    log("missed", **base)


async def main():
    if LIVE:  # your account's key, or a Session Key's (see Keys)
        pm = await AsyncSecureClient.create(private_key=os.environ["POLYMARKET_PRIVATE_KEY"],
                                            wallet=os.environ["POLYMARKET_WALLET_ADDRESS"])
    else:
        pm = AsyncPublicClient()
    spent, bursts, seen, tasks, follow, sync_at, snap = Spend(), {}, set(), set(), {}, 0.0, None
    async with pm, httpx.AsyncClient(timeout=20, follow_redirects=True) as http:  # approvals: set up beforehand
        while True:
            if time.time() >= sync_at:  # at start-up, then daily from SYNC_UTC until the snapshot changes
                try:
                    fresh, sha = await resync(http, follow, snap)
                    follow, snap, sync_at = after_sync(fresh, sha, snap, time.time())
                except NET as x:  # keep the current list; retry soon
                    log("error", where="resync", error=str(x))
                    sync_at = time.time() + backoff_s(x)
            for wallet, recs in follow.items():
                any_rec = next(iter(recs.values()))
                try:
                    for row in await leader_buys(http, wallet, int(time.time()) - any_rec["entry"]["window_s"]):
                        key = (row["transaction_hash"], row["token_id"], row["size"], row["timestamp"])
                        if key in seen:  # prune seen and bursts in a long-running bot
                            continue
                        seen.add(key)
                        if not burst_copy(bursts, row, any_rec["watch"]["trigger"]):
                            continue
                        if hit := await gate(http, row, recs):
                            t = asyncio.create_task(try_copy(pm, row, *hit, spent))
                            tasks.add(t)
                            t.add_done_callback(tasks.discard)
                except NET as x:  # 429 + Retry-After is Polymarket's busy signal
                    log("error", wallet=wallet, error=str(x))
                    await asyncio.sleep(backoff_s(x))
            await asyncio.sleep(POLL_S)


if __name__ == "__main__":
    asyncio.run(main())
```

## For agents

- MCP (no key): `claude mcp add --transport http chameleons https://chameleons.markets/mcp`. Tools: `find_traders`, `get_trader`, `get_copy_recipe`, `get_bot_guide`, `list_categories`, `explain_rating`. Other clients: `/agents`.
- `find_traders` takes the screener's parameters or a page URL; it refuses or names unknown ones:

```bash
curl -s -X POST https://chameleons.markets/mcp \
  -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_traders",
       "arguments":{"url":"https://chameleons.markets/categories/geopolitics?uncrowded=1"}}}'
```

- `get_copy_recipe` returns the same text as `copy.md`; `get_bot_guide`, this file.
- Limits per IP: 30/min tool calls or uncached reads (burst 20), 20/min heavy. Over that: 429 with `Retry-After`.
- Also `/llms.txt`, `/schema.json`, `/bots` (short version).

## Sources

- https://docs.polymarket.com/trading/place-orders.md
- https://docs.polymarket.com/trading/quickstart.md
- https://docs.polymarket.com/getting-started/python.md
- https://docs.polymarket.com/trading/wallets-auth.md
- https://docs.polymarket.com/trading/session-keys.md
- https://docs.polymarket.com/migrate/data-api-v1-to-v2.md
- https://docs.polymarket.com/api-reference/feeds/list-account-activity.md
- https://docs.polymarket.com/api-reference/feeds/list-trades.md
- https://docs.polymarket.com/market-data/market-details.md
- https://docs.polymarket.com/market-data/prices-order-books.md
- https://docs.polymarket.com/api-spec/gamma-openapi.yaml
- https://docs.polymarket.com/trading/matching-engine.md
- https://docs.polymarket.com/trading/fees.md
- https://docs.polymarket.com/api-reference/rate-limits.md
- https://docs.polymarket.com/api-reference/geoblock.md
- https://docs.polymarket.com/migrate/rtds-to-polybolt.md
- https://github.com/Polymarket/py-sdk
- https://github.com/Polymarket/real-time-data-client

## Disclosure

Independent; not affiliated with Polymarket. You run your own account and keys; we never trade for you or hold your funds. Past 90 d, modelled, not advice. Contact ox@mail.grokbot.com.
