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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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