nexode-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nexode/__init__.py +67 -0
- nexode/_resolver.py +57 -0
- nexode/_transport.py +36 -0
- nexode/auth.py +71 -0
- nexode/client.py +77 -0
- nexode/enums.py +43 -0
- nexode/errors.py +65 -0
- nexode/models/__init__.py +27 -0
- nexode/models/_base.py +5 -0
- nexode/models/activity.py +48 -0
- nexode/models/api_key.py +15 -0
- nexode/models/market.py +25 -0
- nexode/models/nexode.py +140 -0
- nexode/models/order.py +24 -0
- nexode/models/portfolio.py +27 -0
- nexode/models/token.py +15 -0
- nexode/models/trade.py +31 -0
- nexode/resources/__init__.py +21 -0
- nexode/resources/_base.py +6 -0
- nexode/resources/activity.py +30 -0
- nexode/resources/api_keys.py +18 -0
- nexode/resources/events.py +61 -0
- nexode/resources/markets.py +65 -0
- nexode/resources/orders.py +73 -0
- nexode/resources/portfolio.py +66 -0
- nexode/resources/tokens.py +12 -0
- nexode/resources/trades.py +28 -0
- nexode_sdk-0.1.0.dist-info/METADATA +96 -0
- nexode_sdk-0.1.0.dist-info/RECORD +30 -0
- nexode_sdk-0.1.0.dist-info/WHEEL +4 -0
nexode/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from .client import Nexode
|
|
2
|
+
from .enums import EventSort, MarketStatus, OrderSide, OrderStatus, Outcome, TimeInForce
|
|
3
|
+
from .errors import (
|
|
4
|
+
AuthError,
|
|
5
|
+
NexodeError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
ServerError,
|
|
9
|
+
UnprocessableError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
ApiKey,
|
|
14
|
+
BookSnapshot,
|
|
15
|
+
Burn,
|
|
16
|
+
Event,
|
|
17
|
+
Instrument,
|
|
18
|
+
Market,
|
|
19
|
+
MarketEvent,
|
|
20
|
+
MarketTrade,
|
|
21
|
+
Mint,
|
|
22
|
+
Order,
|
|
23
|
+
PortfolioSnapshot,
|
|
24
|
+
PriceLevel,
|
|
25
|
+
Redeem,
|
|
26
|
+
Ticker,
|
|
27
|
+
Token,
|
|
28
|
+
Trade,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Nexode",
|
|
33
|
+
# enums
|
|
34
|
+
"Outcome",
|
|
35
|
+
"OrderSide",
|
|
36
|
+
"OrderStatus",
|
|
37
|
+
"MarketStatus",
|
|
38
|
+
"EventSort",
|
|
39
|
+
"TimeInForce",
|
|
40
|
+
# models
|
|
41
|
+
"Market",
|
|
42
|
+
"Event",
|
|
43
|
+
"MarketEvent",
|
|
44
|
+
"Instrument",
|
|
45
|
+
"Order",
|
|
46
|
+
"Trade",
|
|
47
|
+
"MarketTrade",
|
|
48
|
+
"Ticker",
|
|
49
|
+
"PriceLevel",
|
|
50
|
+
"BookSnapshot",
|
|
51
|
+
"Token",
|
|
52
|
+
"Mint",
|
|
53
|
+
"Redeem",
|
|
54
|
+
"Burn",
|
|
55
|
+
"PortfolioSnapshot",
|
|
56
|
+
"ApiKey",
|
|
57
|
+
# errors
|
|
58
|
+
"NexodeError",
|
|
59
|
+
"AuthError",
|
|
60
|
+
"ValidationError",
|
|
61
|
+
"UnprocessableError",
|
|
62
|
+
"NotFoundError",
|
|
63
|
+
"RateLimitError",
|
|
64
|
+
"ServerError",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
__version__ = "0.1.0"
|
nexode/_resolver.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Iterable, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from ._transport import Transport
|
|
6
|
+
from .enums import Outcome
|
|
7
|
+
|
|
8
|
+
_OUTCOME_KEYS = (
|
|
9
|
+
(Outcome.YES, "atomix_yes_market_id"),
|
|
10
|
+
(Outcome.NO, "atomix_no_market_id"),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Resolver:
|
|
15
|
+
"""Translates between a (market, outcome) and its order book, both ways.
|
|
16
|
+
|
|
17
|
+
The platform runs a separate order book per outcome; the SDK hides that by
|
|
18
|
+
routing on (market, outcome). Lookups are cached, so a market resolves once.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, nexode: Transport) -> None:
|
|
22
|
+
self._n = nexode
|
|
23
|
+
self._to_book: Dict[Tuple[str, Outcome], str] = {}
|
|
24
|
+
self._from_book: Dict[str, Tuple[str, Outcome]] = {}
|
|
25
|
+
|
|
26
|
+
def order_book_id(self, market_id: str, outcome) -> str:
|
|
27
|
+
outcome = Outcome(outcome)
|
|
28
|
+
if (market_id, outcome) not in self._to_book:
|
|
29
|
+
self._load_market(market_id)
|
|
30
|
+
try:
|
|
31
|
+
return self._to_book[(market_id, outcome)]
|
|
32
|
+
except KeyError:
|
|
33
|
+
raise ValueError(f"market {market_id!r} has no {outcome.value} order book")
|
|
34
|
+
|
|
35
|
+
def resolve(self, order_book_ids: Iterable[str]) -> Dict[str, Tuple[str, Outcome]]:
|
|
36
|
+
ids = list(order_book_ids)
|
|
37
|
+
unknown = sorted({i for i in ids if i not in self._from_book})
|
|
38
|
+
if unknown:
|
|
39
|
+
body = self._n.get(
|
|
40
|
+
"/v0/markets/by-atomix-id", params={"ids": ",".join(unknown)}
|
|
41
|
+
)
|
|
42
|
+
for m in body["mappings"]:
|
|
43
|
+
self._index(m["atomix_market_id"], m["market"]["id"], Outcome(m["outcome"]))
|
|
44
|
+
return {i: self._from_book[i] for i in ids if i in self._from_book}
|
|
45
|
+
|
|
46
|
+
def market_outcome(self, order_book_id: str) -> Optional[Tuple[str, Outcome]]:
|
|
47
|
+
return self.resolve([order_book_id]).get(order_book_id)
|
|
48
|
+
|
|
49
|
+
def _load_market(self, market_id: str) -> None:
|
|
50
|
+
metadata = self._n.get(f"/v0/markets/{market_id}")["market"].get("metadata") or {}
|
|
51
|
+
for outcome, key in _OUTCOME_KEYS:
|
|
52
|
+
if metadata.get(key):
|
|
53
|
+
self._index(metadata[key], market_id, outcome)
|
|
54
|
+
|
|
55
|
+
def _index(self, order_book_id: str, market_id: str, outcome: Outcome) -> None:
|
|
56
|
+
self._to_book[(market_id, outcome)] = order_book_id
|
|
57
|
+
self._from_book[order_book_id] = (market_id, outcome)
|
nexode/_transport.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .errors import error_from_response
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Transport:
|
|
11
|
+
"""Issues requests against a fixed base URL and returns the flattened body.
|
|
12
|
+
|
|
13
|
+
Builds absolute URLs (base + path) rather than relying on httpx base_url
|
|
14
|
+
merging, so a base that includes a path prefix (e.g. ``.../atomix``) is
|
|
15
|
+
preserved even though request paths start with ``/``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: httpx.Client, base_url: str) -> None:
|
|
19
|
+
self._client = client
|
|
20
|
+
self._base = base_url.rstrip("/")
|
|
21
|
+
|
|
22
|
+
def request(self, method: str, path: str, **kwargs: Any) -> dict:
|
|
23
|
+
response = self._client.request(method, self._base + path, **kwargs)
|
|
24
|
+
body = response.json() if response.content else {}
|
|
25
|
+
if response.status_code >= 400:
|
|
26
|
+
raise error_from_response(response.status_code, body)
|
|
27
|
+
return body
|
|
28
|
+
|
|
29
|
+
def get(self, path: str, **kwargs: Any) -> dict:
|
|
30
|
+
return self.request("GET", path, **kwargs)
|
|
31
|
+
|
|
32
|
+
def post(self, path: str, **kwargs: Any) -> dict:
|
|
33
|
+
return self.request("POST", path, **kwargs)
|
|
34
|
+
|
|
35
|
+
def delete(self, path: str, **kwargs: Any) -> dict:
|
|
36
|
+
return self.request("DELETE", path, **kwargs)
|
nexode/auth.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Generator
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .errors import AuthError
|
|
9
|
+
|
|
10
|
+
# Re-exchange this many seconds before the token's stated expiry.
|
|
11
|
+
_EXPIRY_SKEW_SECONDS = 30
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiKeyAuth(httpx.Auth):
|
|
15
|
+
"""Exchanges a long-lived API key for short-lived access tokens, caches
|
|
16
|
+
them, and re-exchanges transparently on expiry or 401."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, api_key: str, *, exchange_url: str) -> None:
|
|
19
|
+
self._api_key = api_key
|
|
20
|
+
self._exchange_url = exchange_url
|
|
21
|
+
self._token: str | None = None
|
|
22
|
+
self._expires_at: float = 0.0
|
|
23
|
+
|
|
24
|
+
def sync_auth_flow(
|
|
25
|
+
self, request: httpx.Request
|
|
26
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
27
|
+
if not self._fresh():
|
|
28
|
+
response = yield self._exchange()
|
|
29
|
+
response.read()
|
|
30
|
+
self._store(response)
|
|
31
|
+
self._apply(request)
|
|
32
|
+
response = yield request
|
|
33
|
+
if response.status_code == 401:
|
|
34
|
+
response = yield self._exchange()
|
|
35
|
+
response.read()
|
|
36
|
+
self._store(response)
|
|
37
|
+
self._apply(request)
|
|
38
|
+
yield request
|
|
39
|
+
|
|
40
|
+
async def async_auth_flow(
|
|
41
|
+
self, request: httpx.Request
|
|
42
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
43
|
+
if not self._fresh():
|
|
44
|
+
response = yield self._exchange()
|
|
45
|
+
await response.aread()
|
|
46
|
+
self._store(response)
|
|
47
|
+
self._apply(request)
|
|
48
|
+
response = yield request
|
|
49
|
+
if response.status_code == 401:
|
|
50
|
+
response = yield self._exchange()
|
|
51
|
+
await response.aread()
|
|
52
|
+
self._store(response)
|
|
53
|
+
self._apply(request)
|
|
54
|
+
yield request
|
|
55
|
+
|
|
56
|
+
def _fresh(self) -> bool:
|
|
57
|
+
return self._token is not None and time.time() < self._expires_at - _EXPIRY_SKEW_SECONDS
|
|
58
|
+
|
|
59
|
+
def _exchange(self) -> httpx.Request:
|
|
60
|
+
# Absolute URL so the exchange always targets the auth endpoint.
|
|
61
|
+
return httpx.Request("POST", self._exchange_url, json={"api_key": self._api_key})
|
|
62
|
+
|
|
63
|
+
def _apply(self, request: httpx.Request) -> None:
|
|
64
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
65
|
+
|
|
66
|
+
def _store(self, response: httpx.Response) -> None:
|
|
67
|
+
if response.status_code != 200:
|
|
68
|
+
raise AuthError("API key exchange failed.", status=response.status_code)
|
|
69
|
+
data = response.json()
|
|
70
|
+
self._token = data["access_token"]
|
|
71
|
+
self._expires_at = float(data["access_expires_at"])
|
nexode/client.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._resolver import Resolver
|
|
9
|
+
from ._transport import Transport
|
|
10
|
+
from .auth import ApiKeyAuth
|
|
11
|
+
from .resources import (
|
|
12
|
+
ApiKeys,
|
|
13
|
+
Burns,
|
|
14
|
+
Events,
|
|
15
|
+
Markets,
|
|
16
|
+
Mints,
|
|
17
|
+
Orders,
|
|
18
|
+
Portfolio,
|
|
19
|
+
Redeems,
|
|
20
|
+
Tokens,
|
|
21
|
+
Trades,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_API_BASE = "https://api.nexode.io"
|
|
25
|
+
DEFAULT_ATOMIX_BASE = "https://api.nexode.io/atomix"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Nexode:
|
|
29
|
+
"""Synchronous Nexode client.
|
|
30
|
+
|
|
31
|
+
A single API key authenticates the whole platform — prediction markets,
|
|
32
|
+
the order book, and your account activity.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
api_key: A ``nxd_...`` key. Falls back to ``$NEXODE_API_KEY``.
|
|
36
|
+
api_base: Override the Nexode API base URL.
|
|
37
|
+
atomix_base: Override the order-book (Atomix) base URL.
|
|
38
|
+
timeout: Per-request timeout in seconds.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
api_key: Optional[str] = None,
|
|
44
|
+
*,
|
|
45
|
+
api_base: str = DEFAULT_API_BASE,
|
|
46
|
+
atomix_base: str = DEFAULT_ATOMIX_BASE,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
api_key = api_key or os.environ.get("NEXODE_API_KEY")
|
|
50
|
+
if not api_key:
|
|
51
|
+
raise ValueError("api_key is required — pass api_key=... or set NEXODE_API_KEY.")
|
|
52
|
+
|
|
53
|
+
auth = ApiKeyAuth(api_key, exchange_url=f"{api_base.rstrip('/')}/v0/api-keys/exchange")
|
|
54
|
+
self._http = httpx.Client(auth=auth, timeout=timeout)
|
|
55
|
+
nexode = Transport(self._http, api_base)
|
|
56
|
+
atomix = Transport(self._http, atomix_base)
|
|
57
|
+
resolver = Resolver(nexode)
|
|
58
|
+
|
|
59
|
+
self.markets = Markets(nexode, atomix, resolver)
|
|
60
|
+
self.events = Events(nexode)
|
|
61
|
+
self.orders = Orders(atomix, resolver)
|
|
62
|
+
self.trades = Trades(atomix, resolver)
|
|
63
|
+
self.tokens = Tokens(atomix)
|
|
64
|
+
self.mints = Mints(nexode)
|
|
65
|
+
self.redeems = Redeems(nexode)
|
|
66
|
+
self.burns = Burns(nexode)
|
|
67
|
+
self.portfolio = Portfolio(atomix=atomix, nexode=nexode, resolver=resolver)
|
|
68
|
+
self.api_keys = ApiKeys(nexode)
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._http.close()
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> "Nexode":
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, *_exc: object) -> None:
|
|
77
|
+
self.close()
|
nexode/enums.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Outcome(str, Enum):
|
|
5
|
+
YES = "yes"
|
|
6
|
+
NO = "no"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrderSide(str, Enum):
|
|
10
|
+
BUY = "buy"
|
|
11
|
+
SELL = "sell"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OrderStatus(str, Enum):
|
|
15
|
+
OPEN = "open"
|
|
16
|
+
PARTIALLY_FILLED = "partially_filled"
|
|
17
|
+
FILLED = "filled"
|
|
18
|
+
CANCELLED = "cancelled"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MarketStatus(str, Enum):
|
|
22
|
+
ACTIVE = "active"
|
|
23
|
+
RESOLVED = "resolved"
|
|
24
|
+
VOIDED = "voided"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EventSort(str, Enum):
|
|
28
|
+
"""Sort order for the events list. Every mode keeps active events ahead
|
|
29
|
+
of overdue and settled ones; the mode picks the tiebreaker."""
|
|
30
|
+
|
|
31
|
+
ENDING = "ending"
|
|
32
|
+
TRENDING = "trending"
|
|
33
|
+
NEW = "new"
|
|
34
|
+
MARKETS = "markets"
|
|
35
|
+
NAME = "name"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TimeInForce(str, Enum):
|
|
39
|
+
GTC = "gtc"
|
|
40
|
+
IOC = "ioc"
|
|
41
|
+
FOK = "fok"
|
|
42
|
+
GTD = "gtd"
|
|
43
|
+
DAY = "day"
|
nexode/errors.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional, Type
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NexodeError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status: Optional[int] = None,
|
|
12
|
+
trace_id: Optional[str] = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.status = status
|
|
17
|
+
self.trace_id = trace_id
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthError(NexodeError):
|
|
21
|
+
"""Invalid or expired credentials (401/403)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationError(NexodeError):
|
|
25
|
+
"""The request was malformed (400)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnprocessableError(NexodeError):
|
|
29
|
+
"""A well-formed request rejected by a business rule — insufficient funds,
|
|
30
|
+
missing trade delegation, market halted, etc. (422)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotFoundError(NexodeError):
|
|
34
|
+
"""The resource does not exist (404)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RateLimitError(NexodeError):
|
|
38
|
+
"""Too many requests (429)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ServerError(NexodeError):
|
|
42
|
+
"""The server failed to handle the request (5xx)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def error_from_response(status: int, body: Mapping[str, Any]) -> NexodeError:
|
|
46
|
+
match status:
|
|
47
|
+
case 400:
|
|
48
|
+
cls: Type[NexodeError] = ValidationError
|
|
49
|
+
case 401 | 403:
|
|
50
|
+
cls = AuthError
|
|
51
|
+
case 404:
|
|
52
|
+
cls = NotFoundError
|
|
53
|
+
case 422:
|
|
54
|
+
cls = UnprocessableError
|
|
55
|
+
case 429:
|
|
56
|
+
cls = RateLimitError
|
|
57
|
+
case s if s >= 500:
|
|
58
|
+
cls = ServerError
|
|
59
|
+
case _:
|
|
60
|
+
cls = NexodeError
|
|
61
|
+
return cls(
|
|
62
|
+
body.get("message") or f"HTTP {status}",
|
|
63
|
+
status=status,
|
|
64
|
+
trace_id=body.get("trace_id"),
|
|
65
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .activity import Burn, Mint, Redeem
|
|
2
|
+
from .api_key import ApiKey
|
|
3
|
+
from .market import BookSnapshot, PriceLevel, Ticker
|
|
4
|
+
from .nexode import Event, Instrument, Market, MarketEvent
|
|
5
|
+
from .order import Order
|
|
6
|
+
from .portfolio import PortfolioSnapshot
|
|
7
|
+
from .token import Token
|
|
8
|
+
from .trade import MarketTrade, Trade
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Market",
|
|
12
|
+
"Event",
|
|
13
|
+
"MarketEvent",
|
|
14
|
+
"Instrument",
|
|
15
|
+
"Order",
|
|
16
|
+
"Trade",
|
|
17
|
+
"MarketTrade",
|
|
18
|
+
"Ticker",
|
|
19
|
+
"PriceLevel",
|
|
20
|
+
"BookSnapshot",
|
|
21
|
+
"Token",
|
|
22
|
+
"Mint",
|
|
23
|
+
"Redeem",
|
|
24
|
+
"Burn",
|
|
25
|
+
"PortfolioSnapshot",
|
|
26
|
+
"ApiKey",
|
|
27
|
+
]
|
nexode/models/_base.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._base import Model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Mint(Model):
|
|
11
|
+
"""Collateral paid in to create a YES+NO pair."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
market_id: str
|
|
15
|
+
party_id: str
|
|
16
|
+
amount: Decimal
|
|
17
|
+
created_at: datetime
|
|
18
|
+
transaction_id: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Redeem(Model):
|
|
22
|
+
"""Payout claimed on a resolved market."""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
market_id: str
|
|
26
|
+
party_id: str
|
|
27
|
+
status: str
|
|
28
|
+
amount: Optional[Decimal] = None
|
|
29
|
+
reason: Optional[str] = None
|
|
30
|
+
created_at: datetime
|
|
31
|
+
started_at: Optional[datetime] = None
|
|
32
|
+
completed_at: Optional[datetime] = None
|
|
33
|
+
transaction_id: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Burn(Model):
|
|
37
|
+
"""A YES+NO pair burned back to collateral."""
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
market_id: str
|
|
41
|
+
party_id: str
|
|
42
|
+
status: str
|
|
43
|
+
amount: Decimal
|
|
44
|
+
reason: Optional[str] = None
|
|
45
|
+
created_at: datetime
|
|
46
|
+
started_at: Optional[datetime] = None
|
|
47
|
+
completed_at: Optional[datetime] = None
|
|
48
|
+
transaction_id: Optional[str] = None
|
nexode/models/api_key.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ._base import Model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiKey(Model):
|
|
10
|
+
"""Masked metadata for an API key — the plaintext is never re-shown."""
|
|
11
|
+
|
|
12
|
+
id: str
|
|
13
|
+
last_four: str
|
|
14
|
+
name: Optional[str] = None
|
|
15
|
+
created_at: datetime
|
nexode/models/market.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from ._base import Model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Ticker(Model):
|
|
11
|
+
best_bid: Optional[Decimal] = None
|
|
12
|
+
best_ask: Optional[Decimal] = None
|
|
13
|
+
last_trade_price: Optional[Decimal] = None
|
|
14
|
+
captured_at: datetime
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PriceLevel(Model):
|
|
18
|
+
price: Decimal
|
|
19
|
+
quantity: Decimal
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BookSnapshot(Model):
|
|
23
|
+
bids: List[PriceLevel]
|
|
24
|
+
asks: List[PriceLevel]
|
|
25
|
+
captured_at: datetime
|
nexode/models/nexode.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from pydantic import model_validator
|
|
8
|
+
|
|
9
|
+
from ..enums import MarketStatus, Outcome
|
|
10
|
+
from ._base import Model
|
|
11
|
+
|
|
12
|
+
_DEFAULT_LABELS = {Outcome.YES: "Yes", Outcome.NO: "No"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Instrument(Model):
|
|
16
|
+
id: str
|
|
17
|
+
admin_id: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MarketEvent(Model):
|
|
21
|
+
id: str
|
|
22
|
+
slug: str
|
|
23
|
+
title: str
|
|
24
|
+
image_url: Optional[str] = None
|
|
25
|
+
market_count: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Market(Model):
|
|
29
|
+
"""A prediction market (the question), with both outcomes' order books."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
slug: str
|
|
33
|
+
name: str
|
|
34
|
+
status: str
|
|
35
|
+
category: Optional[str] = None
|
|
36
|
+
title: Optional[str] = None
|
|
37
|
+
yes_label: Optional[str] = None
|
|
38
|
+
no_label: Optional[str] = None
|
|
39
|
+
collateral_price: Decimal
|
|
40
|
+
redeem_fee_rate: Decimal
|
|
41
|
+
collateral_instrument: Instrument
|
|
42
|
+
yes_instrument: Instrument
|
|
43
|
+
no_instrument: Instrument
|
|
44
|
+
yes_order_book_id: Optional[str] = None
|
|
45
|
+
no_order_book_id: Optional[str] = None
|
|
46
|
+
liquidity: Optional[Decimal] = None
|
|
47
|
+
resolved_outcome: Optional[Outcome] = None
|
|
48
|
+
resolved_at: Optional[datetime] = None
|
|
49
|
+
voided_at: Optional[datetime] = None
|
|
50
|
+
event: Optional[MarketEvent] = None
|
|
51
|
+
|
|
52
|
+
@model_validator(mode="before")
|
|
53
|
+
@classmethod
|
|
54
|
+
def _lift_metadata(cls, data: Any) -> Any:
|
|
55
|
+
if isinstance(data, dict):
|
|
56
|
+
meta = data.get("metadata") or {}
|
|
57
|
+
data = {
|
|
58
|
+
**data,
|
|
59
|
+
"category": meta.get("category"),
|
|
60
|
+
"title": meta.get("title"),
|
|
61
|
+
"yes_label": meta.get("yes_label"),
|
|
62
|
+
"no_label": meta.get("no_label"),
|
|
63
|
+
"yes_order_book_id": meta.get("atomix_yes_market_id"),
|
|
64
|
+
"no_order_book_id": meta.get("atomix_no_market_id"),
|
|
65
|
+
}
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_active(self) -> bool:
|
|
70
|
+
return self.status == MarketStatus.ACTIVE
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_resolved(self) -> bool:
|
|
74
|
+
return self.status == MarketStatus.RESOLVED
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_voided(self) -> bool:
|
|
78
|
+
return self.status == MarketStatus.VOIDED
|
|
79
|
+
|
|
80
|
+
def outcome_label(self, outcome: Union[Outcome, str]) -> str:
|
|
81
|
+
"""Display label for an outcome — e.g. ``"Lakers"``/``"Celtics"`` on a
|
|
82
|
+
game market. Falls back to ``"Yes"``/``"No"`` when unset."""
|
|
83
|
+
outcome = Outcome(outcome)
|
|
84
|
+
raw = self.yes_label if outcome is Outcome.YES else self.no_label
|
|
85
|
+
return (raw or "").strip() or _DEFAULT_LABELS[outcome]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def labels(self) -> Dict[Outcome, str]:
|
|
89
|
+
"""Both outcomes' display labels, keyed by :class:`Outcome`."""
|
|
90
|
+
return {o: self.outcome_label(o) for o in Outcome}
|
|
91
|
+
|
|
92
|
+
def outcome_for_label(self, label: str) -> Outcome:
|
|
93
|
+
"""Map a display label (or a literal ``"yes"``/``"no"``) back to its
|
|
94
|
+
outcome side. Matching is case-insensitive."""
|
|
95
|
+
needle = label.strip().casefold()
|
|
96
|
+
for outcome in Outcome:
|
|
97
|
+
if needle in (self.outcome_label(outcome).casefold(), outcome.value):
|
|
98
|
+
return outcome
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"label {label!r} does not match either outcome of market {self.slug!r} "
|
|
101
|
+
f"({self.outcome_label(Outcome.YES)!r} / {self.outcome_label(Outcome.NO)!r})"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def winning_outcome(self) -> Optional[Outcome]:
|
|
106
|
+
"""The resolved outcome, or ``None`` while unresolved/voided."""
|
|
107
|
+
return self.resolved_outcome
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def winning_label(self) -> Optional[str]:
|
|
111
|
+
"""Display label of the winning outcome, or ``None`` while unresolved."""
|
|
112
|
+
return None if self.resolved_outcome is None else self.outcome_label(self.resolved_outcome)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Event(Model):
|
|
116
|
+
id: str
|
|
117
|
+
slug: str
|
|
118
|
+
admin_party: str
|
|
119
|
+
title: Optional[str] = None
|
|
120
|
+
category: Optional[str] = None
|
|
121
|
+
description: Optional[str] = None
|
|
122
|
+
image_url: Optional[str] = None
|
|
123
|
+
created_at: Optional[datetime] = None
|
|
124
|
+
market_ids: List[str]
|
|
125
|
+
markets: List[Market]
|
|
126
|
+
|
|
127
|
+
@model_validator(mode="before")
|
|
128
|
+
@classmethod
|
|
129
|
+
def _lift_metadata(cls, data: Any) -> Any:
|
|
130
|
+
if isinstance(data, dict):
|
|
131
|
+
meta = data.get("metadata") or {}
|
|
132
|
+
data = {
|
|
133
|
+
**data,
|
|
134
|
+
"title": meta.get("title"),
|
|
135
|
+
"category": meta.get("category"),
|
|
136
|
+
"description": meta.get("description"),
|
|
137
|
+
"image_url": meta.get("image_url"),
|
|
138
|
+
"created_at": meta.get("created_at"),
|
|
139
|
+
}
|
|
140
|
+
return data
|
nexode/models/order.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..enums import Outcome, OrderSide, OrderStatus
|
|
8
|
+
from ._base import Model
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Order(Model):
|
|
12
|
+
id: str
|
|
13
|
+
market_id: str
|
|
14
|
+
outcome: Outcome
|
|
15
|
+
party_id: str
|
|
16
|
+
side: OrderSide
|
|
17
|
+
status: OrderStatus
|
|
18
|
+
quantity: Decimal
|
|
19
|
+
filled_quantity: Decimal
|
|
20
|
+
remaining_quantity: Decimal
|
|
21
|
+
price: Optional[Decimal] = None
|
|
22
|
+
cancel_reason: Optional[str] = None
|
|
23
|
+
created_at: datetime
|
|
24
|
+
updated_at: datetime
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
from .activity import Burn, Mint, Redeem
|
|
6
|
+
from .market import Ticker
|
|
7
|
+
from .nexode import Market
|
|
8
|
+
from .order import Order
|
|
9
|
+
from .trade import Trade
|
|
10
|
+
from ._base import Model
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PortfolioSnapshot(Model):
|
|
14
|
+
"""Everything needed to compute PnL — raw data, no math.
|
|
15
|
+
|
|
16
|
+
A position is reconstructed from `mints` (+ buys − sells) and `trades`; use
|
|
17
|
+
`markets` for cost-basis/resolution and `tickers` to mark open positions.
|
|
18
|
+
`markets` is keyed by market id; `tickers` by ``"{market_id}:{outcome}"``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
trades: List[Trade]
|
|
22
|
+
open_orders: List[Order]
|
|
23
|
+
mints: List[Mint]
|
|
24
|
+
redeems: List[Redeem]
|
|
25
|
+
burns: List[Burn]
|
|
26
|
+
markets: Dict[str, Market]
|
|
27
|
+
tickers: Dict[str, Ticker]
|
nexode/models/token.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ._base import Model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Token(Model):
|
|
10
|
+
instrument_id: str
|
|
11
|
+
instrument_admin_id: str
|
|
12
|
+
symbol: str
|
|
13
|
+
name: str
|
|
14
|
+
decimals: int
|
|
15
|
+
total_supply: Optional[Decimal] = None
|
nexode/models/trade.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
|
|
6
|
+
from ..enums import Outcome, OrderSide
|
|
7
|
+
from ._base import Model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Trade(Model):
|
|
11
|
+
id: str
|
|
12
|
+
market_id: str
|
|
13
|
+
outcome: Outcome
|
|
14
|
+
price: Decimal
|
|
15
|
+
quantity: Decimal
|
|
16
|
+
taker_side: OrderSide
|
|
17
|
+
maker_order_id: str
|
|
18
|
+
taker_order_id: str
|
|
19
|
+
maker_party_id: str
|
|
20
|
+
taker_party_id: str
|
|
21
|
+
maker_fee: Decimal
|
|
22
|
+
taker_fee: Decimal
|
|
23
|
+
executed_at: datetime
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MarketTrade(Model):
|
|
27
|
+
trade_id: str
|
|
28
|
+
price: Decimal
|
|
29
|
+
quantity: Decimal
|
|
30
|
+
taker_side: OrderSide
|
|
31
|
+
executed_at: datetime
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .activity import Burns, Mints, Redeems
|
|
2
|
+
from .api_keys import ApiKeys
|
|
3
|
+
from .events import Events
|
|
4
|
+
from .markets import Markets
|
|
5
|
+
from .orders import Orders
|
|
6
|
+
from .portfolio import Portfolio
|
|
7
|
+
from .tokens import Tokens
|
|
8
|
+
from .trades import Trades
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ApiKeys",
|
|
12
|
+
"Markets",
|
|
13
|
+
"Events",
|
|
14
|
+
"Orders",
|
|
15
|
+
"Trades",
|
|
16
|
+
"Tokens",
|
|
17
|
+
"Mints",
|
|
18
|
+
"Redeems",
|
|
19
|
+
"Burns",
|
|
20
|
+
"Portfolio",
|
|
21
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from ..models import Burn, Mint, Redeem
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Mints(Resource):
|
|
10
|
+
def list(self) -> List[Mint]:
|
|
11
|
+
body = self._t.get("/v0/mints")
|
|
12
|
+
return [Mint.model_validate(m) for m in body["mints"]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Redeems(Resource):
|
|
16
|
+
def list(self) -> List[Redeem]:
|
|
17
|
+
body = self._t.get("/v0/redeems")
|
|
18
|
+
return [Redeem.model_validate(r) for r in body["redeems"]]
|
|
19
|
+
|
|
20
|
+
def get(self, redeem_id: str) -> Redeem:
|
|
21
|
+
return Redeem.model_validate(self._t.get(f"/v0/redeems/{redeem_id}"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Burns(Resource):
|
|
25
|
+
def list(self) -> List[Burn]:
|
|
26
|
+
body = self._t.get("/v0/burns")
|
|
27
|
+
return [Burn.model_validate(b) for b in body["burns"]]
|
|
28
|
+
|
|
29
|
+
def get(self, burn_id: str) -> Burn:
|
|
30
|
+
return Burn.model_validate(self._t.get(f"/v0/burns/{burn_id}"))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from ..models import ApiKey
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiKeys(Resource):
|
|
10
|
+
"""Inspect and revoke the API keys on your account. New keys are created
|
|
11
|
+
in the web app (Settings → Developer), never through the SDK."""
|
|
12
|
+
|
|
13
|
+
def list(self) -> List[ApiKey]:
|
|
14
|
+
body = self._t.get("/v0/api-keys/me")
|
|
15
|
+
return [ApiKey.model_validate(k) for k in body["api_keys"]]
|
|
16
|
+
|
|
17
|
+
def revoke(self, api_key_id: str) -> None:
|
|
18
|
+
self._t.delete(f"/v0/api-keys/me/{api_key_id}")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Sequence, Union
|
|
4
|
+
|
|
5
|
+
from ..enums import EventSort
|
|
6
|
+
from ..models import Event
|
|
7
|
+
from ._base import Resource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Events(Resource):
|
|
11
|
+
def list(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
q: Optional[str] = None,
|
|
15
|
+
category: Optional[str] = None,
|
|
16
|
+
sort: Optional[Union[EventSort, str]] = None,
|
|
17
|
+
market_ids: Optional[Sequence[str]] = None,
|
|
18
|
+
hide_stale_settled: Optional[bool] = None,
|
|
19
|
+
limit: Optional[int] = None,
|
|
20
|
+
offset: Optional[int] = None,
|
|
21
|
+
) -> List[Event]:
|
|
22
|
+
"""List events, filtered and sorted server-side.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
q: Full-text search over event titles.
|
|
26
|
+
category: Only events in this category.
|
|
27
|
+
sort: ``ending``/``trending``/``new``/``markets``/``name``;
|
|
28
|
+
defaults to ``new``. Active events always sort ahead of
|
|
29
|
+
overdue and settled ones.
|
|
30
|
+
market_ids: Only events containing at least one of these markets.
|
|
31
|
+
hide_stale_settled: Drop fully-settled events whose dispute
|
|
32
|
+
windows have elapsed.
|
|
33
|
+
limit: Maximum events to return (server default 100, max 1000).
|
|
34
|
+
offset: Number of sorted events to skip before the returned
|
|
35
|
+
window — pair with ``limit`` to page through the feed.
|
|
36
|
+
"""
|
|
37
|
+
params: dict = {}
|
|
38
|
+
if q is not None:
|
|
39
|
+
params["q"] = q
|
|
40
|
+
if category is not None:
|
|
41
|
+
params["category"] = category
|
|
42
|
+
if sort is not None:
|
|
43
|
+
params["sort"] = EventSort(sort).value
|
|
44
|
+
if market_ids:
|
|
45
|
+
params["market_ids"] = ",".join(market_ids)
|
|
46
|
+
if hide_stale_settled is not None:
|
|
47
|
+
params["hide_stale_settled"] = hide_stale_settled
|
|
48
|
+
if limit is not None:
|
|
49
|
+
params["limit"] = limit
|
|
50
|
+
if offset is not None:
|
|
51
|
+
params["offset"] = offset
|
|
52
|
+
body = self._t.get("/v0/events", params=params)
|
|
53
|
+
return [Event.model_validate(e) for e in body["events"]]
|
|
54
|
+
|
|
55
|
+
def get(self, event_id: str) -> Event:
|
|
56
|
+
body = self._t.get(f"/v0/events/{event_id}")
|
|
57
|
+
return Event.model_validate(body["event"])
|
|
58
|
+
|
|
59
|
+
def by_slug(self, slug: str) -> Event:
|
|
60
|
+
body = self._t.get(f"/v0/events/by-slug/{slug}")
|
|
61
|
+
return Event.model_validate(body["event"])
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from .._resolver import Resolver
|
|
6
|
+
from .._transport import Transport
|
|
7
|
+
from ..enums import MarketStatus, Outcome
|
|
8
|
+
from ..models import BookSnapshot, Market, MarketTrade, Ticker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Markets:
|
|
12
|
+
"""Prediction markets: browse them, and read live data per outcome."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, nexode: Transport, atomix: Transport, resolver: Resolver) -> None:
|
|
15
|
+
self._n = nexode
|
|
16
|
+
self._a = atomix
|
|
17
|
+
self._r = resolver
|
|
18
|
+
|
|
19
|
+
def list(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
category: Optional[str] = None,
|
|
23
|
+
status: Optional[Union[MarketStatus, str]] = None,
|
|
24
|
+
q: Optional[str] = None,
|
|
25
|
+
limit: Optional[int] = None,
|
|
26
|
+
) -> List[Market]:
|
|
27
|
+
"""List prediction markets, filtered server-side.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
category: Only markets in this category.
|
|
31
|
+
status: Only markets in this lifecycle state
|
|
32
|
+
(``active``/``resolved``/``voided``).
|
|
33
|
+
q: Full-text search over market questions.
|
|
34
|
+
limit: Maximum markets to return (max 1000).
|
|
35
|
+
"""
|
|
36
|
+
params: dict = {}
|
|
37
|
+
if category is not None:
|
|
38
|
+
params["category"] = category
|
|
39
|
+
if status is not None:
|
|
40
|
+
params["status"] = MarketStatus(status).value
|
|
41
|
+
if q is not None:
|
|
42
|
+
params["q"] = q
|
|
43
|
+
if limit is not None:
|
|
44
|
+
params["limit"] = limit
|
|
45
|
+
body = self._n.get("/v0/markets", params=params)
|
|
46
|
+
return [Market.model_validate(m) for m in body["markets"]]
|
|
47
|
+
|
|
48
|
+
def get(self, market_id: str) -> Market:
|
|
49
|
+
return Market.model_validate(self._n.get(f"/v0/markets/{market_id}")["market"])
|
|
50
|
+
|
|
51
|
+
def by_slug(self, slug: str) -> Market:
|
|
52
|
+
return Market.model_validate(self._n.get(f"/v0/markets/by-slug/{slug}")["market"])
|
|
53
|
+
|
|
54
|
+
def ticker(self, market_id: str, outcome: Union[Outcome, str]) -> Ticker:
|
|
55
|
+
book = self._r.order_book_id(market_id, outcome)
|
|
56
|
+
return Ticker.model_validate(self._a.get(f"/v0/markets/{book}/ticker")["ticker"])
|
|
57
|
+
|
|
58
|
+
def book(self, market_id: str, outcome: Union[Outcome, str]) -> BookSnapshot:
|
|
59
|
+
book = self._r.order_book_id(market_id, outcome)
|
|
60
|
+
return BookSnapshot.model_validate(self._a.get(f"/v0/markets/{book}/book")["snapshot"])
|
|
61
|
+
|
|
62
|
+
def recent_trades(self, market_id: str, outcome: Union[Outcome, str]) -> List[MarketTrade]:
|
|
63
|
+
book = self._r.order_book_id(market_id, outcome)
|
|
64
|
+
body = self._a.get(f"/v0/markets/{book}/trades")
|
|
65
|
+
return [MarketTrade.model_validate(t) for t in body["trades"]]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import List, Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
from .._resolver import Resolver
|
|
8
|
+
from .._transport import Transport
|
|
9
|
+
from ..enums import Outcome, OrderSide, TimeInForce
|
|
10
|
+
from ..models import Order
|
|
11
|
+
|
|
12
|
+
Number = Union[str, int, float, Decimal]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Orders:
|
|
16
|
+
def __init__(self, atomix: Transport, resolver: Resolver) -> None:
|
|
17
|
+
self._a = atomix
|
|
18
|
+
self._r = resolver
|
|
19
|
+
|
|
20
|
+
def create(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
market_id: str,
|
|
24
|
+
outcome: Union[Outcome, str],
|
|
25
|
+
side: Union[OrderSide, str],
|
|
26
|
+
price: Number,
|
|
27
|
+
quantity: Number,
|
|
28
|
+
time_in_force: Union[TimeInForce, str] = TimeInForce.GTC,
|
|
29
|
+
expires_at: Optional[datetime] = None,
|
|
30
|
+
) -> Order:
|
|
31
|
+
book = self._r.order_book_id(market_id, outcome)
|
|
32
|
+
body = self._a.post(
|
|
33
|
+
"/v0/orders",
|
|
34
|
+
json={
|
|
35
|
+
"market_id": book,
|
|
36
|
+
"side": OrderSide(side).value,
|
|
37
|
+
"quantity": str(quantity),
|
|
38
|
+
"order_type": {
|
|
39
|
+
"type": "limit",
|
|
40
|
+
"price": str(price),
|
|
41
|
+
"time_in_force": _time_in_force(time_in_force, expires_at),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
return self._order(body["order"])
|
|
46
|
+
|
|
47
|
+
def list(self) -> List[Order]:
|
|
48
|
+
raw = self._a.get("/v0/orders")["orders"]
|
|
49
|
+
resolved = self._r.resolve(r["market_id"] for r in raw)
|
|
50
|
+
return [_build(r, resolved.get(r["market_id"])) for r in raw]
|
|
51
|
+
|
|
52
|
+
def get(self, order_id: str) -> Order:
|
|
53
|
+
return self._order(self._a.get(f"/v0/orders/{order_id}")["order"])
|
|
54
|
+
|
|
55
|
+
def cancel(self, order_id: str) -> None:
|
|
56
|
+
self._a.post(f"/v0/orders/{order_id}/cancel")
|
|
57
|
+
|
|
58
|
+
def _order(self, raw: dict) -> Order:
|
|
59
|
+
return _build(raw, self._r.market_outcome(raw["market_id"]))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build(raw: dict, mapped: Optional[Tuple[str, Outcome]]) -> Order:
|
|
63
|
+
market_id, outcome = mapped if mapped else (raw["market_id"], None)
|
|
64
|
+
return Order.model_validate({**raw, "market_id": market_id, "outcome": outcome})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _time_in_force(value: Union[TimeInForce, str], expires_at: Optional[datetime]) -> dict:
|
|
68
|
+
tif = TimeInForce(value)
|
|
69
|
+
if tif is TimeInForce.GTD:
|
|
70
|
+
if expires_at is None:
|
|
71
|
+
raise ValueError("time_in_force=gtd requires expires_at")
|
|
72
|
+
return {"type": "gtd", "expires_at": expires_at.isoformat()}
|
|
73
|
+
return {"type": tif.value}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .._resolver import Resolver
|
|
4
|
+
from .._transport import Transport
|
|
5
|
+
from ..enums import OrderStatus
|
|
6
|
+
from ..errors import NotFoundError
|
|
7
|
+
from ..models import PortfolioSnapshot
|
|
8
|
+
from .activity import Burns, Mints, Redeems
|
|
9
|
+
from .markets import Markets
|
|
10
|
+
from .orders import Orders
|
|
11
|
+
from .trades import Trades
|
|
12
|
+
|
|
13
|
+
_OPEN = (OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Portfolio:
|
|
17
|
+
"""Fetches everything needed to compute PnL in one call."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *, atomix: Transport, nexode: Transport, resolver: Resolver) -> None:
|
|
20
|
+
self._markets = Markets(nexode, atomix, resolver)
|
|
21
|
+
self._orders = Orders(atomix, resolver)
|
|
22
|
+
self._trades = Trades(atomix, resolver)
|
|
23
|
+
self._mints = Mints(nexode)
|
|
24
|
+
self._redeems = Redeems(nexode)
|
|
25
|
+
self._burns = Burns(nexode)
|
|
26
|
+
|
|
27
|
+
def snapshot(self) -> PortfolioSnapshot:
|
|
28
|
+
trades = self._trades.list()
|
|
29
|
+
open_orders = [o for o in self._orders.list() if o.status in _OPEN]
|
|
30
|
+
mints = self._mints.list()
|
|
31
|
+
redeems = self._redeems.list()
|
|
32
|
+
burns = self._burns.list()
|
|
33
|
+
|
|
34
|
+
market_ids = sorted(
|
|
35
|
+
{t.market_id for t in trades}
|
|
36
|
+
| {o.market_id for o in open_orders}
|
|
37
|
+
| {m.market_id for m in mints}
|
|
38
|
+
| {r.market_id for r in redeems}
|
|
39
|
+
)
|
|
40
|
+
markets = {}
|
|
41
|
+
for market_id in market_ids:
|
|
42
|
+
try:
|
|
43
|
+
markets[market_id] = self._markets.get(market_id)
|
|
44
|
+
except NotFoundError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
# Tickers exist only for markets that are currently open.
|
|
48
|
+
tickers = {}
|
|
49
|
+
for trade in trades:
|
|
50
|
+
key = f"{trade.market_id}:{trade.outcome.value}"
|
|
51
|
+
if key in tickers:
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
tickers[key] = self._markets.ticker(trade.market_id, trade.outcome)
|
|
55
|
+
except NotFoundError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return PortfolioSnapshot(
|
|
59
|
+
trades=trades,
|
|
60
|
+
open_orders=open_orders,
|
|
61
|
+
mints=mints,
|
|
62
|
+
redeems=redeems,
|
|
63
|
+
burns=burns,
|
|
64
|
+
markets=markets,
|
|
65
|
+
tickers=tickers,
|
|
66
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
from ..models import Token
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Tokens(Resource):
|
|
10
|
+
def list(self) -> List[Token]:
|
|
11
|
+
body = self._t.get("/v0/tokens")
|
|
12
|
+
return [Token.model_validate(t) for t in body["tokens"]]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from .._resolver import Resolver
|
|
6
|
+
from .._transport import Transport
|
|
7
|
+
from ..enums import Outcome
|
|
8
|
+
from ..models import Trade
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Trades:
|
|
12
|
+
def __init__(self, atomix: Transport, resolver: Resolver) -> None:
|
|
13
|
+
self._a = atomix
|
|
14
|
+
self._r = resolver
|
|
15
|
+
|
|
16
|
+
def list(self) -> List[Trade]:
|
|
17
|
+
raw = self._a.get("/v0/trades")["trades"]
|
|
18
|
+
resolved = self._r.resolve(r["market_id"] for r in raw)
|
|
19
|
+
return [_build(r, resolved.get(r["market_id"])) for r in raw]
|
|
20
|
+
|
|
21
|
+
def get(self, trade_id: str) -> Trade:
|
|
22
|
+
raw = self._a.get(f"/v0/trades/{trade_id}")["trade"]
|
|
23
|
+
return _build(raw, self._r.market_outcome(raw["market_id"]))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build(raw: dict, mapped: Optional[Tuple[str, Outcome]]) -> Trade:
|
|
27
|
+
market_id, outcome = mapped if mapped else (raw["market_id"], None)
|
|
28
|
+
return Trade.model_validate({**raw, "market_id": market_id, "outcome": outcome})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nexode-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Nexode prediction-market API
|
|
5
|
+
Project-URL: Homepage, https://nexode.io
|
|
6
|
+
Project-URL: Documentation, https://docs.nexode.io
|
|
7
|
+
Project-URL: Source, https://github.com/k2flabs/nexode
|
|
8
|
+
Author: Nexode
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: nexode,prediction-markets,sdk,trading
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: pydantic>=2.7
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# nexode-sdk
|
|
20
|
+
|
|
21
|
+
Official Python SDK for the [Nexode](https://nexode.io) prediction-market API.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install nexode-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from nexode import Nexode, Outcome, OrderSide
|
|
29
|
+
|
|
30
|
+
nx = Nexode(api_key="nxd_...") # or set NEXODE_API_KEY
|
|
31
|
+
|
|
32
|
+
market = nx.markets.list()[0]
|
|
33
|
+
print(nx.markets.ticker(market.id, Outcome.YES).best_bid)
|
|
34
|
+
|
|
35
|
+
order = nx.orders.create(
|
|
36
|
+
market_id=market.id, outcome=Outcome.YES,
|
|
37
|
+
side=OrderSide.BUY, price="0.55", quantity=100,
|
|
38
|
+
)
|
|
39
|
+
nx.orders.cancel(order.id)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Create an API key in **Settings → Developer**. The SDK exchanges it for
|
|
43
|
+
short-lived access tokens and refreshes them automatically.
|
|
44
|
+
|
|
45
|
+
You work in terms of a **market** and an **outcome** (`yes`/`no`); the SDK
|
|
46
|
+
handles the routing. `nx.markets` and `nx.events` browse markets,
|
|
47
|
+
`nx.orders`/`nx.trades` trade and report fills, and `nx.mints`/`nx.redeems`/
|
|
48
|
+
`nx.burns` cover account activity.
|
|
49
|
+
|
|
50
|
+
Browsing is filtered and sorted server-side:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from nexode import EventSort
|
|
54
|
+
|
|
55
|
+
events = nx.events.list(
|
|
56
|
+
category="sports",
|
|
57
|
+
sort=EventSort.TRENDING, # ending / trending / new / markets / name
|
|
58
|
+
hide_stale_settled=True,
|
|
59
|
+
limit=20,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
event = nx.events.by_slug("nba-finals") # markets too: nx.markets.by_slug(...)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Outcomes carry display labels — on a game market `yes`/`no` might read
|
|
66
|
+
"Lakers"/"Celtics". The SDK maps both ways:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
market.labels # {Outcome.YES: "Lakers", Outcome.NO: "Celtics"}
|
|
70
|
+
market.outcome_label(Outcome.YES) # "Lakers" (falls back to "Yes"/"No")
|
|
71
|
+
market.outcome_for_label("Celtics") # Outcome.NO — trade by team name
|
|
72
|
+
market.winning_label # label of the resolved outcome, or None
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`nx.api_keys` lists and revokes keys on your account; create them in
|
|
76
|
+
**Settings → Developer**.
|
|
77
|
+
|
|
78
|
+
`nx.portfolio.snapshot()` returns the raw inputs for PnL in one call — trades,
|
|
79
|
+
open orders, mints, redeems, burns, the markets involved, and current tickers:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
s = nx.portfolio.snapshot()
|
|
83
|
+
# s.trades[i].market_id / .outcome / .price / .quantity
|
|
84
|
+
# s.markets[market_id].collateral_price # cost basis / resolution
|
|
85
|
+
# s.tickers[f"{market_id}:{outcome}"].best_bid # mark open positions
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
See the full reference at [docs.nexode.io](https://docs.nexode.io).
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
cd nexode-sdk
|
|
94
|
+
pip install -e '.[dev]'
|
|
95
|
+
pytest
|
|
96
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
nexode/__init__.py,sha256=j-0T0RNRhX23cNM2aeOP1Ux-NdS46FABHlz9484OByM,1111
|
|
2
|
+
nexode/_resolver.py,sha256=89l09yZbqIL_PMEea8wviF5_-17OfUupsuoZ0gi3Og8,2274
|
|
3
|
+
nexode/_transport.py,sha256=_X9L1fNOv1n1LrWsuFoH9Jb8n3D3QaAhPTPvN3mSLnU,1231
|
|
4
|
+
nexode/auth.py,sha256=nLRAjkJH0C8A9viVi1nsKQtgSlNz177xqLye5XooXsU,2450
|
|
5
|
+
nexode/client.py,sha256=oNNae4PQd8idUpvKZALS2sGgeVpWj4OAw1k7ZYKxoP4,2203
|
|
6
|
+
nexode/enums.py,sha256=YKW3HxJt_3zzGlZqHqPMtpeTJAzvwKwESpaFGCkY5Pg,781
|
|
7
|
+
nexode/errors.py,sha256=hRHJ6sdDxF-_GY9jleI2HkiHR6J260Ld_2LHRxGDgdM,1606
|
|
8
|
+
nexode/models/__init__.py,sha256=hKdJFojiUHOl93M523kY22NkhRpADf7aKK_TvCYVkZY,576
|
|
9
|
+
nexode/models/_base.py,sha256=9ZAttGlYJ2lMgBeAl9XIHPpx2xGuexbzTKfr_-Zf0DI,128
|
|
10
|
+
nexode/models/activity.py,sha256=anaQroT3ctos2p0Ltr7nc1GxrXiatyYTT0avB29YPnQ,1043
|
|
11
|
+
nexode/models/api_key.py,sha256=PSnxTOIBuS80AT1Ek_2fO-ERQ0aXWMmV81V690MSmuA,309
|
|
12
|
+
nexode/models/market.py,sha256=nWklrxT0-0BwF0CzZMV20oasjKs-PpcijCyBpdKUU6I,505
|
|
13
|
+
nexode/models/nexode.py,sha256=3dWnDMfVki5LVAoVU6uCbsaLWSx16fy0N2XUuZ6WFks,4550
|
|
14
|
+
nexode/models/order.py,sha256=eq5gPtghW4sw51kfYomTgLYWgf38P4njNydwvTD43GY,545
|
|
15
|
+
nexode/models/portfolio.py,sha256=ym_8obQLc16M5xuZ8BXEdGqmLv8MXOFDx6VGFJJQb-Q,768
|
|
16
|
+
nexode/models/token.py,sha256=GzjIgQ7Ek4gN1yCHG1_OlFdh34cLOnae6EDLHWuQ4qo,283
|
|
17
|
+
nexode/models/trade.py,sha256=cKoEmJSo5jxIcSsZtaL3KqLBJpk5NOnGVE7dJNiXMO4,607
|
|
18
|
+
nexode/resources/__init__.py,sha256=UhSxegO8PN4npROk5NiK-0OFhFBbPpv4FBaMJ-P-VTs,403
|
|
19
|
+
nexode/resources/_base.py,sha256=x1Q-XCzsuzA5ZhJG7mrRXac27Ems_IFpj0wOifVClT4,135
|
|
20
|
+
nexode/resources/activity.py,sha256=A56J-kM2af5HU5alIwS35h_XfnF_rbTEdSzQSVBM5JI,863
|
|
21
|
+
nexode/resources/api_keys.py,sha256=vSGd4w_qrQ91sQXyYlBPj1_GG9ScChTSpptDmxnn_x0,547
|
|
22
|
+
nexode/resources/events.py,sha256=s1DQ5ya6afaD-2ZUsmfMroMGscy783fcNLNEvV2C6r8,2322
|
|
23
|
+
nexode/resources/markets.py,sha256=CWnzt4_tta12yaGS0q6yE32IVT8_ceP1hFccU2-IsSk,2552
|
|
24
|
+
nexode/resources/orders.py,sha256=v_FqDPOOKtxIfYJ0L_oz8VaP19wP30QumBZm82wZV6A,2458
|
|
25
|
+
nexode/resources/portfolio.py,sha256=stI2iQB2aN7ZBqnjcQQzYPeju2_3QWGwooqprmjuOPM,2157
|
|
26
|
+
nexode/resources/tokens.py,sha256=nTXiqT1xTD9yZvdRarjSpTAcQIp2dGt9R0dIjwB89jI,283
|
|
27
|
+
nexode/resources/trades.py,sha256=7upx8TgKdcBdYMvR9GfwnLJfWK7mjAfEGFVJVQ-Ma1Y,964
|
|
28
|
+
nexode_sdk-0.1.0.dist-info/METADATA,sha256=eazWZBw1HBzOG1-N09FD20K7AkLQVShmN1Tq-o6zM9c,2901
|
|
29
|
+
nexode_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
30
|
+
nexode_sdk-0.1.0.dist-info/RECORD,,
|