dijkies 0.0.3__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.
dijkies/executors.py ADDED
@@ -0,0 +1,665 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Literal, Optional, Union
3
+
4
+ from pydantic import BaseModel
5
+
6
+ import uuid
7
+ import time
8
+
9
+ import pandas as pd
10
+ from pandas.core.series import Series
11
+
12
+
13
+ import json
14
+ import logging
15
+
16
+ from decimal import Decimal, getcontext, ROUND_DOWN
17
+ from tenacity import retry, wait_fixed, stop_after_attempt
18
+
19
+ from python_bitvavo_api.bitvavo import Bitvavo
20
+
21
+ from dijkies.exceptions import (
22
+ NoOrderFoundError,
23
+ MultipleOrdersFoundError,
24
+ PlaceOrderError,
25
+ GetOrderInfoError,
26
+ CancelOrderError,
27
+ MethodNotDefinedError
28
+ )
29
+
30
+ from dijkies.credentials import Credentials
31
+
32
+
33
+ class Order(BaseModel):
34
+ order_id: str
35
+ exchange: Literal["bitvavo"]
36
+ market: str
37
+ time_created: int
38
+ time_canceled: Union[int, None] = None
39
+ time_filled: Union[int, None] = None
40
+ on_hold: float = 0
41
+ side: Literal["buy", "sell"]
42
+ limit_price: Optional[float] = None
43
+ actual_price: Optional[float] = None
44
+ filled: float = 0
45
+ filled_quote: float = 0
46
+ fee: float = 0
47
+ is_taker: bool
48
+ status: Literal["open", "filled", "cancelled"]
49
+
50
+ @property
51
+ def is_filled(self) -> bool:
52
+ return self.status == "filled"
53
+
54
+ @property
55
+ def is_open(self) -> bool:
56
+ return self.status == "open"
57
+
58
+ @property
59
+ def is_cancelled(self) -> bool:
60
+ return self.status == "cancelled"
61
+
62
+ def is_equal(self, order: "Order") -> bool:
63
+ return self.status == order.status
64
+
65
+ def is_not_equal(self, order: "Order") -> bool:
66
+ return not self.is_equal(order)
67
+
68
+
69
+ class State(BaseModel):
70
+ base: str
71
+ total_base: float
72
+ total_quote: float
73
+ orders: list[Order] = []
74
+
75
+ @property
76
+ def number_of_transactions(self) -> int:
77
+ return len(self.filled_orders)
78
+
79
+ @property
80
+ def total_fee_paid(self) -> float:
81
+ return sum([o.fee for o in self.filled_orders])
82
+
83
+ @property
84
+ def filled_orders(self) -> list[Order]:
85
+ return [o for o in self.orders if o.is_filled]
86
+
87
+ @property
88
+ def open_orders(self) -> list[Order]:
89
+ return [o for o in self.orders if o.is_open]
90
+
91
+ @property
92
+ def cancelled_orders(self) -> list[Order]:
93
+ return [o for o in self.orders if o.is_cancelled]
94
+
95
+ @property
96
+ def base_on_hold(self) -> float:
97
+ return sum([order.on_hold for order in self.sell_orders])
98
+
99
+ @property
100
+ def quote_on_hold(self) -> float:
101
+ return sum([order.on_hold for order in self.buy_orders])
102
+
103
+ @property
104
+ def base_available(self) -> float:
105
+ return self.total_base - self.base_on_hold
106
+
107
+ @property
108
+ def quote_available(self) -> float:
109
+ return self.total_quote - self.quote_on_hold
110
+
111
+ @property
112
+ def buy_orders(self) -> list[Order]:
113
+ return [o for o in self.open_orders if o.side == "buy"]
114
+
115
+ @property
116
+ def sell_orders(self) -> list[Order]:
117
+ return [o for o in self.open_orders if o.side == "sell"]
118
+
119
+ def add_order(self, order: Order) -> None:
120
+ self.orders.append(order)
121
+
122
+ def get_order(self, order_id: str) -> Order:
123
+ list_found_order = [o for o in self.orders if o.order_id == order_id]
124
+ if len(list_found_order) == 0:
125
+ raise NoOrderFoundError(order_id)
126
+ elif len(list_found_order) > 1:
127
+ raise MultipleOrdersFoundError(order_id)
128
+ return list_found_order[0]
129
+
130
+ def cancel_order(self, order: Order) -> None:
131
+ found_order = self.get_order(order.order_id)
132
+ found_order.status = "cancelled"
133
+
134
+ def process_filled_order(self, filled_order: Order) -> None:
135
+ if filled_order.side == "buy":
136
+ quote_mutation = -(filled_order.filled_quote + filled_order.fee)
137
+ base_mutation = filled_order.filled
138
+ else:
139
+ quote_mutation = filled_order.filled_quote - filled_order.fee
140
+ base_mutation = -filled_order.filled
141
+
142
+ self.total_quote += quote_mutation
143
+ self.total_base += base_mutation
144
+
145
+ if filled_order.is_taker:
146
+ self.add_order(filled_order)
147
+ else:
148
+ found_order = self.get_order(filled_order.order_id)
149
+ found_order.status = "filled"
150
+
151
+ self._check_non_negative()
152
+
153
+ def _check_non_negative(self) -> None:
154
+ if self.base_available < -1e-9:
155
+ raise ValueError(f"Negative base balance: {self.base_available}")
156
+ if self.quote_available < -1e-9:
157
+ raise ValueError(f"Negative quote balance: {self.quote_available}")
158
+
159
+ def total_value_in_base(self, price: float) -> float:
160
+ return self.total_base + self.total_quote / price
161
+
162
+ def total_value_in_quote(self, price: float) -> float:
163
+ return self.total_quote + self.total_base * price
164
+
165
+ def fraction_value_in_quote(self, price: float) -> float:
166
+ return self.total_quote / max(self.total_value_in_quote(price), 0.00000001)
167
+
168
+ def fraction_value_in_base(self, price: float) -> float:
169
+ return 1 - self.fraction_value_in_quote(price)
170
+
171
+
172
+ class ExchangeAssetClient(ABC):
173
+ def __init__(self, state: State) -> None:
174
+ self.state = state
175
+
176
+ def clear_credentials(self) -> None:
177
+ raise MethodNotDefinedError()
178
+
179
+ def set_credentials(self, credentials: Credentials) -> None:
180
+ raise MethodNotDefinedError()
181
+
182
+ @abstractmethod
183
+ def place_limit_buy_order(
184
+ self, base: str, limit_price: float, amount_in_quote: float
185
+ ) -> Order:
186
+ pass
187
+
188
+ @abstractmethod
189
+ def place_limit_sell_order(
190
+ self, base: str, limit_price: float, amount_in_base: float
191
+ ) -> Order:
192
+ pass
193
+
194
+ @abstractmethod
195
+ def place_market_buy_order(self, base: str, amount_in_quote: float) -> Order:
196
+ pass
197
+
198
+ @abstractmethod
199
+ def place_market_sell_order(self, base: str, amount_in_base: float) -> Order:
200
+ pass
201
+
202
+ @abstractmethod
203
+ def get_order_info(self, order: Order) -> Order:
204
+ pass
205
+
206
+ @abstractmethod
207
+ def cancel_order(self, order: Order) -> Order:
208
+ pass
209
+
210
+ def update_state(self) -> None:
211
+ for order in self.state.open_orders:
212
+ newest_info_order = self.get_order_info(order)
213
+ if order.is_not_equal(newest_info_order):
214
+ self.state.process_filled_order(newest_info_order)
215
+
216
+
217
+ class BacktestExchangeAssetClient(ExchangeAssetClient):
218
+ def __init__(
219
+ self, state: State, fee_market_order: float, fee_limit_order: float
220
+ ) -> None:
221
+ super().__init__(state)
222
+ self.fee_market_order = fee_market_order
223
+ self.fee_limit_order = fee_limit_order
224
+ self.current_candle = pd.Series({"high": 0, "low": 0})
225
+
226
+ def update_current_candle(self, current_candle: Series) -> None:
227
+ self.current_candle = current_candle
228
+
229
+ def place_limit_buy_order(
230
+ self, base: str, limit_price: float, amount_in_quote: float
231
+ ) -> Order:
232
+ order = Order(
233
+ order_id=str(uuid.uuid4()),
234
+ exchange="bitvavo",
235
+ time_created=int(time.time()),
236
+ market=base,
237
+ side="buy",
238
+ limit_price=limit_price,
239
+ on_hold=amount_in_quote,
240
+ status="open",
241
+ is_taker=False,
242
+ )
243
+
244
+ self.state.add_order(order)
245
+
246
+ return order
247
+
248
+ def place_limit_sell_order(
249
+ self, base: str, limit_price: float, amount_in_base: float
250
+ ) -> Order:
251
+ order = Order(
252
+ order_id=str(uuid.uuid4()),
253
+ exchange="bitvavo",
254
+ time_created=int(time.time()),
255
+ market=base,
256
+ side="sell",
257
+ limit_price=limit_price,
258
+ on_hold=amount_in_base,
259
+ status="open",
260
+ is_taker=False,
261
+ )
262
+
263
+ self.state.add_order(order)
264
+
265
+ return order
266
+
267
+ def place_market_buy_order(self, base: str, amount_in_quote: float) -> Order:
268
+ fee = amount_in_quote * self.fee_market_order / (1 + self.fee_market_order)
269
+ amount_in_base = (amount_in_quote - fee) / self.current_candle.close
270
+
271
+ order = Order(
272
+ order_id=str(uuid.uuid4()),
273
+ exchange="bitvavo",
274
+ time_created=int(time.time()),
275
+ market=base,
276
+ side="buy",
277
+ filled=amount_in_base,
278
+ filled_quote=amount_in_quote - fee,
279
+ status="filled",
280
+ fee=fee,
281
+ is_taker=True,
282
+ )
283
+
284
+ self.state.process_filled_order(order)
285
+
286
+ return order
287
+
288
+ def place_market_sell_order(self, base: str, amount_in_base: float) -> Order:
289
+ amount_in_quote = amount_in_base * self.current_candle.close
290
+ fee = amount_in_quote * self.fee_market_order
291
+
292
+ order = Order(
293
+ order_id=str(uuid.uuid4()),
294
+ exchange="bitvavo",
295
+ time_created=int(time.time()),
296
+ market=base,
297
+ side="sell",
298
+ filled=amount_in_base,
299
+ filled_quote=amount_in_quote,
300
+ status="filled",
301
+ fee=fee,
302
+ is_taker=True,
303
+ )
304
+
305
+ self.state.process_filled_order(order)
306
+
307
+ return order
308
+
309
+ def get_order_info(self, order: Order) -> Order:
310
+ found_order = self.state.get_order(order.order_id)
311
+ if found_order.status == "open":
312
+ is_filled = (
313
+ found_order.side == "buy"
314
+ and found_order.limit_price >= self.current_candle.low
315
+ ) or (
316
+ found_order.side == "sell"
317
+ and found_order.limit_price <= self.current_candle.high
318
+ )
319
+ if is_filled:
320
+ return self.fill_open_order(found_order)
321
+ return found_order
322
+
323
+ def cancel_order(self, order: Order) -> Order:
324
+ self.state.cancel_order(order)
325
+ return order
326
+
327
+ def fill_open_order(self, order: Order) -> Order:
328
+ fee_limit_order = self.fee_limit_order
329
+ if order.status != "open":
330
+ raise ValueError("only open orders can be filled")
331
+ if order.side == "buy":
332
+ fee = order.on_hold * fee_limit_order / (1 + fee_limit_order)
333
+ filled_quote = order.on_hold - fee
334
+ filled = filled_quote / order.limit_price # type: ignore
335
+ else:
336
+ filled = order.on_hold
337
+ filled_quote = order.on_hold * order.limit_price # type: ignore
338
+ fee = filled_quote * fee_limit_order
339
+ return Order(
340
+ order_id=order.order_id,
341
+ exchange=order.exchange,
342
+ time_created=order.time_created,
343
+ market=order.market,
344
+ side=order.side,
345
+ limit_price=order.limit_price,
346
+ on_hold=0,
347
+ status="filled",
348
+ is_taker=False,
349
+ fee=fee,
350
+ filled_quote=filled_quote,
351
+ filled=filled,
352
+ )
353
+
354
+
355
+ def order_from_bitvavo_response(response: dict) -> Order:
356
+ return Order(
357
+ exchange="bitvavo",
358
+ order_id=response["orderId"],
359
+ market=response["market"],
360
+ time_created=int(response["created"]),
361
+ time_canceled=None,
362
+ time_filled=(
363
+ max([int(fill["timestamp"]) for fill in response["fills"]])
364
+ if len(response["fills"]) > 0
365
+ else None
366
+ ),
367
+ on_hold=float(response["onHold"]),
368
+ side=response["side"],
369
+ limit_price=float(response["price"]) if "price" in response else None,
370
+ actual_price=(
371
+ float(response["filledAmountQuote"]) / float(response["filledAmount"])
372
+ if float(response["filledAmount"]) > 0
373
+ else None
374
+ ),
375
+ filled=float(response["filledAmount"]),
376
+ filled_quote=float(response["filledAmountQuote"]),
377
+ fee=float(response["feePaid"]),
378
+ is_taker=response["fills"][0]["taker"] if response["fills"] else False,
379
+ status=(
380
+ response["status"]
381
+ if response["status"] in ["filled", "cancelled"]
382
+ else "open"
383
+ ),
384
+ )
385
+
386
+
387
+ class BitvavoExchangeAssetClient(ExchangeAssetClient):
388
+ max_fee = 0.0025
389
+
390
+ def __init__(
391
+ self,
392
+ state: State,
393
+ bitvavo_api_key: str,
394
+ bitvavo_api_secret_key: str,
395
+ operator_id: int,
396
+ logger: logging.Logger
397
+ ) -> None:
398
+ super().__init__(state)
399
+ self.operator_id = operator_id
400
+ self.bitvavo = Bitvavo(
401
+ {
402
+ "APIKEY": bitvavo_api_key,
403
+ "APISECRET": bitvavo_api_secret_key,
404
+ "RESTURL": "https://api.bitvavo.com/v2",
405
+ "WSURL": "wss://ws.bitvavo.com/v2/",
406
+ "ACCESSWINDOW": 10000,
407
+ "DEBUGGING": False,
408
+ }
409
+ )
410
+ self.logger = logger
411
+
412
+ def set_credentials(self, api_key: str, api_secret_key: str) -> None:
413
+ self.bitvavo.APIKEY = api_key
414
+ self.bitvavo.APISECRET = api_secret_key
415
+
416
+ def clear_credentials(self) -> None:
417
+ self.bitvavo.APIKEY = ""
418
+ self.bitvavo.APISECRET = ""
419
+
420
+ def quantity_decimals(self, base: str) -> int:
421
+ trading_pair = base + "-EUR"
422
+ return self.bitvavo.markets(
423
+ {'market': trading_pair}
424
+ )['quantityDecimals']
425
+
426
+ @staticmethod
427
+ def __closest_valid_price(price: float) -> float:
428
+ getcontext().prec = 20
429
+ price = Decimal(str(price))
430
+ x = 0
431
+
432
+ ten = Decimal("10")
433
+
434
+ if price > 1:
435
+ while price / (ten**x) > 1:
436
+ x += 1
437
+ else:
438
+ while price / (ten**x) < 1:
439
+ x -= 1
440
+ x += 1
441
+
442
+ shifted = price / (ten**x)
443
+ rounded = shifted.quantize(Decimal("1.00000"), rounding=ROUND_DOWN)
444
+ corrected = rounded * (ten**x)
445
+
446
+ return float(corrected)
447
+
448
+ @retry(wait=wait_fixed(2), stop=stop_after_attempt(2))
449
+ def place_limit_buy_order(
450
+ self, base: str, limit_price: float, amount_in_quote: float
451
+ ) -> Order:
452
+ trading_pair = base + "-EUR"
453
+ limit_price = self.__closest_valid_price(price=float(limit_price))
454
+
455
+ amount_in_base = round(
456
+ (float(amount_in_quote) - 0.01) / (limit_price * (1 + self.max_fee)),
457
+ self.quantity_decimals(base),
458
+ )
459
+
460
+ response = self.bitvavo.placeOrder(
461
+ market=trading_pair,
462
+ side="buy",
463
+ orderType="limit",
464
+ body={
465
+ "amount": str(amount_in_base),
466
+ "price": str(limit_price),
467
+ "operatorId": self.operator_id,
468
+ },
469
+ )
470
+ if "errorCode" in response:
471
+ true_balance = self.bitvavo.balance({"symbol": "EUR"})[0]
472
+ error_message = \
473
+ f"""
474
+ failed to place limit buy-order\n\n
475
+
476
+ trading_pair: {trading_pair}\n
477
+ limit_price: {limit_price}\n
478
+ amount_in_quote: {amount_in_quote}\n\n
479
+ available: {true_balance['available']}\n\n
480
+ in orders: {true_balance['inOrder']}\n\n
481
+
482
+ error: \n\n
483
+
484
+ {response}
485
+ """
486
+ self.logger.error(error_message)
487
+ raise PlaceOrderError(error_message)
488
+
489
+ self.logger.info(f"limit order placed: {json.dumps(response)}")
490
+ order = order_from_bitvavo_response(response)
491
+ self.state.add_order(order)
492
+ return order
493
+
494
+ @retry(wait=wait_fixed(2), stop=stop_after_attempt(2))
495
+ def place_limit_sell_order(
496
+ self, base: str, limit_price: float, amount_in_base: float
497
+ ) -> Order:
498
+ trading_pair = base + "-EUR"
499
+
500
+ quantity_decimals = self.quantity_decimals(base)
501
+
502
+ factor = 1 / (10 ** quantity_decimals)
503
+ amount_in_base = round(
504
+ (float(amount_in_base) // factor) * factor,
505
+ quantity_decimals,
506
+ )
507
+
508
+ limit_price = self.__closest_valid_price(price=float(limit_price))
509
+
510
+ response = self.bitvavo.placeOrder(
511
+ market=trading_pair,
512
+ side="sell",
513
+ orderType="limit",
514
+ body={
515
+ "amount": str(amount_in_base),
516
+ "price": str(limit_price),
517
+ "operatorId": self.operator_id,
518
+ },
519
+ )
520
+ if "errorCode" in response:
521
+ true_balance = self.bitvavo.balance({"symbol": base})[0]
522
+ error_message = \
523
+ f"""
524
+ failed to place limit buy-order\n\n
525
+
526
+ trading_pair: {trading_pair}\n
527
+ limit_price: {limit_price}\n
528
+ amount_in_base: {amount_in_base}\n\n
529
+ available: {true_balance['available']}\n\n
530
+ in orders: {true_balance['inOrder']}\n\n
531
+
532
+ error: \n\n
533
+
534
+ {response}
535
+ """
536
+ self.logger.error(error_message)
537
+ raise PlaceOrderError(error_message)
538
+
539
+ self.logger.info(f"limit order placed: {json.dumps(response)}")
540
+ order = order_from_bitvavo_response(response)
541
+ self.state.add_order(order)
542
+ return order
543
+
544
+ # @retry(wait=wait_fixed(2), stop=stop_after_attempt(2))
545
+ def place_market_buy_order(
546
+ self, base: str, amount_in_quote: float
547
+ ) -> Order:
548
+ trading_pair = base + "-EUR"
549
+
550
+ amount_in_quote = str(round(float(amount_in_quote), 2))
551
+
552
+ response = self.bitvavo.placeOrder(
553
+ market=trading_pair,
554
+ side="buy",
555
+ orderType="market",
556
+ body={"amountQuote": amount_in_quote, "operatorId": self.operator_id},
557
+ )
558
+ if "errorCode" in response:
559
+
560
+ error_message = \
561
+ f"""
562
+ failed to place market buy-order\n\n
563
+ trading_pair: {trading_pair}\n
564
+ amount_in_quote: {amount_in_quote}\n\n
565
+ error:\n\n
566
+ {response}
567
+ """
568
+
569
+ self.logger.error(error_message)
570
+ raise PlaceOrderError(error_message)
571
+
572
+ self.logger.info(f"market buy order placed: {json.dumps(response)}")
573
+ order = order_from_bitvavo_response(response)
574
+ time.sleep(3)
575
+ order = self.get_order_info(order)
576
+ self.state.process_filled_order(order)
577
+ return order
578
+
579
+ @retry(wait=wait_fixed(2), stop=stop_after_attempt(2))
580
+ def place_market_sell_order(
581
+ self, base: str, amount_in_base: float
582
+ ) -> Order:
583
+ trading_pair = base + "-EUR"
584
+ quantity_decimals = self.quantity_decimals(base)
585
+
586
+ factor = 1 / (10 ** quantity_decimals)
587
+ amount_in_base = round(
588
+ (float(amount_in_base) // factor) * factor,
589
+ quantity_decimals,
590
+ )
591
+ response = self.bitvavo.placeOrder(
592
+ market=trading_pair,
593
+ side="sell",
594
+ orderType="market",
595
+ body={"amount": str(amount_in_base), "operatorId": self.operator_id},
596
+ )
597
+ if "errorCode" in response:
598
+ error_message = \
599
+ f"""
600
+ failed to place market sell-order\n\n
601
+ trading_pair: {trading_pair}\n
602
+ amount_in_base: {amount_in_base}\n\n
603
+ error:\n\n
604
+ {response}
605
+ """
606
+ self.logger.error(error_message)
607
+ raise PlaceOrderError(error_message)
608
+
609
+ self.logger.info(f"market sell order placed: {json.dumps(response)}")
610
+ order = order_from_bitvavo_response(response)
611
+ time.sleep(3)
612
+ order = self.get_order_info(order)
613
+ self.state.process_filled_order(order)
614
+ return order
615
+
616
+ @retry(wait=wait_fixed(2), stop=stop_after_attempt(2))
617
+ def get_order_info(
618
+ self, order: Order
619
+ ) -> Order:
620
+
621
+ response = self.bitvavo.getOrder(
622
+ market=order.market, orderId=order.order_id
623
+ )
624
+ if "errorCode" in response:
625
+ error_message = \
626
+ f"""
627
+ failed to retrieve order information\n\n
628
+ order: {order.order_id}\n
629
+ trading_pair: {order.market}\n\n
630
+ error: \n\n
631
+
632
+ {response}
633
+ """
634
+ self.logger.exception(error_message)
635
+ raise GetOrderInfoError(response)
636
+ return order_from_bitvavo_response(response)
637
+
638
+ @retry(wait=wait_fixed(2), stop=stop_after_attempt(2))
639
+ def cancel_order(self, order: Order) -> Order:
640
+ response = self.bitvavo.cancelOrder(
641
+ market=order.market,
642
+ orderId=order.order_id,
643
+ operatorId=self.operator_id,
644
+ )
645
+ if "errorCode" in response:
646
+ error_message = \
647
+ f"""
648
+ failed to cancel order\n\n
649
+ trading_pair: {order.market}\n
650
+ order_id: {order.order_id}\n\n
651
+ error:\n\n
652
+
653
+ {response}
654
+ """
655
+
656
+ self.logger.exception(error_message)
657
+
658
+ if not (
659
+ response["errorCode"] == 240 and
660
+ "No active order found." in response["error"]
661
+ ):
662
+ raise CancelOrderError(response)
663
+
664
+ self.logger.info(f"order cancelled: {order.order_id}")
665
+ self.state.cancel_order(order)
dijkies/logger.py ADDED
@@ -0,0 +1,16 @@
1
+ import logging
2
+
3
+
4
+ def get_logger() -> logging.Logger:
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ if not logger.handlers: # Prevent adding multiple handlers in interactive use
9
+ handler = logging.StreamHandler()
10
+ handler.setFormatter(
11
+ logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
12
+ )
13
+ logger.addHandler(handler)
14
+
15
+ logger.setLevel(logging.INFO)
16
+ return logger