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/account.py ADDED
@@ -0,0 +1,203 @@
1
+ """Account and wallet methods for the Revolut X API.
2
+
3
+ This module provides :class:`AccountMixin`, which is mixed into
4
+ :class:`~revolut_x.client.RevolutXClient` to add authenticated endpoints
5
+ for account balances, transaction history, and private trade history.
6
+
7
+ All methods in this mixin **require** an API key and private key.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ import logging
12
+ from typing import Any, TYPE_CHECKING
13
+ from revolut_x.types import Balance, Trade
14
+
15
+ if TYPE_CHECKING:
16
+ from revolut_x._http import HttpClient
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class AccountMixin:
22
+ """Authenticated account endpoints — balances, transactions, private trades.
23
+
24
+ This mixin is not meant to be used directly. It is mixed into
25
+ :class:`~revolut_x.client.RevolutXClient`.
26
+ """
27
+
28
+ # Provided by RevolutXClient
29
+ _http: HttpClient
30
+
31
+ # ------------------------------------------------------------------
32
+ # Balances
33
+ # ------------------------------------------------------------------
34
+
35
+ def get_balances(self) -> list[Balance]:
36
+ """Retrieve current balances for all currencies on the account.
37
+
38
+ Returns:
39
+ A list of :class:`~revolut_x.types.Balance` dictionaries, each
40
+ containing ``currency``, ``available``, ``reserved``, and
41
+ ``total`` fields.
42
+
43
+ Raises:
44
+ AuthenticationError: If the client is not authenticated.
45
+ ApiError: If the API returns a non-2xx response.
46
+
47
+ Example::
48
+
49
+ >>> balances = client.get_balances()
50
+ >>> for b in balances:
51
+ ... if float(b["total"]) > 0:
52
+ ... print(f"{b['currency']}: {b['available']} available")
53
+ EUR: 150.00 available
54
+ BTC: 0.00050463 available
55
+ """
56
+ _status, data = self._http.request(
57
+ "GET", "/balances", authenticated=True
58
+ )
59
+ return data if isinstance(data, list) else []
60
+
61
+ def get_balance(self, currency: str) -> Balance:
62
+ """Retrieve the balance for a specific currency.
63
+
64
+ Args:
65
+ currency: Currency symbol (e.g. ``'EUR'``, ``'BTC'``, ``'ETH'``).
66
+ Case-insensitive.
67
+
68
+ Returns:
69
+ A :class:`~revolut_x.types.Balance` dictionary. If the currency
70
+ is not found on the account, returns a zero-balance entry.
71
+
72
+ Raises:
73
+ AuthenticationError: If the client is not authenticated.
74
+
75
+ Example::
76
+
77
+ >>> eur = client.get_balance("EUR")
78
+ >>> print(f"Available: {eur['available']} EUR")
79
+ Available: 150.00 EUR
80
+ """
81
+ balances = self.get_balances()
82
+ target = currency.upper()
83
+
84
+ for item in balances:
85
+ if isinstance(item, dict) and item.get("currency", "").upper() == target:
86
+ return item
87
+
88
+ # Currency not found — return zero balance
89
+ return {
90
+ "currency": target,
91
+ "available": "0.00",
92
+ "reserved": "0.00",
93
+ "total": "0.00",
94
+ }
95
+
96
+ # ------------------------------------------------------------------
97
+ # Transactions
98
+ # ------------------------------------------------------------------
99
+
100
+ def get_transactions(
101
+ self,
102
+ *,
103
+ limit: int = 50,
104
+ cursor: str | None = None,
105
+ ) -> tuple[list[dict[str, Any]], str | None]:
106
+ """Retrieve account transaction history (ledger).
107
+
108
+ Transactions include deposits, withdrawals, trade settlements, and
109
+ fee charges. Each entry shows the source and destination amounts
110
+ and currencies.
111
+
112
+ Args:
113
+ limit: Number of transactions per page (default: 50).
114
+ cursor: Pagination cursor from a previous response.
115
+
116
+ Returns:
117
+ A tuple of ``(transactions, next_cursor)``. Each transaction is
118
+ a dictionary with keys like ``source``, ``destination``,
119
+ ``created_date``, ``processed_date``. ``next_cursor`` is ``None``
120
+ if there are no more pages.
121
+
122
+ Raises:
123
+ AuthenticationError: If the client is not authenticated.
124
+
125
+ Example::
126
+
127
+ >>> txns, cursor = client.get_transactions(limit=10)
128
+ >>> for tx in txns:
129
+ ... print(f"{tx['source']['currency']} -> {tx['destination']['currency']}")
130
+ """
131
+ params: dict[str, Any] = {"limit": limit}
132
+ if cursor:
133
+ params["cursor"] = cursor
134
+
135
+ _status, resp = self._http.request(
136
+ "GET", "/transactions", params=params, authenticated=True
137
+ )
138
+
139
+ if isinstance(resp, dict):
140
+ transactions = resp.get("data", [])
141
+ next_cursor = resp.get("metadata", {}).get("next_cursor")
142
+ else:
143
+ transactions = resp if isinstance(resp, list) else []
144
+ next_cursor = None
145
+
146
+ return transactions, next_cursor
147
+
148
+ # ------------------------------------------------------------------
149
+ # Private Trades
150
+ # ------------------------------------------------------------------
151
+
152
+ def get_account_trades(
153
+ self,
154
+ symbol: str,
155
+ *,
156
+ limit: int = 50,
157
+ cursor: str | None = None,
158
+ ) -> tuple[list[Trade], str | None]:
159
+ """Retrieve private (your own) trade history for a trading pair.
160
+
161
+ Unlike public trades from :meth:`get_trades`, these are your
162
+ personal executed trades with full fee information.
163
+
164
+ Args:
165
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
166
+ limit: Number of trades per page (default: 50).
167
+ cursor: Pagination cursor from a previous response.
168
+
169
+ Returns:
170
+ A tuple of ``(trades, next_cursor)`` where ``trades`` is a list
171
+ of :class:`~revolut_x.types.Trade` dicts with your private
172
+ execution details.
173
+
174
+ Raises:
175
+ AuthenticationError: If the client is not authenticated.
176
+ ApiError: If the API returns a non-2xx response.
177
+
178
+ Example::
179
+
180
+ >>> trades, cursor = client.get_account_trades("BTC-EUR", limit=10)
181
+ >>> for t in trades:
182
+ ... print(f"{t['side']} {t['quantity']} BTC @ {t['price']} EUR")
183
+ """
184
+ api_symbol = symbol.replace("/", "-").upper()
185
+ params: dict[str, Any] = {"limit": limit}
186
+ if cursor:
187
+ params["cursor"] = cursor
188
+
189
+ _status, resp = self._http.request(
190
+ "GET",
191
+ f"/trades/private/{api_symbol}",
192
+ params=params,
193
+ authenticated=True,
194
+ )
195
+
196
+ if isinstance(resp, dict):
197
+ trades = resp.get("data", [])
198
+ next_cursor = resp.get("metadata", {}).get("next_cursor")
199
+ else:
200
+ trades = resp if isinstance(resp, list) else []
201
+ next_cursor = None
202
+
203
+ return trades, next_cursor
revolut_x/client.py ADDED
@@ -0,0 +1,170 @@
1
+ """Main client for the Revolut X Crypto Exchange REST API.
2
+
3
+ This module provides :class:`RevolutXClient`, the single entry point for all
4
+ Revolut X API operations. It combines public market data, order management,
5
+ and account endpoints into one unified interface.
6
+
7
+ Example — public data (no API key needed)::
8
+
9
+ from revolut_x import RevolutXClient
10
+
11
+ client = RevolutXClient()
12
+ ticker = client.get_ticker("BTC-EUR")
13
+ print(f"BTC price: {ticker['last_price']} EUR")
14
+
15
+ Example — trading (requires API key and private key)::
16
+
17
+ from revolut_x import RevolutXClient, OrderSide
18
+
19
+ client = RevolutXClient(
20
+ api_key="Vw7fXD...",
21
+ private_key_path="keys/private.pem",
22
+ )
23
+ order = client.place_market_order("BTC-EUR", OrderSide.BUY, quote_size="50.00")
24
+ print(f"Order ID: {order['venue_order_id']}")
25
+ """
26
+
27
+ from __future__ import annotations
28
+ import logging
29
+ from pathlib import Path
30
+ from revolut_x._auth import load_private_key
31
+ from revolut_x._http import HttpClient
32
+ from revolut_x.account import AccountMixin
33
+ from revolut_x.market import MarketMixin
34
+ from revolut_x.orders import OrdersMixin
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class RevolutXClient(MarketMixin, OrdersMixin, AccountMixin):
40
+ """Client for the Revolut X Crypto Exchange REST API.
41
+
42
+ Provides complete access to:
43
+
44
+ * **Public market data** (no API key needed): trading pair configuration,
45
+ currency info, tickers, order book, OHLCV candles, and public trades.
46
+ * **Order management** (API key required): place market/limit orders,
47
+ cancel orders, inspect fills, view active and historical orders.
48
+ * **Account data** (API key required): balances, transaction ledger,
49
+ private trade history.
50
+
51
+ Args:
52
+ api_key: Your Revolut X API key. Required only for authenticated
53
+ endpoints (orders, balances, etc.). Get it from your
54
+ `Revolut X account settings <https://revx.revolut.com>`_.
55
+ private_key_path: Path to your Ed25519 private key PEM file.
56
+ Mutually exclusive with ``private_key_bytes``.
57
+ private_key_bytes: Ed25519 private key as raw PEM bytes.
58
+ Mutually exclusive with ``private_key_path``.
59
+ base_url: API base URL (default: ``'https://revx.revolut.com/api'``).
60
+ Change for sandbox/testing environments.
61
+ api_version: API version string (default: ``'1.0'``).
62
+ request_delay: Minimum seconds between HTTP requests to respect
63
+ rate limits (default: ``0.85``). The Revolut X public API
64
+ allows 1 request per second.
65
+ timeout: HTTP request timeout in seconds (default: ``15``).
66
+ max_retries: Maximum retry attempts for rate-limited (429) or
67
+ failed requests (default: ``3``).
68
+
69
+ Raises:
70
+ AuthenticationError: If ``private_key_path`` is provided but the
71
+ file does not exist or contains an invalid key.
72
+
73
+ Example::
74
+
75
+ # Public data only — no credentials needed
76
+ client = RevolutXClient()
77
+ pairs = client.get_pairs()
78
+ candles = client.get_candles("BTC-EUR", Interval.HOUR_1)
79
+
80
+ # Full access — trading and account management
81
+ client = RevolutXClient(
82
+ api_key="your-api-key",
83
+ private_key_path="/path/to/private.pem",
84
+ )
85
+ balances = client.get_balances()
86
+ order = client.place_market_order("BTC-EUR", OrderSide.BUY, quote_size="50")
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ api_key: str | None = None,
93
+ private_key_path: str | Path | None = None,
94
+ private_key_bytes: bytes | None = None,
95
+ base_url: str = "https://revx.revolut.com/api",
96
+ api_version: str = "1.0",
97
+ request_delay: float = 0.85,
98
+ timeout: int = 15,
99
+ max_retries: int = 3,
100
+ timestamp_offset_ms: int = 0,
101
+ ) -> None:
102
+ # Load private key if credentials are provided
103
+ private_key = None
104
+ if private_key_path is not None:
105
+ private_key = load_private_key(private_key_path)
106
+ logger.info("Ed25519 private key loaded from %s", private_key_path)
107
+ elif private_key_bytes is not None:
108
+ private_key = load_private_key(private_key_bytes)
109
+ logger.info("Ed25519 private key loaded from bytes")
110
+
111
+ # Create the internal HTTP client shared by all mixins
112
+ self._http = HttpClient(
113
+ base_url=base_url,
114
+ api_version=api_version,
115
+ api_key=api_key,
116
+ private_key=private_key,
117
+ request_delay=request_delay,
118
+ timeout=timeout,
119
+ max_retries=max_retries,
120
+ timestamp_offset_ms=timestamp_offset_ms,
121
+ )
122
+
123
+ # Store for diagnostics
124
+ self._api_key = api_key
125
+ self._has_private_key = private_key is not None
126
+
127
+ @property
128
+ def timestamp_offset(self) -> int:
129
+ """Timestamp clock offset in milliseconds."""
130
+ return self._http.timestamp_offset_ms
131
+
132
+ @timestamp_offset.setter
133
+ def timestamp_offset(self, val: int) -> None:
134
+ self._http.timestamp_offset_ms = int(val)
135
+
136
+ @property
137
+ def timestamp_offset_ms(self) -> int:
138
+ """Alias for timestamp_offset."""
139
+ return self._http.timestamp_offset_ms
140
+
141
+ @timestamp_offset_ms.setter
142
+ def timestamp_offset_ms(self, val: int) -> None:
143
+ self._http.timestamp_offset_ms = int(val)
144
+
145
+ def sync_time(self) -> int:
146
+ """Synchronize timestamp offset with Revolut X server time."""
147
+ return self._http.sync_time()
148
+
149
+ @property
150
+ def is_authenticated(self) -> bool:
151
+ """Whether the client has API key and private key configured.
152
+
153
+ Returns:
154
+ ``True`` if both ``api_key`` and a private key were provided,
155
+ meaning authenticated endpoints (orders, balances) can be used.
156
+
157
+ Example::
158
+
159
+ >>> client = RevolutXClient()
160
+ >>> client.is_authenticated
161
+ False
162
+ >>> client = RevolutXClient(api_key="...", private_key_path="...")
163
+ >>> client.is_authenticated
164
+ True
165
+ """
166
+ return bool(self._api_key and self._has_private_key)
167
+
168
+ def __repr__(self) -> str:
169
+ auth = "authenticated" if self.is_authenticated else "public-only"
170
+ return f"<RevolutXClient({auth}, base_url='{self._http.base_url}')>"
@@ -0,0 +1,147 @@
1
+ """Custom exceptions for the revolut-x-python library.
2
+
3
+ Exception hierarchy::
4
+
5
+ RevolutXError (base)
6
+ ├── AuthenticationError — missing/invalid API key or private key
7
+ ├── RateLimitError — HTTP 429, retry after N seconds
8
+ ├── ApiError — any non-2xx API response
9
+ ├── OrderValidationError — payload violates exchange rules
10
+ └── NetworkError — connection timeout, DNS failure, etc.
11
+
12
+ All exceptions inherit from :class:`RevolutXError`, so you can catch them all
13
+ with a single ``except RevolutXError`` block, or handle each type individually.
14
+ """
15
+
16
+ from __future__ import annotations
17
+ from typing import Any
18
+
19
+
20
+ class RevolutXError(Exception):
21
+ """Base exception for all errors raised by the revolut-x-python library.
22
+
23
+ You can catch this to handle any library error generically::
24
+
25
+ try:
26
+ client.get_ticker("BTC-EUR")
27
+ except RevolutXError as exc:
28
+ logging.error("Revolut X error: %s", exc)
29
+ """
30
+
31
+
32
+ class AuthenticationError(RevolutXError):
33
+ """Raised when authentication is missing, misconfigured, or rejected.
34
+
35
+ Common causes:
36
+
37
+ * ``api_key`` was not provided when creating :class:`RevolutXClient`.
38
+ * The private key file does not exist or has an invalid format.
39
+ * The API returned HTTP 401 or 403 (expired key, wrong signature, …).
40
+
41
+ Attributes:
42
+ hint: A human-readable suggestion on how to fix the problem.
43
+ """
44
+
45
+ def __init__(self, message: str, *, hint: str | None = None) -> None:
46
+ self.hint = hint
47
+ full = message
48
+ if hint:
49
+ full = f"{message}\n Hint: {hint}"
50
+ super().__init__(full)
51
+
52
+
53
+ class RateLimitError(RevolutXError):
54
+ """Raised when the Revolut X API returns HTTP 429 (Too Many Requests).
55
+
56
+ The library retries automatically up to ``max_retries`` times. This
57
+ exception is raised only when all retries have been exhausted.
58
+
59
+ Attributes:
60
+ retry_after_seconds: Suggested wait time (from the ``Retry-After``
61
+ header), in seconds. May be ``None`` if the header was absent.
62
+ endpoint: The API endpoint that triggered the rate limit.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ message: str,
68
+ *,
69
+ retry_after_seconds: float | None = None,
70
+ endpoint: str | None = None,
71
+ ) -> None:
72
+ self.retry_after_seconds = retry_after_seconds
73
+ self.endpoint = endpoint
74
+ super().__init__(message)
75
+
76
+
77
+ class ApiError(RevolutXError):
78
+ """Raised when the Revolut X API returns a non-2xx HTTP status code.
79
+
80
+ This covers client errors (4xx) and server errors (5xx) that are not
81
+ authentication (401/403) or rate-limit (429) related.
82
+
83
+ Attributes:
84
+ status_code: The HTTP status code returned by the API (e.g. 400, 404, 500).
85
+ response_body: The parsed JSON response body, or raw text if parsing failed.
86
+ endpoint: The API endpoint that was called (e.g. ``POST /api/1.0/orders``).
87
+ method: The HTTP method used (e.g. ``GET``, ``POST``).
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ message: str,
93
+ *,
94
+ status_code: int,
95
+ response_body: Any = None,
96
+ endpoint: str = "",
97
+ method: str = "",
98
+ ) -> None:
99
+ self.status_code = status_code
100
+ self.response_body = response_body
101
+ self.endpoint = endpoint
102
+ self.method = method
103
+ super().__init__(message)
104
+
105
+
106
+ class OrderValidationError(RevolutXError, ValueError):
107
+ """Raised when an order payload violates exchange rules before submission.
108
+
109
+ This is a **client-side** validation — the order is not sent to the
110
+ exchange. Fix the payload and try again.
111
+
112
+ Attributes:
113
+ errors: A list of human-readable strings describing each violated rule.
114
+
115
+ Example::
116
+
117
+ try:
118
+ client.place_market_order("BTC-EUR", OrderSide.BUY, quote_size="0.01")
119
+ except OrderValidationError as exc:
120
+ for err in exc.errors:
121
+ print(f" - {err}")
122
+ """
123
+
124
+ def __init__(self, message: str, *, errors: list[str] | None = None) -> None:
125
+ self.errors = errors or []
126
+ if self.errors:
127
+ details = "\n".join(f" - {e}" for e in self.errors)
128
+ full = f"{message}\n{details}"
129
+ else:
130
+ full = message
131
+ super().__init__(full)
132
+
133
+
134
+ class NetworkError(RevolutXError):
135
+ """Raised when a network-level error prevents the request from completing.
136
+
137
+ This wraps connection timeouts, DNS resolution failures, refused
138
+ connections, and other transport-layer errors.
139
+
140
+ Attributes:
141
+ original_error: The underlying :class:`requests.RequestException`
142
+ (or similar) that caused this error.
143
+ """
144
+
145
+ def __init__(self, message: str, *, original_error: Exception | None = None) -> None:
146
+ self.original_error = original_error
147
+ super().__init__(message)