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.
@@ -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"]
@@ -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,5 @@
1
+ {
2
+ "name": "Buy the underdog with 10 minutes left, take 5 cents or stop at 10",
3
+ "entry": {"side": "underdog", "seconds_before_end": 600, "min_price": 0.3, "max_price": 0.5, "usd": 250, "max_slippage": 0.02},
4
+ "exit": {"take_profit": 0.05, "stop_loss": 0.10}
5
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "Buy the favorite in the last 2 minutes, hold to resolution",
3
+ "entry": {"side": "favorite", "seconds_before_end": 120, "min_price": 0.6, "max_price": 0.9, "usd": 250, "max_slippage": 0.02}
4
+ }
@@ -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
+ ![Same strategy backtested with mid-price fills and on the real order book](https://raw.githubusercontent.com/resolvedmarkets/resolvedkit/main/docs/mid-vs-book.png)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ resolvedkit = resolvedkit.cli:main
@@ -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.