pmquant 0.4.0__tar.gz → 0.4.2__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.
Files changed (40) hide show
  1. {pmquant-0.4.0 → pmquant-0.4.2}/CHANGELOG.md +19 -0
  2. {pmquant-0.4.0 → pmquant-0.4.2}/PKG-INFO +3 -1
  3. {pmquant-0.4.0 → pmquant-0.4.2}/README.md +2 -0
  4. {pmquant-0.4.0 → pmquant-0.4.2}/pyproject.toml +1 -1
  5. pmquant-0.4.2/server.json +61 -0
  6. {pmquant-0.4.0 → pmquant-0.4.2}/src/pmq/__init__.py +1 -1
  7. {pmquant-0.4.0 → pmquant-0.4.2}/src/pmq/doctor.py +13 -3
  8. {pmquant-0.4.0 → pmquant-0.4.2}/src/pmq/executor.py +21 -2
  9. {pmquant-0.4.0 → pmquant-0.4.2}/tests/test_executor.py +30 -0
  10. {pmquant-0.4.0 → pmquant-0.4.2}/.github/workflows/canary.yml +0 -0
  11. {pmquant-0.4.0 → pmquant-0.4.2}/.github/workflows/publish.yml +0 -0
  12. {pmquant-0.4.0 → pmquant-0.4.2}/.github/workflows/test.yml +0 -0
  13. {pmquant-0.4.0 → pmquant-0.4.2}/.gitignore +0 -0
  14. {pmquant-0.4.0 → pmquant-0.4.2}/AGENTS.md +0 -0
  15. {pmquant-0.4.0 → pmquant-0.4.2}/CLAUDE.md +0 -0
  16. {pmquant-0.4.0 → pmquant-0.4.2}/CONTRIBUTING.md +0 -0
  17. {pmquant-0.4.0 → pmquant-0.4.2}/LICENSE +0 -0
  18. {pmquant-0.4.0 → pmquant-0.4.2}/SECURITY.md +0 -0
  19. {pmquant-0.4.0 → pmquant-0.4.2}/bot-template/README.md +0 -0
  20. {pmquant-0.4.0 → pmquant-0.4.2}/bot-template/bot.py +0 -0
  21. {pmquant-0.4.0 → pmquant-0.4.2}/bot-template/dash/bot_dash.py +0 -0
  22. {pmquant-0.4.0 → pmquant-0.4.2}/bot-template/dash/dash.html +0 -0
  23. {pmquant-0.4.0 → pmquant-0.4.2}/bot-template/pmq-bot.service +0 -0
  24. {pmquant-0.4.0 → pmquant-0.4.2}/bot-template/strategy.py +0 -0
  25. {pmquant-0.4.0 → pmquant-0.4.2}/docs/assets/pmq-doctor.svg +0 -0
  26. {pmquant-0.4.0 → pmquant-0.4.2}/docs/recipes.md +0 -0
  27. {pmquant-0.4.0 → pmquant-0.4.2}/docs/rounding-study.md +0 -0
  28. {pmquant-0.4.0 → pmquant-0.4.2}/docs/war-story.md +0 -0
  29. {pmquant-0.4.0 → pmquant-0.4.2}/examples/fak_buy_guarded.py +0 -0
  30. {pmquant-0.4.0 → pmquant-0.4.2}/examples/read_market.py +0 -0
  31. {pmquant-0.4.0 → pmquant-0.4.2}/llms.txt +0 -0
  32. {pmquant-0.4.0 → pmquant-0.4.2}/src/pmq/data.py +0 -0
  33. {pmquant-0.4.0 → pmquant-0.4.2}/src/pmq/exceptions.py +0 -0
  34. {pmquant-0.4.0 → pmquant-0.4.2}/src/pmq/mcp.py +0 -0
  35. {pmquant-0.4.0 → pmquant-0.4.2}/src/pmq/py.typed +0 -0
  36. {pmquant-0.4.0 → pmquant-0.4.2}/tests/test_canary_live.py +0 -0
  37. {pmquant-0.4.0 → pmquant-0.4.2}/tests/test_data.py +0 -0
  38. {pmquant-0.4.0 → pmquant-0.4.2}/tests/test_doctor.py +0 -0
  39. {pmquant-0.4.0 → pmquant-0.4.2}/tests/test_mcp.py +0 -0
  40. {pmquant-0.4.0 → pmquant-0.4.2}/tests/test_template_engine.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2 (2026-07-03)
