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 +103 -0
- revolut_x/_auth.py +139 -0
- revolut_x/_http.py +270 -0
- revolut_x/_version.py +10 -0
- revolut_x/account.py +203 -0
- revolut_x/client.py +170 -0
- revolut_x/exceptions.py +147 -0
- revolut_x/helpers.py +540 -0
- revolut_x/market.py +469 -0
- revolut_x/orders.py +522 -0
- revolut_x/py.typed +0 -0
- revolut_x/types.py +397 -0
- revolut_x_python-1.0.0.dist-info/METADATA +164 -0
- revolut_x_python-1.0.0.dist-info/RECORD +17 -0
- revolut_x_python-1.0.0.dist-info/WHEEL +5 -0
- revolut_x_python-1.0.0.dist-info/licenses/LICENSE +21 -0
- revolut_x_python-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
]
|