pax-api 2.0.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.
- pax_api/__init__.py +60 -0
- pax_api/auth.py +66 -0
- pax_api/client.py +349 -0
- pax_api/errors.py +146 -0
- pax_api/py.typed +0 -0
- pax_api/retry.py +97 -0
- pax_api/ws_client.py +195 -0
- pax_api-2.0.0.dist-info/METADATA +209 -0
- pax_api-2.0.0.dist-info/RECORD +12 -0
- pax_api-2.0.0.dist-info/WHEEL +5 -0
- pax_api-2.0.0.dist-info/licenses/LICENSE +21 -0
- pax_api-2.0.0.dist-info/top_level.txt +1 -0
pax_api/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""PredictAsiaX (PAX) Python SDK.
|
|
2
|
+
|
|
3
|
+
Web3-native prediction market REST + WebSocket API client.
|
|
4
|
+
Homepage: https://predictasiax.com/developer
|
|
5
|
+
Docs: https://docs.predictasiax.com
|
|
6
|
+
|
|
7
|
+
Quick start (anonymous sandbox mint in 30 sec):
|
|
8
|
+
>>> from pax_api import PaxClient
|
|
9
|
+
>>> boot = PaxClient(api_key="anonymous")
|
|
10
|
+
>>> key = boot.mint_sandbox_key(org_name="my-app")
|
|
11
|
+
>>> print(key["api_key"]) # sk_live_..., shown ONCE — save it
|
|
12
|
+
>>> client = PaxClient(api_key=key["api_key"])
|
|
13
|
+
>>> client.list_markets(category="crypto", limit=10)
|
|
14
|
+
|
|
15
|
+
For HMAC signing (production trading bots):
|
|
16
|
+
>>> client = PaxClient(
|
|
17
|
+
... api_key="sk_live_...",
|
|
18
|
+
... secret="<hex-secret>",
|
|
19
|
+
... passphrase="<passphrase>",
|
|
20
|
+
... )
|
|
21
|
+
>>> client.place_order(market_id="m_...", outcome_id="yes",
|
|
22
|
+
... side="buy", order_type="market", size="100")
|
|
23
|
+
|
|
24
|
+
Environment model: sandbox is a *tier* on the key (tier=self_serve, $10/$100
|
|
25
|
+
caps), not a separate host. Same prefix, same URL — promote to production by
|
|
26
|
+
upgrading the key's tier.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__version__ = "2.0.0"
|
|
30
|
+
|
|
31
|
+
from pax_api.client import PaxClient # noqa: E402
|
|
32
|
+
from pax_api.ws_client import PaxWSClient # noqa: E402
|
|
33
|
+
from pax_api.errors import ( # noqa: E402
|
|
34
|
+
PaxError,
|
|
35
|
+
PaxAuthError,
|
|
36
|
+
PaxRateLimitError,
|
|
37
|
+
PaxValidationError,
|
|
38
|
+
PaxNotFoundError,
|
|
39
|
+
PaxConflictError,
|
|
40
|
+
PaxServerError,
|
|
41
|
+
PaxReadOnlyModeError,
|
|
42
|
+
PaxSandboxOnlyError,
|
|
43
|
+
PaxWrongEnvKeyError,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"PaxClient",
|
|
48
|
+
"PaxWSClient",
|
|
49
|
+
"PaxError",
|
|
50
|
+
"PaxAuthError",
|
|
51
|
+
"PaxRateLimitError",
|
|
52
|
+
"PaxValidationError",
|
|
53
|
+
"PaxNotFoundError",
|
|
54
|
+
"PaxConflictError",
|
|
55
|
+
"PaxServerError",
|
|
56
|
+
"PaxReadOnlyModeError",
|
|
57
|
+
"PaxSandboxOnlyError",
|
|
58
|
+
"PaxWrongEnvKeyError",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
pax_api/auth.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""HMAC signing helpers — Polymarket-compatible 5-header pattern.
|
|
2
|
+
|
|
3
|
+
Signature construction:
|
|
4
|
+
message = timestamp_ms + method_upper + path + body
|
|
5
|
+
signature = base64(HMAC-SHA256(secret, message))
|
|
6
|
+
|
|
7
|
+
Headers sent:
|
|
8
|
+
POLY_ACCESS_KEY: <key_id>
|
|
9
|
+
POLY_TIMESTAMP: <ms epoch>
|
|
10
|
+
POLY_PASSPHRASE: <passphrase>
|
|
11
|
+
POLY_SIGNATURE: <base64 sig>
|
|
12
|
+
|
|
13
|
+
Server rejects with 401 INVALID_SIGNATURE if timestamp is more than
|
|
14
|
+
±30 seconds from server clock. Sync your machine via NTP.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import hmac
|
|
22
|
+
import time
|
|
23
|
+
from typing import Dict, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sign_request(
|
|
27
|
+
method: str,
|
|
28
|
+
path: str,
|
|
29
|
+
body: str,
|
|
30
|
+
key_id: str,
|
|
31
|
+
secret: str,
|
|
32
|
+
passphrase: str,
|
|
33
|
+
timestamp_ms: Optional[int] = None,
|
|
34
|
+
) -> Dict[str, str]:
|
|
35
|
+
"""Build the 5 POLY_* headers for an HMAC-signed request.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
method: HTTP method (GET/POST/DELETE/etc.). Case-normalized to upper.
|
|
39
|
+
path: URL path INCLUDING leading slash and query string, e.g.
|
|
40
|
+
``/v1/markets?category=crypto``.
|
|
41
|
+
body: Raw request body as string (empty string for GET/DELETE
|
|
42
|
+
without body). Must be the exact bytes sent on the wire.
|
|
43
|
+
key_id: Your ``sk_live_*`` key ID.
|
|
44
|
+
secret: HMAC secret (hex string) obtained at key mint time.
|
|
45
|
+
passphrase: Passphrase obtained at key mint time.
|
|
46
|
+
timestamp_ms: Override timestamp for testing. Defaults to now.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Dict of 5 headers to merge into the request.
|
|
50
|
+
"""
|
|
51
|
+
ts = str(timestamp_ms if timestamp_ms is not None else int(time.time() * 1000))
|
|
52
|
+
method_u = method.upper()
|
|
53
|
+
message = ts + method_u + path + (body or "")
|
|
54
|
+
digest = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest()
|
|
55
|
+
signature = base64.b64encode(digest).decode("ascii")
|
|
56
|
+
return {
|
|
57
|
+
"POLY_ACCESS_KEY": key_id,
|
|
58
|
+
"POLY_TIMESTAMP": ts,
|
|
59
|
+
"POLY_PASSPHRASE": passphrase,
|
|
60
|
+
"POLY_SIGNATURE": signature,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_message(method: str, path: str, body: str, timestamp_ms: int) -> str:
|
|
65
|
+
"""Return the canonical signing message (exposed for debugging)."""
|
|
66
|
+
return f"{timestamp_ms}{method.upper()}{path}{body or ''}"
|
pax_api/client.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Synchronous REST client for the PAX Trader Track API.
|
|
2
|
+
|
|
3
|
+
Auth modes (auto-detected):
|
|
4
|
+
1. HMAC — pass ``api_key`` + ``secret`` + ``passphrase``. Every request
|
|
5
|
+
signed with 5 POLY_* headers. Use for high-security trading bots.
|
|
6
|
+
2. API Key — pass only ``api_key``. Sent via X-Api-Key header. Simple
|
|
7
|
+
for read-only or low-risk M2M flows.
|
|
8
|
+
3. Bearer — pass ``bearer_token``. Session-based. Browser/mobile only.
|
|
9
|
+
|
|
10
|
+
Environment:
|
|
11
|
+
All ``env`` values resolve to the same canonical origin —
|
|
12
|
+
``https://api.predictasiax.com/v1``. Sandbox is a *tier* on the key
|
|
13
|
+
(``tier=self_serve``), not a separate host. Pass a full ``base_url``
|
|
14
|
+
to override.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json as _json
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from pax_api.auth import sign_request
|
|
25
|
+
from pax_api.errors import PaxError, build_error
|
|
26
|
+
from pax_api.retry import run_with_retry
|
|
27
|
+
|
|
28
|
+
__version__ = "2.0.0" # keep in sync with __init__.py + pyproject.toml
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# v2: sandbox is a tier on the key, not a separate host. All env aliases
|
|
32
|
+
# resolve to the same canonical origin.
|
|
33
|
+
_ENV_URLS = {
|
|
34
|
+
"production": "https://api.predictasiax.com/v1",
|
|
35
|
+
"prod": "https://api.predictasiax.com/v1",
|
|
36
|
+
"live": "https://api.predictasiax.com/v1",
|
|
37
|
+
"sandbox": "https://api.predictasiax.com/v1",
|
|
38
|
+
"test": "https://api.predictasiax.com/v1",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
DEFAULT_TIMEOUT = 30.0
|
|
42
|
+
DEFAULT_USER_AGENT = f"pax-api-python/{__version__}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PaxClient:
|
|
46
|
+
"""Synchronous PAX API client.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
Simple API-key read (mint an anonymous sandbox key first):
|
|
50
|
+
|
|
51
|
+
>>> boot = PaxClient(api_key="anonymous")
|
|
52
|
+
>>> key = boot.mint_sandbox_key(org_name="my-app")
|
|
53
|
+
>>> print(key["api_key"]) # sk_live_..., shown ONCE — save it
|
|
54
|
+
>>> client = PaxClient(api_key=key["api_key"])
|
|
55
|
+
>>> client.list_markets(category="crypto", limit=10)
|
|
56
|
+
|
|
57
|
+
HMAC-signed trade (production tier):
|
|
58
|
+
|
|
59
|
+
>>> client = PaxClient(
|
|
60
|
+
... api_key="sk_live_...",
|
|
61
|
+
... secret="<32-bytes hex>",
|
|
62
|
+
... passphrase="<passphrase>",
|
|
63
|
+
... )
|
|
64
|
+
>>> client.place_order(market_id="m_...", outcome_id="yes",
|
|
65
|
+
... side="buy", order_type="market", size="100")
|
|
66
|
+
|
|
67
|
+
Session bearer (rare — browser/mobile):
|
|
68
|
+
|
|
69
|
+
>>> client = PaxClient(bearer_token="<session token>")
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: Optional[str] = None,
|
|
75
|
+
secret: Optional[str] = None,
|
|
76
|
+
passphrase: Optional[str] = None,
|
|
77
|
+
bearer_token: Optional[str] = None,
|
|
78
|
+
env: str = "production",
|
|
79
|
+
base_url: Optional[str] = None,
|
|
80
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
81
|
+
max_retries: int = 5,
|
|
82
|
+
user_agent: Optional[str] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
if not (api_key or bearer_token):
|
|
85
|
+
raise ValueError("Provide api_key or bearer_token")
|
|
86
|
+
if secret and not passphrase:
|
|
87
|
+
raise ValueError("Provide passphrase together with secret for HMAC")
|
|
88
|
+
|
|
89
|
+
self.api_key = api_key
|
|
90
|
+
self.secret = secret
|
|
91
|
+
self.passphrase = passphrase
|
|
92
|
+
self.bearer_token = bearer_token
|
|
93
|
+
self.base_url = (base_url or _ENV_URLS.get(env.lower()) or _ENV_URLS["production"]).rstrip("/")
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
self.max_retries = max_retries
|
|
96
|
+
self.user_agent = user_agent or DEFAULT_USER_AGENT
|
|
97
|
+
self._session = requests.Session()
|
|
98
|
+
self._session.headers.update({"User-Agent": self.user_agent, "Accept": "application/json"})
|
|
99
|
+
|
|
100
|
+
# ── low-level request plumbing ──────────────────────────────────────────
|
|
101
|
+
def _headers_for(self, method: str, path_with_query: str, body: str) -> Dict[str, str]:
|
|
102
|
+
headers: Dict[str, str] = {}
|
|
103
|
+
if body:
|
|
104
|
+
headers["Content-Type"] = "application/json"
|
|
105
|
+
if self.secret and self.passphrase and self.api_key:
|
|
106
|
+
# HMAC — path must NOT include /api prefix if base_url already includes /api.
|
|
107
|
+
# Server signs against the URL path AFTER /api. We strip /api if present.
|
|
108
|
+
sig_path = path_with_query
|
|
109
|
+
headers.update(sign_request(method, sig_path, body, self.api_key, self.secret, self.passphrase))
|
|
110
|
+
elif self.api_key:
|
|
111
|
+
headers["X-Api-Key"] = self.api_key
|
|
112
|
+
elif self.bearer_token:
|
|
113
|
+
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
|
114
|
+
return headers
|
|
115
|
+
|
|
116
|
+
def _do_request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None,
|
|
117
|
+
json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
118
|
+
url = self.base_url + path
|
|
119
|
+
body = _json.dumps(json_body, separators=(",", ":"), sort_keys=False) if json_body is not None else ""
|
|
120
|
+
# Reconstruct path+query for signing to match what server sees
|
|
121
|
+
# (requests will apply params to URL — replicate here for signature)
|
|
122
|
+
path_for_sig = path
|
|
123
|
+
if params:
|
|
124
|
+
from urllib.parse import urlencode
|
|
125
|
+
qs = urlencode(params, doseq=True)
|
|
126
|
+
if qs:
|
|
127
|
+
path_for_sig = f"{path}?{qs}"
|
|
128
|
+
headers = self._headers_for(method, path_for_sig, body)
|
|
129
|
+
|
|
130
|
+
resp = self._session.request(
|
|
131
|
+
method=method,
|
|
132
|
+
url=url,
|
|
133
|
+
params=params,
|
|
134
|
+
data=body if body else None,
|
|
135
|
+
headers=headers,
|
|
136
|
+
timeout=self.timeout,
|
|
137
|
+
allow_redirects=False,
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
payload = resp.json()
|
|
141
|
+
except ValueError:
|
|
142
|
+
payload = {"ok": False, "error": {"code": "MALFORMED_RESPONSE", "message": resp.text[:300]}}
|
|
143
|
+
|
|
144
|
+
if resp.status_code >= 400 or (isinstance(payload, dict) and payload.get("ok") is False):
|
|
145
|
+
retry_after = resp.headers.get("Retry-After")
|
|
146
|
+
retry_after_i: Optional[int] = None
|
|
147
|
+
try:
|
|
148
|
+
retry_after_i = int(retry_after) if retry_after is not None else None
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
retry_after_i = None
|
|
151
|
+
raise build_error(resp.status_code, payload, retry_after=retry_after_i)
|
|
152
|
+
return payload
|
|
153
|
+
|
|
154
|
+
def _request(self, method: str, path: str, params: Optional[Dict[str, Any]] = None,
|
|
155
|
+
json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
156
|
+
return run_with_retry(
|
|
157
|
+
lambda: self._do_request(method, path, params=params, json_body=json_body),
|
|
158
|
+
max_attempts=self.max_retries,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# ── Discovery ────────────────────────────────────────────────────────────
|
|
162
|
+
def list_markets(self, category: Optional[str] = None, market_type: Optional[str] = None,
|
|
163
|
+
status: Optional[str] = None, cursor: Optional[str] = None,
|
|
164
|
+
limit: int = 50) -> Dict[str, Any]:
|
|
165
|
+
"""List active markets. Public — no auth strictly required.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
category: e.g. "crypto", "sports", "politics".
|
|
169
|
+
market_type: "fast" | "normal" | "creator".
|
|
170
|
+
status: "active" | "locked" | "settling".
|
|
171
|
+
cursor: Opaque pagination cursor from previous ``meta.next_cursor``.
|
|
172
|
+
limit: 1-500 (default 50).
|
|
173
|
+
"""
|
|
174
|
+
params: Dict[str, Any] = {"limit": max(1, min(500, limit))}
|
|
175
|
+
if category:
|
|
176
|
+
params["category"] = category
|
|
177
|
+
if market_type:
|
|
178
|
+
params["market_type"] = market_type
|
|
179
|
+
if status:
|
|
180
|
+
params["status"] = status
|
|
181
|
+
if cursor:
|
|
182
|
+
params["cursor"] = cursor
|
|
183
|
+
return self._request("GET", "/v1/markets", params=params)
|
|
184
|
+
|
|
185
|
+
def get_market(self, market_id: str) -> Dict[str, Any]:
|
|
186
|
+
"""Get one market's detail (envelope with ``data`` containing Market)."""
|
|
187
|
+
return self._request("GET", f"/v1/markets/{market_id}")
|
|
188
|
+
|
|
189
|
+
def list_templates(self, category: Optional[str] = None,
|
|
190
|
+
market_type: Optional[str] = None) -> Dict[str, Any]:
|
|
191
|
+
"""List active market templates."""
|
|
192
|
+
params: Dict[str, Any] = {}
|
|
193
|
+
if category:
|
|
194
|
+
params["category"] = category
|
|
195
|
+
if market_type:
|
|
196
|
+
params["market_type"] = market_type
|
|
197
|
+
return self._request("GET", "/v1/markets/templates", params=params or None)
|
|
198
|
+
|
|
199
|
+
def get_health(self) -> Dict[str, Any]:
|
|
200
|
+
"""API health check."""
|
|
201
|
+
return self._request("GET", "/v1/health")
|
|
202
|
+
|
|
203
|
+
# ── Market Data ──────────────────────────────────────────────────────────
|
|
204
|
+
def get_candles(self, market_id: str, outcome_id: str, interval: str = "1m",
|
|
205
|
+
from_ms: Optional[int] = None, to_ms: Optional[int] = None,
|
|
206
|
+
limit: int = 500) -> Dict[str, Any]:
|
|
207
|
+
"""OHLCV candles for a market outcome's price history.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
interval: "1m" | "5m" | "15m" | "30m" | "1h" | "4h" | "1d".
|
|
211
|
+
"""
|
|
212
|
+
params: Dict[str, Any] = {"outcome_id": outcome_id, "interval": interval,
|
|
213
|
+
"limit": max(1, min(500, limit))}
|
|
214
|
+
if from_ms is not None:
|
|
215
|
+
params["from_ms"] = from_ms
|
|
216
|
+
if to_ms is not None:
|
|
217
|
+
params["to_ms"] = to_ms
|
|
218
|
+
return self._request("GET", f"/v1/candles/{market_id}", params=params)
|
|
219
|
+
|
|
220
|
+
def get_orderbook(self, market_id: str, outcome_id: str, depth: int = 20) -> Dict[str, Any]:
|
|
221
|
+
"""Top-of-book depth snapshot."""
|
|
222
|
+
return self._request("GET", f"/v1/orderbook/{market_id}",
|
|
223
|
+
params={"outcome_id": outcome_id, "depth": max(1, min(100, depth))})
|
|
224
|
+
|
|
225
|
+
def get_trades(self, market_id: str, outcome_id: Optional[str] = None,
|
|
226
|
+
cursor: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
227
|
+
"""Recent public trades tape for a market."""
|
|
228
|
+
params: Dict[str, Any] = {"limit": max(1, min(500, limit))}
|
|
229
|
+
if outcome_id:
|
|
230
|
+
params["outcome_id"] = outcome_id
|
|
231
|
+
if cursor:
|
|
232
|
+
params["cursor"] = cursor
|
|
233
|
+
return self._request("GET", f"/v1/trades/{market_id}", params=params)
|
|
234
|
+
|
|
235
|
+
# ── Account ──────────────────────────────────────────────────────────────
|
|
236
|
+
def get_account(self) -> Dict[str, Any]:
|
|
237
|
+
"""Get authenticated account snapshot."""
|
|
238
|
+
return self._request("GET", "/v1/account")
|
|
239
|
+
|
|
240
|
+
def get_positions(self) -> Dict[str, Any]:
|
|
241
|
+
"""List open positions for the authenticated account."""
|
|
242
|
+
return self._request("GET", "/v1/account/positions")
|
|
243
|
+
|
|
244
|
+
def get_orders(self, status: Optional[str] = None, cursor: Optional[str] = None,
|
|
245
|
+
limit: int = 50) -> Dict[str, Any]:
|
|
246
|
+
"""List orders (active + recent)."""
|
|
247
|
+
params: Dict[str, Any] = {"limit": max(1, min(500, limit))}
|
|
248
|
+
if status:
|
|
249
|
+
params["status"] = status
|
|
250
|
+
if cursor:
|
|
251
|
+
params["cursor"] = cursor
|
|
252
|
+
return self._request("GET", "/v1/account/orders", params=params)
|
|
253
|
+
|
|
254
|
+
def get_own_trades(self, cursor: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
255
|
+
"""List own trade fills."""
|
|
256
|
+
params: Dict[str, Any] = {"limit": max(1, min(500, limit))}
|
|
257
|
+
if cursor:
|
|
258
|
+
params["cursor"] = cursor
|
|
259
|
+
return self._request("GET", "/v1/account/trades", params=params)
|
|
260
|
+
|
|
261
|
+
# ── Trading ──────────────────────────────────────────────────────────────
|
|
262
|
+
def place_order(self, market_id: str, outcome_id: str, side: str, order_type: str,
|
|
263
|
+
size: str, price: Optional[str] = None,
|
|
264
|
+
client_order_id: Optional[str] = None) -> Dict[str, Any]:
|
|
265
|
+
"""Place an order. Engine-agnostic (server routes to AMM or CLOB).
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
side: "buy" | "sell".
|
|
269
|
+
order_type: "market" | "limit". ``price`` required if limit.
|
|
270
|
+
size: Decimal string (e.g. "100" or "0.5").
|
|
271
|
+
price: Required when order_type=limit. Decimal string.
|
|
272
|
+
client_order_id: Idempotency key — retry-safe within 24h.
|
|
273
|
+
"""
|
|
274
|
+
body: Dict[str, Any] = {
|
|
275
|
+
"market_id": market_id, "outcome_id": outcome_id,
|
|
276
|
+
"side": side, "order_type": order_type, "size": str(size),
|
|
277
|
+
}
|
|
278
|
+
if price is not None:
|
|
279
|
+
body["price"] = str(price)
|
|
280
|
+
if client_order_id:
|
|
281
|
+
body["client_order_id"] = client_order_id
|
|
282
|
+
return self._request("POST", "/v1/orders", json_body=body)
|
|
283
|
+
|
|
284
|
+
def cancel_order(self, order_id: str) -> Dict[str, Any]:
|
|
285
|
+
"""Cancel an open order."""
|
|
286
|
+
return self._request("DELETE", f"/v1/orders/{order_id}")
|
|
287
|
+
|
|
288
|
+
def create_market(self, template_id: str, params: Dict[str, Any],
|
|
289
|
+
creator_metadata: Optional[Dict[str, Any]] = None,
|
|
290
|
+
idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
291
|
+
"""Create a market from a template. Deterministic ID — idempotent."""
|
|
292
|
+
body: Dict[str, Any] = {"template_id": template_id, "params": params}
|
|
293
|
+
if creator_metadata:
|
|
294
|
+
body["creator_metadata"] = creator_metadata
|
|
295
|
+
if idempotency_key:
|
|
296
|
+
body["idempotency_key"] = idempotency_key
|
|
297
|
+
return self._request("POST", "/v1/markets", json_body=body)
|
|
298
|
+
|
|
299
|
+
# ── Keys ─────────────────────────────────────────────────────────────────
|
|
300
|
+
def create_api_key(self, name: str = "default", scopes: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
301
|
+
"""Mint a new API key. Requires session bearer auth."""
|
|
302
|
+
body = {"name": name, "scopes": scopes or ["read"]}
|
|
303
|
+
return self._request("POST", "/v1/keys", json_body=body)
|
|
304
|
+
|
|
305
|
+
def list_api_keys(self) -> Dict[str, Any]:
|
|
306
|
+
return self._request("GET", "/v1/keys")
|
|
307
|
+
|
|
308
|
+
def revoke_api_key(self, key_id: str) -> Dict[str, Any]:
|
|
309
|
+
return self._request("DELETE", f"/v1/keys/{key_id}")
|
|
310
|
+
|
|
311
|
+
# ── Sandbox mint + Builder applications (anonymous, no auth) ────────────
|
|
312
|
+
def mint_sandbox_key(self, org_name: str, contact_email: Optional[str] = None) -> Dict[str, Any]:
|
|
313
|
+
"""POST /v1/sandbox-keys — mint an anonymous sk_live_ key tagged
|
|
314
|
+
tier=self_serve with $10/order + $100/day notional caps. The response
|
|
315
|
+
contains ``api_key`` shown ONCE — save it immediately."""
|
|
316
|
+
body: Dict[str, Any] = {"org_name": org_name}
|
|
317
|
+
if contact_email is not None:
|
|
318
|
+
body["contact_email"] = contact_email
|
|
319
|
+
return self._request("POST", "/v1/sandbox-keys", json_body=body)
|
|
320
|
+
|
|
321
|
+
def apply(self, track: str, *, org: Optional[str] = None, email: Optional[str] = None,
|
|
322
|
+
url: Optional[str] = None, description: Optional[str] = None) -> Dict[str, Any]:
|
|
323
|
+
"""POST /v1/apply — apply for a Builder track (``genesis`` / ``self_serve``
|
|
324
|
+
/ ``rfb-japan-app`` / ``rfb-ai-agent`` / ``rfb-telegram-bot`` /
|
|
325
|
+
``rfb-news-media`` / ``rfb-sports`` / ``rfb-election`` / ``rfb-terminal``
|
|
326
|
+
/ ``rfb-mm-stack`` / ``data`` / ``app`` / ``agent`` / ``distribution`` /
|
|
327
|
+
``market`` / ``liquidity`` / ``oracle`` / ``reviewer`` / ``operator`` /
|
|
328
|
+
``institutional``). Auto-approve tracks return ``api_key`` immediately;
|
|
329
|
+
``genesis`` and ``institutional`` queue for human review."""
|
|
330
|
+
body: Dict[str, Any] = {"track": track}
|
|
331
|
+
if org is not None: body["org"] = org
|
|
332
|
+
if email is not None: body["email"] = email
|
|
333
|
+
if url is not None: body["url"] = url
|
|
334
|
+
if description is not None: body["description"] = description
|
|
335
|
+
return self._request("POST", "/v1/apply", json_body=body)
|
|
336
|
+
|
|
337
|
+
def get_application(self, application_code: str) -> Dict[str, Any]:
|
|
338
|
+
"""GET /v1/apply/{code} — check application review status."""
|
|
339
|
+
return self._request("GET", f"/v1/apply/{application_code}")
|
|
340
|
+
|
|
341
|
+
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
342
|
+
def close(self) -> None:
|
|
343
|
+
self._session.close()
|
|
344
|
+
|
|
345
|
+
def __enter__(self) -> "PaxClient":
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
def __exit__(self, *exc: Any) -> None:
|
|
349
|
+
self.close()
|
pax_api/errors.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Exception hierarchy for PAX SDK.
|
|
2
|
+
|
|
3
|
+
Every PAX error response returns a canonical envelope with an ``error.code``.
|
|
4
|
+
The client maps HTTP status + error.code to the most specific exception.
|
|
5
|
+
Catch ``PaxError`` for a blanket catch, or specific subclasses to react to
|
|
6
|
+
individual failures.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PaxError(Exception):
|
|
13
|
+
"""Base class for all PAX SDK errors.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
code: PAX error code (e.g. "MARKET_NOT_FOUND", "READ_ONLY_MODE").
|
|
17
|
+
message: Human-readable message from the server.
|
|
18
|
+
http_status: HTTP status code of the failed response.
|
|
19
|
+
request_id: Server-assigned request ID — include this in support tickets.
|
|
20
|
+
details: Optional structured details dict (per-endpoint schema).
|
|
21
|
+
response_json: Raw response payload for debugging.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
code: str,
|
|
27
|
+
message: str,
|
|
28
|
+
http_status: int,
|
|
29
|
+
request_id: Optional[str] = None,
|
|
30
|
+
details: Optional[Dict[str, Any]] = None,
|
|
31
|
+
response_json: Optional[Dict[str, Any]] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.code = code
|
|
34
|
+
self.message = message
|
|
35
|
+
self.http_status = http_status
|
|
36
|
+
self.request_id = request_id
|
|
37
|
+
self.details = details or {}
|
|
38
|
+
self.response_json = response_json
|
|
39
|
+
parts = [f"[{code}]", message]
|
|
40
|
+
if request_id:
|
|
41
|
+
parts.append(f"(request_id={request_id})")
|
|
42
|
+
super().__init__(" ".join(parts))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── 4xx family ──────────────────────────────────────────────────────────────
|
|
46
|
+
class PaxAuthError(PaxError):
|
|
47
|
+
"""401 — MISSING_AUTH / INVALID_KEY / INVALID_SIGNATURE / SESSION_EXPIRED / KEY_REVOKED."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PaxWrongEnvKeyError(PaxAuthError):
|
|
51
|
+
"""401 WRONG_ENV_KEY — key's tier flag does not match the requested capability
|
|
52
|
+
(e.g. a tier=self_serve key attempting an operation that requires a
|
|
53
|
+
production tier like ``verified`` / ``trade_capped`` / ``trade_full``)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PaxValidationError(PaxError):
|
|
57
|
+
"""400 — MISSING_FIELD / INVALID_TYPE / INVALID_ENUM / TEMPLATE_PARAMS_INVALID / INVALID_ORDER_SIZE."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PaxNotFoundError(PaxError):
|
|
61
|
+
"""404 — MARKET_NOT_FOUND / ORDER_NOT_FOUND / TEMPLATE_NOT_FOUND / SANDBOX_ONLY."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PaxSandboxOnlyError(PaxNotFoundError):
|
|
65
|
+
"""404 SANDBOX_ONLY — sandbox endpoint called on production."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PaxConflictError(PaxError):
|
|
69
|
+
"""409 — ORDER_ALREADY_FILLED / MARKET_CLOSED / DUPLICATE_CLIENT_ORDER_ID."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PaxRateLimitError(PaxError):
|
|
73
|
+
"""429 — RATE_LIMITED. Check ``retry_after`` for wait seconds."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, *args: Any, retry_after: Optional[int] = None, **kwargs: Any) -> None:
|
|
76
|
+
super().__init__(*args, **kwargs)
|
|
77
|
+
self.retry_after = retry_after
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── 5xx family ──────────────────────────────────────────────────────────────
|
|
81
|
+
class PaxServerError(PaxError):
|
|
82
|
+
"""500/502/504 — INTERNAL_ERROR / UPSTREAM_ERROR / UPSTREAM_TIMEOUT."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PaxReadOnlyModeError(PaxError):
|
|
86
|
+
"""503 READ_ONLY_MODE — writes gated by ops team."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PaxCircuitBreakerError(PaxError):
|
|
90
|
+
"""503 CIRCUIT_BREAKER_OPEN — trading paused on this market/asset."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Mapping helper ──────────────────────────────────────────────────────────
|
|
94
|
+
_CODE_MAP = {
|
|
95
|
+
"WRONG_ENV_KEY": PaxWrongEnvKeyError,
|
|
96
|
+
"SANDBOX_ONLY": PaxSandboxOnlyError,
|
|
97
|
+
"READ_ONLY_MODE": PaxReadOnlyModeError,
|
|
98
|
+
"CIRCUIT_BREAKER_OPEN": PaxCircuitBreakerError,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_STATUS_MAP = {
|
|
102
|
+
400: PaxValidationError,
|
|
103
|
+
401: PaxAuthError,
|
|
104
|
+
403: PaxAuthError,
|
|
105
|
+
404: PaxNotFoundError,
|
|
106
|
+
409: PaxConflictError,
|
|
107
|
+
429: PaxRateLimitError,
|
|
108
|
+
500: PaxServerError,
|
|
109
|
+
502: PaxServerError,
|
|
110
|
+
503: PaxServerError,
|
|
111
|
+
504: PaxServerError,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_error(http_status: int, response_json: Dict[str, Any], retry_after: Optional[int] = None) -> PaxError:
|
|
116
|
+
"""Construct the most specific PaxError from an HTTP response."""
|
|
117
|
+
raw_err = response_json.get("error")
|
|
118
|
+
if isinstance(raw_err, dict):
|
|
119
|
+
err = raw_err
|
|
120
|
+
code = str(err.get("code") or "UNKNOWN")
|
|
121
|
+
message = str(err.get("message") or code)
|
|
122
|
+
else:
|
|
123
|
+
# Legacy shape: {"ok": false, "error": "missing-api-key", "message": "..."}
|
|
124
|
+
err = {}
|
|
125
|
+
code = str(raw_err or "UNKNOWN")
|
|
126
|
+
message = str(response_json.get("message") or code)
|
|
127
|
+
|
|
128
|
+
meta = response_json.get("meta") if isinstance(response_json.get("meta"), dict) else {}
|
|
129
|
+
request_id = meta.get("request_id") if isinstance(meta, dict) else None
|
|
130
|
+
details = err.get("details") if isinstance(err.get("details"), dict) else None
|
|
131
|
+
|
|
132
|
+
exc_class = _CODE_MAP.get(code) or _STATUS_MAP.get(http_status) or PaxError
|
|
133
|
+
|
|
134
|
+
kwargs: Dict[str, Any] = {}
|
|
135
|
+
if exc_class is PaxRateLimitError and retry_after is not None:
|
|
136
|
+
kwargs["retry_after"] = retry_after
|
|
137
|
+
|
|
138
|
+
return exc_class(
|
|
139
|
+
code=code,
|
|
140
|
+
message=message,
|
|
141
|
+
http_status=http_status,
|
|
142
|
+
request_id=request_id,
|
|
143
|
+
details=details,
|
|
144
|
+
response_json=response_json,
|
|
145
|
+
**kwargs,
|
|
146
|
+
)
|
pax_api/py.typed
ADDED
|
File without changes
|
pax_api/retry.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Retry logic with exponential backoff for transient failures.
|
|
2
|
+
|
|
3
|
+
Retry policy (matches docs.predictasiax.com/errors#retry-policy):
|
|
4
|
+
- 4xx errors (except 429): DO NOT retry — fix the request.
|
|
5
|
+
- 429: respect Retry-After header, otherwise exponential backoff.
|
|
6
|
+
- 500/502/504: retry once immediately, then exponential backoff.
|
|
7
|
+
- 503 READ_ONLY_MODE / CIRCUIT_BREAKER_OPEN: do not retry.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from typing import Callable, Optional, TypeVar
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compute_backoff(attempt: int, base: float = 1.0, cap: float = 60.0, jitter: bool = True) -> float:
|
|
20
|
+
"""Full-jitter exponential backoff.
|
|
21
|
+
|
|
22
|
+
``sleep = random(0, min(cap, base * 2^attempt))``
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
attempt: 0-indexed retry attempt (0 = first retry).
|
|
26
|
+
base: Base wait in seconds (default 1s).
|
|
27
|
+
cap: Max wait in seconds (default 60s).
|
|
28
|
+
jitter: If True, use full-jitter randomization.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Seconds to sleep.
|
|
32
|
+
"""
|
|
33
|
+
exp = min(cap, base * (2 ** attempt))
|
|
34
|
+
return random.uniform(0, exp) if jitter else exp
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sleep_with_retry_after(retry_after_header: Optional[str], attempt: int, cap: float = 60.0) -> float:
|
|
38
|
+
"""Compute sleep duration for a 429 response.
|
|
39
|
+
|
|
40
|
+
Respects Retry-After header (seconds) if present, else uses backoff.
|
|
41
|
+
"""
|
|
42
|
+
if retry_after_header:
|
|
43
|
+
try:
|
|
44
|
+
return min(cap, float(retry_after_header))
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
pass
|
|
47
|
+
return compute_backoff(attempt, cap=cap)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_with_retry(
|
|
51
|
+
fn: Callable[[], T],
|
|
52
|
+
max_attempts: int = 5,
|
|
53
|
+
retryable_statuses: tuple = (429, 500, 502, 504),
|
|
54
|
+
on_retry: Optional[Callable[[int, Exception], None]] = None,
|
|
55
|
+
) -> T:
|
|
56
|
+
"""Execute ``fn`` with retry on transient errors.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
fn: Callable that returns T or raises PaxError. Should be idempotent
|
|
60
|
+
(send ``client_order_id`` for order creates to guarantee this).
|
|
61
|
+
max_attempts: Total attempts including the first (default 5).
|
|
62
|
+
retryable_statuses: HTTP statuses that trigger retry.
|
|
63
|
+
on_retry: Optional hook called before each retry with (attempt, exception).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Result of successful ``fn`` invocation.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
The last exception if all attempts fail.
|
|
70
|
+
"""
|
|
71
|
+
from pax_api.errors import PaxError, PaxRateLimitError, PaxReadOnlyModeError
|
|
72
|
+
|
|
73
|
+
last_exc: Optional[Exception] = None
|
|
74
|
+
for attempt in range(max_attempts):
|
|
75
|
+
try:
|
|
76
|
+
return fn()
|
|
77
|
+
except PaxReadOnlyModeError:
|
|
78
|
+
# Never retry — ops team decision, will not self-heal
|
|
79
|
+
raise
|
|
80
|
+
except PaxRateLimitError as e:
|
|
81
|
+
last_exc = e
|
|
82
|
+
if attempt >= max_attempts - 1:
|
|
83
|
+
raise
|
|
84
|
+
wait = e.retry_after if e.retry_after is not None else compute_backoff(attempt)
|
|
85
|
+
if on_retry:
|
|
86
|
+
on_retry(attempt + 1, e)
|
|
87
|
+
time.sleep(wait)
|
|
88
|
+
except PaxError as e:
|
|
89
|
+
last_exc = e
|
|
90
|
+
if e.http_status not in retryable_statuses or attempt >= max_attempts - 1:
|
|
91
|
+
raise
|
|
92
|
+
wait = compute_backoff(attempt)
|
|
93
|
+
if on_retry:
|
|
94
|
+
on_retry(attempt + 1, e)
|
|
95
|
+
time.sleep(wait)
|
|
96
|
+
assert last_exc is not None
|
|
97
|
+
raise last_exc
|
pax_api/ws_client.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""WebSocket client for the PAX real-time API.
|
|
2
|
+
|
|
3
|
+
Wraps ``websocket-client`` with:
|
|
4
|
+
- Auto-reconnect + exponential backoff
|
|
5
|
+
- SUBSCRIBE / UNSUBSCRIBE / AUTH / LOCALE helpers
|
|
6
|
+
- Per-type event dispatch via callbacks
|
|
7
|
+
- Heartbeat handled by underlying lib
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> ws = PaxWSClient(api_key="sk_live_...")
|
|
11
|
+
>>> ws.on("fast_tick", lambda evt: print("tick:", evt))
|
|
12
|
+
>>> ws.subscribe(["fast_tick", "trade_executed"])
|
|
13
|
+
>>> ws.run_forever()
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import websocket # type: ignore
|
|
26
|
+
except ImportError as e:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"PaxWSClient requires the 'websocket-client' package. Install with: pip install pax-api[ws] "
|
|
29
|
+
"or pip install websocket-client"
|
|
30
|
+
) from e
|
|
31
|
+
|
|
32
|
+
# Same WSS endpoint for every env — sandbox is a tier on the key, not a host.
|
|
33
|
+
_ENV_URLS = {
|
|
34
|
+
"production": "wss://predictasiax.com/ws",
|
|
35
|
+
"prod": "wss://predictasiax.com/ws",
|
|
36
|
+
"live": "wss://predictasiax.com/ws",
|
|
37
|
+
"sandbox": "wss://predictasiax.com/ws",
|
|
38
|
+
"test": "wss://predictasiax.com/ws",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_log = logging.getLogger("pax_api.ws")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PaxWSClient:
|
|
45
|
+
"""PAX WebSocket client with auto-reconnect + typed event dispatch.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
api_key: Optional sk_live_ key for AUTH after connect (unlocks
|
|
49
|
+
private channels: account, deposit, security_alert, etc.).
|
|
50
|
+
env: "production" (default) or "sandbox". Overridden by ``url``.
|
|
51
|
+
url: Full ``wss://...`` URL (overrides env).
|
|
52
|
+
locale: BCP-47 tag for i18n rendering (e.g. "zh-CN"). Auto-sent post-connect.
|
|
53
|
+
reconnect: Auto-reconnect on close/error.
|
|
54
|
+
backoff_min: Initial reconnect wait (seconds).
|
|
55
|
+
backoff_max: Max reconnect wait (seconds).
|
|
56
|
+
subscribe_on_connect: List of channels to auto-subscribe on connect + reconnect.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
api_key: Optional[str] = None,
|
|
62
|
+
env: str = "production",
|
|
63
|
+
url: Optional[str] = None,
|
|
64
|
+
locale: Optional[str] = None,
|
|
65
|
+
reconnect: bool = True,
|
|
66
|
+
backoff_min: float = 1.0,
|
|
67
|
+
backoff_max: float = 60.0,
|
|
68
|
+
subscribe_on_connect: Optional[List[str]] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.api_key = api_key
|
|
71
|
+
self.locale = locale
|
|
72
|
+
self.url = url or _ENV_URLS.get(env.lower()) or _ENV_URLS["production"]
|
|
73
|
+
self.reconnect = reconnect
|
|
74
|
+
self.backoff_min = backoff_min
|
|
75
|
+
self.backoff_max = backoff_max
|
|
76
|
+
self._subscribe_on_connect: List[str] = list(subscribe_on_connect or [])
|
|
77
|
+
self._handlers: Dict[str, List[Callable[[Dict[str, Any]], None]]] = {}
|
|
78
|
+
self._catch_all: List[Callable[[Dict[str, Any]], None]] = []
|
|
79
|
+
self._ws: Optional["websocket.WebSocketApp"] = None
|
|
80
|
+
self._thread: Optional[threading.Thread] = None
|
|
81
|
+
self._stop = threading.Event()
|
|
82
|
+
self._backoff_current = backoff_min
|
|
83
|
+
|
|
84
|
+
# ── event handlers ──────────────────────────────────────────────────────
|
|
85
|
+
def on(self, event_type: str, handler: Callable[[Dict[str, Any]], None]) -> None:
|
|
86
|
+
"""Register a handler for a specific event type (e.g. "fast_tick")."""
|
|
87
|
+
self._handlers.setdefault(event_type, []).append(handler)
|
|
88
|
+
|
|
89
|
+
def on_any(self, handler: Callable[[Dict[str, Any]], None]) -> None:
|
|
90
|
+
"""Register a catch-all handler for every incoming event."""
|
|
91
|
+
self._catch_all.append(handler)
|
|
92
|
+
|
|
93
|
+
# ── client → server commands ────────────────────────────────────────────
|
|
94
|
+
def subscribe(self, channels: Iterable[str]) -> None:
|
|
95
|
+
"""Batch subscribe (10 msgs/sec client throttle — batch when possible)."""
|
|
96
|
+
self._send({"method": "SUBSCRIBE", "params": list(channels)})
|
|
97
|
+
|
|
98
|
+
def unsubscribe(self, channels: Iterable[str]) -> None:
|
|
99
|
+
self._send({"method": "UNSUBSCRIBE", "params": list(channels)})
|
|
100
|
+
|
|
101
|
+
def auth(self, token: Optional[str] = None) -> None:
|
|
102
|
+
"""Send AUTH command. Uses ``token`` if given, else self.api_key."""
|
|
103
|
+
tok = token or self.api_key
|
|
104
|
+
if not tok:
|
|
105
|
+
raise ValueError("No auth token provided (pass token or set api_key on client)")
|
|
106
|
+
self._send({"method": "AUTH", "token": tok})
|
|
107
|
+
|
|
108
|
+
def set_locale(self, locale: str) -> None:
|
|
109
|
+
self.locale = locale
|
|
110
|
+
self._send({"method": "LOCALE", "locale": locale})
|
|
111
|
+
|
|
112
|
+
# ── lifecycle ───────────────────────────────────────────────────────────
|
|
113
|
+
def run_forever(self, ping_interval: float = 25.0, ping_timeout: float = 10.0) -> None:
|
|
114
|
+
"""Blocking event loop with auto-reconnect. Call ``close()`` to stop."""
|
|
115
|
+
self._stop.clear()
|
|
116
|
+
while not self._stop.is_set():
|
|
117
|
+
try:
|
|
118
|
+
self._connect_and_run(ping_interval=ping_interval, ping_timeout=ping_timeout)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
_log.warning("WS loop error: %s", e)
|
|
121
|
+
if not self.reconnect or self._stop.is_set():
|
|
122
|
+
break
|
|
123
|
+
wait = min(self.backoff_max, self._backoff_current)
|
|
124
|
+
_log.info("Reconnecting in %.1fs", wait)
|
|
125
|
+
time.sleep(wait)
|
|
126
|
+
self._backoff_current = min(self.backoff_max, self._backoff_current * 2)
|
|
127
|
+
|
|
128
|
+
def start(self, ping_interval: float = 25.0, ping_timeout: float = 10.0) -> None:
|
|
129
|
+
"""Start the WS in a background thread."""
|
|
130
|
+
if self._thread and self._thread.is_alive():
|
|
131
|
+
return
|
|
132
|
+
self._thread = threading.Thread(
|
|
133
|
+
target=self.run_forever,
|
|
134
|
+
kwargs={"ping_interval": ping_interval, "ping_timeout": ping_timeout},
|
|
135
|
+
daemon=True,
|
|
136
|
+
)
|
|
137
|
+
self._thread.start()
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
"""Stop reconnecting and close the connection."""
|
|
141
|
+
self._stop.set()
|
|
142
|
+
if self._ws is not None:
|
|
143
|
+
try:
|
|
144
|
+
self._ws.close()
|
|
145
|
+
except Exception: # noqa: BLE001
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# ── internals ───────────────────────────────────────────────────────────
|
|
149
|
+
def _connect_and_run(self, ping_interval: float, ping_timeout: float) -> None:
|
|
150
|
+
def on_open(ws: "websocket.WebSocketApp") -> None:
|
|
151
|
+
self._backoff_current = self.backoff_min
|
|
152
|
+
_log.info("WS connected: %s", self.url)
|
|
153
|
+
if self.api_key:
|
|
154
|
+
self._send({"method": "AUTH", "token": self.api_key})
|
|
155
|
+
if self.locale:
|
|
156
|
+
self._send({"method": "LOCALE", "locale": self.locale})
|
|
157
|
+
if self._subscribe_on_connect:
|
|
158
|
+
self._send({"method": "SUBSCRIBE", "params": self._subscribe_on_connect})
|
|
159
|
+
|
|
160
|
+
def on_message(ws: "websocket.WebSocketApp", raw: str) -> None:
|
|
161
|
+
try:
|
|
162
|
+
evt = json.loads(raw)
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
_log.warning("WS non-JSON message: %s", raw[:120])
|
|
165
|
+
return
|
|
166
|
+
evt_type = evt.get("type") if isinstance(evt, dict) else None
|
|
167
|
+
for cb in self._catch_all:
|
|
168
|
+
try:
|
|
169
|
+
cb(evt)
|
|
170
|
+
except Exception as e: # noqa: BLE001
|
|
171
|
+
_log.exception("catch-all handler raised: %s", e)
|
|
172
|
+
if isinstance(evt_type, str):
|
|
173
|
+
for cb in self._handlers.get(evt_type, ()):
|
|
174
|
+
try:
|
|
175
|
+
cb(evt)
|
|
176
|
+
except Exception as e: # noqa: BLE001
|
|
177
|
+
_log.exception("handler for %s raised: %s", evt_type, e)
|
|
178
|
+
|
|
179
|
+
def on_error(ws: "websocket.WebSocketApp", err: Any) -> None:
|
|
180
|
+
_log.warning("WS error: %s", err)
|
|
181
|
+
|
|
182
|
+
def on_close(ws: "websocket.WebSocketApp", code: Any, reason: Any) -> None:
|
|
183
|
+
_log.info("WS closed code=%s reason=%s", code, reason)
|
|
184
|
+
|
|
185
|
+
self._ws = websocket.WebSocketApp(
|
|
186
|
+
self.url, on_open=on_open, on_message=on_message,
|
|
187
|
+
on_error=on_error, on_close=on_close,
|
|
188
|
+
)
|
|
189
|
+
# Blocks until closed
|
|
190
|
+
self._ws.run_forever(ping_interval=ping_interval, ping_timeout=ping_timeout)
|
|
191
|
+
|
|
192
|
+
def _send(self, obj: Dict[str, Any]) -> None:
|
|
193
|
+
if not self._ws or not getattr(self._ws, "sock", None):
|
|
194
|
+
raise RuntimeError("WS not connected yet — call after on_open or via subscribe_on_connect")
|
|
195
|
+
self._ws.send(json.dumps(obj, separators=(",", ":")))
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pax-api
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Official Python SDK for the PredictAsiaX Trader Track API — Web3-native prediction markets
|
|
5
|
+
Author-email: PredictAsiaX <support@predictasiax.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://predictasiax.com/developer
|
|
8
|
+
Project-URL: Documentation, https://docs.predictasiax.com
|
|
9
|
+
Project-URL: Repository, https://github.com/predictasiax/pax-python-sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/predictasiax/pax-python-sdk/issues
|
|
11
|
+
Project-URL: Changelog, https://docs.predictasiax.com/changelog
|
|
12
|
+
Keywords: predictasiax,pax,prediction-market,trading-api,web3,hmac,polymarket-compatible
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: requests>=2.28
|
|
30
|
+
Requires-Dist: websocket-client>=1.5
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=4; extra == "dev"
|
|
34
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1; extra == "dev"
|
|
37
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
38
|
+
Requires-Dist: twine>=4; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# pax-api — Official Python SDK for PredictAsiaX
|
|
42
|
+
|
|
43
|
+
Web3-native prediction market REST + WebSocket API client. Polymarket-compatible HMAC signing pattern.
|
|
44
|
+
|
|
45
|
+
**Version 1.0.0** · MIT license · Python 3.8+
|
|
46
|
+
|
|
47
|
+
- **Docs**: https://docs.predictasiax.com
|
|
48
|
+
- **Developer landing**: https://predictasiax.com/developer
|
|
49
|
+
- **Get API key**: https://predictasiax.com/settings/api-keys
|
|
50
|
+
- **Support**: support@predictasiax.com
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Direct install from PredictAsiaX-hosted wheel
|
|
56
|
+
pip install https://docs.predictasiax.com/downloads/sdk/python/pax_api-1.0.0-py3-none-any.whl
|
|
57
|
+
|
|
58
|
+
# Or download tarball + install locally
|
|
59
|
+
curl -O https://docs.predictasiax.com/downloads/sdk/python/pax_api-1.0.0.tar.gz
|
|
60
|
+
pip install pax_api-1.0.0.tar.gz
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
(PyPI publish coming soon — install methods above work today.)
|
|
64
|
+
|
|
65
|
+
## Quickstart (sandbox — no real money)
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pax_api import PaxClient
|
|
69
|
+
|
|
70
|
+
with PaxClient(api_key="sk_test_YOUR_KEY", env="sandbox") as pax:
|
|
71
|
+
faucet = pax.faucet() # get 10k test USDT
|
|
72
|
+
print(faucet["data"]["balance_free"]) # → "10000.000000"
|
|
73
|
+
|
|
74
|
+
templates = pax.list_templates()
|
|
75
|
+
markets = pax.list_markets(category="crypto", limit=10)
|
|
76
|
+
|
|
77
|
+
market = pax.create_market(
|
|
78
|
+
template_id="crypto_price_binary_60s",
|
|
79
|
+
params={"asset": "BTC"},
|
|
80
|
+
)
|
|
81
|
+
print(market["data"]["market"]["market_id"]) # → "m_..."
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## HMAC-signed requests (production)
|
|
85
|
+
|
|
86
|
+
Machine-to-machine trading bots should use HMAC signing (Polymarket-compatible 5-header pattern).
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from pax_api import PaxClient
|
|
90
|
+
|
|
91
|
+
pax = PaxClient(
|
|
92
|
+
api_key="sk_live_YOUR_KEY",
|
|
93
|
+
secret="<64-hex secret>",
|
|
94
|
+
passphrase="<passphrase>",
|
|
95
|
+
env="production",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
pax.place_order(
|
|
99
|
+
market_id="m_...",
|
|
100
|
+
outcome_id="yes",
|
|
101
|
+
side="buy",
|
|
102
|
+
order_type="limit",
|
|
103
|
+
size="100",
|
|
104
|
+
price="0.55",
|
|
105
|
+
client_order_id="unique-per-intent-id", # retry-safe within 24h
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## WebSocket streams
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from pax_api import PaxWSClient
|
|
113
|
+
|
|
114
|
+
ws = PaxWSClient(
|
|
115
|
+
api_key="sk_test_...",
|
|
116
|
+
env="sandbox",
|
|
117
|
+
subscribe_on_connect=["fast_tick", "trade_executed", "account"],
|
|
118
|
+
)
|
|
119
|
+
ws.on("fast_tick", lambda e: print("tick:", e))
|
|
120
|
+
ws.on("trade_executed", lambda e: print("trade:", e))
|
|
121
|
+
ws.on("account", lambda e: print("balance:", e.get("balance_free")))
|
|
122
|
+
ws.run_forever() # blocks; Ctrl+C to exit
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Auto-reconnect + exponential backoff built-in. All 4 client methods supported:
|
|
126
|
+
`subscribe`, `unsubscribe`, `auth`, `set_locale`.
|
|
127
|
+
|
|
128
|
+
## Error handling
|
|
129
|
+
|
|
130
|
+
Every response error becomes a typed exception. Catch the specific type
|
|
131
|
+
you want to handle:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from pax_api import (
|
|
135
|
+
PaxClient,
|
|
136
|
+
PaxRateLimitError,
|
|
137
|
+
PaxReadOnlyModeError,
|
|
138
|
+
PaxValidationError,
|
|
139
|
+
PaxWrongEnvKeyError,
|
|
140
|
+
PaxError, # base class — catch-all
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
pax.place_order(...)
|
|
145
|
+
except PaxRateLimitError as e:
|
|
146
|
+
time.sleep(e.retry_after or 5)
|
|
147
|
+
# then retry
|
|
148
|
+
except PaxValidationError as e:
|
|
149
|
+
print(f"Bad request: {e.details}") # {'field': 'size', ...}
|
|
150
|
+
except PaxReadOnlyModeError:
|
|
151
|
+
print("Trading paused by ops")
|
|
152
|
+
except PaxWrongEnvKeyError:
|
|
153
|
+
print("Wrong environment key")
|
|
154
|
+
except PaxError as e:
|
|
155
|
+
print(f"[{e.code}] {e.message} (request_id={e.request_id})")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Automatic retry
|
|
159
|
+
|
|
160
|
+
Built-in exponential backoff on `429`, `500`, `502`, `504` responses. `Retry-After`
|
|
161
|
+
header respected on rate limits. Non-idempotent creates are safe when you send
|
|
162
|
+
`client_order_id`.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
pax = PaxClient(api_key="sk_test_...", env="sandbox", max_retries=5)
|
|
166
|
+
# max_retries=0 disables retries entirely
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Environments
|
|
170
|
+
|
|
171
|
+
| Env | Base URL | Keys |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `production` | `https://api.predictasiax.com/v1` | `sk_live_*` |
|
|
174
|
+
| `sandbox` | `https://api.predictasiax.com/v1` | `sk_test_*` |
|
|
175
|
+
|
|
176
|
+
`sk_test_*` on production returns `401 WRONG_ENV_KEY`. Same the other way. See
|
|
177
|
+
[docs auth guide](https://docs.predictasiax.com/auth#env-separation).
|
|
178
|
+
|
|
179
|
+
## Custom base URL
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
pax = PaxClient(api_key="...", base_url="https://your-mirror/api")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# Get source (tarball)
|
|
189
|
+
curl -O https://docs.predictasiax.com/downloads/sdk/python/pax_api-1.0.0.tar.gz
|
|
190
|
+
tar -xzf pax_api-1.0.0.tar.gz && cd pax_api-1.0.0
|
|
191
|
+
pip install -e ".[dev]"
|
|
192
|
+
pytest # run all tests
|
|
193
|
+
ruff check src tests # lint
|
|
194
|
+
mypy src # type-check
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Links
|
|
198
|
+
|
|
199
|
+
- [OpenAPI spec](https://docs.predictasiax.com/openapi)
|
|
200
|
+
- [AsyncAPI (WebSocket) spec](https://docs.predictasiax.com/asyncapi)
|
|
201
|
+
- [Auth guide](https://docs.predictasiax.com/auth)
|
|
202
|
+
- [Error codes](https://docs.predictasiax.com/errors)
|
|
203
|
+
- [Rate limits](https://docs.predictasiax.com/rate-limits)
|
|
204
|
+
- [FAQ](https://docs.predictasiax.com/faq)
|
|
205
|
+
- [API Terms](https://docs.predictasiax.com/api-terms)
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pax_api/__init__.py,sha256=vf_ZTe-aY4nrsGzGMxHlSkZ5lvOcdiRoqCQzUlT4bc4,1760
|
|
2
|
+
pax_api/auth.py,sha256=AjpPOmPxykndoY12lZLg99VRpeUwzdVbwSLs-_Gswrg,2200
|
|
3
|
+
pax_api/client.py,sha256=pXGVFXhKvd115u3txWy8-Bh8XevGIn844WFpoJjN90Q,16647
|
|
4
|
+
pax_api/errors.py,sha256=wtgnXiA1DO389f0vMuLb_1aioda5qviEYjdL6JLGHL0,5374
|
|
5
|
+
pax_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pax_api/retry.py,sha256=6GCSKsuV0I754QmeeWdP0Dq9aWRHUMF1MWvf161X60w,3294
|
|
7
|
+
pax_api/ws_client.py,sha256=P9Zd7EIZtKo_Ilt-lgg0JgsCASczHBsLF_s5N4vTiP0,8694
|
|
8
|
+
pax_api-2.0.0.dist-info/licenses/LICENSE,sha256=2xepe-7T7Wrs8WMzu3tfbiB5lZPqNzvKHcdFK4qkfgE,1069
|
|
9
|
+
pax_api-2.0.0.dist-info/METADATA,sha256=19KBL6qTk2URBvv7Vhl9_Fnl73Az27GkdLzzZ3PxYV8,6679
|
|
10
|
+
pax_api-2.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
pax_api-2.0.0.dist-info/top_level.txt,sha256=ubONv5nsTA8lcuaqqMXovax0Q10q0RCshzUE7CzUgKY,8
|
|
12
|
+
pax_api-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PredictAsiaX
|
|
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 @@
|
|
|
1
|
+
pax_api
|