pmquant 0.1.0__tar.gz → 0.2.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.
- pmquant-0.2.0/.github/workflows/canary.yml +34 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/.github/workflows/test.yml +1 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/CHANGELOG.md +10 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/PKG-INFO +18 -1
- {pmquant-0.1.0 → pmquant-0.2.0}/README.md +16 -0
- pmquant-0.2.0/SECURITY.md +29 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/bot-template/bot.py +40 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/bot-template/dash/bot_dash.py +2 -1
- {pmquant-0.1.0 → pmquant-0.2.0}/bot-template/dash/dash.html +4 -2
- pmquant-0.2.0/docs/rounding-study.md +45 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/pyproject.toml +10 -2
- {pmquant-0.1.0 → pmquant-0.2.0}/src/pmq/__init__.py +19 -6
- {pmquant-0.1.0 → pmquant-0.2.0}/src/pmq/data.py +23 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/src/pmq/executor.py +31 -3
- {pmquant-0.1.0 → pmquant-0.2.0}/src/pmq/mcp.py +13 -0
- pmquant-0.2.0/tests/test_canary_live.py +72 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/tests/test_data.py +8 -2
- {pmquant-0.1.0 → pmquant-0.2.0}/tests/test_executor.py +18 -0
- pmquant-0.2.0/tests/test_template_engine.py +82 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/.github/workflows/publish.yml +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/.gitignore +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/AGENTS.md +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/LICENSE +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/bot-template/README.md +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/bot-template/pmq-bot.service +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/bot-template/strategy.py +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/docs/war-story.md +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/examples/fak_buy_guarded.py +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/examples/read_market.py +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/llms.txt +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/src/pmq/exceptions.py +0 -0
- {pmquant-0.1.0 → pmquant-0.2.0}/tests/test_mcp.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: canary
|
|
2
|
+
on:
|
|
3
|
+
schedule:
|
|
4
|
+
- cron: "17 6 * * 1" # weekly, monday 06:17 UTC
|
|
5
|
+
workflow_dispatch:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
issues: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
canary:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
- run: pip install -e ".[dev]"
|
|
20
|
+
- name: Live canary against real Polymarket endpoints
|
|
21
|
+
env:
|
|
22
|
+
PMQ_CANARY: "1"
|
|
23
|
+
run: pytest tests/test_canary_live.py -q
|
|
24
|
+
- name: Open an issue if Polymarket drifted
|
|
25
|
+
if: failure()
|
|
26
|
+
env:
|
|
27
|
+
GH_TOKEN: ${{ github.token }}
|
|
28
|
+
run: |
|
|
29
|
+
existing=$(gh issue list --label canary --state open --json number --jq length)
|
|
30
|
+
if [ "$existing" = "0" ]; then
|
|
31
|
+
gh issue create --label canary \
|
|
32
|
+
--title "Canary: Polymarket surface drifted ($(date -u +%F))" \
|
|
33
|
+
--body "The weekly live canary failed: an endpoint shape or the installed py-clob-client-v2 surface no longer matches what pmq was verified against. See the failed run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
|
34
|
+
fi
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-07-03)
|
|
4
|
+
|
|
5
|
+
* New: `positions(user)` and `event_markets(slug)` in the data layer; `event`
|
|
6
|
+
tool in the MCP server; `fee_rate(condition_id)` (authoritative per-market
|
|
7
|
+
taker rate from the exchange) and `cancel_order(order_id)` on the executor.
|
|
8
|
+
* Trust: weekly live canary workflow (real-endpoint checks, auto-opens an
|
|
9
|
+
issue on drift), SECURITY.md, production receipt in the README,
|
|
10
|
+
docs/rounding-study.md (measured V2 rounding behavior).
|
|
11
|
+
* Quality: ruff in CI, tests for the bot-template engine (34 tests total).
|
|
12
|
+
|
|
3
13
|
## 0.1.0 (2026-07-03)
|
|
4
14
|
|
|
5
15
|
First release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pmquant
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Fail-closed execution and market-data layer for Polymarket CLOB V2: local signing, confirmed fills only, fee-correct math, working deposit-wallet (POLY_1271) support.
|
|
5
5
|
Project-URL: Homepage, https://github.com/crp4222/pmq
|
|
6
6
|
Project-URL: Issues, https://github.com/crp4222/pmq/issues
|
|
@@ -19,12 +19,18 @@ Requires-Dist: py-clob-client-v2<2,>=1.0.2
|
|
|
19
19
|
Provides-Extra: dev
|
|
20
20
|
Requires-Dist: mcp>=1.2; extra == 'dev'
|
|
21
21
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
22
23
|
Provides-Extra: mcp
|
|
23
24
|
Requires-Dist: mcp>=1.2; extra == 'mcp'
|
|
24
25
|
Description-Content-Type: text/markdown
|
|
25
26
|
|
|
26
27
|
# pmq
|
|
27
28
|
|
|
29
|
+
[](https://pypi.org/project/pmquant/)
|
|
30
|
+
[](https://github.com/crp4222/pmq/actions/workflows/test.yml)
|
|
31
|
+
[](https://github.com/crp4222/pmq/actions/workflows/canary.yml)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
28
34
|
Fail-closed execution and market data for **Polymarket CLOB V2**, in Python.
|
|
29
35
|
Local signing (your keys never leave your process), exchange-confirmed fills
|
|
30
36
|
only, fee-correct math, and deposit-wallet (`POLY_1271`) support that actually
|
|
@@ -63,6 +69,17 @@ a real error in live trading:
|
|
|
63
69
|
|
|
64
70
|
The full write-up with reproduction details: [docs/war-story.md](docs/war-story.md).
|
|
65
71
|
|
|
72
|
+
## Runs in production
|
|
73
|
+
|
|
74
|
+
The maintainer's own bot trades through this exact executor 24/7 with real
|
|
75
|
+
money. Example receipt (2026-07-03): settlement transaction
|
|
76
|
+
[`0x387f5f09...100d88a8`](https://polygonscan.com/tx/0x387f5f09c031bb36a71c54adc978b1ed4d50c67f6dd3f0c2c8068391100d88a8)
|
|
77
|
+
on the CTF Exchange V2: a FAK market buy built by this library, matched and
|
|
78
|
+
settled, with the builder code visible in the calldata. Additionally, a weekly
|
|
79
|
+
[canary workflow](.github/workflows/canary.yml) exercises the real endpoints
|
|
80
|
+
and the installed client surface, and opens an issue by itself if Polymarket
|
|
81
|
+
drifts.
|
|
82
|
+
|
|
66
83
|
## The contract: nothing is booked without exchange confirmation
|
|
67
84
|
|
|
68
85
|
| Situation | What pmq does |
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# pmq
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/pmquant/)
|
|
4
|
+
[](https://github.com/crp4222/pmq/actions/workflows/test.yml)
|
|
5
|
+
[](https://github.com/crp4222/pmq/actions/workflows/canary.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
3
8
|
Fail-closed execution and market data for **Polymarket CLOB V2**, in Python.
|
|
4
9
|
Local signing (your keys never leave your process), exchange-confirmed fills
|
|
5
10
|
only, fee-correct math, and deposit-wallet (`POLY_1271`) support that actually
|
|
@@ -38,6 +43,17 @@ a real error in live trading:
|
|
|
38
43
|
|
|
39
44
|
The full write-up with reproduction details: [docs/war-story.md](docs/war-story.md).
|
|
40
45
|
|
|
46
|
+
## Runs in production
|
|
47
|
+
|
|
48
|
+
The maintainer's own bot trades through this exact executor 24/7 with real
|
|
49
|
+
money. Example receipt (2026-07-03): settlement transaction
|
|
50
|
+
[`0x387f5f09...100d88a8`](https://polygonscan.com/tx/0x387f5f09c031bb36a71c54adc978b1ed4d50c67f6dd3f0c2c8068391100d88a8)
|
|
51
|
+
on the CTF Exchange V2: a FAK market buy built by this library, matched and
|
|
52
|
+
settled, with the builder code visible in the calldata. Additionally, a weekly
|
|
53
|
+
[canary workflow](.github/workflows/canary.yml) exercises the real endpoints
|
|
54
|
+
and the installed client surface, and opens an issue by itself if Polymarket
|
|
55
|
+
drifts.
|
|
56
|
+
|
|
41
57
|
## The contract: nothing is booked without exchange confirmation
|
|
42
58
|
|
|
43
59
|
| Situation | What pmq does |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Posture
|
|
4
|
+
|
|
5
|
+
* Your private key is read from the environment, used to instantiate the
|
|
6
|
+
local signer, and never logged, transmitted or stored by pmq. There is no
|
|
7
|
+
backend, no telemetry, no custody. The only hosts contacted are Polymarket
|
|
8
|
+
endpoints (clob/gamma/data-api) and, for the on-chain debug helpers you run
|
|
9
|
+
yourself, the RPC you choose.
|
|
10
|
+
* The builder code embedded by default is attribution metadata inside the
|
|
11
|
+
signed order (public on-chain either way). It carries 0/0 commission and is
|
|
12
|
+
disabled with `builder_code=None`. It cannot access funds.
|
|
13
|
+
* The executor refuses to trade if the installed py-clob-client-v2 no longer
|
|
14
|
+
matches the API surface pmq was verified against (introspection at startup),
|
|
15
|
+
rather than signing through changed semantics.
|
|
16
|
+
|
|
17
|
+
## Reading the source before trusting it
|
|
18
|
+
|
|
19
|
+
A documented wave of fake "polymarket bot" repositories steals private keys.
|
|
20
|
+
pmq is deliberately small (five modules) so you can audit the entire execution
|
|
21
|
+
path in minutes. Grep targets that settle the important questions fast:
|
|
22
|
+
`POLY_PRIVATE_KEY` (read once, passed to the official client), `builder_code`
|
|
23
|
+
(the disclosure and the opt-out), `http` (every host contacted).
|
|
24
|
+
|
|
25
|
+
## Reporting a vulnerability
|
|
26
|
+
|
|
27
|
+
Open a GitHub security advisory on this repository (Security tab, "Report a
|
|
28
|
+
vulnerability") or an issue with the `security` label if it is not sensitive.
|
|
29
|
+
You will get an answer within a few days.
|
|
@@ -86,6 +86,39 @@ def write_halt_flag(utc_day, pnl):
|
|
|
86
86
|
log(f"halt flag write failed ({e}); halt still enforced in-process")
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def recover_orphans(tracked):
|
|
90
|
+
"""Markets that got a fill but no scoring row (a restart between fill and
|
|
91
|
+
resolution wipes in-memory state) are rebuilt so the scoring loop settles
|
|
92
|
+
them. Live scoring re-pulls exchange truth, so numbers cannot drift."""
|
|
93
|
+
try:
|
|
94
|
+
scored = set()
|
|
95
|
+
if os.path.exists(WINDOWS_CSV):
|
|
96
|
+
for r in csv.DictReader(open(WINDOWS_CSV)):
|
|
97
|
+
scored.add((r["family"], r["mode"]))
|
|
98
|
+
if not os.path.exists(FILLS_CSV):
|
|
99
|
+
return
|
|
100
|
+
now, orphans = time.time(), {}
|
|
101
|
+
for r in csv.DictReader(open(FILLS_CSV)):
|
|
102
|
+
if r["mode"] != MODE or (r["family"], MODE) in scored:
|
|
103
|
+
continue
|
|
104
|
+
o = orphans.setdefault(r["family"], {"side": r["side"], "fills": []})
|
|
105
|
+
o["fills"].append((float(r["price"]), float(r["shares"])))
|
|
106
|
+
for slug, o in orphans.items():
|
|
107
|
+
pm = pmq.parse_market(pmq.get_market(slug, log))
|
|
108
|
+
if not pm:
|
|
109
|
+
log(f"orphan {slug}: market unresolvable, skipped")
|
|
110
|
+
continue
|
|
111
|
+
spent = sum(p * s for p, s in o["fills"])
|
|
112
|
+
fees = sum(pmq.fee(p, s, FEE_RATE) for p, s in o["fills"])
|
|
113
|
+
tracked[slug] = {"pm": pm, "first_seen": now - MAX_TRACK_H * 1800,
|
|
114
|
+
"fills": o["fills"], "spent": spent, "fees": fees,
|
|
115
|
+
"side": o["side"], "winner": None, "src": "",
|
|
116
|
+
"resolved": False, "poisoned": False, "sstate": {}}
|
|
117
|
+
log(f"orphan recovered: {slug} ({len(o['fills'])} fill(s)), will be scored")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
log(f"orphan recovery failed (continuing without): {e}")
|
|
120
|
+
|
|
121
|
+
|
|
89
122
|
def main(run_hours):
|
|
90
123
|
t_end = time.time() + run_hours * 3600
|
|
91
124
|
ex = None
|
|
@@ -109,6 +142,7 @@ def main(run_hours):
|
|
|
109
142
|
f"daily_halt={DAILY_HALT_USD}$ strategy={strategy.NAME}")
|
|
110
143
|
|
|
111
144
|
tracked, day_pnl, halted_day = {}, {}, None
|
|
145
|
+
recover_orphans(tracked)
|
|
112
146
|
last_score_poll = 0.0
|
|
113
147
|
|
|
114
148
|
while time.time() < t_end:
|
|
@@ -251,6 +285,12 @@ def main(run_hours):
|
|
|
251
285
|
log(f"{slug}: SCORED {st['side']} net={net:+.2f}$ "
|
|
252
286
|
f"winner={winner} day={day_pnl[d]:+.2f}$")
|
|
253
287
|
st["resolved"] = True
|
|
288
|
+
if ex:
|
|
289
|
+
# republish the CLOB-visible balance so the dashboard
|
|
290
|
+
# tracks exchange truth without ever holding keys
|
|
291
|
+
c = ex.collateral()
|
|
292
|
+
if c > 0:
|
|
293
|
+
log(f"collateral {c:.2f} USDC")
|
|
254
294
|
|
|
255
295
|
except SystemExit:
|
|
256
296
|
raise
|
|
@@ -94,7 +94,8 @@ def service_state():
|
|
|
94
94
|
"-n", "1500", "--no-pager", "-q"])
|
|
95
95
|
lines = jout.splitlines()
|
|
96
96
|
for line in reversed(lines):
|
|
97
|
-
|
|
97
|
+
# matches both the startup line and the per-scoring republication
|
|
98
|
+
if "collateral" in line:
|
|
98
99
|
try:
|
|
99
100
|
collateral = float(line.split("collateral", 1)[1].split()[0])
|
|
100
101
|
collateral_ts = float(line.split(" ", 1)[0])
|
|
@@ -97,7 +97,7 @@ footer .stale{color:var(--warn);font-weight:600}
|
|
|
97
97
|
</div>
|
|
98
98
|
|
|
99
99
|
<div class="tiles">
|
|
100
|
-
<div class="tile"><div class="lbl">Balance
|
|
100
|
+
<div class="tile"><div class="lbl">Balance</div><div class="val" id="tBal">…</div><div class="hint" id="tBalH"></div></div>
|
|
101
101
|
<div class="tile"><div class="lbl">Win rate</div><div class="val" id="tWr">…</div><div class="hint" id="tWrH"></div></div>
|
|
102
102
|
<div class="tile"><div class="lbl">PnL période</div><div class="val" id="tPnl">…</div><div class="hint" id="tPnlH"></div></div>
|
|
103
103
|
<div class="tile"><div class="lbl">Frais payés</div><div class="val" id="tFee">…</div><div class="hint" id="tFeeH"></div></div>
|
|
@@ -186,7 +186,9 @@ function renderTiles(){
|
|
|
186
186
|
const extra = d.windows.filter(w => w.mode === "live" && (w.scored_ts||0) > (s.collateral_ts||0))
|
|
187
187
|
.reduce((a,w) => a + (w.net_pnl||0), 0);
|
|
188
188
|
$("tBal").textContent = "~" + (s.collateral + extra).toFixed(2) + "$";
|
|
189
|
-
$("tBalH").textContent = "
|
|
189
|
+
$("tBalH").textContent = "vue exchange, maj il y a "
|
|
190
|
+
+ (s.collateral_ts ? ago(d.now - s.collateral_ts) : "?")
|
|
191
|
+
+ (extra ? " + scoré depuis" : "");
|
|
190
192
|
} else { $("tBal").textContent = "?"; $("tBalH").textContent = "visible en mode live"; }
|
|
191
193
|
$("tWr").textContent = rows.length ? Math.round(100*wins/rows.length) + "%" : "…";
|
|
192
194
|
$("tWrH").textContent = rows.length ? wins + "W/" + (rows.length-wins) + "L · " + state.mode + " " + per : "aucune fenêtre";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# CLOB V2 order rounding, measured
|
|
2
|
+
|
|
3
|
+
*Controlled experiment against production, 2026-07-03, py-clob-client-v2
|
|
4
|
+
1.0.2, total risk budget 10 USD (net result of the run: +0.39). Raw data
|
|
5
|
+
posted on the client's issues
|
|
6
|
+
[#89](https://github.com/Polymarket/py-clob-client-v2/issues/89#issuecomment-4875871905)
|
|
7
|
+
and [#66](https://github.com/Polymarket/py-clob-client-v2/issues/66#issuecomment-4875871996).*
|
|
8
|
+
|
|
9
|
+
## Method
|
|
10
|
+
|
|
11
|
+
Phase A: resting GTC bids far below the touch (cannot fill, cancelled after
|
|
12
|
+
each case, zero cost), submitted with adversarial sizes and prices, then read
|
|
13
|
+
back through `get_open_orders` to see what the server actually recorded.
|
|
14
|
+
Phase B: two marketable limit buys on a liquid market to observe matched
|
|
15
|
+
amounts against requested size, cross-checked with `get_trades`.
|
|
16
|
+
|
|
17
|
+
## Results
|
|
18
|
+
|
|
19
|
+
| Case | Requested | Server-side view |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Baseline 2-decimal size | 5.25 @ 0.05 | 5.25, exact |
|
|
22
|
+
| 4-decimal size | 5.2537 | **5.25, silently rounded down** |
|
|
23
|
+
| Float drift | 5.100000000000001 | 5.1, normalized |
|
|
24
|
+
| Price finer than tick | 0.0515 (tick 0.01) | **0.05, silently rounded down** |
|
|
25
|
+
| Sub-cent notional | 5.007 @ 0.03 | accepted, size recorded as 5.00 |
|
|
26
|
+
| Below minimum size | size 4 (min 5) | HTTP 400: `Size (4) lower than the minimum: 5` |
|
|
27
|
+
| Marketable fill, odd size | 5.1234 @ 0.96 | signed as 5.12; matched **exactly 5.12** shares for 4.9152 USDC (0.96 x 5.12 to the cent) |
|
|
28
|
+
| Marketable fill, control | 5.0 @ 0.96 | matched exactly 5.00 for 4.80 USDC |
|
|
29
|
+
|
|
30
|
+
## What this means
|
|
31
|
+
|
|
32
|
+
1. On 1.0.2, every input is normalized CLIENT-side (rounded DOWN to the
|
|
33
|
+
allowed decimals per the tick's RoundConfig) before signing. Float-drift
|
|
34
|
+
artifacts never reach the wire.
|
|
35
|
+
2. The normalization is **silent**. If your own accounting keeps the
|
|
36
|
+
unrounded size or price, it diverges from what the exchange signed. Book
|
|
37
|
+
from the response's matched amounts (what pmq's `Fill` does), never from
|
|
38
|
+
your request.
|
|
39
|
+
3. Limit-path fills never exceeded the signed size in these tests. The
|
|
40
|
+
overfill reports (filled 5.051 on a size-5 order) are consistent with the
|
|
41
|
+
MARKET-order path instead, where the contract is "spend this amount" and
|
|
42
|
+
the share count is the division remainder of amount by price.
|
|
43
|
+
4. The per-market minimum size is enforced server-side with a clean 400, and
|
|
44
|
+
is readable in advance from the book response (`min_order_size`, exposed
|
|
45
|
+
by `pmq.book_meta`).
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pmquant"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Fail-closed execution and market-data layer for Polymarket CLOB V2: local signing, confirmed fills only, fee-correct math, working deposit-wallet (POLY_1271) support."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -31,10 +31,18 @@ Issues = "https://github.com/crp4222/pmq/issues"
|
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
33
|
mcp = ["mcp>=1.2"]
|
|
34
|
-
dev = ["pytest>=8", "mcp>=1.2"]
|
|
34
|
+
dev = ["pytest>=8", "mcp>=1.2", "ruff>=0.6"]
|
|
35
35
|
|
|
36
36
|
[project.scripts]
|
|
37
37
|
pmq-mcp = "pmq.mcp:main"
|
|
38
38
|
|
|
39
39
|
[tool.hatch.build.targets.wheel]
|
|
40
40
|
packages = ["src/pmq"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py310"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "W", "I"]
|
|
48
|
+
ignore = ["E501"]
|
|
@@ -7,16 +7,29 @@ get_tape, fee, FEE_RATES.
|
|
|
7
7
|
Execution layer (keys stay local, nothing booked without exchange
|
|
8
8
|
confirmation): PolymarketExecutor, Fill, OrderUncertain.
|
|
9
9
|
"""
|
|
10
|
-
from .data import (
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
from .data import (
|
|
11
|
+
FEE_RATES,
|
|
12
|
+
band_ask_depth_usd,
|
|
13
|
+
best_bid_ask,
|
|
14
|
+
book_inferred_winner,
|
|
15
|
+
book_meta,
|
|
16
|
+
event_markets,
|
|
17
|
+
fee,
|
|
18
|
+
get_book,
|
|
19
|
+
get_market,
|
|
20
|
+
get_tape,
|
|
21
|
+
http_get_json,
|
|
22
|
+
parse_market,
|
|
23
|
+
positions,
|
|
24
|
+
resolved_winner,
|
|
25
|
+
)
|
|
13
26
|
from .exceptions import IntrospectionMismatch, OrderUncertain, PmqError
|
|
14
27
|
|
|
15
|
-
__version__ = "0.
|
|
28
|
+
__version__ = "0.2.0"
|
|
16
29
|
__all__ = [
|
|
17
30
|
"FEE_RATES", "band_ask_depth_usd", "best_bid_ask", "book_inferred_winner",
|
|
18
|
-
"book_meta", "fee", "get_book", "get_market", "get_tape",
|
|
19
|
-
"parse_market", "resolved_winner",
|
|
31
|
+
"book_meta", "event_markets", "fee", "get_book", "get_market", "get_tape",
|
|
32
|
+
"http_get_json", "parse_market", "positions", "resolved_winner",
|
|
20
33
|
"PolymarketExecutor", "Fill",
|
|
21
34
|
"PmqError", "OrderUncertain", "IntrospectionMismatch",
|
|
22
35
|
"__version__",
|
|
@@ -191,6 +191,29 @@ def book_inferred_winner(bid_a, bid_b, threshold=0.90):
|
|
|
191
191
|
return None
|
|
192
192
|
|
|
193
193
|
|
|
194
|
+
def event_markets(slug, logger=None):
|
|
195
|
+
"""All binary markets of one event (multi-outcome events like elections
|
|
196
|
+
or tournaments are one binary market per candidate). Returns a list of
|
|
197
|
+
:func:`parse_market` dicts; unparseable members are skipped."""
|
|
198
|
+
ev = http_get_json(f"{GAMMA}/events?slug={slug}", logger=logger)
|
|
199
|
+
if not ev:
|
|
200
|
+
return []
|
|
201
|
+
out = []
|
|
202
|
+
for m in ev[0].get("markets") or []:
|
|
203
|
+
pm = parse_market(m)
|
|
204
|
+
if pm:
|
|
205
|
+
out.append(pm)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def positions(user_address, logger=None, limit=200):
|
|
210
|
+
"""Current holdings of a wallet per the data-api (public, ~1 min lag).
|
|
211
|
+
Answers "what do I hold?" after fills: list of dicts with asset,
|
|
212
|
+
conditionId, size, avgPrice, currentValue and friends."""
|
|
213
|
+
return http_get_json(f"{DATA}/positions?user={user_address}&limit={limit}",
|
|
214
|
+
logger=logger) or []
|
|
215
|
+
|
|
216
|
+
|
|
194
217
|
def get_tape(condition_id, since_ts, max_pages=4, logger=None):
|
|
195
218
|
"""Complete trade tape for a closed market (paginated, newest first).
|
|
196
219
|
|
|
@@ -101,9 +101,16 @@ class PolymarketExecutor:
|
|
|
101
101
|
builder_code=_UNSET, host=HOST, chain_id=CHAIN_ID,
|
|
102
102
|
client=None, derive_creds=True):
|
|
103
103
|
from py_clob_client_v2.clob_types import (
|
|
104
|
-
AssetType,
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
AssetType,
|
|
105
|
+
BalanceAllowanceParams,
|
|
106
|
+
BuilderConfig,
|
|
107
|
+
MarketOrderArgsV2,
|
|
108
|
+
OpenOrderParams,
|
|
109
|
+
OrderArgsV2,
|
|
110
|
+
OrderMarketCancelParams,
|
|
111
|
+
OrderType,
|
|
112
|
+
TradeParams,
|
|
113
|
+
)
|
|
107
114
|
from py_clob_client_v2.exceptions import PolyApiException
|
|
108
115
|
self._t = {
|
|
109
116
|
"AssetType": AssetType, "BalanceAllowanceParams": BalanceAllowanceParams,
|
|
@@ -271,6 +278,27 @@ class PolymarketExecutor:
|
|
|
271
278
|
return self._parse_fill(resp, side)
|
|
272
279
|
|
|
273
280
|
# ---------------- reconciliation ----------------
|
|
281
|
+
def fee_rate(self, condition_id):
|
|
282
|
+
"""Authoritative taker fee rate for one market, straight from the
|
|
283
|
+
exchange (``get_clob_market_info`` field ``fd.r``). Falls back to the
|
|
284
|
+
published crypto rate on failure, so treat the result as an estimate
|
|
285
|
+
exactly like :data:`~pmq.data.FEE_RATES`."""
|
|
286
|
+
try:
|
|
287
|
+
mi = self.client.get_clob_market_info(condition_id)
|
|
288
|
+
return float(mi["fd"]["r"])
|
|
289
|
+
except Exception as e:
|
|
290
|
+
log.warning("fee_rate(%s) fell back to static table: %s", condition_id, e)
|
|
291
|
+
return FEE_RATES["crypto"]
|
|
292
|
+
|
|
293
|
+
def cancel_order(self, order_id):
|
|
294
|
+
"""Cancel one resting order by id. Never raises."""
|
|
295
|
+
try:
|
|
296
|
+
self.client.cancel_orders([order_id])
|
|
297
|
+
return True
|
|
298
|
+
except Exception as e:
|
|
299
|
+
log.warning("cancel_order(%s) failed: %s", order_id, e)
|
|
300
|
+
return False
|
|
301
|
+
|
|
274
302
|
def cancel_market(self, condition_id):
|
|
275
303
|
"""Cancel every resting order of ours on one market. Never raises."""
|
|
276
304
|
try:
|
|
@@ -80,6 +80,19 @@ def find_markets(query: str = "", limit: int = 10) -> list:
|
|
|
80
80
|
return out or [{"error": "nothing found"}]
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def event(slug: str) -> list:
|
|
85
|
+
"""All binary markets of one multi-outcome EVENT (an election, a
|
|
86
|
+
tournament: one market per candidate). Use the event slug from
|
|
87
|
+
find_markets. Returns per market: slug, outcome names with token ids,
|
|
88
|
+
close time, settled winner if any."""
|
|
89
|
+
out = [{"market_slug": pm["slug"],
|
|
90
|
+
"outcomes": {pm["outcome_a"]: pm["token_a"], pm["outcome_b"]: pm["token_b"]},
|
|
91
|
+
"end_ts": pm["end_ts"], "settled_winner": data.resolved_winner(pm)}
|
|
92
|
+
for pm in data.event_markets(slug)]
|
|
93
|
+
return out or [{"error": f"no event found for slug {slug!r}"}]
|
|
94
|
+
|
|
95
|
+
|
|
83
96
|
@mcp.tool()
|
|
84
97
|
def market(slug: str) -> dict:
|
|
85
98
|
"""Resolve one Polymarket market by its gamma slug (any category, works
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Live canary: exercises the REAL Polymarket endpoints and the installed
|
|
2
|
+
py-clob-client-v2 surface. Runs only when PMQ_CANARY=1 (weekly scheduled
|
|
3
|
+
workflow); regular CI stays offline. A failure here means Polymarket or the
|
|
4
|
+
client drifted, not that pmq broke."""
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import pmq
|
|
10
|
+
|
|
11
|
+
pytestmark = pytest.mark.skipif(os.environ.get("PMQ_CANARY") != "1",
|
|
12
|
+
reason="live canary runs on schedule only")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _top_market():
|
|
16
|
+
evs = pmq.http_get_json(
|
|
17
|
+
f"{pmq.data.GAMMA}/events?closed=false&order=volume24hr"
|
|
18
|
+
f"&ascending=false&limit=1")
|
|
19
|
+
assert evs, "gamma events endpoint returned nothing"
|
|
20
|
+
pm = pmq.parse_market(evs[0]["markets"][0])
|
|
21
|
+
assert pm and pm["token_a"] and pm["condition_id"]
|
|
22
|
+
return pm
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_gamma_and_slug_resolution():
|
|
26
|
+
pm = _top_market()
|
|
27
|
+
again = pmq.parse_market(pmq.get_market(pm["slug"]))
|
|
28
|
+
assert again and again["condition_id"] == pm["condition_id"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_book_shape_and_exchange_rules():
|
|
32
|
+
pm = _top_market()
|
|
33
|
+
book = pmq.get_book(pm["token_a"])
|
|
34
|
+
assert book, "CLOB book endpoint returned nothing"
|
|
35
|
+
bid, bid_sz, ask, ask_sz = pmq.best_bid_ask(book)
|
|
36
|
+
assert (bid is not None) or (ask is not None), "book has no quotes at all"
|
|
37
|
+
meta = pmq.book_meta(book)
|
|
38
|
+
assert meta["min_order_size"] and meta["tick_size"], \
|
|
39
|
+
"book no longer carries min_order_size/tick_size"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_positions_endpoint_shape():
|
|
43
|
+
rows = pmq.positions("0x0000000000000000000000000000000000000001")
|
|
44
|
+
assert isinstance(rows, list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_installed_client_surface_still_matches():
|
|
48
|
+
import inspect
|
|
49
|
+
|
|
50
|
+
from py_clob_client_v2.client import ClobClient
|
|
51
|
+
from py_clob_client_v2.clob_types import MarketOrderArgsV2, OrderType
|
|
52
|
+
|
|
53
|
+
from pmq.executor import _EXPECTED_MARKET_ARGS, _EXPECTED_METHODS
|
|
54
|
+
for name, params in _EXPECTED_METHODS.items():
|
|
55
|
+
fn = getattr(ClobClient, name, None)
|
|
56
|
+
assert fn is not None, f"client lost method {name}"
|
|
57
|
+
have = set(inspect.signature(fn).parameters)
|
|
58
|
+
for p in params:
|
|
59
|
+
assert p in have, f"{name}() lost parameter {p}"
|
|
60
|
+
have = set(inspect.signature(MarketOrderArgsV2).parameters)
|
|
61
|
+
for p in _EXPECTED_MARKET_ARGS:
|
|
62
|
+
assert p in have, f"MarketOrderArgsV2 lost field {p}"
|
|
63
|
+
assert hasattr(OrderType, "FAK")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_market_info_fee_field():
|
|
67
|
+
pm = _top_market()
|
|
68
|
+
from py_clob_client_v2.client import ClobClient
|
|
69
|
+
c = ClobClient("https://clob.polymarket.com", chain_id=137)
|
|
70
|
+
mi = c.get_clob_market_info(pm["condition_id"])
|
|
71
|
+
assert isinstance(mi, dict) and "fd" in mi and "r" in mi["fd"], \
|
|
72
|
+
"get_clob_market_info no longer exposes fd.r (authoritative fee rate)"
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
|
-
from pmq import (
|
|
4
|
-
|
|
3
|
+
from pmq import (
|
|
4
|
+
band_ask_depth_usd,
|
|
5
|
+
best_bid_ask,
|
|
6
|
+
book_inferred_winner,
|
|
7
|
+
fee,
|
|
8
|
+
parse_market,
|
|
9
|
+
resolved_winner,
|
|
10
|
+
)
|
|
5
11
|
|
|
6
12
|
|
|
7
13
|
def test_fee_matches_official_formula():
|
|
@@ -160,3 +160,21 @@ def test_reconcile_cancels_then_reports_truth():
|
|
|
160
160
|
sh, usd, fees = make(fc).reconcile("0xc")
|
|
161
161
|
assert sh == 2.0 and abs(usd - 1.9) < 1e-9
|
|
162
162
|
assert fc.calls and fc.calls[0][0] == "cancel"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_fee_rate_authoritative_with_fallback():
|
|
166
|
+
class WithInfo(FakeClient):
|
|
167
|
+
def get_clob_market_info(self, condition_id):
|
|
168
|
+
return {"fd": {"r": 0.03, "e": 1}}
|
|
169
|
+
assert make(WithInfo()).fee_rate("0xc") == 0.03
|
|
170
|
+
assert make(FakeClient()).fee_rate("0xc") == 0.07 # fallback: method missing
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_cancel_order_single():
|
|
174
|
+
class WithCancel(FakeClient):
|
|
175
|
+
def cancel_orders(self, order_hashes):
|
|
176
|
+
self.calls.append(("cancel_orders", order_hashes))
|
|
177
|
+
fc = WithCancel()
|
|
178
|
+
assert make(fc).cancel_order("0xdead") is True
|
|
179
|
+
assert ("cancel_orders", ["0xdead"]) in fc.calls
|
|
180
|
+
assert make(FakeClient()).cancel_order("0xdead") is False
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Tests for the bot-template engine (loaded from bot-template/bot.py)."""
|
|
2
|
+
import csv
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
TEMPLATE = os.path.join(os.path.dirname(__file__), "..", "bot-template", "bot.py")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture()
|
|
13
|
+
def engine(tmp_path, monkeypatch):
|
|
14
|
+
monkeypatch.setenv("BOT_OUT_DIR", str(tmp_path))
|
|
15
|
+
monkeypatch.setenv("BOT_MODE", "paper")
|
|
16
|
+
for mod in ("bot", "strategy"):
|
|
17
|
+
sys.modules.pop(mod, None)
|
|
18
|
+
spec = importlib.util.spec_from_file_location("bot", os.path.abspath(TEMPLATE))
|
|
19
|
+
bot = importlib.util.module_from_spec(spec)
|
|
20
|
+
sys.modules["bot"] = bot
|
|
21
|
+
spec.loader.exec_module(bot)
|
|
22
|
+
return bot
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _write_csv(path, header, rows):
|
|
26
|
+
with open(path, "w", newline="") as f:
|
|
27
|
+
w = csv.DictWriter(f, fieldnames=header)
|
|
28
|
+
w.writeheader()
|
|
29
|
+
for r in rows:
|
|
30
|
+
w.writerow(r)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_recover_orphans_rebuilds_unscored_markets(engine, monkeypatch):
|
|
34
|
+
_write_csv(engine.FILLS_CSV, engine.FILLS_HEADER, [
|
|
35
|
+
{"ts": 1, "family": "some-market", "window": 100, "mode": "paper",
|
|
36
|
+
"side": "Yes", "price": 0.5, "shares": 10, "notional": 5,
|
|
37
|
+
"ask_size_at_fill": "", "band_depth_usd": "", "secs_left": "",
|
|
38
|
+
"order_id": ""},
|
|
39
|
+
{"ts": 2, "family": "scored-market", "window": 100, "mode": "paper",
|
|
40
|
+
"side": "Yes", "price": 0.5, "shares": 10, "notional": 5,
|
|
41
|
+
"ask_size_at_fill": "", "band_depth_usd": "", "secs_left": "",
|
|
42
|
+
"order_id": ""},
|
|
43
|
+
])
|
|
44
|
+
_write_csv(engine.WINDOWS_CSV, engine.WINDOWS_HEADER, [
|
|
45
|
+
{"family": "scored-market", "window": 100, "mode": "paper",
|
|
46
|
+
"side": "Yes", "n_fills": 1, "avg_price": 0.5, "shares": 10,
|
|
47
|
+
"spent": 5, "winner": "Yes", "winner_source": "gamma",
|
|
48
|
+
"gross_pnl": 5, "fee": 0, "net_pnl": 5, "scored_ts": 3}])
|
|
49
|
+
fake_pm = {"condition_id": "0xc", "slug": "some-market", "token_a": "1",
|
|
50
|
+
"token_b": "2", "outcome_a": "Yes", "outcome_b": "No",
|
|
51
|
+
"outcome_prices_raw": None, "idx_a": 0, "end_ts": None}
|
|
52
|
+
monkeypatch.setattr(engine.pmq, "get_market", lambda s, logger=None: {"slug": s})
|
|
53
|
+
monkeypatch.setattr(engine.pmq, "parse_market",
|
|
54
|
+
lambda m, *a, **k: dict(fake_pm) if m else None)
|
|
55
|
+
tracked = {}
|
|
56
|
+
engine.recover_orphans(tracked)
|
|
57
|
+
assert "some-market" in tracked and "scored-market" not in tracked
|
|
58
|
+
st = tracked["some-market"]
|
|
59
|
+
assert st["side"] == "Yes" and st["spent"] == 5.0 and not st["resolved"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_recover_orphans_survives_missing_files(engine):
|
|
63
|
+
tracked = {}
|
|
64
|
+
engine.recover_orphans(tracked)
|
|
65
|
+
assert tracked == {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_halt_flag_roundtrip(engine, tmp_path, monkeypatch):
|
|
69
|
+
monkeypatch.setattr(engine, "STATE_DIR", str(tmp_path / "state"))
|
|
70
|
+
day = "2026-07-03"
|
|
71
|
+
engine.write_halt_flag(day, -25.5)
|
|
72
|
+
assert os.path.exists(engine.halt_flag_path(day))
|
|
73
|
+
content = open(engine.halt_flag_path(day)).read()
|
|
74
|
+
assert "day_pnl=-25.50" in content
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_append_csv_writes_header_once(engine, tmp_path):
|
|
78
|
+
p = str(tmp_path / "x.csv")
|
|
79
|
+
engine.append_csv(p, {"a": 1, "b": 2}, ["a", "b"])
|
|
80
|
+
engine.append_csv(p, {"a": 3, "b": 4}, ["a", "b"])
|
|
81
|
+
rows = list(csv.DictReader(open(p)))
|
|
82
|
+
assert len(rows) == 2 and rows[1]["a"] == "3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|