4
+
5
+ * Fix: `buy_fak`/`sell_fak` cent-rounding used `int(x * 100) / 100`, which
6
+ floors a binary-drifted float (`16.90` stored as `16.8999…` became
7
+ `16.89`), silently shaving a cent off intended-clean amounts. Replaced with
8
+ a `Decimal`-based `_floor_cents` that rounds down without the drift, keeping
9
+ the never-exceed-budget contract. Matches the behavior documented in
10
+ docs/rounding-study.md.
11
+
12
+ ## 0.4.1 (2026-07-03)
13
+
14
+ * Introspection guard now also verifies `OrderArgsV2` fields (including
15
+ `builder_code`, which the limit-order path depends on): a client that
16
+ dropped it is refused at startup instead of failing at call time.
17
+ pmq-doctor mirrors the same check.
18
+ * Distribution: `server.json` manifest for the MCP registry, and an
19
+ `mcp-name` ownership token in the README (visible to the registry's PyPI
20
+ verification, invisible when rendered).
21
+
3
22
  ## 0.4.0 (2026-07-03)
4
23
 
5
24
  * Typing: the whole public API is annotated and ships a `py.typed` marker;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pmquant
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -34,6 +34,8 @@ Description-Content-Type: text/markdown
34
34
 
35
35
  # pmq
36
36
 
