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/helpers.py ADDED
@@ -0,0 +1,540 @@
1
+ from __future__ import annotations
2
+ import uuid
3
+ from dataclasses import dataclass
4
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
5
+ from typing import Any
6
+
7
+ from revolut_x.types import OrderSide, OrderType, TimeInForce
8
+ from revolut_x.exceptions import OrderValidationError
9
+
10
+
11
+ def normalize_symbol(symbol: str) -> str:
12
+ """Normalizes the trading symbol for the Revolut X API.
13
+
14
+ Accepts formats such as 'BTC/EUR', 'BTC-EUR', 'btc-eur', 'btceur', 'BTCEUR'.
15
+ Returns the normalized symbol with a dash separator (e.g. 'BTC-EUR').
16
+
17
+ Args:
18
+ symbol: The trading symbol to normalize.
19
+
20
+ Returns:
21
+ str: The normalized symbol.
22
+
23
+ Example:
24
+ >>> normalize_symbol('BTC/EUR')
25
+ 'BTC-EUR'
26
+ >>> normalize_symbol('btceur')
27
+ 'BTC-EUR'
28
+ """
29
+ clean = symbol.replace("/", "").replace("-", "").upper()
30
+ for quote in ("USDC", "EUR", "USD", "GBP", "CZK"):
31
+ if clean.endswith(quote):
32
+ base = clean[: -len(quote)]
33
+ if base:
34
+ return f"{base}-{quote}"
35
+ return symbol.replace("/", "-").upper()
36
+
37
+
38
+ class OrderPayloadBuilder:
39
+ """Builder and validator for Revolut X API order payloads."""
40
+
41
+ @staticmethod
42
+ def _format_decimal(val: Any) -> str:
43
+ """Converts a number, float, or Decimal to a string representation.
44
+
45
+ Args:
46
+ val: The numerical value to convert.
47
+
48
+ Returns:
49
+ str: The string representation of the decimal value.
50
+
51
+ Raises:
52
+ OrderValidationError: If the value is an invalid number or unsupported type.
53
+
54
+ Example:
55
+ >>> OrderPayloadBuilder._format_decimal(50.5)
56
+ '50.5'
57
+ """
58
+ if isinstance(val, str):
59
+ try:
60
+ d = Decimal(val)
61
+ return str(d)
62
+ except InvalidOperation:
63
+ raise OrderValidationError(f"Invalid numerical value: {val}")
64
+ elif isinstance(val, (int, float, Decimal)):
65
+ return str(Decimal(str(val)))
66
+ else:
67
+ raise OrderValidationError(f"Unexpected numerical value type: {type(val)}")
68
+
69
+ @staticmethod
70
+ def _normalize_symbol(symbol: str) -> str:
71
+ """Normalizes the trading symbol for the Revolut X API."""
72
+ return normalize_symbol(symbol)
73
+
74
+ normalize_symbol = staticmethod(normalize_symbol)
75
+
76
+ @classmethod
77
+ def build_market_order(
78
+ cls,
79
+ symbol: str,
80
+ side: OrderSide,
81
+ *,
82
+ base_size: Any | None = None,
83
+ quote_size: Any | None = None,
84
+ client_order_id: str | None = None
85
+ ) -> dict[str, Any]:
86
+ """Builds a payload for a Market Order.
87
+
88
+ Args:
89
+ symbol: The trading symbol (e.g., 'BTC-EUR').
90
+ side: The side of the order (BUY or SELL).
91
+ base_size: The amount of the base currency (e.g., BTC) to buy/sell.
92
+ quote_size: The amount of the quote currency (e.g., EUR) to buy/sell.
93
+ client_order_id: A unique client order ID. A UUID will be generated if not provided.
94
+
95
+ Returns:
96
+ dict[str, Any]: The constructed JSON payload for the Revolut X API.
97
+
98
+ Raises:
99
+ OrderValidationError: If exactly one of base_size or quote_size is not provided.
100
+
101
+ Example:
102
+ >>> OrderPayloadBuilder.build_market_order('BTC-EUR', OrderSide.BUY, quote_size='50.00')
103
+ """
104
+ if (base_size is None and quote_size is None) or (base_size is not None and quote_size is not None):
105
+ raise OrderValidationError("You must specify exactly one field: either 'base_size' or 'quote_size'.")
106
+
107
+ market_conf: dict[str, str] = {}
108
+ if base_size is not None:
109
+ market_conf["base_size"] = cls._format_decimal(base_size)
110
+ else:
111
+ market_conf["quote_size"] = cls._format_decimal(quote_size)
112
+
113
+ return {
114
+ "client_order_id": client_order_id or str(uuid.uuid4()),
115
+ "symbol": cls._normalize_symbol(symbol),
116
+ "side": side.value if isinstance(side, OrderSide) else str(side).lower(),
117
+ "order_configuration": {
118
+ "market": market_conf
119
+ }
120
+ }
121
+
122
+ @classmethod
123
+ def build_limit_order(
124
+ cls,
125
+ symbol: str,
126
+ side: OrderSide,
127
+ price: Any,
128
+ *,
129
+ base_size: Any | None = None,
130
+ quote_size: Any | None = None,
131
+ post_only: bool = False,
132
+ time_in_force: TimeInForce | str = TimeInForce.GTC,
133
+ client_order_id: str | None = None
134
+ ) -> dict[str, Any]:
135
+ """Builds a payload for a Limit Order.
136
+
137
+ Args:
138
+ symbol: The trading symbol (e.g., 'BTC-EUR').
139
+ side: The side of the order (BUY or SELL).
140
+ price: The limit price in quote currency per 1 base currency unit.
141
+ base_size: The amount of the base currency (e.g., BTC) to buy/sell.
142
+ quote_size: The amount of the quote currency (e.g., EUR) to buy/sell.
143
+ post_only: If True, ensures the order is entered as a MAKER order.
144
+ time_in_force: The time in force for the order (e.g., 'gtc' or 'ioc').
145
+ client_order_id: A unique client order ID. A UUID will be generated if not provided.
146
+
147
+ Returns:
148
+ dict[str, Any]: The constructed JSON payload for the Revolut X API.
149
+
150
+ Raises:
151
+ OrderValidationError: If exactly one of base_size or quote_size is not provided.
152
+
153
+ Example:
154
+ >>> OrderPayloadBuilder.build_limit_order('BTC-EUR', OrderSide.SELL, '90000.00', base_size='0.1')
155
+ """
156
+ if (base_size is None and quote_size is None) or (base_size is not None and quote_size is not None):
157
+ raise OrderValidationError("You must specify exactly one field: either 'base_size' or 'quote_size'.")
158
+
159
+ execution_instructions: list[str] = ["post_only"] if post_only else ["allow_taker"]
160
+
161
+ tif_val = time_in_force.value if isinstance(time_in_force, TimeInForce) else str(time_in_force).lower()
162
+
163
+ limit_conf: dict[str, Any] = {
164
+ "price": cls._format_decimal(price),
165
+ "time_in_force": tif_val,
166
+ "execution_instructions": execution_instructions
167
+ }
168
+
169
+ if base_size is not None:
170
+ limit_conf["base_size"] = cls._format_decimal(base_size)
171
+ else:
172
+ limit_conf["quote_size"] = cls._format_decimal(quote_size)
173
+
174
+ return {
175
+ "client_order_id": client_order_id or str(uuid.uuid4()),
176
+ "symbol": cls._normalize_symbol(symbol),
177
+ "side": side.value if isinstance(side, OrderSide) else str(side).lower(),
178
+ "order_configuration": {
179
+ "limit": limit_conf
180
+ }
181
+ }
182
+
183
+ @classmethod
184
+ def build_order(
185
+ cls,
186
+ symbol: str,
187
+ side: OrderSide | str,
188
+ order_type: OrderType | str = OrderType.MARKET,
189
+ *,
190
+ price: Any | None = None,
191
+ base_size: Any | None = None,
192
+ quote_size: Any | None = None,
193
+ post_only: bool = False,
194
+ time_in_force: TimeInForce | str = TimeInForce.GTC,
195
+ client_order_id: str | None = None,
196
+ ) -> dict[str, Any]:
197
+ """Builds a payload for either a Market or Limit Order based on order_type.
198
+
199
+ Args:
200
+ symbol: The trading symbol (e.g., 'BTC-EUR').
201
+ side: The side of the order (BUY or SELL).
202
+ order_type: OrderType.MARKET ('market') or OrderType.LIMIT ('limit').
203
+ price: Required for limit orders. Must be None for market orders.
204
+ base_size: The amount of the base currency (e.g., BTC) to buy/sell.
205
+ quote_size: The amount of the quote currency (e.g., EUR) to buy/sell.
206
+ post_only: If True, ensures the order is entered as a MAKER order (limit only).
207
+ time_in_force: The time in force policy (limit only).
208
+ client_order_id: Optional client-side UUID for idempotency.
209
+
210
+ Returns:
211
+ dict[str, Any]: The constructed JSON payload for the Revolut X API.
212
+
213
+ Raises:
214
+ OrderValidationError: If parameters are invalid for the given order_type.
215
+
216
+ Example:
217
+ >>> OrderPayloadBuilder.build_order('BTC-EUR', OrderSide.BUY, order_type=OrderType.MARKET, quote_size='50.00')
218
+ """
219
+ if isinstance(order_type, str):
220
+ try:
221
+ order_type = OrderType(order_type.lower())
222
+ except ValueError:
223
+ raise OrderValidationError(f"Invalid order_type: '{order_type}'. Expected 'market' or 'limit'.")
224
+
225
+ if isinstance(side, str):
226
+ try:
227
+ side = OrderSide(side.lower())
228
+ except ValueError:
229
+ raise OrderValidationError(f"Invalid side: '{side}'. Expected 'buy' or 'sell'.")
230
+
231
+ if order_type == OrderType.MARKET:
232
+ if price is not None:
233
+ raise OrderValidationError("Parameter 'price' is not supported for market orders.")
234
+ if post_only:
235
+ raise OrderValidationError("Parameter 'post_only' is not supported for market orders.")
236
+ return cls.build_market_order(
237
+ symbol=symbol,
238
+ side=side,
239
+ base_size=base_size,
240
+ quote_size=quote_size,
241
+ client_order_id=client_order_id,
242
+ )
243
+ elif order_type == OrderType.LIMIT:
244
+ if price is None:
245
+ raise OrderValidationError("Parameter 'price' is required for limit orders.")
246
+ return cls.build_limit_order(
247
+ symbol=symbol,
248
+ side=side,
249
+ price=price,
250
+ base_size=base_size,
251
+ quote_size=quote_size,
252
+ post_only=post_only,
253
+ time_in_force=time_in_force,
254
+ client_order_id=client_order_id,
255
+ )
256
+ else:
257
+ raise OrderValidationError(f"Unsupported order_type: {order_type}")
258
+
259
+ @classmethod
260
+ def validate_against_pair_rules(
261
+ cls,
262
+ payload: dict[str, Any],
263
+ pair_rules: dict[str, Any]
264
+ ) -> tuple[bool, list[str]]:
265
+ """Validates the constructed payload against the exchange's trading pair rules.
266
+
267
+ Args:
268
+ payload: The order payload to validate.
269
+ pair_rules: The trading rules for the specific asset pair.
270
+
271
+ Returns:
272
+ tuple[bool, list[str]]: A tuple containing a boolean indicating if the payload is valid,
273
+ and a list of error messages (if any).
274
+
275
+ Example:
276
+ >>> valid, errors = OrderPayloadBuilder.validate_against_pair_rules(payload, rules)
277
+ """
278
+ errors: list[str] = []
279
+ conf = payload.get("order_configuration", {})
280
+
281
+ is_market = "market" in conf
282
+ is_limit = "limit" in conf
283
+ order_data = conf.get("market") if is_market else conf.get("limit", {})
284
+
285
+ base_size = order_data.get("base_size")
286
+ quote_size = order_data.get("quote_size")
287
+ price = order_data.get("price")
288
+
289
+ min_base = Decimal(pair_rules.get("min_order_size", "0.00000001"))
290
+ max_base = Decimal(pair_rules.get("max_order_size", "1000000"))
291
+ min_quote = Decimal(pair_rules.get("min_order_size_quote", "0.1"))
292
+ max_quote = Decimal(pair_rules.get("max_order_size_quote", "1000000"))
293
+
294
+ if base_size:
295
+ b_dec = Decimal(base_size)
296
+ if b_dec < min_base:
297
+ errors.append(f"base_size ({b_dec}) is less than the minimum ({min_base})")
298
+ if b_dec > max_base:
299
+ errors.append(f"base_size ({b_dec}) is greater than the maximum ({max_base})")
300
+
301
+ if quote_size:
302
+ q_dec = Decimal(quote_size)
303
+ if q_dec < min_quote:
304
+ errors.append(f"quote_size ({q_dec}) is less than the minimum ({min_quote})")
305
+ if q_dec > max_quote:
306
+ errors.append(f"quote_size ({q_dec}) is greater than the maximum ({max_quote})")
307
+
308
+ if is_limit and not price:
309
+ errors.append("Limit order is missing the 'price' field!")
310
+
311
+ return len(errors) == 0, errors
312
+
313
+ @classmethod
314
+ def build_maker_order(
315
+ cls,
316
+ symbol: str,
317
+ side: OrderSide | str,
318
+ price: Any,
319
+ *,
320
+ base_size: Any | None = None,
321
+ quote_size: Any | None = None,
322
+ time_in_force: TimeInForce | str = TimeInForce.GTC,
323
+ client_order_id: str | None = None,
324
+ ) -> dict[str, Any]:
325
+ """Builds a payload for a guaranteed zero-fee Maker Order (Limit with post_only=True).
326
+
327
+ Args:
328
+ symbol: The trading symbol (e.g. 'BTC-EUR').
329
+ side: OrderSide.BUY or OrderSide.SELL.
330
+ price: Limit price in quote currency per 1 base unit.
331
+ base_size: Amount in base currency (e.g. BTC).
332
+ quote_size: Amount in quote currency (e.g. EUR).
333
+ time_in_force: Time in force (default: GTC).
334
+ client_order_id: Optional client UUID.
335
+
336
+ Returns:
337
+ dict[str, Any]: JSON payload ready for the Revolut X API.
338
+ """
339
+ return cls.build_limit_order(
340
+ symbol=symbol,
341
+ side=side,
342
+ price=price,
343
+ base_size=base_size,
344
+ quote_size=quote_size,
345
+ post_only=True,
346
+ time_in_force=time_in_force,
347
+ client_order_id=client_order_id,
348
+ )
349
+
350
+
351
+ def calculate_maker_price(
352
+ side: OrderSide | str,
353
+ *,
354
+ best_bid: Any | None = None,
355
+ best_ask: Any | None = None,
356
+ last_price: Any | None = None,
357
+ offset: Any = Decimal("0.10"),
358
+ tick_size: Any = Decimal("0.01"),
359
+ ) -> Decimal:
360
+ """Calculates an optimal limit price to guarantee entry into the order book as a Maker (0.00% fee).
361
+
362
+ For BUY orders:
363
+ price = (best_bid or last_price) - offset
364
+ For SELL orders:
365
+ price = (best_ask or last_price) + offset
366
+
367
+ Args:
368
+ side: OrderSide.BUY / OrderSide.SELL or string ('buy' / 'sell').
369
+ best_bid: Current best bid price (highest buyer in book).
370
+ best_ask: Current best ask price (lowest seller in book).
371
+ last_price: Fallback price if best bid/ask is not available.
372
+ offset: Safety offset in quote currency (default: 0.10 EUR/USD).
373
+ tick_size: Minimum price increment for quantisation (default: 0.01).
374
+
375
+ Returns:
376
+ Decimal: The quantised target price.
377
+
378
+ Raises:
379
+ OrderValidationError: If side is invalid or no valid reference price can be determined.
380
+
381
+ Example:
382
+ >>> calculate_maker_price(OrderSide.BUY, best_bid="80000.00", offset="0.10")
383
+ Decimal('79999.90')
384
+ >>> calculate_maker_price(OrderSide.SELL, best_ask="80000.50", offset="0.10")
385
+ Decimal('80000.60')
386
+ """
387
+ if isinstance(side, str):
388
+ try:
389
+ side_enum = OrderSide(side.lower())
390
+ except ValueError:
391
+ raise OrderValidationError(f"Invalid side: '{side}'. Expected 'buy' or 'sell'.")
392
+ elif isinstance(side, OrderSide):
393
+ side_enum = side
394
+ else:
395
+ raise OrderValidationError(f"Invalid side type: {type(side)}")
396
+
397
+ offset_dec = Decimal(str(offset))
398
+ tick_dec = Decimal(str(tick_size))
399
+
400
+ if side_enum == OrderSide.BUY:
401
+ ref = best_bid if best_bid is not None else last_price
402
+ if ref is None:
403
+ raise OrderValidationError("Cannot calculate Maker BUY price: neither 'best_bid' nor 'last_price' provided.")
404
+ raw_price = Decimal(str(ref)) - offset_dec
405
+ else:
406
+ ref = best_ask if best_ask is not None else last_price
407
+ if ref is None:
408
+ raise OrderValidationError("Cannot calculate Maker SELL price: neither 'best_ask' nor 'last_price' provided.")
409
+ raw_price = Decimal(str(ref)) + offset_dec
410
+
411
+ # Quantize to tick size
412
+ return raw_price.quantize(tick_dec, rounding=ROUND_HALF_UP)
413
+
414
+
415
+ @dataclass
416
+ class FeeEstimate:
417
+ """Estimated fee calculation result for an order."""
418
+ side: str
419
+ order_type: str
420
+ is_maker: bool
421
+ fee_rate_percent: Decimal
422
+ trade_value_eur: Decimal
423
+ estimated_base_qty: Decimal
424
+ price: Decimal
425
+ fee_amount: Decimal
426
+ fee_currency: str
427
+ net_received: Decimal
428
+ net_received_currency: str
429
+ explanation: str
430
+
431
+
432
+ class FeeCalculator:
433
+ """Calculator for order fees on Revolut X."""
434
+
435
+ MAKER_FEE_RATE = Decimal("0.0000") # 0.00%
436
+ TAKER_FEE_RATE = Decimal("0.0009") # 0.09%
437
+
438
+ @classmethod
439
+ def calculate(
440
+ cls,
441
+ side: OrderSide,
442
+ price: Any,
443
+ is_maker: bool,
444
+ *,
445
+ base_size: Any | None = None,
446
+ quote_size: Any | None = None
447
+ ) -> FeeEstimate:
448
+ """Calculates exact fees for a specified trade.
449
+
450
+ Args:
451
+ side: BUY or SELL.
452
+ price: The execution price in quote currency per 1 base currency unit.
453
+ is_maker: True if the order is executed as a maker (0.00% fee), False for taker (0.09% fee).
454
+ base_size: The amount of the base currency (e.g., BTC) if provided.
455
+ quote_size: The amount of the quote currency (e.g., EUR) if provided.
456
+
457
+ Returns:
458
+ FeeEstimate: An object containing detailed fee estimates.
459
+
460
+ Raises:
461
+ OrderValidationError: If neither base_size nor quote_size is provided.
462
+
463
+ Example:
464
+ >>> FeeCalculator.calculate(OrderSide.BUY, '90000', is_maker=False, quote_size='1000')
465
+ """
466
+ price_dec = Decimal(str(price))
467
+ rate = cls.MAKER_FEE_RATE if is_maker else cls.TAKER_FEE_RATE
468
+ rate_pct = rate * Decimal("100")
469
+
470
+ if quote_size is not None:
471
+ value_eur = Decimal(str(quote_size))
472
+ qty_btc = (value_eur / price_dec).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP)
473
+ elif base_size is not None:
474
+ qty_btc = Decimal(str(base_size))
475
+ value_eur = (qty_btc * price_dec).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
476
+ else:
477
+ raise OrderValidationError("You must specify either 'base_size' or 'quote_size'.")
478
+
479
+ fee_eur = (value_eur * rate).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
480
+
481
+ side_val = side.value if isinstance(side, OrderSide) else str(side).lower()
482
+
483
+ if side_val == "buy":
484
+ fee_currency = "EUR"
485
+ net_received = qty_btc
486
+ net_currency = "BTC"
487
+ explanation = (
488
+ f"Buy {net_received} BTC for {value_eur} EUR at price {price_dec}.\n"
489
+ f"- Execution type: {'MAKER' if is_maker else 'TAKER'}\n"
490
+ f"- Fee rate: {rate_pct:.2f} %\n"
491
+ f"- Fee amount: {fee_eur:.4f} EUR\n"
492
+ f"- Net received approx: {net_received:.8f} BTC"
493
+ )
494
+ else:
495
+ fee_currency = "EUR"
496
+ net_received = (value_eur - fee_eur).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
497
+ net_currency = "EUR"
498
+ explanation = (
499
+ f"Sell {qty_btc} BTC at price {price_dec} (Gross value: {value_eur} EUR).\n"
500
+ f"- Execution type: {'MAKER' if is_maker else 'TAKER'}\n"
501
+ f"- Fee rate: {rate_pct:.2f} %\n"
502
+ f"- Fee amount: {fee_eur:.4f} EUR\n"
503
+ f"- Net received: {net_received:.2f} EUR"
504
+ )
505
+
506
+ return FeeEstimate(
507
+ side=side_val,
508
+ order_type="limit" if is_maker else "market/immediate-limit",
509
+ is_maker=is_maker,
510
+ fee_rate_percent=rate_pct,
511
+ trade_value_eur=value_eur,
512
+ estimated_base_qty=qty_btc,
513
+ price=price_dec,
514
+ fee_amount=fee_eur,
515
+ fee_currency=fee_currency,
516
+ net_received=net_received,
517
+ net_received_currency=net_currency,
518
+ explanation=explanation
519
+ )
520
+
521
+
522
+ # Module-level convenience functions
523
+ build_order = OrderPayloadBuilder.build_order
524
+ build_market_order = OrderPayloadBuilder.build_market_order
525
+ build_limit_order = OrderPayloadBuilder.build_limit_order
526
+ build_maker_order = OrderPayloadBuilder.build_maker_order
527
+ validate_against_pair_rules = OrderPayloadBuilder.validate_against_pair_rules
528
+
529
+ __all__ = [
530
+ "OrderPayloadBuilder",
531
+ "FeeEstimate",
532
+ "FeeCalculator",
533
+ "normalize_symbol",
534
+ "calculate_maker_price",
535
+ "build_order",
536
+ "build_market_order",
537
+ "build_limit_order",
538
+ "build_maker_order",
539
+ "validate_against_pair_rules",
540
+ ]