revolut-x-python 1.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.
revolut_x/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """revolut-x-python — Python SDK for the Revolut X Crypto Exchange REST API.
2
+
3
+ Quick start::
4
+
5
+ from revolut_x import RevolutXClient, OrderSide, Interval
6
+
7
+ # Public market data (no API key required)
8
+ client = RevolutXClient()
9
+ ticker = client.get_ticker("BTC-EUR")
10
+ candles = client.get_candles("BTC-EUR", Interval.HOUR_1)
11
+
12
+ # Trading (requires API key + Ed25519 private key)
13
+ client = RevolutXClient(
14
+ api_key="your-api-key",
15
+ private_key_path="keys/private.pem",
16
+ )
17
+ order = client.place_market_order("BTC-EUR", OrderSide.BUY, quote_size="50.00")
18
+ balances = client.get_balances()
19
+
20
+ For full documentation, see https://github.com/janprikryl/revolut-x-python
21
+ """
22
+
23
+ from __future__ import annotations
24
+ from revolut_x._version import __version__
25
+ from revolut_x.client import RevolutXClient
26
+ from revolut_x.types import (
27
+ OrderSide,
28
+ OrderType,
29
+ Interval,
30
+ TimeInForce,
31
+ # TypedDicts
32
+ Balance,
33
+ Candle,
34
+ CurrencyConfig,
35
+ Fill,
36
+ OrderBook,
37
+ OrderBookLevel,
38
+ OrderDetail,
39
+ OrderResponse,
40
+ PairConfig,
41
+ Ticker,
42
+ Trade,
43
+ )
44
+ from revolut_x.helpers import (
45
+ FeeCalculator,
46
+ FeeEstimate,
47
+ OrderPayloadBuilder,
48
+ build_limit_order,
49
+ build_maker_order,
50
+ build_market_order,
51
+ build_order,
52
+ calculate_maker_price,
53
+ normalize_symbol,
54
+ )
55
+ from revolut_x.exceptions import (
56
+ ApiError,
57
+ AuthenticationError,
58
+ NetworkError,
59
+ OrderValidationError,
60
+ RateLimitError,
61
+ RevolutXError,
62
+ )
63
+
64
+ __all__ = [
65
+ # Metadata
66
+ "__version__",
67
+ # Client
68
+ "RevolutXClient",
69
+ # Enums
70
+ "OrderSide",
71
+ "OrderType",
72
+ "Interval",
73
+ "TimeInForce",
74
+ # Helpers
75
+ "OrderPayloadBuilder",
76
+ "FeeCalculator",
77
+ "FeeEstimate",
78
+ "normalize_symbol",
79
+ "calculate_maker_price",
80
+ "build_order",
81
+ "build_market_order",
82
+ "build_limit_order",
83
+ "build_maker_order",
84
+ # Type definitions
85
+ "Balance",
86
+ "Candle",
87
+ "CurrencyConfig",
88
+ "Fill",
89
+ "OrderBook",
90
+ "OrderBookLevel",
91
+ "OrderDetail",
92
+ "OrderResponse",
93
+ "PairConfig",
94
+ "Ticker",
95
+ "Trade",
96
+ # Exceptions
97
+ "RevolutXError",
98
+ "AuthenticationError",
99
+ "RateLimitError",
100
+ "ApiError",
101
+ "OrderValidationError",
102
+ "NetworkError",
103
+ ]
revolut_x/_auth.py ADDED
@@ -0,0 +1,139 @@
1
+ """Internal module for Ed25519 authentication for Revolut X."""
2
+
3
+ from __future__ import annotations
4
+ import base64
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import urlencode
10
+ from cryptography.hazmat.primitives import serialization
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
12
+ from revolut_x.exceptions import AuthenticationError
13
+
14
+
15
+ def load_private_key(source: str | Path | bytes) -> Ed25519PrivateKey:
16
+ """Load an Ed25519 private key from a file or raw PEM bytes.
17
+
18
+ Args:
19
+ source: The path to the PEM file (str or Path),
20
+ or the raw PEM data (bytes).
21
+
22
+ Returns:
23
+ Ed25519PrivateKey: The loaded Ed25519 private key.
24
+
25
+ Raises:
26
+ AuthenticationError: If the file is not found, or the key format is invalid.
27
+ """
28
+
29
+ if isinstance(source, bytes):
30
+ pem_data = source
31
+ else:
32
+ path = Path(source)
33
+ if not path.is_file():
34
+ raise AuthenticationError(f"Private key file not found at: {path}")
35
+ try:
36
+ pem_data = path.read_bytes()
37
+ except OSError as e:
38
+ raise AuthenticationError(f"Failed to read private key file at {path}: {e}")
39
+
40
+ try:
41
+ private_key = serialization.load_pem_private_key(pem_data, password=None)
42
+ if not isinstance(private_key, Ed25519PrivateKey):
43
+ raise AuthenticationError("The provided key is not an Ed25519 private key.")
44
+ return private_key
45
+ except ValueError as e:
46
+ raise AuthenticationError(f"Invalid private key format: {e}")
47
+ except Exception as e:
48
+ raise AuthenticationError(f"Failed to load private key: {e}")
49
+
50
+
51
+ def build_signature_message(
52
+ timestamp_ms: str,
53
+ method: str,
54
+ path: str,
55
+ params: dict[str, Any] | None = None,
56
+ body: Any | None = None
57
+ ) -> str:
58
+ """Build the exact signature message string for Revolut X.
59
+
60
+ The format is: {timestamp}{METHOD}{path}{query_string}{body}.
61
+
62
+ Args:
63
+ timestamp_ms: The current timestamp in milliseconds.
64
+ method: The HTTP method (e.g., 'GET', 'POST').
65
+ path: The request path, which must start with '/api'.
66
+ params: Query parameters to include.
67
+ body: The request body. Can be a dict/list (will be minified JSON)
68
+ or a raw string.
69
+
70
+ Returns:
71
+ str: The constructed message string to be signed.
72
+ """
73
+ method_upper = method.upper()
74
+
75
+ normalized_path = path if path.startswith("/api") else f"/api{path}"
76
+
77
+ query_str = ""
78
+ if params:
79
+ clean_params = [(k, str(v)) for k, v in sorted(params.items()) if v is not None]
80
+ query_str = urlencode(clean_params)
81
+
82
+ body_str = ""
83
+ if body is not None:
84
+ if isinstance(body, (dict, list)):
85
+ body_str = json.dumps(body, separators=(",", ":"))
86
+ elif isinstance(body, str):
87
+ body_str = body
88
+
89
+ return f"{timestamp_ms}{method_upper}{normalized_path}{query_str}{body_str}"
90
+
91
+
92
+ def sign_request(
93
+ api_key: str,
94
+ private_key: Ed25519PrivateKey,
95
+ method: str,
96
+ path: str,
97
+ params: dict[str, Any] | None = None,
98
+ body: Any | None = None,
99
+ timestamp_offset_ms: int = 0,
100
+ ) -> dict[str, str]:
101
+ """Sign a request and return the necessary authentication headers.
102
+
103
+ Args:
104
+ api_key: The Revolut X API key.
105
+ private_key: The loaded Ed25519 private key.
106
+ method: The HTTP method.
107
+ path: The request path.
108
+ params: The query parameters.
109
+ body: The request body.
110
+ timestamp_offset_ms: Optional offset in milliseconds to adjust local clock.
111
+
112
+ Returns:
113
+ A dictionary containing the authentication headers
114
+ (X-Revx-API-Key, X-Revx-Timestamp, X-Revx-Signature) and Content-Type
115
+ if a body is provided.
116
+ """
117
+ timestamp_ms = str(int(time.time() * 1000) + int(timestamp_offset_ms))
118
+
119
+ message = build_signature_message(
120
+ timestamp_ms=timestamp_ms,
121
+ method=method,
122
+ path=path,
123
+ params=params,
124
+ body=body
125
+ )
126
+
127
+ signature_bytes = private_key.sign(message.encode("utf-8"))
128
+ signature_b64 = base64.b64encode(signature_bytes).decode("utf-8")
129
+
130
+ headers = {
131
+ "X-Revx-API-Key": api_key,
132
+ "X-Revx-Timestamp": timestamp_ms,
133
+ "X-Revx-Signature": signature_b64
134
+ }
135
+
136
+ if body is not None:
137
+ headers["Content-Type"] = "application/json"
138
+
139
+ return headers
revolut_x/_http.py ADDED
@@ -0,0 +1,270 @@
1
+ """Internal HTTP client module for Revolut X REST API requests."""
2
+
3
+ from __future__ import annotations
4
+ import email.utils
5
+ import json
6
+ import logging
7
+ import time
8
+ from typing import Any
9
+ import requests
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
11
+ from revolut_x._auth import sign_request
12
+ from revolut_x._version import __version__
13
+ from revolut_x.exceptions import ApiError, AuthenticationError, NetworkError, RateLimitError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ #: Default User-Agent sent to the exchange. Derived from the package version so
18
+ #: it cannot drift out of sync with the actual release.
19
+ DEFAULT_USER_AGENT = f"revolut-x-python/{__version__}"
20
+
21
+
22
+ class HttpClient:
23
+ """HTTP client handling authentication, headers, and rate limiting for Revolut X.
24
+
25
+ Attributes:
26
+ base_url (str): The base URL for the API.
27
+ api_version (str): The API version string.
28
+ api_key (str | None): The API key for authenticated requests.
29
+ private_key (Ed25519PrivateKey | None): The private key for signing requests.
30
+ request_delay (float): Minimum seconds between outgoing requests.
31
+ timeout (int): Timeout in seconds for HTTP requests.
32
+ max_retries (int): Maximum number of retry attempts for 429 and network errors.
33
+ user_agent (str): User agent string for the requests.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ base_url: str = "https://revx.revolut.com/api",
40
+ api_version: str = "1.0",
41
+ api_key: str | None = None,
42
+ private_key: Ed25519PrivateKey | None = None,
43
+ request_delay: float = 0.85,
44
+ timeout: int = 15,
45
+ max_retries: int = 3,
46
+ user_agent: str = DEFAULT_USER_AGENT,
47
+ timestamp_offset_ms: int = 0,
48
+ ):
49
+ """Initialize the HTTP client.
50
+
51
+ Args:
52
+ base_url (str): The base URL for the API.
53
+ api_version (str): The API version string.
54
+ api_key (str | None): The API key for authenticated requests.
55
+ private_key (Ed25519PrivateKey | None): The private key for signing requests.
56
+ request_delay (float): Minimum seconds between outgoing requests.
57
+ timeout (int): Timeout in seconds for HTTP requests.
58
+ max_retries (int): Maximum number of retry attempts for 429 and network errors.
59
+ user_agent (str): User agent string for the requests.
60
+ timestamp_offset_ms (int): Clock offset in milliseconds for request signing.
61
+ """
62
+ self.base_url = base_url.rstrip("/")
63
+ self.api_version = api_version
64
+ self.api_key = api_key
65
+ self.private_key = private_key
66
+ self.request_delay = request_delay
67
+ self.timeout = timeout
68
+ self.max_retries = max_retries
69
+ self.timestamp_offset_ms = timestamp_offset_ms
70
+ self._time_synced = False
71
+
72
+ self.session = requests.Session()
73
+ self.session.headers.update({"User-Agent": user_agent})
74
+ self._last_request_time = 0.0
75
+
76
+ def sync_time(self) -> int:
77
+ """Synchronize timestamp offset with Revolut X server time using the Date response header."""
78
+ try:
79
+ resp = self.session.head(
80
+ f"{self.base_url}/{self.api_version}/public/configuration/currencies",
81
+ timeout=self.timeout,
82
+ )
83
+ date_header = resp.headers.get("Date")
84
+ if date_header:
85
+ parsed = email.utils.parsedate_to_datetime(date_header)
86
+ server_ms = int(parsed.timestamp() * 1000)
87
+ local_ms = int(time.time() * 1000)
88
+ self.timestamp_offset_ms = server_ms - local_ms - 500
89
+ logger.debug("Synchronized time offset: %d ms", self.timestamp_offset_ms)
90
+ return self.timestamp_offset_ms
91
+ except Exception as exc:
92
+ logger.warning("Could not sync server time: %s", exc)
93
+ return self.timestamp_offset_ms
94
+
95
+ def _rate_limit_wait(self) -> None:
96
+ """Enforce spacing between outgoing requests to avoid 429 Rate Limits."""
97
+ elapsed = time.time() - self._last_request_time
98
+ if elapsed < self.request_delay:
99
+ time.sleep(self.request_delay - elapsed)
100
+ self._last_request_time = time.time()
101
+
102
+ def request(
103
+ self,
104
+ method: str,
105
+ endpoint: str,
106
+ *,
107
+ params: dict[str, Any] | None = None,
108
+ json_body: Any | None = None,
109
+ authenticated: bool = False,
110
+ ) -> tuple[int, Any]:
111
+ """Execute an HTTP request against the Revolut X API.
112
+
113
+ Args:
114
+ method (str): The HTTP method (GET, POST, etc.).
115
+ endpoint (str): The API endpoint path (e.g., '/orders').
116
+ params (dict[str, Any] | None): Optional query parameters.
117
+ json_body (Any | None): Optional JSON body data.
118
+ authenticated (bool): Whether the request requires authentication.
119
+
120
+ Returns:
121
+ tuple[int, Any]: A tuple containing the HTTP status code and the parsed response body
122
+ (as a dict or list), or text if not parseable.
123
+
124
+ Raises:
125
+ AuthenticationError: If authentication is required but missing, or on 401/403.
126
+ RateLimitError: If the maximum rate limit retries are exhausted (429).
127
+ ApiError: For other 4xx or 5xx status codes.
128
+ NetworkError: On fundamental network failures (e.g., connection errors).
129
+ """
130
+ if not endpoint.startswith("/"):
131
+ endpoint = f"/{endpoint}"
132
+
133
+ # The canonical signature message uses alphabetically sorted query
134
+ # params (see docs/concepts/auth_ed25519.md), while requests would
135
+ # serialise them in insertion order. Sort here so the signed string and
136
+ # the string actually put on the wire are identical either way. None
137
+ # values are dropped for the same reason — the signer skips them too.
138
+ if params:
139
+ params = {k: v for k, v in sorted(params.items()) if v is not None}
140
+
141
+ api_path = f"/api/{self.api_version}{endpoint}"
142
+ full_url = f"{self.base_url}/{self.api_version}{endpoint}"
143
+
144
+ if authenticated and not getattr(self, "_time_synced", False) and self.timestamp_offset_ms == 0:
145
+ self._time_synced = True
146
+ self.sync_time()
147
+
148
+ headers: dict[str, str] = {}
149
+ if authenticated:
150
+ if not self.api_key or not self.private_key:
151
+ raise AuthenticationError(
152
+ f"Authentication required for {endpoint}, but api_key or private_key is not configured. "
153
+ "Please initialize the client with both api_key and private_key."
154
+ )
155
+
156
+ body_kwargs: dict[str, Any] = {}
157
+ if json_body is not None:
158
+ body_kwargs["data"] = json.dumps(json_body, separators=(",", ":"))
159
+ headers["Content-Type"] = "application/json"
160
+
161
+ wait_sec: float | None = None
162
+ for attempt in range(1, self.max_retries + 1):
163
+ if authenticated:
164
+ auth_headers = sign_request(
165
+ api_key=self.api_key,
166
+ private_key=self.private_key,
167
+ method=method,
168
+ path=api_path,
169
+ params=params,
170
+ body=json_body,
171
+ timestamp_offset_ms=self.timestamp_offset_ms,
172
+ )
173
+ headers.update(auth_headers)
174
+
175
+ self._rate_limit_wait()
176
+ try:
177
+ response = self.session.request(
178
+ method=method.upper(),
179
+ url=full_url,
180
+ params=params,
181
+ headers=headers,
182
+ timeout=self.timeout,
183
+ **body_kwargs
184
+ )
185
+
186
+ status = response.status_code
187
+
188
+ # Check response Date header for background drift correction
189
+ date_hdr = response.headers.get("Date")
190
+ if date_hdr:
191
+ try:
192
+ p = email.utils.parsedate_to_datetime(date_hdr)
193
+ diff = int(p.timestamp() * 1000) - int(time.time() * 1000)
194
+ if abs(diff - self.timestamp_offset_ms) > 3000:
195
+ self.timestamp_offset_ms = diff - 500
196
+ except Exception:
197
+ pass
198
+
199
+ # Handle Rate Limiting (429)
200
+ if status == 429:
201
+ if attempt == self.max_retries:
202
+ raise RateLimitError(
203
+ f"Rate limit exceeded for {method.upper()} {endpoint} after {self.max_retries} retries.",
204
+ retry_after_seconds=wait_sec,
205
+ endpoint=endpoint,
206
+ )
207
+ retry_after_str = response.headers.get("Retry-After", "2000")
208
+ try:
209
+ retry_after_ms = float(retry_after_str)
210
+ wait_sec = max(retry_after_ms / 1000.0, 1.0)
211
+ except ValueError:
212
+ wait_sec = 2.0
213
+
214
+ wait_sec *= attempt
215
+ logger.warning(
216
+ "Rate limited on %s. Waiting %.2fs (attempt %d/%d).",
217
+ endpoint, wait_sec, attempt, self.max_retries
218
+ )
219
+ time.sleep(wait_sec)
220
+ continue
221
+
222
+ try:
223
+ data = response.json()
224
+ except Exception:
225
+ data = response.text
226
+
227
+ # Handle timestamp drift error (409 Request timestamp is in the future)
228
+ if status == 409 and isinstance(data, dict):
229
+ msg = str(data.get("message", "")).lower()
230
+ if "future" in msg or "timestamp" in data:
231
+ server_ts = int(data.get("timestamp") or 0)
232
+ if server_ts:
233
+ self.timestamp_offset_ms = server_ts - int(time.time() * 1000) - 1000
234
+ else:
235
+ self.sync_time()
236
+ logger.warning(
237
+ "Timestamp clock drift detected on %s (HTTP 409). Adjusted offset to %d ms. Retrying...",
238
+ endpoint, self.timestamp_offset_ms
239
+ )
240
+ continue
241
+
242
+ # Handle other status codes
243
+ if status in (401, 403):
244
+ raise AuthenticationError(
245
+ f"Authentication failed for {method.upper()} {endpoint} (HTTP {status}): {data}",
246
+ hint="Check that your API key is valid and the private key matches the registered public key.",
247
+ )
248
+ elif not (200 <= status < 300):
249
+ raise ApiError(
250
+ f"Revolut X API returned HTTP {status} for {method.upper()} {endpoint}: {data}",
251
+ status_code=status,
252
+ response_body=data,
253
+ endpoint=endpoint,
254
+ method=method.upper(),
255
+ )
256
+
257
+ return status, data
258
+
259
+ except requests.RequestException as e:
260
+ logger.error("Network error on %s %s: %s", method, full_url, e)
261
+ if attempt == self.max_retries:
262
+ raise NetworkError(
263
+ f"Network error calling {method.upper()} {endpoint} after {self.max_retries} attempts: {e}",
264
+ original_error=e,
265
+ ) from e
266
+
267
+ # Exponential backoff on network error
268
+ time.sleep(1.0 * (2 ** (attempt - 1)))
269
+
270
+ raise RateLimitError(f"Maximum retries ({self.max_retries}) exhausted for {endpoint}.")
revolut_x/_version.py ADDED
@@ -0,0 +1,10 @@
1
+ """Single source of truth for the package version.
2
+
3
+ Both ``revolut_x.__version__`` and the default ``User-Agent`` in
4
+ :mod:`revolut_x._http` read from here, and ``pyproject.toml`` resolves its
5
+ ``version`` field to this attribute. Bump it in this one place only — the
6
+ three used to be maintained by hand and had drifted apart (0.0.6 / 0.0.8 /
7
+ 0.1.0 simultaneously).
8
+ """
9
+
10
+ __version__ = "1.0.0"