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.
- resolvedkit-0.1.0/.gitignore +5 -0
- resolvedkit-0.1.0/DATA_LICENSE +9 -0
- resolvedkit-0.1.0/LICENSE +21 -0
- resolvedkit-0.1.0/PKG-INFO +136 -0
- resolvedkit-0.1.0/README.md +110 -0
- resolvedkit-0.1.0/docs/mid-vs-book.png +0 -0
- resolvedkit-0.1.0/examples/custom_strategy.py +18 -0
- resolvedkit-0.1.0/examples/mid_vs_book_chart.py +40 -0
- resolvedkit-0.1.0/pyproject.toml +37 -0
- resolvedkit-0.1.0/scripts/build_sample.py +22 -0
- resolvedkit-0.1.0/src/resolvedkit/__init__.py +13 -0
- resolvedkit-0.1.0/src/resolvedkit/cli.py +85 -0
- resolvedkit-0.1.0/src/resolvedkit/data/__init__.py +7 -0
- resolvedkit-0.1.0/src/resolvedkit/data/base.py +35 -0
- resolvedkit-0.1.0/src/resolvedkit/data/parquet.py +88 -0
- resolvedkit-0.1.0/src/resolvedkit/data/resolvedmarkets.py +91 -0
- resolvedkit-0.1.0/src/resolvedkit/data/sample.py +14 -0
- resolvedkit-0.1.0/src/resolvedkit/engine.py +208 -0
- resolvedkit-0.1.0/src/resolvedkit/fees.py +34 -0
- resolvedkit-0.1.0/src/resolvedkit/fills.py +64 -0
- resolvedkit-0.1.0/src/resolvedkit/metrics.py +97 -0
- resolvedkit-0.1.0/src/resolvedkit/models.py +103 -0
- resolvedkit-0.1.0/src/resolvedkit/rules.py +81 -0
- resolvedkit-0.1.0/src/resolvedkit/sample_data/books.parquet +0 -0
- resolvedkit-0.1.0/src/resolvedkit/sample_data/markets.parquet +0 -0
- resolvedkit-0.1.0/src/resolvedkit/specs/early_underdog_scalp.json +5 -0
- resolvedkit-0.1.0/src/resolvedkit/specs/late_favorite.json +4 -0
- resolvedkit-0.1.0/tests/test_core.py +170 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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")
|