37
+ <!-- mcp-name: io.github.crp4222/pmq -->
38
+
37
39
  [![PyPI](https://img.shields.io/pypi/v/pmquant)](https://pypi.org/project/pmquant/)
38
40
  [![tests](https://github.com/crp4222/pmq/actions/workflows/test.yml/badge.svg)](https://github.com/crp4222/pmq/actions/workflows/test.yml)
39
41
  [![canary](https://github.com/crp4222/pmq/actions/workflows/canary.yml/badge.svg)](https://github.com/crp4222/pmq/actions/workflows/canary.yml)
@@ -1,5 +1,7 @@
1
1
  # pmq
2
2
 
3
+ <!-- mcp-name: io.github.crp4222/pmq -->
4
+
3
5
  [![PyPI](https://img.shields.io/pypi/v/pmquant)](https://pypi.org/project/pmquant/)
4
6
  [![tests](https://github.com/crp4222/pmq/actions/workflows/test.yml/badge.svg)](https://github.com/crp4222/pmq/actions/workflows/test.yml)
5
7
  [![canary](https://github.com/crp4222/pmq/actions/workflows/canary.yml/badge.svg)](https://github.com/crp4222/pmq/actions/workflows/canary.yml)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pmquant"
7
- version = "0.4.0"
7
+ version = "0.4.2"
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" }
@@ -0,0 +1,61 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.crp4222/pmq",
4
+ "title": "pmq (Polymarket CLOB V2)",
5
+ "description": "Fail-closed Polymarket CLOB V2 data and execution; trading tools gated behind PMQ_MCP_LIVE=1.",
6
+ "repository": {
7
+ "url": "https://github.com/crp4222/pmq",
8
+ "source": "github"
9
+ },
10
+ "version": "0.4.1",
11
+ "packages": [
12
+ {
13
+ "registryType": "pypi",
14
+ "registryBaseUrl": "https://pypi.org",
15
+ "identifier": "pmquant",
16
+ "version": "0.4.1",
17
+ "runtimeHint": "uvx",
18
+ "transport": {
19
+ "type": "stdio"
20
+ },
21
+ "environmentVariables": [
22
+ {
23
+ "name": "PMQ_MCP_LIVE",
24
+ "description": "Set to 1 to register the trading tools (fak_buy, fak_sell, cancel_and_reconcile). Unset or any other value keeps the server read-only.",
25
+ "isRequired": false,
26
+ "isSecret": false
27
+ },
28
+ {
29
+ "name": "PMQ_MCP_MAX_USD",
30
+ "description": "Per-order hard cap in USD enforced before any live buy (default 10). Only relevant when PMQ_MCP_LIVE=1.",
31
+ "isRequired": false,
32
+ "isSecret": false
33
+ },
34
+ {
35
+ "name": "POLY_PRIVATE_KEY",
36
+ "description": "EOA private key used only to sign orders locally when trading tools are enabled. Never printed or transmitted. Not needed for the read-only tools.",
37
+ "isRequired": false,
38
+ "isSecret": true
39
+ },
40
+ {
41
+ "name": "POLY_FUNDER",
42
+ "description": "Funder wallet address. Required with signature_type > 0 (deposit wallets). Only used when trading tools are enabled.",
43
+ "isRequired": false,
44
+ "isSecret": false
45
+ },
46
+ {
47
+ "name": "POLY_SIG_TYPE",
48
+ "description": "Polymarket signature type: 0 EOA, 1 POLY_PROXY, 2 POLY_GNOSIS_SAFE, 3 POLY_1271 (app deposit wallet). Run pmq-doctor if unsure.",
49
+ "isRequired": false,
50
+ "isSecret": false
51
+ },
52
+ {
53
+ "name": "POLY_BUILDER_CODE",
54
+ "description": "Optional bytes32 builder attribution code. Defaults to the maintainer's public zero-commission code (disclosed in the README); set your own or leave empty to opt out.",
55
+ "isRequired": false,
56
+ "isSecret": false
57
+ }
58
+ ]
59
+ }
60
+ ]
61
+ }
@@ -25,7 +25,7 @@ from .data import (
25
25
  )
26
26
  from .exceptions import IntrospectionMismatch, OrderUncertain, PmqError
27
27
 
28
- __version__ = "0.4.0"
28
+ __version__ = "0.4.2"
29
29
  __all__ = [
30
30
  "FEE_RATES", "band_ask_depth_usd", "best_bid_ask", "book_inferred_winner",
31
31
  "book_meta", "event_markets", "fee", "get_book", "get_market", "get_tape",
@@ -81,9 +81,17 @@ def main(argv: list[str] | None = None) -> int:
81
81
  # 1. installed surface
82
82
  try:
83
83
  from py_clob_client_v2.client import ClobClient
84
- from py_clob_client_v2.clob_types import MarketOrderArgsV2, OrderType
85
-
86
- from .executor import _EXPECTED_MARKET_ARGS, _EXPECTED_METHODS
84
+ from py_clob_client_v2.clob_types import (
85
+ MarketOrderArgsV2,
86
+ OrderArgsV2,
87
+ OrderType,
88
+ )
89
+
90
+ from .executor import (
91
+ _EXPECTED_MARKET_ARGS,
92
+ _EXPECTED_METHODS,
93
+ _EXPECTED_ORDER_ARGS,
94
+ )
87
95
  drifts = []
88
96
  for name, params in _EXPECTED_METHODS.items():
89
97
  fn = getattr(ClobClient, name, None)
@@ -91,6 +99,8 @@ def main(argv: list[str] | None = None) -> int:
91
99
  drifts += [f"{name}.{p}" for p in params if p not in have]
92
100
  have = set(inspect.signature(MarketOrderArgsV2).parameters)
93
101
  drifts += [f"MarketOrderArgsV2.{p}" for p in _EXPECTED_MARKET_ARGS if p not in have]
102
+ have = set(inspect.signature(OrderArgsV2).parameters)
103
+ drifts += [f"OrderArgsV2.{p}" for p in _EXPECTED_ORDER_ARGS if p not in have]
94
104
  if not hasattr(OrderType, "FAK"):
95
105
  drifts.append("OrderType.FAK")
96
106
  all_ok &= check(not drifts, "installed py-clob-client-v2 matches the verified surface",
@@ -36,6 +36,7 @@ import logging
36
36
  import os
37
37
  import re
38
38
  from dataclasses import dataclass, field
39
+ from decimal import ROUND_DOWN, Decimal
39
40
  from typing import Any
40
41
 
41
42
  from .data import FEE_RATES, fee
@@ -55,6 +56,17 @@ BYTES32_RE = re.compile(r"0x[0-9a-fA-F]{64}")
55
56
  DEFAULT_BUILDER_CODE = "0x4b22812cf929165a247b575eb417a3b6c9e3c12e96f0159c4d0ad39f78d17371"
56
57
  _UNSET: Any = object()
57
58
 
59
+ _CENT = Decimal("0.01")
60
+
61
+
62
+ def _floor_cents(x: float) -> float:
63
+ """Round DOWN to the cent without the binary-float drift that makes
64
+ ``int(16.90 * 100) / 100`` return ``16.89``. ``str(x)`` yields the shortest
65
+ decimal repr, so ``16.90`` stays ``16.90`` while a genuine ``16.907`` still
66
+ floors to ``16.90``. Rounding down preserves the never-exceed-budget
67
+ contract for buys and the never-oversell contract for sells."""
68
+ return float(Decimal(str(x)).quantize(_CENT, rounding=ROUND_DOWN))
69
+
58
70
 
59
71
  @dataclass
60
72
  class Fill:
@@ -87,6 +99,8 @@ _EXPECTED_METHODS: dict[str, tuple[str, ...]] = {
87
99
  }
88
100
  _EXPECTED_MARKET_ARGS: tuple[str, ...] = (
89
101
  "token_id", "amount", "side", "price", "builder_code")
102
+ _EXPECTED_ORDER_ARGS: tuple[str, ...] = (
103
+ "token_id", "price", "size", "side", "builder_code")
90
104
 
91
105
 
92
106
  class PolymarketExecutor:
@@ -176,6 +190,11 @@ class PolymarketExecutor:
176
190
  for p in _EXPECTED_MARKET_ARGS:
177
191
  if p not in have:
178
192
  drifts.append(f"MarketOrderArgsV2 lost field {p}")
193
+ oargs = self._t["OrderArgs"]
194
+ have = set(inspect.signature(oargs).parameters)
195
+ for p in _EXPECTED_ORDER_ARGS:
196
+ if p not in have:
197
+ drifts.append(f"OrderArgsV2 lost field {p}")
179
198
  if not hasattr(self._t["OrderType"], "FAK"):
180
199
  drifts.append("OrderType.FAK missing")
181
200
  if drifts:
@@ -257,7 +276,7 @@ class PolymarketExecutor:
257
276
  killed by the exchange; nothing ever rests. Returns a :class:`Fill`;
258
277
  book ONLY ``fill.matched_shares`` and ``fill.matched_usd``.
259
278
  Raises :class:`OrderUncertain` when the outcome is unknown."""
260
- usd = int(usd * 100) / 100.0
279
+ usd = _floor_cents(usd)
261
280
  if usd <= 0:
262
281
  return Fill(rejected=True, error="usd amount rounds to zero")
263
282
  return self._market_order(token_id, usd, "BUY", price_cap)
@@ -267,7 +286,7 @@ class PolymarketExecutor:
267
286
  ``price_floor``. Same confirmation contract as :meth:`buy_fak`.
268
287
  The buy path has carried live volume; the sell path follows the same
269
288
  documented semantics but flag it as less battle-tested."""
270
- shares = int(shares * 100) / 100.0
289
+ shares = _floor_cents(shares)
271
290
  if shares <= 0:
272
291
  return Fill(rejected=True, error="share amount rounds to zero")
273
292
  return self._market_order(token_id, shares, "SELL", price_floor)
@@ -114,6 +114,23 @@ def test_buy_amount_rounds_down_to_cent():
114
114
  assert fc.calls[0][1].amount == 4.99
115
115
 
116
116
 
117
+ def test_cent_rounding_has_no_binary_drift():
118
+ from pmq.executor import _floor_cents
119
+ # int(x*100)/100 would return one cent low here; _floor_cents must not.
120
+ assert _floor_cents(16.90) == 16.90
121
+ assert _floor_cents(33.30) == 33.30
122
+ assert _floor_cents(66.60) == 66.60
123
+ # genuine sub-cent values still floor
124
+ assert _floor_cents(5.007) == 5.00
125
+ assert _floor_cents(4.999) == 4.99
126
+
127
+
128
+ def test_buy_fak_sends_intended_cents_not_drifted():
129
+ fc = FakeClient(market_resp={"orderID": "0x1", "makingAmount": "0", "takingAmount": "0"})
130
+ make(fc).buy_fak("tok", 0.97, 16.90)
131
+ assert fc.calls[0][1].amount == 16.90 # not 16.89
132
+
133
+
117
134
  def test_zero_amount_never_reaches_the_wire():
118
135
  fc = FakeClient()
119
136
  f = make(fc).buy_fak("tok", 0.97, 0.004)
@@ -255,6 +272,19 @@ def test_introspection_guard_refuses_drifted_client():
255
272
  make(Drifted())
256
273
 
257
274
 
275
+ def test_introspection_guard_covers_order_args_builder_code(monkeypatch):
276
+ # limit_gtc now depends on OrderArgsV2.builder_code; the guard must refuse
277
+ # a client whose OrderArgsV2 dropped it, not crash later at call time.
278
+ import py_clob_client_v2.clob_types as ct
279
+
280
+ class NoBuilderOrderArgs:
281
+ def __init__(self, token_id, price, size, side):
282
+ self.token_id, self.price, self.size, self.side = token_id, price, size, side
283
+ monkeypatch.setattr(ct, "OrderArgsV2", NoBuilderOrderArgs)
284
+ with pytest.raises(IntrospectionMismatch, match="OrderArgsV2 lost field builder_code"):
285
+ make(FakeClient())
286
+
287
+
258
288
  def test_reconcile_cancels_then_reports_truth():
259
289
  fc = FakeClient(trades=[{"side": "BUY", "size": "2", "price": "0.95"}],
260
290
  open_orders=[])
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes