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/orders.py ADDED
@@ -0,0 +1,522 @@
1
+ """Order management methods for the Revolut X API.
2
+
3
+ This module provides :class:`OrdersMixin`, which is mixed into
4
+ :class:`~revolut_x.client.RevolutXClient` to add authenticated endpoints
5
+ for placing, inspecting, and canceling orders.
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 decimal import Decimal
13
+ from typing import Any, TYPE_CHECKING
14
+ from revolut_x.types import (
15
+ Fill,
16
+ OrderDetail,
17
+ OrderResponse,
18
+ OrderSide,
19
+ OrderType,
20
+ TimeInForce,
21
+ )
22
+ from revolut_x.helpers import OrderPayloadBuilder, calculate_maker_price
23
+
24
+ if TYPE_CHECKING:
25
+ from revolut_x._http import HttpClient
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class OrdersMixin:
31
+ """Authenticated order management endpoints.
32
+
33
+ This mixin is not meant to be used directly. It is mixed into
34
+ :class:`~revolut_x.client.RevolutXClient`.
35
+ """
36
+
37
+ # Provided by RevolutXClient
38
+ _http: HttpClient
39
+
40
+ # ------------------------------------------------------------------
41
+ # Place orders
42
+ # ------------------------------------------------------------------
43
+
44
+ def place_order(
45
+ self,
46
+ symbol_or_payload: str | dict[str, Any],
47
+ side: OrderSide | str | None = None,
48
+ *,
49
+ order_type: OrderType | str = OrderType.MARKET,
50
+ price: str | None = None,
51
+ base_size: str | None = None,
52
+ quote_size: str | None = None,
53
+ post_only: bool = False,
54
+ time_in_force: TimeInForce | str = TimeInForce.GTC,
55
+ client_order_id: str | None = None,
56
+ ) -> OrderResponse:
57
+ """Submit an order to the exchange.
58
+
59
+ This method supports two calling styles:
60
+
61
+ 1. **Unified high-level placement** (recommended)::
62
+
63
+ >>> # Market order
64
+ >>> order = client.place_order("BTC-EUR", OrderSide.BUY, order_type="market", quote_size="50.00")
65
+ >>> # Limit order
66
+ >>> order = client.place_order("BTC-EUR", OrderSide.BUY, order_type="limit", price="80000.00", quote_size="50.00")
67
+
68
+ 2. **Low-level raw payload dictionary** (backward compatible)::
69
+
70
+ >>> payload = OrderPayloadBuilder.build_market_order("BTC-EUR", OrderSide.BUY, quote_size="50.00")
71
+ >>> order = client.place_order(payload)
72
+
73
+ Args:
74
+ symbol_or_payload: Trading pair (e.g. ``'BTC-EUR'``) or a complete payload dict.
75
+ side: :attr:`OrderSide.BUY` or :attr:`OrderSide.SELL` (required if symbol string is given).
76
+ order_type: :attr:`OrderType.MARKET` (default) or :attr:`OrderType.LIMIT`.
77
+ price: Limit price in quote currency (required for limit orders, omitted for market orders).
78
+ base_size: Amount in the base currency (e.g. ``'0.001'`` BTC). Mutually exclusive with ``quote_size``.
79
+ quote_size: Amount in the quote currency (e.g. ``'50.00'`` EUR). Mutually exclusive with ``base_size``.
80
+ post_only: If ``True``, guarantees the order enters the book as a maker (0% fee, limit orders only).
81
+ time_in_force: :attr:`TimeInForce.GTC` (default) or :attr:`TimeInForce.IOC` (limit orders only).
82
+ client_order_id: Optional client UUID for idempotency. Auto-generated if not provided.
83
+
84
+ Returns:
85
+ An :class:`~revolut_x.types.OrderResponse` with the assigned
86
+ ``venue_order_id`` and initial ``state``.
87
+
88
+ Raises:
89
+ ValueError / OrderValidationError: If order parameters are invalid or missing.
90
+ AuthenticationError: If the client is not authenticated.
91
+ ApiError: If the exchange rejects the order.
92
+
93
+ Example::
94
+
95
+ >>> order = client.place_order(
96
+ ... "BTC-EUR", OrderSide.BUY, order_type="market", quote_size="50.00"
97
+ ... )
98
+ >>> print(f"Order ID: {order['venue_order_id']}")
99
+ """
100
+ if isinstance(symbol_or_payload, dict):
101
+ payload = symbol_or_payload
102
+ else:
103
+ if side is None:
104
+ raise ValueError("Parameter 'side' (OrderSide.BUY / OrderSide.SELL) is required when placing an order by symbol.")
105
+ payload = OrderPayloadBuilder.build_order(
106
+ symbol=symbol_or_payload,
107
+ side=side,
108
+ order_type=order_type,
109
+ price=price,
110
+ base_size=base_size,
111
+ quote_size=quote_size,
112
+ post_only=post_only,
113
+ time_in_force=time_in_force,
114
+ client_order_id=client_order_id,
115
+ )
116
+
117
+ _status, resp = self._http.request(
118
+ "POST", "/orders", json_body=payload, authenticated=True
119
+ )
120
+
121
+ # API wraps response in {"data": [...]} or {"data": {...}}
122
+ data = resp.get("data", resp) if isinstance(resp, dict) else resp
123
+ if isinstance(data, list) and len(data) > 0:
124
+ return data[0]
125
+ return data
126
+
127
+ def place_market_order(
128
+ self,
129
+ symbol: str,
130
+ side: OrderSide | str,
131
+ *,
132
+ base_size: str | None = None,
133
+ quote_size: str | None = None,
134
+ client_order_id: str | None = None,
135
+ ) -> OrderResponse:
136
+ """Place a market order (immediate execution, 0.09% taker fee).
137
+
138
+ Convenience wrapper around :meth:`place_order` with ``order_type=OrderType.MARKET``.
139
+
140
+ Exactly **one** of ``base_size`` or ``quote_size`` must be provided.
141
+
142
+ Args:
143
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
144
+ side: :attr:`OrderSide.BUY` or :attr:`OrderSide.SELL`.
145
+ base_size: Amount in the base currency (e.g. ``'0.001'`` BTC).
146
+ Mutually exclusive with ``quote_size``.
147
+ quote_size: Amount in the quote currency (e.g. ``'50.00'`` EUR).
148
+ Mutually exclusive with ``base_size``.
149
+ client_order_id: Optional UUID for idempotency. Auto-generated
150
+ if not provided.
151
+
152
+ Returns:
153
+ An :class:`~revolut_x.types.OrderResponse` with the exchange
154
+ order ID.
155
+
156
+ Raises:
157
+ ValueError: If neither or both of ``base_size``/``quote_size``
158
+ are provided.
159
+ AuthenticationError: If the client is not authenticated.
160
+ ApiError: If the exchange rejects the order.
161
+
162
+ Example::
163
+
164
+ >>> order = client.place_market_order(
165
+ ... "BTC-EUR", OrderSide.BUY, quote_size="50.00"
166
+ ... )
167
+ >>> print(f"Bought BTC, order ID: {order['venue_order_id']}")
168
+ """
169
+ return self.place_order(
170
+ symbol_or_payload=symbol,
171
+ side=side,
172
+ order_type=OrderType.MARKET,
173
+ base_size=base_size,
174
+ quote_size=quote_size,
175
+ client_order_id=client_order_id,
176
+ )
177
+
178
+ def place_limit_order(
179
+ self,
180
+ symbol: str,
181
+ side: OrderSide | str,
182
+ price: str,
183
+ *,
184
+ base_size: str | None = None,
185
+ quote_size: str | None = None,
186
+ post_only: bool = False,
187
+ time_in_force: TimeInForce | str = TimeInForce.GTC,
188
+ client_order_id: str | None = None,
189
+ ) -> OrderResponse:
190
+ """Place a limit order at a specific price.
191
+
192
+ Convenience wrapper around :meth:`place_order` with ``order_type=OrderType.LIMIT``.
193
+
194
+ Exactly **one** of ``base_size`` or ``quote_size`` must be provided.
195
+
196
+ Args:
197
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
198
+ side: :attr:`OrderSide.BUY` or :attr:`OrderSide.SELL`.
199
+ price: Limit price in the quote currency (e.g. ``'85000.00'`` EUR
200
+ per 1 BTC).
201
+ base_size: Amount in the base currency. Mutually exclusive
202
+ with ``quote_size``.
203
+ quote_size: Amount in the quote currency. Mutually exclusive
204
+ with ``base_size``.
205
+ post_only: If ``True``, guarantees the order enters the book
206
+ as a **maker** (0% fee). If it would match immediately
207
+ (taker), the exchange rejects it.
208
+ time_in_force: :attr:`TimeInForce.GTC` (good 'til canceled,
209
+ default) or :attr:`TimeInForce.IOC` (immediate or cancel).
210
+ client_order_id: Optional UUID for idempotency.
211
+
212
+ Returns:
213
+ An :class:`~revolut_x.types.OrderResponse`.
214
+
215
+ Raises:
216
+ ValueError: If neither or both of ``base_size``/``quote_size``
217
+ are provided.
218
+ AuthenticationError: If the client is not authenticated.
219
+ ApiError: If the exchange rejects the order.
220
+
221
+ Example::
222
+
223
+ >>> order = client.place_limit_order(
224
+ ... "BTC-EUR", OrderSide.BUY, price="80000.00",
225
+ ... quote_size="50.00", post_only=True,
226
+ ... )
227
+ >>> print(f"Limit order placed: {order['venue_order_id']}")
228
+ """
229
+ return self.place_order(
230
+ symbol_or_payload=symbol,
231
+ side=side,
232
+ order_type=OrderType.LIMIT,
233
+ price=price,
234
+ base_size=base_size,
235
+ quote_size=quote_size,
236
+ post_only=post_only,
237
+ time_in_force=time_in_force,
238
+ client_order_id=client_order_id,
239
+ )
240
+
241
+ def calculate_maker_price(
242
+ self,
243
+ symbol: str,
244
+ side: OrderSide | str,
245
+ *,
246
+ offset: Any = Decimal("0.10"),
247
+ tick_size: Any = Decimal("0.01"),
248
+ ) -> Decimal:
249
+ """Fetch current ticker for symbol and calculate the optimal Maker price.
250
+
251
+ Args:
252
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
253
+ side: :attr:`OrderSide.BUY` or :attr:`OrderSide.SELL`.
254
+ offset: Offset in quote currency (default: ``0.10``).
255
+ tick_size: Price tick size for quantisation (default: ``0.01``).
256
+
257
+ Returns:
258
+ Decimal: The optimal limit price for a zero-fee Maker order.
259
+ """
260
+ get_ticker_fn = getattr(self, "get_ticker", None)
261
+ if get_ticker_fn is None:
262
+ raise RuntimeError("get_ticker is not available on this client instance.")
263
+ ticker = get_ticker_fn(symbol)
264
+ return calculate_maker_price(
265
+ side=side,
266
+ best_bid=ticker.get("bid"),
267
+ best_ask=ticker.get("ask"),
268
+ last_price=ticker.get("last_price"),
269
+ offset=offset,
270
+ tick_size=tick_size,
271
+ )
272
+
273
+ def place_maker_order(
274
+ self,
275
+ symbol: str,
276
+ side: OrderSide | str,
277
+ *,
278
+ price: str | None = None,
279
+ offset: Any = Decimal("0.10"),
280
+ tick_size: Any = Decimal("0.01"),
281
+ base_size: str | None = None,
282
+ quote_size: str | None = None,
283
+ time_in_force: TimeInForce | str = TimeInForce.GTC,
284
+ client_order_id: str | None = None,
285
+ ) -> OrderResponse:
286
+ """Place a guaranteed zero-fee Maker limit order (0.00% fee).
287
+
288
+ If ``price`` is not explicitly provided, this method automatically fetches
289
+ the latest market ticker and computes an optimal Maker price with the given
290
+ ``offset``:
291
+ - For BUY: ``best_bid - offset``
292
+ - For SELL: ``best_ask + offset``
293
+
294
+ The order is submitted with ``post_only=True``, ensuring the exchange will
295
+ accept it as a Maker (0% fee) or reject it if it would match immediately.
296
+
297
+ Exactly **one** of ``base_size`` or ``quote_size`` must be provided.
298
+
299
+ Args:
300
+ symbol: Trading pair (e.g. ``'BTC-EUR'``).
301
+ side: :attr:`OrderSide.BUY` or :attr:`OrderSide.SELL`.
302
+ price: Optional fixed limit price. If omitted, calculated dynamically.
303
+ offset: Offset in quote currency from best bid/ask (default: ``0.10``).
304
+ tick_size: Minimum price increment for quantisation (default: ``0.01``).
305
+ base_size: Amount in the base currency (e.g. ``'0.001'`` BTC).
306
+ quote_size: Amount in the quote currency (e.g. ``'50.00'`` EUR).
307
+ time_in_force: :attr:`TimeInForce.GTC` (default) or :attr:`TimeInForce.IOC`.
308
+ client_order_id: Optional UUID for idempotency.
309
+
310
+ Returns:
311
+ An :class:`~revolut_x.types.OrderResponse` from the exchange.
312
+
313
+ Raises:
314
+ ValueError / OrderValidationError: If sizes or side are invalid.
315
+ AuthenticationError: If the client is not authenticated.
316
+ ApiError: If the exchange rejects the order.
317
+
318
+ Example::
319
+
320
+ >>> # Buy BTC for 50 EUR with zero fee at optimal Maker price:
321
+ >>> order = client.place_maker_order("BTC-EUR", OrderSide.BUY, quote_size="50.00")
322
+ >>> print(f"Maker order placed: {order['venue_order_id']}")
323
+ """
324
+ if price is None:
325
+ calc_price = str(self.calculate_maker_price(symbol, side, offset=offset, tick_size=tick_size))
326
+ else:
327
+ calc_price = str(price)
328
+
329
+ return self.place_limit_order(
330
+ symbol=symbol,
331
+ side=side,
332
+ price=calc_price,
333
+ base_size=base_size,
334
+ quote_size=quote_size,
335
+ post_only=True,
336
+ time_in_force=time_in_force,
337
+ client_order_id=client_order_id,
338
+ )
339
+
340
+ # ------------------------------------------------------------------
341
+ # Query orders
342
+ # ------------------------------------------------------------------
343
+
344
+ def get_order(self, order_id: str) -> OrderDetail:
345
+ """Retrieve detailed information about a specific order.
346
+
347
+ Args:
348
+ order_id: The exchange-assigned order ID (``venue_order_id``).
349
+
350
+ Returns:
351
+ An :class:`~revolut_x.types.OrderDetail` with status, filled
352
+ quantity, average fill price, total fee, and timestamps.
353
+
354
+ Raises:
355
+ AuthenticationError: If the client is not authenticated.
356
+ ApiError: If the order is not found (HTTP 404) or other error.
357
+
358
+ Example::
359
+
360
+ >>> detail = client.get_order("7a52e92e-8639-4fe1-abaa-68d3a2d5234b")
361
+ >>> print(f"Status: {detail['status']}, Fee: {detail['total_fee']} EUR")
362
+ """
363
+ _status, resp = self._http.request(
364
+ "GET", f"/orders/{order_id}", authenticated=True
365
+ )
366
+ if isinstance(resp, dict) and "data" in resp:
367
+ return resp["data"]
368
+ return resp
369
+
370
+ def get_order_fills(self, order_id: str) -> list[Fill]:
371
+ """Retrieve individual fills (partial executions) for an order.
372
+
373
+ Each fill includes the execution price, quantity, and whether it
374
+ was a maker or taker trade (``im`` field).
375
+
376
+ Args:
377
+ order_id: The exchange-assigned order ID (``venue_order_id``).
378
+
379
+ Returns:
380
+ A list of :class:`~revolut_x.types.Fill` dictionaries.
381
+ The ``im`` field is ``True`` for maker fills (0% fee) and
382
+ ``False`` for taker fills (0.09% fee).
383
+
384
+ Raises:
385
+ AuthenticationError: If the client is not authenticated.
386
+ ApiError: If the order is not found or other error.
387
+
388
+ Example::
389
+
390
+ >>> fills = client.get_order_fills("7a52e92e-...")
391
+ >>> for fill in fills:
392
+ ... fee_type = "maker (0%)" if fill["im"] else "taker (0.09%)"
393
+ ... print(f"{fill['q']} BTC @ {fill['p']} EUR [{fee_type}]")
394
+ """
395
+ _status, resp = self._http.request(
396
+ "GET", f"/orders/fills/{order_id}", authenticated=True
397
+ )
398
+ if isinstance(resp, dict) and "data" in resp:
399
+ return resp["data"]
400
+ return resp
401
+
402
+ def get_active_orders(
403
+ self, symbol: str | None = None
404
+ ) -> list[OrderDetail]:
405
+ """Retrieve all currently open (active) orders.
406
+
407
+ Args:
408
+ symbol: Optional filter by trading pair (e.g. ``'BTC-EUR'``).
409
+ If ``None``, returns active orders for all pairs.
410
+
411
+ Returns:
412
+ A list of :class:`~revolut_x.types.OrderDetail` dictionaries.
413
+
414
+ Raises:
415
+ AuthenticationError: If the client is not authenticated.
416
+ """
417
+ params: dict[str, Any] | None = None
418
+ if symbol:
419
+ params = {"symbol": symbol.replace("/", "-").upper()}
420
+
421
+ _status, resp = self._http.request(
422
+ "GET", "/orders/active", params=params, authenticated=True
423
+ )
424
+ if isinstance(resp, dict) and "data" in resp:
425
+ return resp["data"]
426
+ return resp if isinstance(resp, list) else []
427
+
428
+ def get_historical_orders(
429
+ self,
430
+ symbol: str | None = None,
431
+ *,
432
+ limit: int = 50,
433
+ cursor: str | None = None,
434
+ ) -> tuple[list[OrderDetail], str | None]:
435
+ """Retrieve historical (completed/canceled) orders with pagination.
436
+
437
+ Args:
438
+ symbol: Optional filter by trading pair.
439
+ limit: Number of orders per page (default: 50).
440
+ cursor: Pagination cursor from a previous response.
441
+
442
+ Returns:
443
+ A tuple of ``(orders, next_cursor)``. ``next_cursor`` is ``None``
444
+ if there are no more pages.
445
+
446
+ Raises:
447
+ AuthenticationError: If the client is not authenticated.
448
+ """
449
+ params: dict[str, Any] = {"limit": limit}
450
+ if symbol:
451
+ params["symbol"] = symbol.replace("/", "-").upper()
452
+ if cursor:
453
+ params["cursor"] = cursor
454
+
455
+ _status, resp = self._http.request(
456
+ "GET", "/orders/historical", params=params, authenticated=True
457
+ )
458
+
459
+ if isinstance(resp, dict):
460
+ orders = resp.get("data", [])
461
+ next_cursor = resp.get("metadata", {}).get("next_cursor")
462
+ else:
463
+ orders = resp if isinstance(resp, list) else []
464
+ next_cursor = None
465
+
466
+ return orders, next_cursor
467
+
468
+ # ------------------------------------------------------------------
469
+ # Cancel orders
470
+ # ------------------------------------------------------------------
471
+
472
+ def cancel_order(self, order_id: str) -> dict[str, Any]:
473
+ """Cancel a specific open order.
474
+
475
+ Args:
476
+ order_id: The exchange-assigned order ID (``venue_order_id``).
477
+
478
+ Returns:
479
+ The API response confirming cancellation.
480
+
481
+ Raises:
482
+ AuthenticationError: If the client is not authenticated.
483
+ ApiError: If the order cannot be canceled (e.g. already filled,
484
+ not found).
485
+
486
+ Example::
487
+
488
+ >>> client.cancel_order("7a52e92e-8639-4fe1-abaa-68d3a2d5234b")
489
+ """
490
+ _status, resp = self._http.request(
491
+ "DELETE", f"/orders/{order_id}", authenticated=True
492
+ )
493
+ return resp
494
+
495
+ def cancel_all_orders(
496
+ self, symbol: str | None = None
497
+ ) -> dict[str, Any]:
498
+ """Cancel all open orders, optionally filtered by trading pair.
499
+
500
+ Args:
501
+ symbol: If provided, only cancel orders for this pair
502
+ (e.g. ``'BTC-EUR'``). If ``None``, cancel **all** open
503
+ orders across all pairs.
504
+
505
+ Returns:
506
+ The API response confirming cancellation.
507
+
508
+ Raises:
509
+ AuthenticationError: If the client is not authenticated.
510
+
511
+ Example::
512
+
513
+ >>> client.cancel_all_orders("BTC-EUR")
514
+ """
515
+ params: dict[str, Any] | None = None
516
+ if symbol:
517
+ params = {"symbol": symbol.replace("/", "-").upper()}
518
+
519
+ _status, resp = self._http.request(
520
+ "DELETE", "/orders", params=params, authenticated=True
521
+ )
522
+ return resp
revolut_x/py.typed ADDED
File without changes