resolvedkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- resolvedkit/__init__.py +13 -0
- resolvedkit/cli.py +85 -0
- resolvedkit/data/__init__.py +7 -0
- resolvedkit/data/base.py +35 -0
- resolvedkit/data/parquet.py +88 -0
- resolvedkit/data/resolvedmarkets.py +91 -0
- resolvedkit/data/sample.py +14 -0
- resolvedkit/engine.py +208 -0
- resolvedkit/fees.py +34 -0
- resolvedkit/fills.py +64 -0
- resolvedkit/metrics.py +97 -0
- resolvedkit/models.py +103 -0
- resolvedkit/rules.py +81 -0
- resolvedkit/sample_data/books.parquet +0 -0
- resolvedkit/sample_data/markets.parquet +0 -0
- resolvedkit/specs/early_underdog_scalp.json +5 -0
- resolvedkit/specs/late_favorite.json +4 -0
- resolvedkit-0.1.0.dist-info/METADATA +136 -0
- resolvedkit-0.1.0.dist-info/RECORD +22 -0
- resolvedkit-0.1.0.dist-info/WHEEL +4 -0
- resolvedkit-0.1.0.dist-info/entry_points.txt +2 -0
- resolvedkit-0.1.0.dist-info/licenses/LICENSE +21 -0
resolvedkit/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Backtest Polymarket strategies against the real order book."""
|
|
2
|
+
from .data import ParquetSource, ResolvedMarketsAPI, load_sample, write_parquet
|
|
3
|
+
from .engine import Backtester, Context, MarketResult, Strategy
|
|
4
|
+
from .fees import taker_fee
|
|
5
|
+
from .metrics import Results
|
|
6
|
+
from .models import DOWN, UP, Book, Fill, Level, Market
|
|
7
|
+
from .rules import RuleStrategy
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Backtester", "Book", "Context", "DOWN", "Fill", "Level", "Market", "MarketResult", "ParquetSource",
|
|
12
|
+
"ResolvedMarketsAPI", "Results", "RuleStrategy", "Strategy", "UP", "load_sample", "taker_fee", "write_parquet",
|
|
13
|
+
]
|
resolvedkit/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""resolvedkit run spec.json [--data sample|api|<folder>] [--compare-mid] [--json]"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .engine import Backtester
|
|
9
|
+
from .rules import RuleStrategy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _source(args):
|
|
13
|
+
if args.data == "sample":
|
|
14
|
+
from .data import load_sample
|
|
15
|
+
|
|
16
|
+
return load_sample()
|
|
17
|
+
if args.data == "api":
|
|
18
|
+
from .data import ResolvedMarketsAPI
|
|
19
|
+
|
|
20
|
+
return ResolvedMarketsAPI(crypto=args.crypto, timeframe=args.timeframe, category=args.category,
|
|
21
|
+
since=args.since, before=args.before, limit=args.limit, thin_ms=args.thin_ms)
|
|
22
|
+
from .data import ParquetSource
|
|
23
|
+
|
|
24
|
+
return ParquetSource(args.data)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _spec_path(name: str):
|
|
28
|
+
"""A path to a JSON spec, or the name of a bundled example such as `late_favorite`."""
|
|
29
|
+
from importlib.resources import files
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
p = Path(name)
|
|
33
|
+
if p.exists():
|
|
34
|
+
return p
|
|
35
|
+
bundled = files("resolvedkit") / "specs" / f"{p.stem}.json"
|
|
36
|
+
if bundled.is_file():
|
|
37
|
+
return bundled
|
|
38
|
+
examples = sorted(x.name.removesuffix(".json") for x in (files("resolvedkit") / "specs").iterdir())
|
|
39
|
+
raise SystemExit(f"No spec file '{name}'. Bundled examples: {', '.join(examples)}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print(label: str, s: dict) -> None:
|
|
43
|
+
print(f"\n{label}")
|
|
44
|
+
for k, v in s.items():
|
|
45
|
+
print(f" {k:<22} {v}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: list[str] | None = None) -> int:
|
|
49
|
+
p = argparse.ArgumentParser(prog="resolvedkit", description="Backtest Polymarket strategies against the real order book.")
|
|
50
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
51
|
+
run = sub.add_parser("run", help="run a JSON strategy spec")
|
|
52
|
+
run.add_argument("spec", help="path to a JSON strategy spec, or a bundled example: late_favorite, early_underdog_scalp")
|
|
53
|
+
run.add_argument("--data", default="sample", help="'sample' (bundled, default), 'api' (Resolved Markets), or a Parquet folder")
|
|
54
|
+
run.add_argument("--latency-ms", type=int, default=250)
|
|
55
|
+
run.add_argument("--compare-mid", action="store_true", help="also run with mid-price fills and show the difference")
|
|
56
|
+
run.add_argument("--json", action="store_true", help="print results as JSON")
|
|
57
|
+
for flag in ("--crypto", "--timeframe", "--category", "--since", "--before"):
|
|
58
|
+
run.add_argument(flag, help="with --data api: filter markets like /v1/markets/history/recent")
|
|
59
|
+
run.add_argument("--limit", type=int, default=20, help="with --data api: number of markets")
|
|
60
|
+
run.add_argument("--thin-ms", type=int, default=None, help="with --data api: keep one snapshot per side per interval")
|
|
61
|
+
args = p.parse_args(argv)
|
|
62
|
+
|
|
63
|
+
spec = json.loads(_spec_path(args.spec).read_text())
|
|
64
|
+
data = _source(args)
|
|
65
|
+
markets = data.markets()
|
|
66
|
+
book = Backtester(data, RuleStrategy(spec), latency_ms=args.latency_ms).run(markets).summary()
|
|
67
|
+
out = {"strategy": spec.get("name", args.spec), "book": book}
|
|
68
|
+
if args.compare_mid:
|
|
69
|
+
out["mid"] = Backtester(data, RuleStrategy(spec), latency_ms=args.latency_ms, fill_model="mid").run(markets).summary()
|
|
70
|
+
if args.json:
|
|
71
|
+
json.dump(out, sys.stdout, indent=2)
|
|
72
|
+
print()
|
|
73
|
+
return 0
|
|
74
|
+
print(f"Strategy: {out['strategy']} ({len(markets)} markets)")
|
|
75
|
+
_print("Fills against the real order book:", book)
|
|
76
|
+
if args.compare_mid:
|
|
77
|
+
_print("Same strategy with mid-price fills (unrealistic):", out["mid"])
|
|
78
|
+
rm, rb = out["mid"]["return_on_invested"], book["return_on_invested"]
|
|
79
|
+
if rm is not None and rb is not None:
|
|
80
|
+
print(f"\nReturn on money invested: {rm:+.1%} with mid-price fills vs {rb:+.1%} on the real book.")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Data sources. Anything with `markets()` and `books(market_id)` can drive a backtest."""
|
|
2
|
+
from .base import DataSource
|
|
3
|
+
from .parquet import ParquetSource, write_parquet
|
|
4
|
+
from .resolvedmarkets import ResolvedMarketsAPI
|
|
5
|
+
from .sample import load_sample
|
|
6
|
+
|
|
7
|
+
__all__ = ["DataSource", "ParquetSource", "ResolvedMarketsAPI", "load_sample", "write_parquet"]
|
resolvedkit/data/base.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from ..models import Book, Market
|
|
7
|
+
|
|
8
|
+
TIMEFRAME_MS = {"5m": 300_000, "15m": 900_000, "1h": 3_600_000, "4h": 14_400_000, "1d": 86_400_000}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataSource(Protocol):
|
|
12
|
+
def markets(self) -> list[Market]:
|
|
13
|
+
"""Markets to backtest over, oldest first."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def books(self, market_id: str) -> list[Book]:
|
|
17
|
+
"""Every order-book snapshot for both outcome tokens of one market, in time order."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_ts(value: str | int | float) -> int:
|
|
22
|
+
"""API timestamps are UTC strings like '2026-09-28 13:15:15.955'; return epoch ms."""
|
|
23
|
+
if isinstance(value, (int, float)):
|
|
24
|
+
return int(value)
|
|
25
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00").replace(" ", "T"))
|
|
26
|
+
dt = dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
|
|
27
|
+
return int(dt.timestamp() * 1000)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def thin(books: list[Book], every_ms: int) -> list[Book]:
|
|
31
|
+
"""Keep the last snapshot per side in each `every_ms` bucket. Book state at bucket ends is exact."""
|
|
32
|
+
last: dict[tuple[str, int], Book] = {}
|
|
33
|
+
for b in books:
|
|
34
|
+
last[(b.side, b.ts // every_ms)] = b
|
|
35
|
+
return sorted(last.values(), key=lambda b: (b.ts, b.side))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""A local dataset: two Parquet files in one folder.
|
|
2
|
+
|
|
3
|
+
markets.parquet market_id, slug, question, category, timeframe, start_ts, end_ts,
|
|
4
|
+
outcome_up, outcome_down, payout_up, payout_down (payouts null if unresolved)
|
|
5
|
+
books.parquet market_id, ts (epoch ms UTC), side ("UP"/"DOWN"),
|
|
6
|
+
bid_px, bid_sz, ask_px, ask_sz (lists, best level first)
|
|
7
|
+
|
|
8
|
+
Any source of Polymarket order books can be converted to this layout and backtested.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pyarrow as pa
|
|
16
|
+
import pyarrow.parquet as pq
|
|
17
|
+
|
|
18
|
+
from ..models import Book, Level, Market
|
|
19
|
+
|
|
20
|
+
_MARKET_SCHEMA = pa.schema([
|
|
21
|
+
("market_id", pa.string()), ("slug", pa.string()), ("question", pa.string()),
|
|
22
|
+
("category", pa.string()), ("timeframe", pa.string()),
|
|
23
|
+
("start_ts", pa.int64()), ("end_ts", pa.int64()),
|
|
24
|
+
("outcome_up", pa.string()), ("outcome_down", pa.string()),
|
|
25
|
+
("payout_up", pa.float64()), ("payout_down", pa.float64()),
|
|
26
|
+
])
|
|
27
|
+
_BOOK_SCHEMA = pa.schema([
|
|
28
|
+
("market_id", pa.string()), ("ts", pa.int64()), ("side", pa.string()),
|
|
29
|
+
("bid_px", pa.list_(pa.float64())), ("bid_sz", pa.list_(pa.float64())),
|
|
30
|
+
("ask_px", pa.list_(pa.float64())), ("ask_sz", pa.list_(pa.float64())),
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ParquetSource:
|
|
35
|
+
def __init__(self, folder: str | Path):
|
|
36
|
+
self.folder = Path(folder)
|
|
37
|
+
self._books: dict[str, list[Book]] | None = None
|
|
38
|
+
|
|
39
|
+
def markets(self) -> list[Market]:
|
|
40
|
+
rows = pq.read_table(self.folder / "markets.parquet").to_pylist()
|
|
41
|
+
out = []
|
|
42
|
+
for r in rows:
|
|
43
|
+
payout = None
|
|
44
|
+
if r["payout_up"] is not None and r["payout_down"] is not None:
|
|
45
|
+
payout = (r["payout_up"], r["payout_down"])
|
|
46
|
+
out.append(Market(
|
|
47
|
+
market_id=r["market_id"], question=r["question"], category=r["category"],
|
|
48
|
+
end_ts=r["end_ts"], outcomes=(r["outcome_up"], r["outcome_down"]), payout=payout,
|
|
49
|
+
start_ts=r["start_ts"], slug=r["slug"], timeframe=r["timeframe"],
|
|
50
|
+
))
|
|
51
|
+
return sorted(out, key=lambda m: m.end_ts)
|
|
52
|
+
|
|
53
|
+
def books(self, market_id: str) -> list[Book]:
|
|
54
|
+
if self._books is None:
|
|
55
|
+
grouped: dict[str, list[Book]] = defaultdict(list)
|
|
56
|
+
for r in pq.read_table(self.folder / "books.parquet").to_pylist():
|
|
57
|
+
grouped[r["market_id"]].append(Book(
|
|
58
|
+
ts=r["ts"], side=r["side"],
|
|
59
|
+
bids=tuple(Level(p, s) for p, s in zip(r["bid_px"], r["bid_sz"])),
|
|
60
|
+
asks=tuple(Level(p, s) for p, s in zip(r["ask_px"], r["ask_sz"])),
|
|
61
|
+
))
|
|
62
|
+
for v in grouped.values():
|
|
63
|
+
v.sort(key=lambda b: (b.ts, b.side))
|
|
64
|
+
self._books = dict(grouped)
|
|
65
|
+
return self._books.get(market_id, [])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_parquet(folder: str | Path, markets: list[Market], books: dict[str, list[Book]], depth: int | None = None) -> None:
|
|
69
|
+
"""Save markets and their books in the layout ParquetSource reads. `depth` keeps the top N levels."""
|
|
70
|
+
folder = Path(folder)
|
|
71
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
pq.write_table(pa.Table.from_pylist([{
|
|
73
|
+
"market_id": m.market_id, "slug": m.slug, "question": m.question, "category": m.category,
|
|
74
|
+
"timeframe": m.timeframe, "start_ts": m.start_ts, "end_ts": m.end_ts,
|
|
75
|
+
"outcome_up": m.outcomes[0], "outcome_down": m.outcomes[1],
|
|
76
|
+
"payout_up": m.payout[0] if m.payout else None, "payout_down": m.payout[1] if m.payout else None,
|
|
77
|
+
} for m in markets], schema=_MARKET_SCHEMA), folder / "markets.parquet", compression="zstd")
|
|
78
|
+
rows = []
|
|
79
|
+
for market_id, bs in books.items():
|
|
80
|
+
for b in bs:
|
|
81
|
+
bids = b.bids if depth is None else b.bids[:depth]
|
|
82
|
+
asks = b.asks if depth is None else b.asks[:depth]
|
|
83
|
+
rows.append({
|
|
84
|
+
"market_id": market_id, "ts": b.ts, "side": b.side,
|
|
85
|
+
"bid_px": [l.price for l in bids], "bid_sz": [l.size for l in bids],
|
|
86
|
+
"ask_px": [l.price for l in asks], "ask_sz": [l.size for l in asks],
|
|
87
|
+
})
|
|
88
|
+
pq.write_table(pa.Table.from_pylist(rows, schema=_BOOK_SCHEMA), folder / "books.parquet", compression="zstd")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Historical Polymarket order books from the Resolved Markets API (https://resolvedmarkets.com).
|
|
2
|
+
|
|
3
|
+
A free API key covers crypto up/down markets (the 5 most recent per coin and timeframe);
|
|
4
|
+
paid plans unlock full history and other categories. Set RESOLVED_MARKETS_API_KEY or pass api_key=.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from ..models import DOWN, UP, Book, Level, Market
|
|
14
|
+
from .base import TIMEFRAME_MS, parse_ts, thin
|
|
15
|
+
|
|
16
|
+
BASE_URL = "https://api.resolvedmarkets.com"
|
|
17
|
+
PAGE = 500
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResolvedMarketsAPI:
|
|
21
|
+
def __init__(self, api_key: str | None = None, *, crypto: str | None = None, timeframe: str | None = None,
|
|
22
|
+
category: str | None = None, since: str | None = None, before: str | None = None,
|
|
23
|
+
limit: int = 20, thin_ms: int | None = None, depth: int | None = None, base_url: str = BASE_URL):
|
|
24
|
+
"""Select closed markets like `/v1/markets/history/recent` does, e.g. crypto="BTC", timeframe="15m".
|
|
25
|
+
|
|
26
|
+
thin_ms keeps one snapshot per side per interval (1000 = one per second) to cut memory;
|
|
27
|
+
depth keeps only the top N levels of each side.
|
|
28
|
+
"""
|
|
29
|
+
key = api_key or os.environ.get("RESOLVED_MARKETS_API_KEY")
|
|
30
|
+
if not key:
|
|
31
|
+
raise ValueError("Set RESOLVED_MARKETS_API_KEY or pass api_key= (free key: https://resolvedmarkets.com/api-keys)")
|
|
32
|
+
self.session = requests.Session()
|
|
33
|
+
self.session.headers["X-API-Key"] = key
|
|
34
|
+
self.base_url = base_url
|
|
35
|
+
self.query = {k: v for k, v in {"crypto": crypto, "timeframe": timeframe, "category": category,
|
|
36
|
+
"since": since, "before": before, "status": "closed"}.items() if v}
|
|
37
|
+
self.limit, self.thin_ms, self.depth = limit, thin_ms, depth
|
|
38
|
+
self._markets: list[Market] | None = None
|
|
39
|
+
|
|
40
|
+
def _get(self, path: str, **params) -> dict:
|
|
41
|
+
for attempt in range(4):
|
|
42
|
+
r = self.session.get(self.base_url + path, params=params, timeout=60)
|
|
43
|
+
if r.status_code == 429 or r.status_code >= 500:
|
|
44
|
+
time.sleep(2 ** attempt)
|
|
45
|
+
continue
|
|
46
|
+
if r.status_code == 403 and "replay_locked" in r.text:
|
|
47
|
+
raise PermissionError(f"{path}: this market is outside the free tier's replay window (upgrade for full history)")
|
|
48
|
+
r.raise_for_status()
|
|
49
|
+
return r.json()
|
|
50
|
+
r.raise_for_status()
|
|
51
|
+
return r.json()
|
|
52
|
+
|
|
53
|
+
def markets(self) -> list[Market]:
|
|
54
|
+
if self._markets is None:
|
|
55
|
+
rows = self._get("/v1/markets/history/recent", **self.query, limit=self.limit)["markets"]
|
|
56
|
+
out = []
|
|
57
|
+
for row in rows:
|
|
58
|
+
if row.get("replay_locked"):
|
|
59
|
+
continue
|
|
60
|
+
meta = self._get("/v1/markets/metadata", market_id=row["market_id"])["markets"][0]
|
|
61
|
+
prices = meta.get("outcome_prices")
|
|
62
|
+
payout = (float(prices[0]), float(prices[1])) if meta.get("resolution_status") == "resolved" and prices else None
|
|
63
|
+
end_ts = parse_ts(meta["end_date"])
|
|
64
|
+
tf = meta.get("timeframe") or ""
|
|
65
|
+
out.append(Market(
|
|
66
|
+
market_id=row["market_id"], question=meta.get("question") or "", category=meta.get("category") or "",
|
|
67
|
+
end_ts=end_ts, outcomes=tuple(meta.get("outcomes") or ("Up", "Down")), payout=payout,
|
|
68
|
+
start_ts=end_ts - TIMEFRAME_MS[tf] if tf in TIMEFRAME_MS else None,
|
|
69
|
+
slug=meta.get("slug") or "", timeframe=tf,
|
|
70
|
+
))
|
|
71
|
+
self._markets = sorted(out, key=lambda m: m.end_ts)
|
|
72
|
+
return self._markets
|
|
73
|
+
|
|
74
|
+
def books(self, market_id: str) -> list[Book]:
|
|
75
|
+
books: list[Book] = []
|
|
76
|
+
for side in (UP, DOWN):
|
|
77
|
+
offset = 0
|
|
78
|
+
while True:
|
|
79
|
+
page = self._get(f"/v1/markets/{market_id}/snapshots", side=side, includebook="true", order="asc",
|
|
80
|
+
count="false", limit=PAGE, offset=offset)["data"]
|
|
81
|
+
for s in page:
|
|
82
|
+
bids = tuple(Level(l["price"], l["size"]) for l in s.get("bids") or ())
|
|
83
|
+
asks = tuple(Level(l["price"], l["size"]) for l in s.get("asks") or ())
|
|
84
|
+
if self.depth is not None:
|
|
85
|
+
bids, asks = bids[: self.depth], asks[: self.depth]
|
|
86
|
+
books.append(Book(ts=parse_ts(s["timestamp"]), side=side, bids=bids, asks=asks))
|
|
87
|
+
if len(page) < PAGE:
|
|
88
|
+
break
|
|
89
|
+
offset += PAGE
|
|
90
|
+
books.sort(key=lambda b: (b.ts, b.side))
|
|
91
|
+
return thin(books, self.thin_ms) if self.thin_ms else books
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The bundled sample: settled BTC 15-minute Polymarket markets with full-depth books.
|
|
2
|
+
|
|
3
|
+
Data © Resolved Markets, licensed CC BY 4.0 (see DATA_LICENSE). Books are thinned to one snapshot per
|
|
4
|
+
side per second and the top 20 levels, which keeps the package small; the API serves every snapshot.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
|
|
10
|
+
from .parquet import ParquetSource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_sample() -> ParquetSource:
|
|
14
|
+
return ParquetSource(files("resolvedkit") / "sample_data")
|
resolvedkit/engine.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The event loop: replay each market's books in time order, execute orders against the book, settle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from .fees import rate_for
|
|
8
|
+
from .fills import walk_buy, walk_sell
|
|
9
|
+
from .models import DOWN, SIDES, UP, Book, Fill, Market, Position
|
|
10
|
+
|
|
11
|
+
FillModel = Literal["book", "mid"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Order:
|
|
16
|
+
side: str
|
|
17
|
+
action: str # BUY or SELL
|
|
18
|
+
submit_ts: int
|
|
19
|
+
usd: float | None = None # BUY size
|
|
20
|
+
shares: float | None = None # SELL size; None = the whole position
|
|
21
|
+
limit: float | None = None # max price for BUY, min price for SELL
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Rejection:
|
|
26
|
+
order: Order
|
|
27
|
+
reason: str # placeholder_book, no_liquidity, price_limit, no_position, invalid_size
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class MarketResult:
|
|
32
|
+
market: Market
|
|
33
|
+
fills: list[Fill] = field(default_factory=list)
|
|
34
|
+
rejections: list[Rejection] = field(default_factory=list)
|
|
35
|
+
settlement: float = 0.0 # USDC received for shares held at resolution
|
|
36
|
+
resolved: bool = True
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def fees(self) -> float:
|
|
40
|
+
return sum(f.fee for f in self.fills)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def pnl(self) -> float:
|
|
44
|
+
cash = sum(-f.notional if f.action == "BUY" else f.notional for f in self.fills)
|
|
45
|
+
return cash + self.settlement - self.fees
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def traded(self) -> bool:
|
|
49
|
+
return bool(self.fills)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Context:
|
|
53
|
+
"""What a strategy sees and does during one market."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, market: Market, engine: "Backtester"):
|
|
56
|
+
self.market = market
|
|
57
|
+
self._engine = engine
|
|
58
|
+
self.now = 0
|
|
59
|
+
self.books: dict[str, Book] = {}
|
|
60
|
+
self.positions = {s: Position(s) for s in SIDES}
|
|
61
|
+
self.pending: list[Order] = []
|
|
62
|
+
self.state: dict = {} # scratch space for the strategy, reset per market
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def seconds_to_end(self) -> float:
|
|
66
|
+
return (self.market.end_ts - self.now) / 1000
|
|
67
|
+
|
|
68
|
+
def book(self, side: str) -> Book | None:
|
|
69
|
+
return self.books.get(side)
|
|
70
|
+
|
|
71
|
+
def position(self, side: str) -> Position:
|
|
72
|
+
return self.positions[side]
|
|
73
|
+
|
|
74
|
+
def buy(self, side: str, usd: float, max_price: float | None = None) -> None:
|
|
75
|
+
"""Marketable buy for `usd` of the side's token, filled after the engine's latency."""
|
|
76
|
+
self.pending.append(Order(side, "BUY", self.now, usd=usd, limit=max_price))
|
|
77
|
+
|
|
78
|
+
def pending_orders(self, side: str | None = None) -> list[Order]:
|
|
79
|
+
"""Orders placed but not yet filled or rejected."""
|
|
80
|
+
return [o for o in self.pending if side is None or o.side == side]
|
|
81
|
+
|
|
82
|
+
def sell(self, side: str, shares: float | None = None, min_price: float | None = None) -> None:
|
|
83
|
+
"""Marketable sell of `shares` (default: the whole position)."""
|
|
84
|
+
self.pending.append(Order(side, "SELL", self.now, shares=shares, limit=min_price))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Strategy:
|
|
88
|
+
"""Subclass and override `on_book`. Called for every snapshot of either side, in time order."""
|
|
89
|
+
|
|
90
|
+
def on_start(self, ctx: Context) -> None:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
def on_book(self, ctx: Context, book: Book) -> None:
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
def on_end(self, ctx: Context) -> None:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Backtester:
|
|
101
|
+
def __init__(self, data, strategy: Strategy, *, latency_ms: int = 250, fill_model: FillModel = "book",
|
|
102
|
+
fee_rate: float | None = None, skip_placeholder_books: bool = True):
|
|
103
|
+
"""
|
|
104
|
+
latency_ms: an order fills against the book standing `latency_ms` after it was placed, i.e. the
|
|
105
|
+
latest snapshot of that token at or before that moment.
|
|
106
|
+
fill_model: "book" walks the ladder (default). "mid" fills the whole order at the mid price with no
|
|
107
|
+
depth limit — unrealistic, provided only to measure how much a mid-price backtest overstates.
|
|
108
|
+
fee_rate: override Polymarket's taker rate for the market's category.
|
|
109
|
+
skip_placeholder_books: reject orders against 0.01/0.99 placeholder books.
|
|
110
|
+
"""
|
|
111
|
+
self.data, self.strategy = data, strategy
|
|
112
|
+
self.latency_ms, self.fill_model = latency_ms, fill_model
|
|
113
|
+
self.fee_rate, self.skip_placeholder_books = fee_rate, skip_placeholder_books
|
|
114
|
+
|
|
115
|
+
def run(self, markets: list[Market] | None = None) -> "Results":
|
|
116
|
+
from .metrics import Results
|
|
117
|
+
|
|
118
|
+
results = [self._run_market(m) for m in (markets if markets is not None else self.data.markets())]
|
|
119
|
+
return Results(results, fill_model=self.fill_model, latency_ms=self.latency_ms)
|
|
120
|
+
|
|
121
|
+
def _run_market(self, market: Market) -> MarketResult:
|
|
122
|
+
res = MarketResult(market)
|
|
123
|
+
ctx = Context(market, self)
|
|
124
|
+
rate = self.fee_rate if self.fee_rate is not None else rate_for(market.category)
|
|
125
|
+
self.strategy.on_start(ctx)
|
|
126
|
+
for book in self.data.books(market.market_id):
|
|
127
|
+
# Orders due before this snapshot fill against the books standing at their due time...
|
|
128
|
+
self._execute_due(ctx, res, rate, before=book.ts)
|
|
129
|
+
ctx.now = book.ts
|
|
130
|
+
ctx.books[book.side] = book
|
|
131
|
+
# ...and orders due exactly now see this snapshot too.
|
|
132
|
+
self._execute_due(ctx, res, rate, before=book.ts + 1)
|
|
133
|
+
self.strategy.on_book(ctx, book)
|
|
134
|
+
self.strategy.on_end(ctx)
|
|
135
|
+
# Orders still pending after the last snapshot fill against the final standing books.
|
|
136
|
+
self._execute_due(ctx, res, rate, before=None)
|
|
137
|
+
for side in SIDES:
|
|
138
|
+
pos = ctx.positions[side]
|
|
139
|
+
if pos.shares > 1e-9:
|
|
140
|
+
payout = market.payout_for(side)
|
|
141
|
+
if payout is None: # unresolved: mark at the last mid so PnL is still defined
|
|
142
|
+
res.resolved = False
|
|
143
|
+
last = ctx.books.get(side)
|
|
144
|
+
payout = last.mid if last else 0.0
|
|
145
|
+
res.settlement += pos.shares * payout
|
|
146
|
+
return res
|
|
147
|
+
|
|
148
|
+
def _execute_due(self, ctx: Context, res: MarketResult, rate: float, before: int | None) -> None:
|
|
149
|
+
"""Execute pending orders whose due time is earlier than `before` (all of them if None)."""
|
|
150
|
+
still = []
|
|
151
|
+
for o in ctx.pending:
|
|
152
|
+
due = o.submit_ts + self.latency_ms
|
|
153
|
+
if before is not None and due >= before:
|
|
154
|
+
still.append(o)
|
|
155
|
+
continue
|
|
156
|
+
book = ctx.books.get(o.side)
|
|
157
|
+
if book is None:
|
|
158
|
+
res.rejections.append(Rejection(o, "no_liquidity"))
|
|
159
|
+
continue
|
|
160
|
+
if self.skip_placeholder_books and book.is_placeholder:
|
|
161
|
+
res.rejections.append(Rejection(o, "placeholder_book"))
|
|
162
|
+
continue
|
|
163
|
+
pos = ctx.positions[o.side]
|
|
164
|
+
fill = self._fill(o, pos, book, rate, due)
|
|
165
|
+
if isinstance(fill, str):
|
|
166
|
+
res.rejections.append(Rejection(o, fill))
|
|
167
|
+
continue
|
|
168
|
+
if o.action == "BUY":
|
|
169
|
+
pos.shares += fill.shares
|
|
170
|
+
pos.cost += fill.notional
|
|
171
|
+
else:
|
|
172
|
+
avg_cost = pos.cost / pos.shares if pos.shares else 0.0
|
|
173
|
+
pos.cost -= avg_cost * fill.shares
|
|
174
|
+
pos.shares -= fill.shares
|
|
175
|
+
pos.fees += fill.fee
|
|
176
|
+
pos.fills.append(fill)
|
|
177
|
+
res.fills.append(fill)
|
|
178
|
+
ctx.pending = still
|
|
179
|
+
|
|
180
|
+
def _fill(self, o: Order, pos: Position, book: Book, rate: float, ts: int) -> Fill | str:
|
|
181
|
+
if o.action == "BUY" and (o.usd is None or o.usd <= 0):
|
|
182
|
+
return "invalid_size"
|
|
183
|
+
if o.action == "SELL":
|
|
184
|
+
want = pos.shares if o.shares is None else min(o.shares, pos.shares)
|
|
185
|
+
if want <= 1e-9:
|
|
186
|
+
return "no_position"
|
|
187
|
+
if not (book.asks if o.action == "BUY" else book.bids):
|
|
188
|
+
return "no_liquidity" # same rule for both fill models, so --compare-mid compares like with like
|
|
189
|
+
if self.fill_model == "mid":
|
|
190
|
+
price = book.mid
|
|
191
|
+
if o.action == "BUY":
|
|
192
|
+
if o.limit is not None and price > o.limit:
|
|
193
|
+
return "price_limit"
|
|
194
|
+
shares = o.usd / price if price > 0 else 0.0
|
|
195
|
+
else:
|
|
196
|
+
if o.limit is not None and price < o.limit:
|
|
197
|
+
return "price_limit"
|
|
198
|
+
shares = want
|
|
199
|
+
fee = round(shares * rate * price * (1 - price), 5)
|
|
200
|
+
return Fill(o.side, o.action, ts, shares, price, fee, book.mid)
|
|
201
|
+
walk = walk_buy(book.asks, o.usd, o.limit) if o.action == "BUY" else walk_sell(book.bids, want, o.limit)
|
|
202
|
+
if walk.shares <= 1e-9:
|
|
203
|
+
return "price_limit"
|
|
204
|
+
return Fill(o.side, o.action, ts, walk.shares, walk.avg_price, walk.fee(rate), book.mid, walk.depth_limited)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def other(side: str) -> str:
|
|
208
|
+
return DOWN if side == UP else UP
|
resolvedkit/fees.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Polymarket taker fees.
|
|
2
|
+
|
|
3
|
+
fee = shares × rate × p × (1 − p), charged to the taker only, rounded to 5 decimals.
|
|
4
|
+
Rates by category from https://docs.polymarket.com/trading/fees (checked 2026-09-28).
|
|
5
|
+
Polymarket changes these; pass `rate=` to override.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
TAKER_FEE_RATES: dict[str, float] = {
|
|
10
|
+
"crypto": 0.07,
|
|
11
|
+
"sports": 0.05,
|
|
12
|
+
"finance": 0.04,
|
|
13
|
+
"equities": 0.04,
|
|
14
|
+
"politics": 0.04,
|
|
15
|
+
"economics": 0.05,
|
|
16
|
+
"culture": 0.05,
|
|
17
|
+
"weather": 0.05,
|
|
18
|
+
"social": 0.05,
|
|
19
|
+
"mentions": 0.04,
|
|
20
|
+
"tech": 0.04,
|
|
21
|
+
"geopolitics": 0.0,
|
|
22
|
+
}
|
|
23
|
+
DEFAULT_RATE = 0.05 # Polymarket's "Other / General"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rate_for(category: str) -> float:
|
|
27
|
+
return TAKER_FEE_RATES.get((category or "").lower(), DEFAULT_RATE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def taker_fee(shares: float, price: float, rate: float) -> float:
|
|
31
|
+
"""Fee in USDC for a taker fill of `shares` at `price`."""
|
|
32
|
+
if shares <= 0 or rate <= 0:
|
|
33
|
+
return 0.0
|
|
34
|
+
return round(shares * rate * price * (1.0 - price), 5)
|
resolvedkit/fills.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Fill simulation: walk the order book level by level, as a real marketable order would."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .models import Level
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class WalkResult:
|
|
11
|
+
legs: tuple[Level, ...] # (price, shares) actually taken at each level
|
|
12
|
+
depth_limited: bool # stopped early: book exhausted or price limit reached
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def shares(self) -> float:
|
|
16
|
+
return sum(l.size for l in self.legs)
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def cost(self) -> float:
|
|
20
|
+
"""USDC paid (buy) or received (sell), before fees."""
|
|
21
|
+
return sum(l.size * l.price for l in self.legs)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def avg_price(self) -> float:
|
|
25
|
+
s = self.shares
|
|
26
|
+
return self.cost / s if s else 0.0
|
|
27
|
+
|
|
28
|
+
def fee(self, rate: float) -> float:
|
|
29
|
+
"""Polymarket charges each match at its own price: Σ shares × rate × p × (1 − p)."""
|
|
30
|
+
return round(sum(l.size * rate * l.price * (1 - l.price) for l in self.legs), 5)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def walk_buy(asks: tuple[Level, ...], usd: float, max_price: float | None = None) -> WalkResult:
|
|
34
|
+
"""Spend up to `usd` lifting asks from the best price up, never paying above `max_price`."""
|
|
35
|
+
legs, spent = [], 0.0
|
|
36
|
+
for lvl in asks:
|
|
37
|
+
if lvl.price <= 0 or lvl.size <= 0:
|
|
38
|
+
continue
|
|
39
|
+
remaining = usd - spent
|
|
40
|
+
if remaining <= 1e-9:
|
|
41
|
+
break
|
|
42
|
+
if max_price is not None and lvl.price > max_price + 1e-12:
|
|
43
|
+
return WalkResult(tuple(legs), True)
|
|
44
|
+
take = min(lvl.size, remaining / lvl.price)
|
|
45
|
+
legs.append(Level(lvl.price, take))
|
|
46
|
+
spent += take * lvl.price
|
|
47
|
+
return WalkResult(tuple(legs), usd - spent > 1e-6)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def walk_sell(bids: tuple[Level, ...], shares: float, min_price: float | None = None) -> WalkResult:
|
|
51
|
+
"""Sell up to `shares` into bids from the best price down, never selling below `min_price`."""
|
|
52
|
+
legs, sold = [], 0.0
|
|
53
|
+
for lvl in bids:
|
|
54
|
+
if lvl.size <= 0:
|
|
55
|
+
continue
|
|
56
|
+
remaining = shares - sold
|
|
57
|
+
if remaining <= 1e-9:
|
|
58
|
+
break
|
|
59
|
+
if min_price is not None and lvl.price < min_price - 1e-12:
|
|
60
|
+
return WalkResult(tuple(legs), True)
|
|
61
|
+
take = min(lvl.size, remaining)
|
|
62
|
+
legs.append(Level(lvl.price, take))
|
|
63
|
+
sold += take
|
|
64
|
+
return WalkResult(tuple(legs), shares - sold > 1e-6)
|
resolvedkit/metrics.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .engine import MarketResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Results:
|
|
10
|
+
markets: list[MarketResult]
|
|
11
|
+
fill_model: str = "book"
|
|
12
|
+
latency_ms: int = 0
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def traded(self) -> list[MarketResult]:
|
|
16
|
+
return [m for m in self.markets if m.traded]
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def pnl(self) -> float:
|
|
20
|
+
return sum(m.pnl for m in self.markets)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def fees(self) -> float:
|
|
24
|
+
return sum(m.fees for m in self.markets)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def invested(self) -> float:
|
|
28
|
+
return sum(f.notional for m in self.markets for f in m.fills if f.action == "BUY")
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def win_rate(self) -> float | None:
|
|
32
|
+
t = self.traded
|
|
33
|
+
return sum(m.pnl > 0 for m in t) / len(t) if t else None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def slippage_vs_mid(self) -> float | None:
|
|
37
|
+
"""Average cost of trading against the book instead of the mid, in price units (0.01 = 1¢).
|
|
38
|
+
|
|
39
|
+
Positive means worse than mid: paid above it on buys, received below it on sells.
|
|
40
|
+
"""
|
|
41
|
+
fills = [f for m in self.markets for f in m.fills]
|
|
42
|
+
if not fills:
|
|
43
|
+
return None
|
|
44
|
+
signed = [(f.avg_price - f.mid_at_fill) * (1 if f.action == "BUY" else -1) * f.shares for f in fills]
|
|
45
|
+
return sum(signed) / sum(f.shares for f in fills)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def brier(self) -> float | None:
|
|
49
|
+
"""Brier score of the first entry price as a probability forecast of the side bought winning.
|
|
50
|
+
|
|
51
|
+
Measures how well the market's price (as you traded it) predicted the outcome. Lower is better;
|
|
52
|
+
always buying at 0.5 scores 0.25.
|
|
53
|
+
"""
|
|
54
|
+
pairs = []
|
|
55
|
+
for m in self.markets:
|
|
56
|
+
buys = [f for f in m.fills if f.action == "BUY"]
|
|
57
|
+
if buys and m.market.payout is not None:
|
|
58
|
+
first = buys[0]
|
|
59
|
+
pairs.append((first.avg_price, m.market.payout_for(first.side)))
|
|
60
|
+
return sum((p - o) ** 2 for p, o in pairs) / len(pairs) if pairs else None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def rejections(self) -> dict[str, int]:
|
|
64
|
+
out: dict[str, int] = {}
|
|
65
|
+
for m in self.markets:
|
|
66
|
+
for r in m.rejections:
|
|
67
|
+
out[r.reason] = out.get(r.reason, 0) + 1
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
def equity_curve(self) -> list[tuple[int, float]]:
|
|
71
|
+
"""Cumulative PnL after each market settles, as (end_ts, pnl)."""
|
|
72
|
+
total, out = 0.0, []
|
|
73
|
+
for m in sorted(self.markets, key=lambda r: r.market.end_ts):
|
|
74
|
+
total += m.pnl
|
|
75
|
+
out.append((m.market.end_ts, total))
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
def summary(self) -> dict:
|
|
79
|
+
def r(x, n=4):
|
|
80
|
+
return None if x is None else round(x, n)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"fill_model": self.fill_model,
|
|
84
|
+
"latency_ms": self.latency_ms,
|
|
85
|
+
"markets": len(self.markets),
|
|
86
|
+
"markets_traded": len(self.traded),
|
|
87
|
+
"invested_usd": r(self.invested, 2),
|
|
88
|
+
"pnl_usd": r(self.pnl, 2),
|
|
89
|
+
"return_on_invested": r(self.pnl / self.invested if self.invested else None),
|
|
90
|
+
"fees_usd": r(self.fees, 2),
|
|
91
|
+
"win_rate": r(self.win_rate),
|
|
92
|
+
"partial_fills": sum(f.depth_limited for m in self.markets for f in m.fills),
|
|
93
|
+
"avg_slippage_vs_mid": r(self.slippage_vs_mid),
|
|
94
|
+
"entry_brier": r(self.brier),
|
|
95
|
+
"rejected_orders": self.rejections,
|
|
96
|
+
"unresolved_markets": sum(not m.resolved for m in self.traded),
|
|
97
|
+
}
|
resolvedkit/models.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Core data types. Prices are probabilities in [0, 1]; sizes are shares; money is USDC."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
UP, DOWN = "UP", "DOWN"
|
|
8
|
+
SIDES = (UP, DOWN)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Level:
|
|
13
|
+
price: float
|
|
14
|
+
size: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Book:
|
|
19
|
+
"""One order-book snapshot for one outcome token.
|
|
20
|
+
|
|
21
|
+
`bids` are sorted best (highest) first, `asks` best (lowest) first. An empty side means
|
|
22
|
+
nobody was quoting it, which happens near settlement.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
ts: int # epoch milliseconds, UTC
|
|
26
|
+
side: str # UP or DOWN: UP is the market's first outcome (e.g. "Up" or "Yes")
|
|
27
|
+
bids: tuple[Level, ...]
|
|
28
|
+
asks: tuple[Level, ...]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def best_bid(self) -> float | None:
|
|
32
|
+
return self.bids[0].price if self.bids else None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def best_ask(self) -> float | None:
|
|
36
|
+
return self.asks[0].price if self.asks else None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def mid(self) -> float:
|
|
40
|
+
"""Polymarket's convention: a missing bid counts as 0 and a missing ask as 1."""
|
|
41
|
+
return ((self.best_bid or 0.0) + (self.best_ask if self.best_ask is not None else 1.0)) / 2
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def two_sided(self) -> bool:
|
|
45
|
+
return bool(self.bids) and bool(self.asks)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_placeholder(self) -> bool:
|
|
49
|
+
"""A freshly listed market often shows a 0.01 bid / 0.99 ask before real quoting starts.
|
|
50
|
+
|
|
51
|
+
Trading against it in a backtest buys at 0.99 or sells at 0.01, which no one would do.
|
|
52
|
+
"""
|
|
53
|
+
return (self.best_bid or 0.0) <= 0.01 and (self.best_ask if self.best_ask is not None else 1.0) >= 0.99
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Market:
|
|
58
|
+
market_id: str
|
|
59
|
+
question: str
|
|
60
|
+
category: str
|
|
61
|
+
end_ts: int # epoch ms: expiry for crypto up/down, kickoff for sports
|
|
62
|
+
outcomes: tuple[str, str] = ("Up", "Down")
|
|
63
|
+
# Settlement value per share of each side, e.g. (1.0, 0.0). None while unresolved.
|
|
64
|
+
payout: tuple[float, float] | None = None
|
|
65
|
+
start_ts: int | None = None # window start for recurring markets, when known
|
|
66
|
+
slug: str = ""
|
|
67
|
+
timeframe: str = ""
|
|
68
|
+
|
|
69
|
+
def payout_for(self, side: str) -> float | None:
|
|
70
|
+
if self.payout is None:
|
|
71
|
+
return None
|
|
72
|
+
return self.payout[0] if side == UP else self.payout[1]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Fill:
|
|
77
|
+
side: str # UP or DOWN token
|
|
78
|
+
action: str # BUY or SELL
|
|
79
|
+
ts: int
|
|
80
|
+
shares: float
|
|
81
|
+
avg_price: float
|
|
82
|
+
fee: float
|
|
83
|
+
mid_at_fill: float
|
|
84
|
+
depth_limited: bool = False # the book (or the price limit) could not absorb the full order
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def notional(self) -> float:
|
|
88
|
+
return self.shares * self.avg_price
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Position:
|
|
93
|
+
side: str
|
|
94
|
+
shares: float = 0.0
|
|
95
|
+
cost: float = 0.0 # USDC paid for the shares still held, excluding fees
|
|
96
|
+
fees: float = 0.0
|
|
97
|
+
fills: list[Fill] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ms_to_dt(ms: int) -> datetime:
|
|
101
|
+
from datetime import timezone
|
|
102
|
+
|
|
103
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
resolvedkit/rules.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Strategies from a JSON spec, so a backtest can be written without Python (or by an AI agent).
|
|
2
|
+
|
|
3
|
+
{
|
|
4
|
+
"name": "late favorite",
|
|
5
|
+
"entry": {
|
|
6
|
+
"side": "favorite", // UP, DOWN, favorite (higher mid) or underdog (lower mid)
|
|
7
|
+
"seconds_before_end": 120, // enter once this close to the market's end time
|
|
8
|
+
"min_price": 0.6, "max_price": 0.9, // only if the side's best ask is in this range
|
|
9
|
+
"usd": 100, // order size
|
|
10
|
+
"max_slippage": 0.02 // never pay more than best ask + this
|
|
11
|
+
},
|
|
12
|
+
"exit": { // optional; without it the position is held to resolution
|
|
13
|
+
"take_profit": 0.05, // sell when the best bid is this far above the entry price
|
|
14
|
+
"stop_loss": 0.10 // sell when the best bid is this far below it
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from .engine import Context, Strategy
|
|
23
|
+
from .models import DOWN, UP, Book
|
|
24
|
+
|
|
25
|
+
_SIDES = {"UP", "DOWN", "favorite", "underdog"}
|
|
26
|
+
EPS = 1e-9 # price comparisons: 0.52 + 0.05 is 0.5700000000000001 in floating point
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RuleStrategy(Strategy):
|
|
31
|
+
spec: dict
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
e = self.spec.get("entry") or {}
|
|
35
|
+
if e.get("side") not in _SIDES:
|
|
36
|
+
raise ValueError(f"entry.side must be one of {sorted(_SIDES)}")
|
|
37
|
+
for k in ("seconds_before_end", "usd"):
|
|
38
|
+
if not isinstance(e.get(k), (int, float)) or e[k] <= 0:
|
|
39
|
+
raise ValueError(f"entry.{k} must be a positive number")
|
|
40
|
+
|
|
41
|
+
def on_start(self, ctx: Context) -> None:
|
|
42
|
+
ctx.state.update(side=None)
|
|
43
|
+
|
|
44
|
+
def _pick_side(self, ctx: Context) -> str | None:
|
|
45
|
+
want = self.spec["entry"]["side"]
|
|
46
|
+
if want in (UP, DOWN):
|
|
47
|
+
return want
|
|
48
|
+
up, down = ctx.book(UP), ctx.book(DOWN)
|
|
49
|
+
if not up or not down:
|
|
50
|
+
return None
|
|
51
|
+
fav = UP if up.mid >= down.mid else DOWN
|
|
52
|
+
return fav if want == "favorite" else (DOWN if fav == UP else UP)
|
|
53
|
+
|
|
54
|
+
def on_book(self, ctx: Context, book: Book) -> None:
|
|
55
|
+
# State comes from fills, not from orders placed: a rejected or partial order is retried.
|
|
56
|
+
e, x, st = self.spec["entry"], self.spec.get("exit") or {}, ctx.state
|
|
57
|
+
if ctx.pending_orders():
|
|
58
|
+
return
|
|
59
|
+
side = st["side"]
|
|
60
|
+
entered = side is not None and any(f.action == "BUY" for f in ctx.position(side).fills)
|
|
61
|
+
if not entered:
|
|
62
|
+
if not (0 < ctx.seconds_to_end <= e["seconds_before_end"]):
|
|
63
|
+
return
|
|
64
|
+
side = self._pick_side(ctx)
|
|
65
|
+
b = ctx.book(side) if side else None
|
|
66
|
+
if not b or b.best_ask is None:
|
|
67
|
+
return
|
|
68
|
+
if not (e.get("min_price", 0.0) - EPS <= b.best_ask <= e.get("max_price", 1.0) + EPS):
|
|
69
|
+
return
|
|
70
|
+
ctx.buy(side, e["usd"], max_price=b.best_ask + e.get("max_slippage", 0.02))
|
|
71
|
+
st["side"] = side
|
|
72
|
+
return
|
|
73
|
+
pos = ctx.position(side)
|
|
74
|
+
if pos.shares <= 1e-9 or book.side != side or not x or book.best_bid is None:
|
|
75
|
+
return
|
|
76
|
+
entry = pos.cost / pos.shares
|
|
77
|
+
bid = book.best_bid
|
|
78
|
+
if ("take_profit" in x and bid >= entry + x["take_profit"] - EPS) or (
|
|
79
|
+
"stop_loss" in x and bid <= entry - x["stop_loss"] + EPS
|
|
80
|
+
):
|
|
81
|
+
ctx.sell(side)
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: resolvedkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Polymarket backtester that tests strategies against the real order book. Fills walk the L2 ladder, fees follow Polymarket's curve, positions settle at resolution.
|
|
5
|
+
Project-URL: Homepage, https://github.com/resolvedmarkets/resolvedkit
|
|
6
|
+
Project-URL: Data, https://resolvedmarkets.com
|
|
7
|
+
Project-URL: Issues, https://github.com/resolvedmarkets/resolvedkit/issues
|
|
8
|
+
Author-email: Resolved Markets <info@resolvedmarkets.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: backtesting,orderbook,polymarket,polymarket-backtester,prediction-markets,quant,trading
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: pyarrow>=14
|
|
19
|
+
Requires-Dist: requests>=2.31
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: matplotlib>=3.7; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Provides-Extra: plot
|
|
24
|
+
Requires-Dist: matplotlib>=3.7; extra == 'plot'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# resolvedkit: a Polymarket backtester
|
|
28
|
+
|
|
29
|
+
**Backtest Polymarket strategies against the real order book.** Orders fill by walking the L2 ladder,
|
|
30
|
+
fees follow Polymarket's taker-fee curve, orders land after a realistic delay, and positions settle at
|
|
31
|
+
the market's actual resolution. It ships with sample data, so it runs straight after `pip install`, with
|
|
32
|
+
no API key.
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
Most prediction-market backtests fill at the mid price. That price is not one you can trade at: a
|
|
37
|
+
market buy pays the ask, walks up the book when the size is larger than the best level, and pays a
|
|
38
|
+
taker fee on top. On the bundled sample, the same strategy with the same fees returns **+29.5%** when
|
|
39
|
+
filled at the mid and **+26.0%** when filled against the book. That gap is often an entire edge.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install resolvedkit
|
|
43
|
+
resolvedkit run late_favorite --compare-mid
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What it models
|
|
47
|
+
|
|
48
|
+
- **Book-walking fills.** Buys lift asks level by level and sells hit bids. A price limit caps the
|
|
49
|
+
walk, and partial fills are flagged when the book runs out. Mid-price fills exist only as an explicit
|
|
50
|
+
`fill_model="mid"`, to measure how much they overstate.
|
|
51
|
+
- **Polymarket fees.** `shares × rate × p × (1 − p)` is charged per matched level, with the rate for
|
|
52
|
+
each category (crypto 0.07, sports 0.05, and so on).
|
|
53
|
+
- **Latency.** An order fills against the book standing `latency_ms` after it was placed (250 ms by
|
|
54
|
+
default), not the book that triggered it.
|
|
55
|
+
- **Settlement.** Shares still held at the end pay out the market's resolved price, 1 or 0 per share, or
|
|
56
|
+
a fraction for split outcomes. Unresolved markets are marked at the last mid and counted in
|
|
57
|
+
`unresolved_markets`.
|
|
58
|
+
- **Placeholder books.** Freshly listed markets often show a 0.01 / 0.99 book before real quoting
|
|
59
|
+
starts. Orders against it are rejected rather than filled at 0.99.
|
|
60
|
+
- **Metrics.** Results include PnL, return on money invested, fees, win rate, partial fills, average
|
|
61
|
+
slippage against the mid, and the Brier score of entry prices.
|
|
62
|
+
|
|
63
|
+
## Two ways to write a strategy
|
|
64
|
+
|
|
65
|
+
**JSON spec**, with no code, which also makes it easy for an AI agent to write one:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"name": "Buy the favorite in the last 2 minutes, take 5 cents or stop at 10",
|
|
70
|
+
"entry": {"side": "favorite", "seconds_before_end": 120, "min_price": 0.6, "max_price": 0.9,
|
|
71
|
+
"usd": 250, "max_slippage": 0.02},
|
|
72
|
+
"exit": {"take_profit": 0.05, "stop_loss": 0.10}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`side` is `UP`, `DOWN`, `favorite` or `underdog`. Without `exit`, the position is held to resolution.
|
|
77
|
+
Save it as `my_strategy.json` and run `resolvedkit run my_strategy.json`. Two examples are
|
|
78
|
+
bundled: `late_favorite` and `early_underdog_scalp`.
|
|
79
|
+
|
|
80
|
+
**Python**, for anything else:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from resolvedkit import UP, Backtester, Strategy, load_sample
|
|
84
|
+
|
|
85
|
+
class DepthImbalance(Strategy):
|
|
86
|
+
def on_book(self, ctx, book):
|
|
87
|
+
if book.side != UP or ctx.state.get("entered") or not book.two_sided:
|
|
88
|
+
return
|
|
89
|
+
bid_depth = sum(l.size * l.price for l in book.bids[:5])
|
|
90
|
+
ask_depth = sum(l.size * l.price for l in book.asks[:5])
|
|
91
|
+
if 60 < ctx.seconds_to_end < 600 and bid_depth > 3 * ask_depth:
|
|
92
|
+
ctx.buy(UP, usd=100, max_price=book.best_ask + 0.02)
|
|
93
|
+
ctx.state["entered"] = True
|
|
94
|
+
|
|
95
|
+
print(Backtester(load_sample(), DepthImbalance()).run().summary())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`on_book` is called for every snapshot of either outcome token, in time order. `ctx` provides:
|
|
99
|
+
- `book(side)`, `position(side)`, `now` and `seconds_to_end`
|
|
100
|
+
- `buy(side, usd, max_price)` and `sell(side, shares, min_price)`
|
|
101
|
+
- `state`, a dict that resets for each market
|
|
102
|
+
|
|
103
|
+
## Data
|
|
104
|
+
|
|
105
|
+
| Source | Use |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `load_sample()` | 18 settled BTC 15-minute markets, bundled (CC BY 4.0) |
|
|
108
|
+
| `ResolvedMarketsAPI(crypto="BTC", timeframe="15m", limit=50)` | Historical Polymarket order books from [Resolved Markets](https://resolvedmarkets.com). A free API key covers recent crypto markets; paid plans add full history plus sports, weather, equities and economics. |
|
|
109
|
+
| `ParquetSource("folder/")` | Your own data in the documented [two-file Parquet layout](https://github.com/resolvedmarkets/resolvedkit/blob/main/src/resolvedkit/data/parquet.py) |
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
export RESOLVED_MARKETS_API_KEY=rm_... # free key: https://resolvedmarkets.com/api-keys
|
|
113
|
+
resolvedkit run late_favorite --data api --crypto ETH --timeframe 5m --limit 50
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The sample is thinned to one snapshot per side per second and the top 20 levels. The API serves every
|
|
117
|
+
snapshot at full depth.
|
|
118
|
+
|
|
119
|
+
## Honest limits
|
|
120
|
+
|
|
121
|
+
- Rejected or partly filled orders are retried by the JSON rules on the next snapshot; in Python
|
|
122
|
+
strategies, check `ctx.pending_orders()` and your position yourself.
|
|
123
|
+
- Your orders don't move the book: each fill walks the snapshot as recorded, and later snapshots don't
|
|
124
|
+
reflect your trades. For small size relative to depth this is close; for large size it's optimistic.
|
|
125
|
+
- Resting (maker) orders aren't simulated yet, since queue position isn't in snapshot data. All fills
|
|
126
|
+
are taker fills.
|
|
127
|
+
- 18 sample markets are enough to show the mechanics, not to prove a strategy. Run on hundreds.
|
|
128
|
+
|
|
129
|
+
## Roadmap
|
|
130
|
+
|
|
131
|
+
Kalshi fees and data adapter · maker orders with queue estimates · wallet-replay (copy-trading)
|
|
132
|
+
backtests · an MCP server so agents can run backtests.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
Code: MIT. Sample data: CC BY 4.0, see [DATA_LICENSE](https://github.com/resolvedmarkets/resolvedkit/blob/main/DATA_LICENSE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
resolvedkit/__init__.py,sha256=pZhDkpabHDkI3VIgYYuq2E3AOVqSeGcAzTJ0txHzqs0,613
|
|
2
|
+
resolvedkit/cli.py,sha256=NFa35VPiHYb37gICo5eNEhQ1PkKJBIVYe2zBtMHWck8,3695
|
|
3
|
+
resolvedkit/engine.py,sha256=4HQAsSDRJwSPhek0ON-vVdDgrrGBfuSivYi0lVOF1bA,8679
|
|
4
|
+
resolvedkit/fees.py,sha256=U8pZiv7E5iuro-r_8idXXP8ncrmX8q0FAz8RfztU8tU,996
|
|
5
|
+
resolvedkit/fills.py,sha256=2K3QqyjUdF3tUX42q4PuUL3nvYWjJ0YsVNKVNtHv2l4,2337
|
|
6
|
+
resolvedkit/metrics.py,sha256=nerIqATQH0p6G0WVAHIhoCCRtNzb8N2WqacBdq5n7Pk,3456
|
|
7
|
+
resolvedkit/models.py,sha256=H5YRQTceeXIz93h-DQsisabEeJbXpO-XBfkdwgeddKE,3039
|
|
8
|
+
resolvedkit/rules.py,sha256=oq-VSN2iHUgt-oOyN9kaExzGv0aMosJmzHVO6zTJwVU,3329
|
|
9
|
+
resolvedkit/data/__init__.py,sha256=eI7F-9Pewjgr2M3XqoQ4k29lYD3RCj5sqJo8vOOl_ZI,347
|
|
10
|
+
resolvedkit/data/base.py,sha256=1gs8NS7WA7UaPBA0OO4Nnj1ZzQGxt1CPDS3Gtzkd5RU,1287
|
|
11
|
+
resolvedkit/data/parquet.py,sha256=tX-M7183Ift4I1OwpV6ifSqeu_TPJZ7-RW4FWECTthE,4200
|
|
12
|
+
resolvedkit/data/resolvedmarkets.py,sha256=QYzY0-0uTReCnZMq_nFieuPBkR3oaBl_HAO1ZB8YDXI,4641
|
|
13
|
+
resolvedkit/data/sample.py,sha256=f2rkl96gT4mp8HevSmm3cvPraHubDSvG_Syzz7xqKjY,507
|
|
14
|
+
resolvedkit/sample_data/books.parquet,sha256=6gzpEOhBFnxHxHDXgEa7tNmu_YWpC1RJOZ9dupPdENY,2647815
|
|
15
|
+
resolvedkit/sample_data/markets.parquet,sha256=CcHMzjIL-cBGO3sFbPbYSCg94ORj2bZuukF-2cyNq8c,4966
|
|
16
|
+
resolvedkit/specs/early_underdog_scalp.json,sha256=Y6cHSqVk74YnIWufREdPJE_99ph7BXTYSXqnfpzwffg,264
|
|
17
|
+
resolvedkit/specs/late_favorite.json,sha256=E90jCrKiAkjyzGKHhBhMGy8MsOaYyiNo00er6NH5b4s,205
|
|
18
|
+
resolvedkit-0.1.0.dist-info/METADATA,sha256=h34eXzTf4FCzIqJN2hv59r4DJLZ2wkTJ15SErecwIiE,6703
|
|
19
|
+
resolvedkit-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
20
|
+
resolvedkit-0.1.0.dist-info/entry_points.txt,sha256=PeddVi_9U9M6epogejAzNfGkLvhb0ziYZ0PUY41M2kI,53
|
|
21
|
+
resolvedkit-0.1.0.dist-info/licenses/LICENSE,sha256=TxXjZsKMxudOqfCxFZyCzocJSjcSdQzwVNAA2_zEiAo,1089
|
|
22
|
+
resolvedkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elcara LLC-FZ (Resolved Markets)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|