pmwallets 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pmwallets/__init__.py +13 -0
- pmwallets/client.py +237 -0
- pmwallets/stream.py +324 -0
- pmwallets/types.py +40 -0
- pmwallets/webhook.py +20 -0
- pmwallets-0.1.0.dist-info/METADATA +84 -0
- pmwallets-0.1.0.dist-info/RECORD +9 -0
- pmwallets-0.1.0.dist-info/WHEEL +4 -0
- pmwallets-0.1.0.dist-info/licenses/LICENSE +21 -0
pmwallets/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Official PMWallets SDK — https://pmwallets.com/docs"""
|
|
2
|
+
|
|
3
|
+
from .client import DEFAULT_BASE_URL, AsyncClient, Client, PmwError
|
|
4
|
+
from .stream import FileStateStore, FillMeta, FillStream, MemoryStateStore, StreamState, UpgradeRefused, websockets_connector
|
|
5
|
+
from .types import Fill, FillCursor, FillsPage
|
|
6
|
+
from .webhook import verify_webhook
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DEFAULT_BASE_URL", "AsyncClient", "Client", "PmwError",
|
|
10
|
+
"FileStateStore", "FillMeta", "FillStream", "MemoryStateStore", "StreamState", "UpgradeRefused", "websockets_connector",
|
|
11
|
+
"Fill", "FillCursor", "FillsPage", "verify_webhook",
|
|
12
|
+
]
|
|
13
|
+
__version__ = "0.1.0"
|
pmwallets/client.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, AsyncIterator, Iterator, Optional
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .types import Fill, FillCursor, FillsPage, Leaderboard, Subscription
|
|
10
|
+
|
|
11
|
+
DEFAULT_BASE_URL = "https://api.pmwallets.com"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PmwError(Exception):
|
|
15
|
+
"""A non-2xx answer from the API. `body` is the parsed JSON body when there was one."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, status: int, body: Any, message: Optional[str] = None):
|
|
18
|
+
self.status = status
|
|
19
|
+
self.body = body
|
|
20
|
+
super().__init__(message or f"PMWallets API {status}: {body if isinstance(body, str) else json.dumps(body)}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _q(v: str) -> str:
|
|
24
|
+
return quote(v, safe="")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Base:
|
|
28
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0):
|
|
29
|
+
if not api_key:
|
|
30
|
+
raise ValueError("api_key is required")
|
|
31
|
+
self.api_key = api_key
|
|
32
|
+
self.base_url = base_url.rstrip("/")
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def ws_url(self) -> str:
|
|
37
|
+
"""The WebSocket URL derived from the base URL (https → wss)."""
|
|
38
|
+
return ("ws" + self.base_url[4:] if self.base_url.startswith("http") else self.base_url) + "/v1/ws"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def _headers(self) -> dict[str, str]:
|
|
42
|
+
return {"x-api-key": self.api_key, "accept": "application/json"}
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _params(query: Optional[dict[str, Any]]) -> dict[str, Any]:
|
|
46
|
+
out: dict[str, Any] = {}
|
|
47
|
+
for k, v in (query or {}).items():
|
|
48
|
+
if v is None:
|
|
49
|
+
continue
|
|
50
|
+
out[k] = ("true" if v else "false") if isinstance(v, bool) else v
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _parse(res: httpx.Response) -> Any:
|
|
55
|
+
text = res.text
|
|
56
|
+
body: Any = text
|
|
57
|
+
if text:
|
|
58
|
+
try:
|
|
59
|
+
body = json.loads(text)
|
|
60
|
+
except ValueError:
|
|
61
|
+
pass
|
|
62
|
+
if res.status_code < 200 or res.status_code >= 300:
|
|
63
|
+
raise PmwError(res.status_code, body)
|
|
64
|
+
return body if text else None
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _fills_query(since_block: int, since_log_index: int, limit: int) -> dict[str, Any]:
|
|
68
|
+
return {"sinceBlock": since_block, "sinceLogIndex": since_log_index, "limit": limit}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Client(_Base):
|
|
72
|
+
"""Synchronous REST client for https://api.pmwallets.com. Every method authenticates with the API key."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0, http: Optional[httpx.Client] = None):
|
|
75
|
+
super().__init__(api_key, base_url, timeout)
|
|
76
|
+
self._http = http or httpx.Client(timeout=timeout)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
self._http.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "Client":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc: Any) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def request(self, method: str, path: str, query: Optional[dict[str, Any]] = None, body: Any = None) -> Any:
|
|
88
|
+
res = self._http.request(method, self.base_url + path, params=self._params(query), headers=self._headers,
|
|
89
|
+
json=body if body is not None else None)
|
|
90
|
+
return self._parse(res)
|
|
91
|
+
|
|
92
|
+
# ── the board
|
|
93
|
+
def leaderboard(self, **query: Any) -> Leaderboard:
|
|
94
|
+
return self.request("GET", "/v1/leaderboard", query)
|
|
95
|
+
|
|
96
|
+
def entity(self, entity_id: str, period: Optional[str] = None) -> dict[str, Any]:
|
|
97
|
+
"""One entity by handle or address. The full address comes back only for entities you own."""
|
|
98
|
+
return self.request("GET", f"/v1/entities/{_q(entity_id)}", {"period": period})
|
|
99
|
+
|
|
100
|
+
def latency(self) -> dict[str, Any]:
|
|
101
|
+
return self.request("GET", "/v1/latency")
|
|
102
|
+
|
|
103
|
+
# ── addresses
|
|
104
|
+
def buy_reveal(self, entity_id: str, max_price_cents: int) -> dict[str, Any]:
|
|
105
|
+
"""Buy the address behind a handle. `max_price_cents` is a ceiling: the charge never exceeds it."""
|
|
106
|
+
return self.request("POST", "/v1/account/reveals", body={"entityId": entity_id, "maxPriceCents": max_price_cents})
|
|
107
|
+
|
|
108
|
+
def reveals(self) -> list[dict[str, Any]]:
|
|
109
|
+
return self.request("GET", "/v1/account/reveals")
|
|
110
|
+
|
|
111
|
+
# ── subscriptions
|
|
112
|
+
def subscriptions(self) -> list[Subscription]:
|
|
113
|
+
"""Active and paused subscriptions, newest first."""
|
|
114
|
+
return self.request("GET", "/v1/account/subscriptions")
|
|
115
|
+
|
|
116
|
+
def subscribe(self, entity_id: str, channels: Optional[list[str]] = None, accept_inactive: Optional[bool] = None) -> Subscription:
|
|
117
|
+
"""Starts billing per hour. 402 = balance too low, 409 = dormant entity (resend with accept_inactive)."""
|
|
118
|
+
body: dict[str, Any] = {"channels": channels or ["ws"], "entityId": entity_id}
|
|
119
|
+
if accept_inactive is not None:
|
|
120
|
+
body["acceptInactive"] = accept_inactive
|
|
121
|
+
return self.request("POST", "/v1/account/subscriptions", body=body)
|
|
122
|
+
|
|
123
|
+
def cancel_subscription(self, sub_id: str) -> Any:
|
|
124
|
+
return self.request("DELETE", f"/v1/account/subscriptions/{_q(sub_id)}")
|
|
125
|
+
|
|
126
|
+
def resume_subscription(self, sub_id: str) -> Subscription:
|
|
127
|
+
"""Charges another hour and resumes from the current head (the paused gap is not backfilled)."""
|
|
128
|
+
return self.request("POST", f"/v1/account/subscriptions/{_q(sub_id)}/resume")
|
|
129
|
+
|
|
130
|
+
# ── fills
|
|
131
|
+
def fills(self, since_block: int = 0, since_log_index: int = 0, limit: int = 500) -> FillsPage:
|
|
132
|
+
"""One page of fills strictly after the cursor, oldest first (all active subscriptions)."""
|
|
133
|
+
return self.request("GET", "/v1/account/fills", self._fills_query(since_block, since_log_index, limit))
|
|
134
|
+
|
|
135
|
+
def fills_since(self, cursor: FillCursor, limit: int = 500) -> Iterator[Fill]:
|
|
136
|
+
"""Every fill after the cursor, walking pages until the end."""
|
|
137
|
+
at: Optional[FillCursor] = cursor
|
|
138
|
+
while at:
|
|
139
|
+
page = self.fills(at["sinceBlock"], at["sinceLogIndex"], limit)
|
|
140
|
+
yield from page["rows"]
|
|
141
|
+
at = page["next"]
|
|
142
|
+
|
|
143
|
+
# ── trade-history exports
|
|
144
|
+
def export_quote(self, entity_ids: list[str], from_: str, to: str) -> dict[str, Any]:
|
|
145
|
+
return self.request("POST", "/v1/account/exports/quote", body={"entityIds": entity_ids, "from": from_, "to": to})
|
|
146
|
+
|
|
147
|
+
def create_export(self, entity_ids: list[str], from_: str, to: str) -> dict[str, Any]:
|
|
148
|
+
"""Charges the balance; the price is recomputed server-side."""
|
|
149
|
+
return self.request("POST", "/v1/account/exports", body={"entityIds": entity_ids, "from": from_, "to": to})
|
|
150
|
+
|
|
151
|
+
def exports(self) -> list[dict[str, Any]]:
|
|
152
|
+
return self.request("GET", "/v1/account/exports")
|
|
153
|
+
|
|
154
|
+
def get_export(self, export_id: str) -> dict[str, Any]:
|
|
155
|
+
return self.request("GET", f"/v1/account/exports/{_q(export_id)}")
|
|
156
|
+
|
|
157
|
+
def export_download(self, export_id: str) -> dict[str, Any]:
|
|
158
|
+
"""A presigned download URL, valid 15 minutes."""
|
|
159
|
+
return self.request("GET", f"/v1/account/exports/{_q(export_id)}/download")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AsyncClient(_Base):
|
|
163
|
+
"""asyncio twin of Client (the FillStream uses it for replays)."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0, http: Optional[httpx.AsyncClient] = None):
|
|
166
|
+
super().__init__(api_key, base_url, timeout)
|
|
167
|
+
self._http = http or httpx.AsyncClient(timeout=timeout)
|
|
168
|
+
|
|
169
|
+
async def aclose(self) -> None:
|
|
170
|
+
await self._http.aclose()
|
|
171
|
+
|
|
172
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
176
|
+
await self.aclose()
|
|
177
|
+
|
|
178
|
+
async def request(self, method: str, path: str, query: Optional[dict[str, Any]] = None, body: Any = None) -> Any:
|
|
179
|
+
res = await self._http.request(method, self.base_url + path, params=self._params(query), headers=self._headers,
|
|
180
|
+
json=body if body is not None else None)
|
|
181
|
+
return self._parse(res)
|
|
182
|
+
|
|
183
|
+
async def leaderboard(self, **query: Any) -> Leaderboard:
|
|
184
|
+
return await self.request("GET", "/v1/leaderboard", query)
|
|
185
|
+
|
|
186
|
+
async def entity(self, entity_id: str, period: Optional[str] = None) -> dict[str, Any]:
|
|
187
|
+
return await self.request("GET", f"/v1/entities/{_q(entity_id)}", {"period": period})
|
|
188
|
+
|
|
189
|
+
async def latency(self) -> dict[str, Any]:
|
|
190
|
+
return await self.request("GET", "/v1/latency")
|
|
191
|
+
|
|
192
|
+
async def buy_reveal(self, entity_id: str, max_price_cents: int) -> dict[str, Any]:
|
|
193
|
+
return await self.request("POST", "/v1/account/reveals", body={"entityId": entity_id, "maxPriceCents": max_price_cents})
|
|
194
|
+
|
|
195
|
+
async def reveals(self) -> list[dict[str, Any]]:
|
|
196
|
+
return await self.request("GET", "/v1/account/reveals")
|
|
197
|
+
|
|
198
|
+
async def subscriptions(self) -> list[Subscription]:
|
|
199
|
+
return await self.request("GET", "/v1/account/subscriptions")
|
|
200
|
+
|
|
201
|
+
async def subscribe(self, entity_id: str, channels: Optional[list[str]] = None, accept_inactive: Optional[bool] = None) -> Subscription:
|
|
202
|
+
body: dict[str, Any] = {"channels": channels or ["ws"], "entityId": entity_id}
|
|
203
|
+
if accept_inactive is not None:
|
|
204
|
+
body["acceptInactive"] = accept_inactive
|
|
205
|
+
return await self.request("POST", "/v1/account/subscriptions", body=body)
|
|
206
|
+
|
|
207
|
+
async def cancel_subscription(self, sub_id: str) -> Any:
|
|
208
|
+
return await self.request("DELETE", f"/v1/account/subscriptions/{_q(sub_id)}")
|
|
209
|
+
|
|
210
|
+
async def resume_subscription(self, sub_id: str) -> Subscription:
|
|
211
|
+
return await self.request("POST", f"/v1/account/subscriptions/{_q(sub_id)}/resume")
|
|
212
|
+
|
|
213
|
+
async def fills(self, since_block: int = 0, since_log_index: int = 0, limit: int = 500) -> FillsPage:
|
|
214
|
+
return await self.request("GET", "/v1/account/fills", self._fills_query(since_block, since_log_index, limit))
|
|
215
|
+
|
|
216
|
+
async def fills_since(self, cursor: FillCursor, limit: int = 500) -> AsyncIterator[Fill]:
|
|
217
|
+
at: Optional[FillCursor] = cursor
|
|
218
|
+
while at:
|
|
219
|
+
page = await self.fills(at["sinceBlock"], at["sinceLogIndex"], limit)
|
|
220
|
+
for row in page["rows"]:
|
|
221
|
+
yield row
|
|
222
|
+
at = page["next"]
|
|
223
|
+
|
|
224
|
+
async def export_quote(self, entity_ids: list[str], from_: str, to: str) -> dict[str, Any]:
|
|
225
|
+
return await self.request("POST", "/v1/account/exports/quote", body={"entityIds": entity_ids, "from": from_, "to": to})
|
|
226
|
+
|
|
227
|
+
async def create_export(self, entity_ids: list[str], from_: str, to: str) -> dict[str, Any]:
|
|
228
|
+
return await self.request("POST", "/v1/account/exports", body={"entityIds": entity_ids, "from": from_, "to": to})
|
|
229
|
+
|
|
230
|
+
async def exports(self) -> list[dict[str, Any]]:
|
|
231
|
+
return await self.request("GET", "/v1/account/exports")
|
|
232
|
+
|
|
233
|
+
async def get_export(self, export_id: str) -> dict[str, Any]:
|
|
234
|
+
return await self.request("GET", f"/v1/account/exports/{_q(export_id)}")
|
|
235
|
+
|
|
236
|
+
async def export_download(self, export_id: str) -> dict[str, Any]:
|
|
237
|
+
return await self.request("GET", f"/v1/account/exports/{_q(export_id)}/download")
|
pmwallets/stream.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, AsyncContextManager, Awaitable, Callable, Optional, Protocol, Union
|
|
12
|
+
|
|
13
|
+
from .client import AsyncClient
|
|
14
|
+
from .types import Fill
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class StreamState:
|
|
19
|
+
"""The WebSocket numbering last accepted and the ledger position of the last delivered fill.
|
|
20
|
+
Persist it (FileStateStore) and a restart resumes exactly where it stopped."""
|
|
21
|
+
|
|
22
|
+
session: Optional[str] = None
|
|
23
|
+
seq: int = 0
|
|
24
|
+
block: int = 0
|
|
25
|
+
logIndex: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StateStore(Protocol):
|
|
29
|
+
async def load(self) -> Optional[StreamState]: ...
|
|
30
|
+
async def save(self, state: StreamState) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MemoryStateStore:
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._state: Optional[StreamState] = None
|
|
36
|
+
|
|
37
|
+
async def load(self) -> Optional[StreamState]:
|
|
38
|
+
return StreamState(**asdict(self._state)) if self._state else None
|
|
39
|
+
|
|
40
|
+
async def save(self, state: StreamState) -> None:
|
|
41
|
+
self._state = StreamState(**asdict(state))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FileStateStore:
|
|
45
|
+
"""JSON file, replaced atomically (temp file + os.replace) so a crash never leaves half a file.
|
|
46
|
+
Same format as the Node SDK's FileStateStore — the two are interchangeable."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, path: Union[str, Path]) -> None:
|
|
49
|
+
self.path = Path(path)
|
|
50
|
+
|
|
51
|
+
async def load(self) -> Optional[StreamState]:
|
|
52
|
+
try:
|
|
53
|
+
s = json.loads(self.path.read_text("utf8"))
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
return None
|
|
56
|
+
return StreamState(session=s.get("session"), seq=s.get("seq", 0), block=s.get("block", 0), logIndex=s.get("logIndex", 0))
|
|
57
|
+
|
|
58
|
+
async def save(self, state: StreamState) -> None:
|
|
59
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
tmp = self.path.with_name(self.path.name + ".tmp")
|
|
61
|
+
tmp.write_text(json.dumps(asdict(state), separators=(",", ":")))
|
|
62
|
+
os.replace(tmp, self.path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class FillMeta:
|
|
67
|
+
source: str # "ws" | "replay"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UpgradeRefused(Exception):
|
|
71
|
+
"""The server answered the WebSocket upgrade with an HTTP status instead of 101."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, status_code: int):
|
|
74
|
+
super().__init__(f"WebSocket upgrade refused with {status_code}")
|
|
75
|
+
self.status_code = status_code
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SocketLike(Protocol):
|
|
79
|
+
close_code: Optional[int]
|
|
80
|
+
close_reason: Optional[str]
|
|
81
|
+
|
|
82
|
+
def __aiter__(self) -> Any: ...
|
|
83
|
+
async def close(self, code: int = 1000, reason: str = "") -> None: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
Connector = Callable[[str, dict[str, str]], AsyncContextManager[SocketLike]]
|
|
87
|
+
OnFill = Callable[[Fill, FillMeta], Union[None, Awaitable[None]]]
|
|
88
|
+
OnEvent = Callable[[dict[str, Any]], None]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def websockets_connector(ping_interval: float = 20.0, **ws_options: Any) -> Connector:
|
|
92
|
+
"""The real transport: `websockets` asyncio client. websockets>=15 picks up HTTPS_PROXY by itself."""
|
|
93
|
+
from websockets.asyncio.client import connect
|
|
94
|
+
from websockets.exceptions import InvalidStatus
|
|
95
|
+
|
|
96
|
+
@contextlib.asynccontextmanager
|
|
97
|
+
async def _connect(url: str, headers: dict[str, str]):
|
|
98
|
+
try:
|
|
99
|
+
cm = connect(url, additional_headers=headers, ping_interval=ping_interval, ping_timeout=ping_interval, **ws_options)
|
|
100
|
+
ws = await cm.__aenter__()
|
|
101
|
+
except InvalidStatus as e:
|
|
102
|
+
raise UpgradeRefused(e.response.status_code) from e
|
|
103
|
+
try:
|
|
104
|
+
yield ws
|
|
105
|
+
finally:
|
|
106
|
+
await cm.__aexit__(None, None, None)
|
|
107
|
+
|
|
108
|
+
return _connect
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FillStream:
|
|
112
|
+
"""The fills of every entity your account subscribes to, delivered exactly once and in order.
|
|
113
|
+
|
|
114
|
+
The WebSocket is best effort: a frame dropped for a slow consumer, or everything sent while you were
|
|
115
|
+
reconnecting, is not resent. Every frame carries `session` + a consecutive `seq`, so a skip is
|
|
116
|
+
detectable — and on any skip or new session this stream pulls the gap from GET /v1/account/fills
|
|
117
|
+
(keyset-paged from the last delivered fill) before it moves on. Duplicates are dropped by eventId.
|
|
118
|
+
|
|
119
|
+
`on_fill(fill, meta)` is called once per fill, in ledger order, never concurrently. A fill counts as
|
|
120
|
+
delivered only after it returns: if it raises, the connection is dropped and the fill is offered again
|
|
121
|
+
after the reconnect's replay — make it idempotent on eventId, or never raise.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
client: AsyncClient,
|
|
127
|
+
on_fill: OnFill,
|
|
128
|
+
on_event: Optional[OnEvent] = None,
|
|
129
|
+
store: Optional[StateStore] = None,
|
|
130
|
+
ping_interval: float = 20.0,
|
|
131
|
+
min_backoff: float = 1.0,
|
|
132
|
+
max_backoff: float = 30.0,
|
|
133
|
+
seen_capacity: int = 10_000,
|
|
134
|
+
connector: Optional[Connector] = None,
|
|
135
|
+
ws_options: Optional[dict[str, Any]] = None,
|
|
136
|
+
replay_without_cursor: bool = False,
|
|
137
|
+
anchor_lag_blocks: int = 200,
|
|
138
|
+
) -> None:
|
|
139
|
+
self.client = client
|
|
140
|
+
self.on_fill = on_fill
|
|
141
|
+
self.on_event = on_event
|
|
142
|
+
self.store: StateStore = store or MemoryStateStore()
|
|
143
|
+
self.min_backoff = min_backoff
|
|
144
|
+
self.max_backoff = max_backoff
|
|
145
|
+
self.seen_capacity = seen_capacity
|
|
146
|
+
# Where to start when there is no saved position. Default False: at the first connection the cursor
|
|
147
|
+
# is anchored at the current chain head, so a disconnect before the first fill is still replayed —
|
|
148
|
+
# but the history from before you started is not. True: start from zero and receive every fill
|
|
149
|
+
# since each subscription began.
|
|
150
|
+
self.replay_without_cursor = replay_without_cursor
|
|
151
|
+
# How far behind the chain head to anchor (~5 min). The head can run ahead of the fills already indexed for
|
|
152
|
+
# your account; anchoring exactly at it could exclude a fill mined earlier but not yet pushed. The extra
|
|
153
|
+
# blocks are replayed at most once more and dropped by eventId.
|
|
154
|
+
self.anchor_lag_blocks = anchor_lag_blocks
|
|
155
|
+
self.connector = connector or websockets_connector(ping_interval, **(ws_options or {}))
|
|
156
|
+
self.state = StreamState()
|
|
157
|
+
self._seen: "OrderedDict[str, bool]" = OrderedDict()
|
|
158
|
+
self._running = False
|
|
159
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
160
|
+
self._socket: Optional[SocketLike] = None
|
|
161
|
+
self._wake = asyncio.Event()
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def position(self) -> StreamState:
|
|
165
|
+
return StreamState(**asdict(self.state))
|
|
166
|
+
|
|
167
|
+
async def start(self) -> None:
|
|
168
|
+
"""Loads the saved state and starts connecting. Returns immediately; call stop() to end."""
|
|
169
|
+
if self._running:
|
|
170
|
+
return
|
|
171
|
+
self.state = (await self.store.load()) or self.state
|
|
172
|
+
self._running = True
|
|
173
|
+
self._task = asyncio.create_task(self._loop())
|
|
174
|
+
|
|
175
|
+
async def stop(self) -> None:
|
|
176
|
+
"""Closes the socket and waits for the fill being handled (if any) to finish."""
|
|
177
|
+
self._running = False
|
|
178
|
+
self._wake.set()
|
|
179
|
+
if self._socket is not None:
|
|
180
|
+
with contextlib.suppress(Exception):
|
|
181
|
+
await self._socket.close(1000)
|
|
182
|
+
if self._task is not None:
|
|
183
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
184
|
+
await self._task
|
|
185
|
+
|
|
186
|
+
async def run(self) -> None:
|
|
187
|
+
"""start() and block until the stream stops."""
|
|
188
|
+
await self.start()
|
|
189
|
+
await self.wait()
|
|
190
|
+
|
|
191
|
+
async def wait(self) -> None:
|
|
192
|
+
"""Block until the stream stops (stop() or a fatal error)."""
|
|
193
|
+
if self._task is not None:
|
|
194
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
195
|
+
await self._task
|
|
196
|
+
|
|
197
|
+
def _emit(self, event: dict[str, Any]) -> None:
|
|
198
|
+
if self.on_event is None:
|
|
199
|
+
return
|
|
200
|
+
with contextlib.suppress(Exception): # a listener must not break the stream
|
|
201
|
+
self.on_event(event)
|
|
202
|
+
|
|
203
|
+
async def _loop(self) -> None:
|
|
204
|
+
backoff = self.min_backoff
|
|
205
|
+
while self._running:
|
|
206
|
+
healthy = await self._connect_once()
|
|
207
|
+
if not self._running:
|
|
208
|
+
break
|
|
209
|
+
backoff = self.min_backoff if healthy else min(backoff * 2, self.max_backoff)
|
|
210
|
+
self._wake.clear()
|
|
211
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
212
|
+
await asyncio.wait_for(self._wake.wait(), backoff)
|
|
213
|
+
|
|
214
|
+
async def _connect_once(self) -> bool:
|
|
215
|
+
"""One connection's life; True when it got as far as a hello and did not fail."""
|
|
216
|
+
url = self.client.ws_url
|
|
217
|
+
self._emit({"type": "connecting", "url": url})
|
|
218
|
+
greeted = False
|
|
219
|
+
failed = False
|
|
220
|
+
code, reason = 1006, ""
|
|
221
|
+
try:
|
|
222
|
+
async with self.connector(url, {"x-api-key": self.client.api_key}) as ws:
|
|
223
|
+
self._socket = ws
|
|
224
|
+
self._emit({"type": "connected"})
|
|
225
|
+
try:
|
|
226
|
+
async for raw in ws:
|
|
227
|
+
try:
|
|
228
|
+
frame = json.loads(raw)
|
|
229
|
+
except (ValueError, TypeError):
|
|
230
|
+
continue
|
|
231
|
+
if not isinstance(frame, dict):
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
await self._handle_frame(frame)
|
|
235
|
+
except Exception as e: # leave the position where it is; the next hello replays from it
|
|
236
|
+
failed = True
|
|
237
|
+
self._emit({"type": "error", "error": e})
|
|
238
|
+
break
|
|
239
|
+
if frame.get("type") == "hello":
|
|
240
|
+
greeted = True
|
|
241
|
+
except Exception as e: # ConnectionClosedError and friends
|
|
242
|
+
self._emit({"type": "error", "error": e})
|
|
243
|
+
code = getattr(ws, "close_code", None) or 1006
|
|
244
|
+
reason = getattr(ws, "close_reason", None) or ""
|
|
245
|
+
except UpgradeRefused as e:
|
|
246
|
+
if e.status_code in (401, 403):
|
|
247
|
+
self._running = False
|
|
248
|
+
self._emit({"type": "fatal", "error": RuntimeError(f"WebSocket upgrade refused with {e.status_code}: check the API key")})
|
|
249
|
+
else:
|
|
250
|
+
self._emit({"type": "error", "error": e})
|
|
251
|
+
return False
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self._emit({"type": "error", "error": e})
|
|
254
|
+
return False
|
|
255
|
+
finally:
|
|
256
|
+
self._socket = None
|
|
257
|
+
if code == 1000 and "replaced" in reason.lower():
|
|
258
|
+
self._emit({"type": "replaced"})
|
|
259
|
+
self._emit({"type": "disconnected", "code": code, "reason": reason})
|
|
260
|
+
return greeted and not failed
|
|
261
|
+
|
|
262
|
+
async def _handle_frame(self, m: dict[str, Any]) -> None:
|
|
263
|
+
if m.get("type") == "hello" and isinstance(m.get("session"), str):
|
|
264
|
+
# A new session means the socket (or this process) was down: replay BEFORE adopting it —
|
|
265
|
+
# adopting first is what silently swallows an outage.
|
|
266
|
+
if self.state.block == 0 and not self.replay_without_cursor:
|
|
267
|
+
await self._anchor()
|
|
268
|
+
elif self.state.session is not None and m["session"] != self.state.session:
|
|
269
|
+
await self._replay("new_session")
|
|
270
|
+
self.state.session = m["session"]
|
|
271
|
+
self.state.seq = int(m.get("seq") or 0)
|
|
272
|
+
await self.store.save(self.state)
|
|
273
|
+
self._emit({"type": "hello", "session": m["session"], "seq": self.state.seq})
|
|
274
|
+
return
|
|
275
|
+
if m.get("type") != "fill" or not m.get("data") or not isinstance(m.get("seq"), int):
|
|
276
|
+
return
|
|
277
|
+
if m.get("session") != self.state.session or m["seq"] != self.state.seq + 1:
|
|
278
|
+
await self._replay("seq_skip")
|
|
279
|
+
await self._deliver(m["data"], "ws")
|
|
280
|
+
# only once the fill is handled: a position that ran ahead of a failed delivery would hide the gap
|
|
281
|
+
self.state.session = m.get("session")
|
|
282
|
+
self.state.seq = m["seq"]
|
|
283
|
+
await self.store.save(self.state)
|
|
284
|
+
|
|
285
|
+
async def _anchor(self) -> None:
|
|
286
|
+
"""No position yet: take the chain head as the starting point. Without it a disconnect before the first
|
|
287
|
+
fill could not be replayed, and replaying from zero would hand over every fill since each subscription began."""
|
|
288
|
+
r = await self.client.latency()
|
|
289
|
+
raw = (r.get("head") or {}).get("block") if isinstance(r, dict) else None
|
|
290
|
+
try:
|
|
291
|
+
head = float(raw) if raw is not None and not isinstance(raw, bool) else math.nan
|
|
292
|
+
except (TypeError, ValueError):
|
|
293
|
+
head = math.nan
|
|
294
|
+
if not (math.isfinite(head) and head == int(head) and head > 0):
|
|
295
|
+
raise RuntimeError("could not read the chain head to anchor the stream")
|
|
296
|
+
head = int(head)
|
|
297
|
+
# strictly-after semantics: everything from (head − lag) on; never 0, which means "no position"
|
|
298
|
+
self.state.block = max(1, head - self.anchor_lag_blocks - 1)
|
|
299
|
+
self.state.logIndex = 0xFFFFFFFF
|
|
300
|
+
self._emit({"type": "anchored", "block": self.state.block + 1})
|
|
301
|
+
|
|
302
|
+
async def _replay(self, reason: str) -> None:
|
|
303
|
+
self._emit({"type": "gap", "reason": reason, "fromBlock": self.state.block, "fromLogIndex": self.state.logIndex})
|
|
304
|
+
delivered = 0
|
|
305
|
+
async for fill in self.client.fills_since({"sinceBlock": self.state.block, "sinceLogIndex": self.state.logIndex}):
|
|
306
|
+
if await self._deliver(fill, "replay"):
|
|
307
|
+
delivered += 1
|
|
308
|
+
await self.store.save(self.state)
|
|
309
|
+
self._emit({"type": "replayed", "delivered": delivered})
|
|
310
|
+
|
|
311
|
+
async def _deliver(self, fill: Fill, source: str) -> bool:
|
|
312
|
+
if fill["eventId"] in self._seen:
|
|
313
|
+
return False
|
|
314
|
+
r = self.on_fill(fill, FillMeta(source))
|
|
315
|
+
if asyncio.iscoroutine(r) or isinstance(r, asyncio.Future):
|
|
316
|
+
await r
|
|
317
|
+
self._seen[fill["eventId"]] = True
|
|
318
|
+
if len(self._seen) > self.seen_capacity:
|
|
319
|
+
self._seen.popitem(last=False)
|
|
320
|
+
# never move backwards: a live frame can be older than what the replay just walked past
|
|
321
|
+
if fill["block"] > self.state.block or (fill["block"] == self.state.block and fill["logIndex"] > self.state.logIndex):
|
|
322
|
+
self.state.block = fill["block"]
|
|
323
|
+
self.state.logIndex = fill["logIndex"]
|
|
324
|
+
return True
|
pmwallets/types.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, Optional, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Fill(TypedDict):
|
|
7
|
+
"""One fill of an entity you subscribe to, exactly as the WebSocket frame and GET /v1/account/fills carry it."""
|
|
8
|
+
|
|
9
|
+
eventId: str # chain:block:blockHash:txHash:logIndex — deduplicate on it
|
|
10
|
+
chain: int
|
|
11
|
+
entityId: str # the subscribed entity (0x address)
|
|
12
|
+
wallet: str # the address inside the entity that traded
|
|
13
|
+
ts: str # block time, UTC, "YYYY-MM-DD HH:MM:SS"
|
|
14
|
+
block: int
|
|
15
|
+
blockHash: str
|
|
16
|
+
txHash: str
|
|
17
|
+
logIndex: int
|
|
18
|
+
exchange: str
|
|
19
|
+
side: Literal["BUY", "SELL"]
|
|
20
|
+
role: Literal["maker", "taker"]
|
|
21
|
+
tokenId: str # Polymarket outcome token id (uint256, decimal string)
|
|
22
|
+
price: str # decimal string, e.g. "0.570000"
|
|
23
|
+
shares: str # integer string, 1e-6 share units
|
|
24
|
+
usdc: str # integer string, 1e-6 USDC units
|
|
25
|
+
fee: str # integer string, 1e-6 USDC units
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FillCursor(TypedDict):
|
|
29
|
+
sinceBlock: int
|
|
30
|
+
sinceLogIndex: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FillsPage(TypedDict):
|
|
34
|
+
rows: list[Fill]
|
|
35
|
+
next: Optional[FillCursor]
|
|
36
|
+
subscriptions: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
Subscription = dict[str, Any]
|
|
40
|
+
Leaderboard = dict[str, Any]
|
pmwallets/webhook.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
_HEX = re.compile(r"^[0-9a-fA-F]+$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_webhook(raw_body: Union[bytes, str], signature_hex: Optional[str], secret: str) -> bool:
|
|
12
|
+
"""Verify `x-pmw-signature`: hex HMAC-SHA256 of the RAW request body, keyed with your webhook secret.
|
|
13
|
+
|
|
14
|
+
Pass the raw bytes, before any JSON parsing — re-serialising changes them and every check fails.
|
|
15
|
+
"""
|
|
16
|
+
if not signature_hex or not secret or not _HEX.match(signature_hex) or len(signature_hex) % 2:
|
|
17
|
+
return False
|
|
18
|
+
body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
19
|
+
mine = hmac.new(secret.encode(), body, hashlib.sha256).digest()
|
|
20
|
+
return hmac.compare_digest(bytes.fromhex(signature_hex), mine)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pmwallets
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official PMWallets SDK: Polymarket smart-money leaderboard, real-time wallet fills over WebSocket with gap replay, webhooks and exports.
|
|
5
|
+
Project-URL: Homepage, https://pmwallets.com/docs
|
|
6
|
+
Project-URL: Repository, https://github.com/polymarketwallets/pmwallets-python
|
|
7
|
+
Project-URL: Issues, https://github.com/polymarketwallets/pmwallets-python/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: copy-trading,pmwallets,polymarket,prediction-markets,smart-money,wallet-tracker
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: websockets>=15
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# pmwallets — Python SDK for PMWallets
|
|
20
|
+
|
|
21
|
+
[](https://pypi.org/project/pmwallets/) [](LICENSE)
|
|
22
|
+
|
|
23
|
+
Official Python client for [PMWallets](https://pmwallets.com): the **Polymarket smart-money leaderboard** computed
|
|
24
|
+
from the Polygon chain, and the **real-time fills of the Polymarket wallets you follow** — delivered in order,
|
|
25
|
+
exactly once, with every gap replayed.
|
|
26
|
+
|
|
27
|
+
[中文说明](README.zh.md) · Node.js SDK: [pmwallets-node](https://github.com/polymarketwallets/pmwallets-node) ·
|
|
28
|
+
Ready-to-run copy-trading bot built on it: [polymarket-copy-trading-bot-python](https://github.com/polymarketwallets/polymarket-copy-trading-bot-python)
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install pmwallets # Python ≥ 3.10
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Create an API key on [pmwallets.com/keys](https://pmwallets.com/keys).
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
from pmwallets import AsyncClient, Client, FillStream, FileStateStore
|
|
43
|
+
|
|
44
|
+
# synchronous REST
|
|
45
|
+
with Client(api_key="pmw_...") as pmw:
|
|
46
|
+
board = pmw.leaderboard(minWinLo=0.55, minEligible=30, style="taker", status="active", limit=50)
|
|
47
|
+
pmw.subscribe(board["rows"][0]["entityId"], channels=["ws"]) # billed per entity per hour
|
|
48
|
+
|
|
49
|
+
# every fill of every entity you follow
|
|
50
|
+
async def main():
|
|
51
|
+
async with AsyncClient(api_key="pmw_...") as pmw:
|
|
52
|
+
stream = FillStream(
|
|
53
|
+
client=pmw,
|
|
54
|
+
store=FileStateStore("stream.json"), # a restart resumes exactly where it stopped
|
|
55
|
+
on_fill=lambda fill, meta: print(meta.source, fill["side"], fill["price"], fill["tokenId"]),
|
|
56
|
+
)
|
|
57
|
+
await stream.run()
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## What is in it
|
|
63
|
+
|
|
64
|
+
| | |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `Client` / `AsyncClient` | leaderboard, entities, address unlocks, subscriptions, fills replay (`fills`, `fills_since`), trade-history exports |
|
|
67
|
+
| `FillStream` | asyncio WebSocket with reconnect and keep-alive; detects missed frames by `session`/`seq` and replays them from the last fill delivered; de-duplicates by `eventId`; anchors behind the chain head on first start; persisted cursor |
|
|
68
|
+
| `verify_webhook(raw_body, signature, secret)` | checks `x-pmw-signature` (HMAC-SHA256 of the raw body) |
|
|
69
|
+
|
|
70
|
+
One stream per account: the newest connection wins, so run one consumer per API account. `HTTPS_PROXY` is honoured.
|
|
71
|
+
|
|
72
|
+
## Resources
|
|
73
|
+
|
|
74
|
+
- [Polymarket smart-money leaderboard](https://pmwallets.com) — profitable Polymarket traders scored from the Polygon chain, with win-rate confidence intervals
|
|
75
|
+
- [Polymarket copy trading guide](https://pmwallets.com/copy-trading) — which wallets are worth following and how to get their fills in time
|
|
76
|
+
- [How to learn from Polymarket smart money](https://pmwallets.com/learn) — reading a trader's record: confidence intervals, maker vs taker, market specialism
|
|
77
|
+
- [PMWallets API documentation](https://pmwallets.com/docs) — WebSocket and webhook fill push, fills replay, trade-history exports
|
|
78
|
+
- [Measured fill-push latency](https://pmwallets.com/latency) — block-to-push p50 / p95, published live
|
|
79
|
+
- [Ways to follow Polymarket wallets, compared](https://pmwallets.com/compare) — official leaderboard, free trackers, SQL dashboards
|
|
80
|
+
- [FAQ](https://pmwallets.com/faq) · [中文站](https://pmwallets.com/zh)
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pmwallets/__init__.py,sha256=2DKRRc521D10ThunksJ21MkRht5sMiLSCXs8fWB1NJE,618
|
|
2
|
+
pmwallets/client.py,sha256=HNruqJpsUw50-zJI7fhEiFvX5qyO3OvrLhvPhB9MNvw,10821
|
|
3
|
+
pmwallets/stream.py,sha256=BXyrjQRI_O_1gPddLGx8TvPtHIucjp4084biFx_OuO0,14135
|
|
4
|
+
pmwallets/types.py,sha256=SgWBRE6js1QJOvoCdxZLi_DMgWW3dk0E2rDJckYm03Q,1166
|
|
5
|
+
pmwallets/webhook.py,sha256=YV0t8YEqg5WSbIkF3u2Uuy33fVoAkl-34gNa0sBBjAg,793
|
|
6
|
+
pmwallets-0.1.0.dist-info/METADATA,sha256=cbrUv6O_WJ0scjDGTmwSIdJk-qIymbDHlBhgL4yvXOA,4069
|
|
7
|
+
pmwallets-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
pmwallets-0.1.0.dist-info/licenses/LICENSE,sha256=nwgseDA8QSmr2yXioOFDRfUAqAw6ZZ85NTWBC6luvEg,1066
|
|
9
|
+
pmwallets-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PMWallets
|
|
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.
|