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/market.py ADDED
@@ -0,0 +1,469 @@
1
+ """Public market data methods for the Revolut X API.
2
+
3
+ This module provides :class:`MarketMixin`, which is mixed into
4
+ :class:`~revolut_x.client.RevolutXClient` to add public (unauthenticated)
5
+ endpoints for market data: trading pairs, currencies, tickers, order book,
6
+ candlestick charts (OHLCV), and public trade history.
7
+
8
+ All methods in this mixin work **without** an API key.
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import logging
13
+ import time
14
+ from typing import Any, Iterator, TYPE_CHECKING
15
+ from revolut_x.types import (
16
+ Candle,
17
+ CurrencyConfig,
18
+ Interval,
19
+ OrderBook,
20
+ PairConfig,
21
+ Ticker,
22
+ Trade,
23
+ )
24
+ from revolut_x.helpers import normalize_symbol as _normalize_symbol
25
+
26
+ if TYPE_CHECKING:
27
+ from revolut_x._http import HttpClient
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ #: Largest page size the ``/public/trades/all`` endpoint accepts. Asking for
32
+ #: more is rejected with HTTP 400 ('Limit must be between 1 and 100').
33
+ MAX_TRADES_PER_PAGE = 100
34
+
35
+
36
+ class MarketMixin:
37
+ """Public market data endpoints — no authentication required.
38
+
39
+ This mixin is not meant to be used directly. It is mixed into
40
+ :class:`~revolut_x.client.RevolutXClient`.
41
+ """
42
+
43
+ # Provided by RevolutXClient
44
+ _http: HttpClient
45
+
46
+ # ------------------------------------------------------------------
47
+ # Configuration
48
+ # ------------------------------------------------------------------
49
+
50
+ def get_pairs(self) -> dict[str, PairConfig]:
51
+ """Retrieve the configuration and trading limits for all active pairs.
52
+
53
+ Returns:
54
+ A dictionary mapping pair symbols (e.g. ``'BTC/EUR'``) to their
55
+ :class:`~revolut_x.types.PairConfig` configuration, including
56
+ minimum/maximum order sizes, price/quantity step sizes, and status.
57
+
58
+ Raises:
59
+ ApiError: If the API returns a non-2xx response.
60
+ NetworkError: On connection failure.
61
+
62
+ Example::
63
+
64
+ >>> client = RevolutXClient()
65
+ >>> pairs = client.get_pairs()
66
+ >>> btc = pairs["BTC/EUR"]
67
+ >>> print(f"Min order: {btc['min_order_size']} BTC")
68
+ Min order: 0.00000001 BTC
69
+ """
70
+ _status, data = self._http.request("GET", "/public/configuration/pairs")
71
+ return data
72
+
73
+ def get_pair(self, symbol: str) -> PairConfig:
74
+ """Retrieve the configuration for a specific trading pair.
75
+
76
+ Args:
77
+ symbol: Trading pair in any common format
78
+ (e.g. ``'BTC-EUR'``, ``'BTC/EUR'``, ``'btceur'``).
79
+
80
+ Returns:
81
+ The :class:`~revolut_x.types.PairConfig` for the requested pair.
82
+
83
+ Raises:
84
+ ValueError: If the pair is not found or not active on the exchange.
85
+ ApiError: If the API returns a non-2xx response.
86
+
87
+ Example::
88
+
89
+ >>> pair = client.get_pair("ETH-EUR")
90
+ >>> print(f"Step: {pair['base_step']}")
91
+ """
92
+ pairs = self.get_pairs()
93
+
94
+ # Try both slash and dash formats (API returns slash-keyed dict)
95
+ clean_slash = symbol.replace("-", "/").upper()
96
+ clean_dash = symbol.replace("/", "-").upper()
97
+
98
+ info = pairs.get(clean_slash) or pairs.get(clean_dash)
99
+ if info is not None:
100
+ return info
101
+
102
+ # Case-insensitive fallback
103
+ for key, val in pairs.items():
104
+ if key.upper() in (clean_slash, clean_dash):
105
+ return val
106
+
107
+ available = ", ".join(sorted(pairs.keys())[:10])
108
+ raise ValueError(
109
+ f"Trading pair '{symbol}' not found on Revolut X. "
110
+ f"Available pairs include: {available}… "
111
+ f"Use get_pairs() to see the full list."
112
+ )
113
+
114
+ def get_currencies(self) -> dict[str, CurrencyConfig]:
115
+ """Retrieve the configuration for all supported currencies.
116
+
117
+ Returns:
118
+ A dictionary mapping currency symbols (e.g. ``'BTC'``, ``'EUR'``)
119
+ to their :class:`~revolut_x.types.CurrencyConfig`.
120
+
121
+ Raises:
122
+ ApiError: If the API returns a non-2xx response.
123
+
124
+ Example::
125
+
126
+ >>> currencies = client.get_currencies()
127
+ >>> print(currencies["BTC"]["name"])
128
+ Bitcoin
129
+ """
130
+ _status, data = self._http.request("GET", "/public/configuration/currencies")
131
+ return data
132
+
133
+ # ------------------------------------------------------------------
134
+ # Tickers & Order Book
135
+ # ------------------------------------------------------------------
136
+
137
+ def get_tickers(self) -> list[Ticker]:
138
+ """Retrieve current tickers (best bid, best ask, last price) for all pairs.
139
+
140
+ Returns:
141
+ A list of :class:`~revolut_x.types.Ticker` dictionaries.
142
+
143
+ Raises:
144
+ ApiError: If the API returns a non-2xx response.
145
+ """
146
+ _status, data = self._http.request("GET", "/public/tickers")
147
+ if isinstance(data, dict):
148
+ return data.get("data", [])
149
+ return data
150
+
151
+ def get_ticker(self, symbol: str) -> Ticker:
152
+ """Retrieve the current ticker for a specific trading pair.
153
+
154
+ Args:
155
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
156
+
157
+ Returns:
158
+ A :class:`~revolut_x.types.Ticker` with best bid, best ask,
159
+ and last traded price.
160
+
161
+ Raises:
162
+ ValueError: If ticker data is not available for the given symbol.
163
+ ApiError: If the API returns a non-2xx response.
164
+
165
+ Example::
166
+
167
+ >>> ticker = client.get_ticker("BTC-EUR")
168
+ >>> print(f"BTC price: {ticker['last_price']} EUR")
169
+ """
170
+ tickers = self.get_tickers()
171
+
172
+ target_slash = symbol.replace("-", "/").upper()
173
+ target_dash = symbol.replace("/", "-").upper()
174
+
175
+ if isinstance(tickers, list):
176
+ for t in tickers:
177
+ sym = t.get("symbol", "").upper()
178
+ if sym in (target_slash, target_dash):
179
+ return t
180
+ elif isinstance(tickers, dict):
181
+ result = tickers.get(target_slash) or tickers.get(target_dash)
182
+ if result is not None:
183
+ return result
184
+
185
+ raise ValueError(
186
+ f"Ticker data not available for '{symbol}'. "
187
+ f"Check that the pair exists with get_pairs()."
188
+ )
189
+
190
+ def get_order_book(self, symbol: str, depth: int = 10) -> OrderBook:
191
+ """Retrieve the current order book (bids and asks) for a trading pair.
192
+
193
+ Args:
194
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
195
+ depth: Requested number of price levels. **Currently ignored by
196
+ the exchange** — the venue always returns 5 levels per side,
197
+ regardless of this value. Kept so that the SDK picks up
198
+ deeper books automatically if Revolut X starts honouring it.
199
+
200
+ Returns:
201
+ An :class:`~revolut_x.types.OrderBook` with ``bids`` and ``asks``
202
+ lists. Each entry is an
203
+ :class:`~revolut_x.types.OrderBookLevel` **dictionary** — read the
204
+ price from ``['p']`` and the quantity from ``['q']``. Levels are
205
+ sorted best-first.
206
+
207
+ Raises:
208
+ ApiError: If the API returns a non-2xx response.
209
+
210
+ Example::
211
+
212
+ >>> book = client.get_order_book("BTC-EUR")
213
+ >>> best_bid = book["bids"][0]
214
+ >>> print(f"Best bid: {best_bid['p']} EUR x {best_bid['q']} BTC")
215
+ """
216
+ api_symbol = _normalize_symbol(symbol)
217
+ _status, data = self._http.request(
218
+ "GET",
219
+ f"/public/order-book/{api_symbol}",
220
+ params={"limit": depth},
221
+ )
222
+ if isinstance(data, dict):
223
+ return data.get("data", data)
224
+ return data
225
+
226
+ # ------------------------------------------------------------------
227
+ # Candles (OHLCV)
228
+ # ------------------------------------------------------------------
229
+
230
+ def get_candles(
231
+ self,
232
+ symbol: str,
233
+ interval: Interval | int = Interval.HOUR_1,
234
+ *,
235
+ since: int | None = None,
236
+ until: int | None = None,
237
+ ) -> list[Candle]:
238
+ """Fetch up to 1000 OHLCV candlesticks for a trading pair.
239
+
240
+ Args:
241
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
242
+ interval: Candle interval — use the :class:`~revolut_x.types.Interval`
243
+ enum (e.g. ``Interval.MIN_1``, ``Interval.HOUR_1``) or an integer
244
+ number of minutes.
245
+ since: Start time as Unix timestamp in **milliseconds** (inclusive).
246
+ until: End time as Unix timestamp in **milliseconds** (inclusive).
247
+
248
+ Returns:
249
+ A list of :class:`~revolut_x.types.Candle` dictionaries with keys:
250
+ ``start``, ``open``, ``high``, ``low``, ``close``, ``volume``.
251
+
252
+ Note:
253
+ A single request covers at most **1000 candles**. The exchange
254
+ does *not* silently truncate a wider window — it rejects it with
255
+ HTTP 400 (``'Lookup window interval exceeds the limit of 1000
256
+ candles'``). So ``until - since`` must stay within
257
+ ``999 * interval``; to download a longer range use
258
+ :meth:`iter_candles`, which paginates automatically.
259
+
260
+ Raises:
261
+ ApiError: If the API returns a non-2xx response — including when
262
+ ``since``/``until`` span more than 1000 candles.
263
+
264
+ Example::
265
+
266
+ >>> from revolut_x import Interval
267
+ >>> # last 500 hours — comfortably inside the 1000-candle limit
268
+ >>> now_ms = int(time.time() * 1000)
269
+ >>> candles = client.get_candles("BTC-EUR", Interval.HOUR_1,
270
+ ... since=now_ms - 500 * 3_600_000)
271
+ >>> for c in candles[:3]:
272
+ ... print(f"{c['start']}: O={c['open']} H={c['high']}")
273
+ """
274
+ api_symbol = _normalize_symbol(symbol)
275
+ params: dict[str, Any] = {"interval": int(interval)}
276
+ if since is not None:
277
+ params["since"] = int(since)
278
+ if until is not None:
279
+ params["until"] = int(until)
280
+
281
+ _status, data = self._http.request(
282
+ "GET",
283
+ f"/public/candles/{api_symbol}",
284
+ params=params,
285
+ )
286
+
287
+ if isinstance(data, dict):
288
+ return data.get("data", [])
289
+ return data
290
+
291
+ def iter_candles(
292
+ self,
293
+ symbol: str,
294
+ interval: Interval | int = Interval.HOUR_1,
295
+ *,
296
+ since: int | None = None,
297
+ until: int | None = None,
298
+ ) -> Iterator[Candle]:
299
+ """Iterate over all candles in a time range, automatically paginating.
300
+
301
+ This is a generator that yields :class:`~revolut_x.types.Candle`
302
+ dictionaries one by one, fetching additional pages from the API as
303
+ needed. It handles the 1000-candle-per-request limit transparently.
304
+
305
+ Args:
306
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
307
+ interval: Candle interval (see :meth:`get_candles`).
308
+ since: Start time as Unix timestamp in **milliseconds**.
309
+ until: End time as Unix timestamp in **milliseconds**.
310
+
311
+ Yields:
312
+ :class:`~revolut_x.types.Candle` dictionaries in chronological
313
+ order (oldest first). No candle is yielded twice, and none is
314
+ yielded with a ``start`` later than ``until``.
315
+
316
+ Example::
317
+
318
+ >>> for candle in client.iter_candles("BTC-EUR", Interval.MIN_1,
319
+ ... since=1700000000000):
320
+ ... process(candle) # each candle is a dict
321
+ """
322
+ interval_ms = int(interval) * 60 * 1000 # interval in milliseconds
323
+ # The API rejects a window spanning more than 1000 candles, and the
324
+ # window is inclusive on both ends — so 999 intervals == 1000 candles.
325
+ max_batch_ms = 999 * interval_ms
326
+
327
+ if since is None:
328
+ # Without a start there is nothing to paginate: a single request
329
+ # already returns the most recent batch.
330
+ yield from self.get_candles(symbol, interval, until=until)
331
+ return
332
+
333
+ current_since = since
334
+
335
+ while True:
336
+ # Walk fixed-size windows forward rather than chaining off the last
337
+ # returned candle, so that gaps in the series (illiquid pairs with
338
+ # no trades in an interval) cannot cut the iteration short.
339
+ hard_end = until if until is not None else int(time.time() * 1000)
340
+ batch_until = min(current_since + max_batch_ms, hard_end)
341
+ # The API rejects a window where 'until' equals 'since', which is
342
+ # what a final single-candle window would produce. Always ask for
343
+ # at least two candles and drop the overshoot below.
344
+ batch_until = max(batch_until, current_since + interval_ms)
345
+
346
+ batch = self.get_candles(
347
+ symbol, interval, since=current_since, until=batch_until
348
+ )
349
+ if until is not None:
350
+ batch = [c for c in batch if int(c.get("start", 0)) <= until]
351
+
352
+ yield from batch
353
+
354
+ if batch_until >= hard_end:
355
+ break
356
+
357
+ current_since = batch_until + interval_ms
358
+
359
+ # ------------------------------------------------------------------
360
+ # Public Trades
361
+ # ------------------------------------------------------------------
362
+
363
+ def get_trades(
364
+ self,
365
+ symbol: str,
366
+ *,
367
+ start_date: int | None = None,
368
+ end_date: int | None = None,
369
+ cursor: str | None = None,
370
+ limit: int = MAX_TRADES_PER_PAGE,
371
+ ) -> tuple[list[Trade], str | None]:
372
+ """Fetch a single page of public trades (up to 100 per request).
373
+
374
+ Args:
375
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
376
+ start_date: Start time as Unix timestamp in **milliseconds**.
377
+ end_date: End time as Unix timestamp in **milliseconds**.
378
+ cursor: Pagination cursor from a previous response's
379
+ ``metadata.next_cursor`` field.
380
+ limit: Maximum number of trades to return. The exchange accepts
381
+ **1–100** and rejects anything larger with HTTP 400, so values
382
+ outside that range are clamped into it. To read more than one
383
+ page, follow ``next_cursor`` or use :meth:`iter_trades`.
384
+
385
+ Returns:
386
+ A tuple of ``(trades, next_cursor)`` where ``trades`` is a list of
387
+ :class:`~revolut_x.types.Trade` dicts and ``next_cursor`` is a
388
+ string for fetching the next page (or ``None`` if no more data).
389
+
390
+ Raises:
391
+ ApiError: If the API returns a non-2xx response.
392
+
393
+ Example::
394
+
395
+ >>> trades, cursor = client.get_trades("BTC-EUR", limit=100)
396
+ >>> print(f"Got {len(trades)} trades, next cursor: {cursor}")
397
+ """
398
+ api_symbol = _normalize_symbol(symbol)
399
+ params: dict[str, Any] = {
400
+ "symbol": api_symbol,
401
+ "limit": max(1, min(limit, MAX_TRADES_PER_PAGE)),
402
+ }
403
+ if start_date is not None:
404
+ params["start_date"] = int(start_date)
405
+ if end_date is not None:
406
+ params["end_date"] = int(end_date)
407
+ if cursor:
408
+ params["cursor"] = cursor
409
+
410
+ _status, data = self._http.request(
411
+ "GET", "/public/trades/all", params=params
412
+ )
413
+
414
+ if isinstance(data, dict):
415
+ trades = data.get("data", [])
416
+ next_cursor = data.get("metadata", {}).get("next_cursor")
417
+ else:
418
+ trades = data if isinstance(data, list) else []
419
+ next_cursor = None
420
+
421
+ return trades, next_cursor
422
+
423
+ def iter_trades(
424
+ self,
425
+ symbol: str,
426
+ *,
427
+ start_date: int | None = None,
428
+ end_date: int | None = None,
429
+ limit: int = MAX_TRADES_PER_PAGE,
430
+ ) -> Iterator[Trade]:
431
+ """Iterate over all public trades in a time range, automatically paginating.
432
+
433
+ This generator fetches pages of trades using cursor-based pagination
434
+ and yields :class:`~revolut_x.types.Trade` dicts one by one.
435
+
436
+ Args:
437
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
438
+ start_date: Start time as Unix timestamp in **milliseconds**.
439
+ end_date: End time as Unix timestamp in **milliseconds**.
440
+ limit: Page size per request (1–100, clamped into that range).
441
+
442
+ Yields:
443
+ :class:`~revolut_x.types.Trade` dictionaries.
444
+
445
+ Example::
446
+
447
+ >>> for trade in client.iter_trades("BTC-EUR",
448
+ ... start_date=1700000000000):
449
+ ... print(f"{trade['price']} EUR x {trade['quantity']} BTC")
450
+ """
451
+ cursor: str | None = None
452
+
453
+ while True:
454
+ trades, next_cursor = self.get_trades(
455
+ symbol,
456
+ start_date=start_date,
457
+ end_date=end_date,
458
+ cursor=cursor,
459
+ limit=limit,
460
+ )
461
+ if not trades:
462
+ break
463
+
464
+ yield from trades
465
+
466
+ if not next_cursor:
467
+ break
468
+
469
+ cursor = next_cursor