resolvedkit 0.1.0__tar.gz

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,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
@@ -0,0 +1,9 @@
1
+ The sample dataset in src/resolvedkit/sample_data/ is © 2026 Elcara LLC-FZ (Resolved Markets)
2
+ and licensed under the Creative Commons Attribution 4.0 International License (CC BY 4.0):
3
+ https://creativecommons.org/licenses/by/4.0/
4
+
5
+ Attribution: "Polymarket order-book data from Resolved Markets (https://resolvedmarkets.com)".
6
+
7
+ It contains settled BTC 15-minute Polymarket markets, thinned to one snapshot per side per second and the
8
+ top 20 price levels. The full archive (every snapshot, full depth, all categories) is available through
9
+ the Resolved Markets API.
@@ -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.
@@ -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,110 @@
1
+ # resolvedkit: a Polymarket backtester
2
+
3
+ **Backtest Polymarket strategies against the real order book.** Orders fill by walking the L2 ladder,
4
+ fees follow Polymarket's taker-fee curve, orders land after a realistic delay, and positions settle at
5
+ the market's actual resolution. It ships with sample data, so it runs straight after `pip install`, with
6
+ no API key.
7
+
8
+ ![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)
9
+
10
+ Most prediction-market backtests fill at the mid price. That price is not one you can trade at: a
11
+ market buy pays the ask, walks up the book when the size is larger than the best level, and pays a
12
+ taker fee on top. On the bundled sample, the same strategy with the same fees returns **+29.5%** when
13
+ filled at the mid and **+26.0%** when filled against the book. That gap is often an entire edge.
14
+
15
+ ```bash
16
+ pip install resolvedkit
17
+ resolvedkit run late_favorite --compare-mid
18
+ ```
19
+
20
+ ## What it models
21
+
22
+ - **Book-walking fills.** Buys lift asks level by level and sells hit bids. A price limit caps the
23
+ walk, and partial fills are flagged when the book runs out. Mid-price fills exist only as an explicit
24
+ `fill_model="mid"`, to measure how much they overstate.
25
+ - **Polymarket fees.** `shares × rate × p × (1 − p)` is charged per matched level, with the rate for
26
+ each category (crypto 0.07, sports 0.05, and so on).
27
+ - **Latency.** An order fills against the book standing `latency_ms` after it was placed (250 ms by
28
+ default), not the book that triggered it.
29
+ - **Settlement.** Shares still held at the end pay out the market's resolved price, 1 or 0 per share, or
30
+ a fraction for split outcomes. Unresolved markets are marked at the last mid and counted in
31
+ `unresolved_markets`.
32
+ - **Placeholder books.** Freshly listed markets often show a 0.01 / 0.99 book before real quoting
33
+ starts. Orders against it are rejected rather than filled at 0.99.
34
+ - **Metrics.** Results include PnL, return on money invested, fees, win rate, partial fills, average
35
+ slippage against the mid, and the Brier score of entry prices.
36
+
37
+ ## Two ways to write a strategy
38
+
39
+ **JSON spec**, with no code, which also makes it easy for an AI agent to write one:
40
+
41
+ ```json
42
+ {
43
+ "name": "Buy the favorite in the last 2 minutes, take 5 cents or stop at 10",
44
+ "entry": {"side": "favorite", "seconds_before_end": 120, "min_price": 0.6, "max_price": 0.9,
45
+ "usd": 250, "max_slippage": 0.02},
46
+ "exit": {"take_profit": 0.05, "stop_loss": 0.10}
47
+ }
48
+ ```
49
+
50
+ `side` is `UP`, `DOWN`, `favorite` or `underdog`. Without `exit`, the position is held to resolution.
51
+ Save it as `my_strategy.json` and run `resolvedkit run my_strategy.json`. Two examples are
52
+ bundled: `late_favorite` and `early_underdog_scalp`.
53
+
54
+ **Python**, for anything else:
55
+
56
+ ```python
57
+ from resolvedkit import UP, Backtester, Strategy, load_sample
58
+
59
+ class DepthImbalance(Strategy):
60
+ def on_book(self, ctx, book):
61
+ if book.side != UP or ctx.state.get("entered") or not book.two_sided:
62
+ return
63
+ bid_depth = sum(l.size * l.price for l in book.bids[:5])
64
+ ask_depth = sum(l.size * l.price for l in book.asks[:5])
65
+ if 60 < ctx.seconds_to_end < 600 and bid_depth > 3 * ask_depth:
66
+ ctx.buy(UP, usd=100, max_price=book.best_ask + 0.02)
67
+ ctx.state["entered"] = True
68
+
69
+ print(Backtester(load_sample(), DepthImbalance()).run().summary())
70
+ ```
71
+
72
+ `on_book` is called for every snapshot of either outcome token, in time order. `ctx` provides:
73
+ - `book(side)`, `position(side)`, `now` and `seconds_to_end`
74
+ - `buy(side, usd, max_price)` and `sell(side, shares, min_price)`
75
+ - `state`, a dict that resets for each market
76
+
77
+ ## Data
78
+
79
+ | Source | Use |
80
+ |---|---|
81
+ | `load_sample()` | 18 settled BTC 15-minute markets, bundled (CC BY 4.0) |
82
+ | `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. |
83
+ | `ParquetSource("folder/")` | Your own data in the documented [two-file Parquet layout](https://github.com/resolvedmarkets/resolvedkit/blob/main/src/resolvedkit/data/parquet.py) |
84
+
85
+ ```bash
86
+ export RESOLVED_MARKETS_API_KEY=rm_... # free key: https://resolvedmarkets.com/api-keys
87
+ resolvedkit run late_favorite --data api --crypto ETH --timeframe 5m --limit 50
88
+ ```
89
+
90
+ The sample is thinned to one snapshot per side per second and the top 20 levels. The API serves every
91
+ snapshot at full depth.
92
+
93
+ ## Honest limits
94
+
95
+ - Rejected or partly filled orders are retried by the JSON rules on the next snapshot; in Python
96
+ strategies, check `ctx.pending_orders()` and your position yourself.
97
+ - Your orders don't move the book: each fill walks the snapshot as recorded, and later snapshots don't
98
+ reflect your trades. For small size relative to depth this is close; for large size it's optimistic.
99
+ - Resting (maker) orders aren't simulated yet, since queue position isn't in snapshot data. All fills
100
+ are taker fills.
101
+ - 18 sample markets are enough to show the mechanics, not to prove a strategy. Run on hundreds.
102
+
103
+ ## Roadmap
104
+
105
+ Kalshi fees and data adapter · maker orders with queue estimates · wallet-replay (copy-trading)
106
+ backtests · an MCP server so agents can run backtests.
107
+
108
+ ## License
109
+
110
+ Code: MIT. Sample data: CC BY 4.0, see [DATA_LICENSE](https://github.com/resolvedmarkets/resolvedkit/blob/main/DATA_LICENSE).
Binary file
@@ -0,0 +1,18 @@
1
+ """A strategy in Python: buy UP when its book shows 3x more bid depth than ask depth."""
2
+ from resolvedkit import UP, Backtester, Strategy, load_sample
3
+
4
+
5
+ class DepthImbalance(Strategy):
6
+ def on_book(self, ctx, book):
7
+ if book.side != UP or ctx.state.get("entered") or not book.two_sided:
8
+ return
9
+ bid_depth = sum(l.size * l.price for l in book.bids[:5])
10
+ ask_depth = sum(l.size * l.price for l in book.asks[:5])
11
+ if 60 < ctx.seconds_to_end < 600 and bid_depth > 3 * ask_depth:
12
+ ctx.buy(UP, usd=100, max_price=book.best_ask + 0.02)
13
+ ctx.state["entered"] = True
14
+
15
+
16
+ results = Backtester(load_sample(), DepthImbalance(), latency_ms=250).run()
17
+ for k, v in results.summary().items():
18
+ print(f"{k:<22} {v}")
@@ -0,0 +1,40 @@
1
+ """Draw docs/mid-vs-book.png: the same strategy backtested with mid-price fills and on the real book."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import matplotlib.pyplot as plt
6
+
7
+ from resolvedkit import Backtester, RuleStrategy, load_sample
8
+
9
+ HERE = Path(__file__).parent
10
+ spec = json.loads((HERE.parent / "src" / "resolvedkit" / "specs" / "late_favorite.json").read_text())
11
+ data = load_sample()
12
+
13
+
14
+ def cumulative_return(results):
15
+ invested = pnl = 0.0
16
+ xs, ys = [], []
17
+ for i, m in enumerate(sorted(results.markets, key=lambda r: r.market.end_ts), 1):
18
+ invested += sum(f.notional for f in m.fills if f.action == "BUY")
19
+ pnl += m.pnl
20
+ xs.append(i)
21
+ ys.append(100 * pnl / invested if invested else 0.0)
22
+ return xs, ys
23
+
24
+
25
+ fig, ax = plt.subplots(figsize=(8, 4.2), dpi=150)
26
+ for model, style in (("mid", "--"), ("book", "-")):
27
+ res = Backtester(data, RuleStrategy(spec), fill_model=model).run()
28
+ xs, ys = cumulative_return(res)
29
+ label = "Filled at the mid price (what most backtests do)" if model == "mid" else "Filled by walking the real order book"
30
+ ax.plot(xs, ys, style, linewidth=2.2, label=f"{label}: {ys[-1]:+.1f}%")
31
+ ax.set_title(f"Same strategy, same fees and 250 ms latency, two fill models\n{spec['name']} · {len(data.markets())} BTC 15-minute Polymarket markets", fontsize=10)
32
+ ax.xaxis.get_major_locator().set_params(integer=True)
33
+ ax.set_xlabel("Markets")
34
+ ax.set_ylabel("Cumulative return on money invested (%)")
35
+ ax.grid(alpha=0.3)
36
+ ax.legend(fontsize=8, loc="lower right")
37
+ fig.tight_layout()
38
+ out = HERE.parent / "docs" / "mid-vs-book.png"
39
+ fig.savefig(out)
40
+ print("wrote", out)
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "resolvedkit"
3
+ version = "0.1.0"
4
+ description = "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
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ authors = [{ name = "Resolved Markets", email = "info@resolvedmarkets.com" }]
10
+ keywords = ["polymarket", "polymarket-backtester", "backtesting", "prediction-markets", "orderbook", "trading", "quant"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Financial and Insurance Industry",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Office/Business :: Financial :: Investment",
17
+ ]
18
+ dependencies = ["pyarrow>=14", "requests>=2.31"]
19
+
20
+ [project.optional-dependencies]
21
+ plot = ["matplotlib>=3.7"]
22
+ dev = ["pytest>=8", "matplotlib>=3.7"]
23
+
24
+ [project.scripts]
25
+ resolvedkit = "resolvedkit.cli:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/resolvedmarkets/resolvedkit"
29
+ Data = "https://resolvedmarkets.com"
30
+ Issues = "https://github.com/resolvedmarkets/resolvedkit/issues"
31
+
32
+ [build-system]
33
+ requires = ["hatchling>=1.26"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,22 @@
1
+ """Rebuild the bundled sample from the Resolved Markets API.
2
+
3
+ RESOLVED_MARKETS_API_KEY=... python scripts/build_sample.py [n_markets]
4
+
5
+ Takes the most recent settled BTC 15-minute markets, one snapshot per side per second, top 20 levels.
6
+ """
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from resolvedkit.data import ResolvedMarketsAPI, write_parquet
11
+
12
+ N = int(sys.argv[1]) if len(sys.argv) > 1 else 20
13
+ OUT = Path(__file__).resolve().parent.parent / "src" / "resolvedkit" / "sample_data"
14
+
15
+ api = ResolvedMarketsAPI(crypto="BTC", timeframe="15m", limit=N, thin_ms=1000, depth=20)
16
+ markets = [m for m in api.markets() if m.payout is not None]
17
+ books = {}
18
+ for i, m in enumerate(markets, 1):
19
+ books[m.market_id] = api.books(m.market_id)
20
+ print(f"{i}/{len(markets)} {m.question}: {len(books[m.market_id])} snapshots", flush=True)
21
+ write_parquet(OUT, markets, books, depth=20)
22
+ print("wrote", OUT, sum(f.stat().st_size for f in OUT.iterdir()) // 1024, "KiB")
@@ -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
+ ]
@@ -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")