Coverage for django_napse/utils/trading/binance_controller.py: 52%
303 statements
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-12 13:49 +0000
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-12 13:49 +0000
1import shutil
2import time
3from datetime import datetime, timedelta, timezone
4from time import sleep
6import binance.enums as binance_enums
7from binance.client import Client
8from binance.enums import HistoricalKlinesType
9from binance.exceptions import BinanceAPIException
10from binance.helpers import convert_ts_str, interval_to_milliseconds
11from django.apps import apps
12from pytz import UTC
13from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
15from django_napse.utils.constants import DEFAULT_TAX, DOWNLOAD_STATUS, SIDES
16from django_napse.utils.usefull_functions import round_down, round_up
19class BinanceController:
20 """Commmunication interface with binance account."""
22 def __init__(self, public_key, private_key):
23 client_created = False
24 retries = 0
25 while not client_created:
26 try:
27 self.client = Client(public_key, private_key)
28 client_created = True
29 except ConnectTimeout:
30 print(f"ConnectionTimout error while creating client (try: {retries}), retrying...")
31 time.sleep(1)
32 except ConnectionError:
33 print(f"ConnectionError error while creating client (try: {retries}), retrying...")
34 time.sleep(1)
35 except ReadTimeout:
36 print(f"ReadTimeout error while creating client (try: {retries}), retrying...")
37 time.sleep(1)
38 else:
39 if retries > 0:
40 print(f"Client created after {retries} retries.")
41 retries += 1
43 self.client.get_historical_klines = self._get_historical_klines
44 self.client._historical_klines = self._historical_klines
45 self.nb_retry: int = 3
46 self.recv: int = 5000
48 def get_info(self) -> dict:
49 """Get the data of the binance account."""
50 meter = 0
51 while meter < self.nb_retry:
52 meter += 1
53 try:
54 return self.client.get_account(recvWindow=self.recv)
55 except ReadTimeout:
56 continue
57 except BinanceAPIException:
58 continue
60 return {"error": 408}
62 def get_balance(self, ticker: str) -> dict:
63 """Return balance of the selected ticker."""
64 meter = 0
65 while meter < self.nb_retry:
66 meter += 1
67 try:
68 return self.client.get_asset_balance(asset=ticker.upper(), recvWindow=self.recv)
69 except ReadTimeout:
70 continue
71 except BinanceAPIException:
72 continue
73 return {"error": 408}
75 def get_tickers(self) -> list[dict]:
76 """Return all available tickers."""
77 meter = 0
78 while meter < self.nb_retry:
79 meter += 1
80 try:
81 return self.client.get_all_tickers()
83 except ConnectionError:
84 continue
85 return [{"error": 408}]
87 def buy_market(self, pair: str, quantity: float) -> dict:
88 """Place a spot buy market order.
90 !!! WARNING: quantity is in base currency !!!
91 """
92 Controller = apps.get_model("django_napse_core", "Controller")
93 meter = 0
94 while meter < self.nb_retry:
95 meter += 1
97 _, current_candle = Controller.get_candles(pair, "1m")
98 price = current_candle["C"]
99 quantity = quantity / price
100 try:
101 order = self.client.order_market_buy(symbol=pair.upper(), quantity=quantity, recvWindow=self.recv)
102 except BinanceAPIException:
103 continue
104 else:
105 return order
106 return {"error": 408}
108 def sell_market(self, pair: str, quantity: float) -> dict:
109 """Place a sell market order.
111 !!! WARNING: quantity is in quote currency !!!
112 """
113 meter = 0
114 while meter < self.nb_retry:
115 meter += 1
117 try:
118 order = self.client.order_market_sell(symbol=pair.upper(), quantity=quantity, recvWindow=self.recv)
119 except BinanceAPIException:
120 continue
121 else:
122 return order
123 return {"error": 408}
125 def test_market_order(self, pair: str, quantity: float, side_buy: bool = True) -> dict:
126 """Simulate a binance order."""
127 side = binance_enums.SIDE_BUY if side_buy else binance_enums.SIDE_SELL
129 return self.client.create_test_order(
130 symbol=pair.upper(),
131 side=side,
132 type=binance_enums.ORDER_TYPE_MARKET,
133 quantity=quantity,
134 # price = price,
135 recvWindow=self.recv,
136 )
138 def get_historical_klines(
139 self,
140 dataset,
141 start: datetime,
142 end: datetime,
143 pair: str,
144 interval: str = "1m",
145 limit: int = 1000,
146 verbose: int = 0,
147 ) -> None:
148 start_str = str(start)
149 end_str = str(end)
151 self.client.get_historical_klines(
152 pair=pair,
153 interval=interval,
154 start_str=start_str,
155 end_str=end_str,
156 limit=limit,
157 dataset=dataset,
158 verbose=verbose,
159 )
161 def _get_historical_klines(
162 self,
163 pair,
164 interval,
165 dataset,
166 start_str,
167 end_str,
168 limit=1000,
169 klines_type: HistoricalKlinesType = HistoricalKlinesType.SPOT,
170 verbose: int = 0,
171 ):
172 dataset.completion = 0
173 dataset.set_downloading()
174 dataset.save()
176 klines = self._historical_klines(
177 pair=pair,
178 interval=interval,
179 start_str=start_str,
180 end_str=end_str,
181 limit=limit,
182 klines_type=klines_type,
183 dataset=dataset,
184 verbose=verbose,
185 )
187 dataset.completion = 100
188 dataset.set_idle()
189 dataset.save()
190 return klines
192 def _historical_klines(
193 self,
194 pair,
195 interval,
196 dataset,
197 start_str=None,
198 end_str=None,
199 limit=1000,
200 klines_type: HistoricalKlinesType = HistoricalKlinesType.SPOT,
201 verbose: int = 0,
202 ):
203 Candle = apps.get_model("django_napse_simulations", "Candle")
205 output_data = []
207 timeframe = interval_to_milliseconds(interval)
209 start_ts = convert_ts_str(start_str)
210 if start_ts is not None:
211 first_valid_ts = self.client._get_earliest_valid_timestamp(pair, interval, klines_type)
212 start_ts = max(start_ts, first_valid_ts)
214 end_ts = convert_ts_str(end_str)
215 total_loops = int(round_up((end_ts - start_ts) / limit / timeframe, 0))
216 if end_ts and start_ts and end_ts <= start_ts:
217 return output_data
219 idx = 0
220 start_time = time.time()
221 last_percentage_saved = 0
223 dataset.save()
225 while True:
226 found = False
227 while not found:
228 try:
229 temp_data = self.client._klines(
230 klines_type=klines_type,
231 symbol=pair,
232 interval=interval,
233 limit=limit,
234 startTime=start_ts,
235 endTime=end_ts,
236 )
237 found = True
238 except ReadTimeout:
239 time.sleep(1)
240 except ConnectionError:
241 time.sleep(1)
242 dataset.create_candles(
243 [
244 Candle(
245 dataset=dataset,
246 open_time=datetime.fromtimestamp(float(candle[0]) // 1000, tz=timezone.utc),
247 open=float(candle[1]),
248 high=float(candle[2]),
249 low=float(candle[3]),
250 close=float(candle[4]),
251 volume=float(candle[5]),
252 )
253 for candle in temp_data
254 ],
255 )
257 idx += 1
258 current_time = time.time()
259 eta = (total_loops - idx) * (current_time - start_time) / idx
260 eta = timedelta(seconds=eta)
261 if verbose > 0:
262 elapsed = timedelta(seconds=current_time - start_time)
263 progress_str = f"Progress: {idx/total_loops*100:.2f} %"
264 eta_str = f" (eta: {eta} s)"
265 elapsed_str = f" (elapsed: {elapsed})"
266 loop_str = f" | Loop {idx}/{total_loops} completed"
267 loop_size_str = f" | Loop size: {limit} candles."
268 columns, rows = shutil.get_terminal_size()
269 full_str = ""
270 if columns <= len(progress_str):
271 pass
272 elif columns <= len(progress_str + eta_str):
273 full_str = progress_str
274 elif columns <= len(progress_str + eta_str + elapsed_str):
275 full_str = progress_str + eta_str
276 elif columns <= len(progress_str + eta_str + elapsed_str + loop_str):
277 full_str = progress_str + eta_str + elapsed_str
278 elif columns <= len(progress_str + eta_str + elapsed_str + loop_str + loop_size_str):
279 full_str = progress_str + eta_str + elapsed_str + loop_str
280 else:
281 full_str = progress_str + eta_str + elapsed_str + loop_str + loop_size_str
282 full_str += " " * (columns - len(full_str)) + "\r"
283 print(full_str, end="")
285 percentage = idx / total_loops * 100
286 if percentage - last_percentage_saved > 0.1:
287 last_percentage_saved = percentage
288 dataset.completion = percentage
289 dataset.last_update = datetime.now(tz=timezone.utc)
290 dataset.eta = eta
291 dataset.save()
293 if not len(temp_data) or len(temp_data) < limit:
294 break
296 start_ts = temp_data[-1][0] + timeframe
297 if end_ts and start_ts >= end_ts:
298 break
300 if verbose > 0:
301 print()
302 return output_data
304 def fill_dataset(self, start_date, end_date, dataset, batch_size: int = 1000, verbose: int = 0):
305 """Fill the dataset with historical data from the exchange."""
306 if verbose > 1:
307 print(
308 f"### Starting to download: Dataset: pair={dataset.controller.pair}, interval={dataset.controller.interval}\n",
309 f"\tstart={start_date}, end={end_date}",
310 sep="",
311 )
312 self.get_historical_klines(
313 start=start_date,
314 end=end_date,
315 pair=dataset.controller.pair,
316 interval=dataset.controller.interval,
317 limit=batch_size,
318 dataset=dataset,
319 verbose=verbose,
320 )
321 return dataset
323 def download(
324 self,
325 controller,
326 start_date: datetime,
327 end_date: datetime,
328 squash: bool = False,
329 verbose: int = 0,
330 ):
331 """Download all the missing data to complete the dataset."""
332 DataSet = apps.get_model("django_napse_simulations", "DataSet")
334 start_time = datetime.now(tz=UTC)
336 if end_date.second + end_date.microsecond != 0:
337 end_date = end_date.replace(second=0, microsecond=0)
338 end_date -= timedelta(microseconds=1)
340 dataset = DataSet.objects.get(controller=controller)
342 if datetime.now(tz=UTC) - dataset.last_update > timedelta(minutes=1):
343 dataset.status = DOWNLOAD_STATUS.IDLE
344 dataset.save()
345 start_wait_time = time.time()
346 while dataset.status == DOWNLOAD_STATUS.DOWNLOADING:
347 time.sleep(0.1)
348 if time.time() - start_wait_time > 10:
349 error_msg = "Dataset is currently being downloaded. Come back later."
350 raise TimeoutError(error_msg)
352 if squash:
353 dataset.delete()
354 self.download(verbose=verbose)
355 else:
356 if dataset.start_date is None or dataset.end_date is None:
357 dataset.save()
358 if dataset.start_date is None or dataset.end_date is None:
359 self.fill_dataset(start_date=start_date, end_date=end_date, dataset=dataset, verbose=verbose)
360 else:
361 if start_date < dataset.start_date:
362 end = end_date
363 end_date = dataset.start_date - timedelta(milliseconds=interval_to_milliseconds(dataset.controller.interval))
364 self.fill_dataset(start_date=start_date, end_date=end_date, dataset=dataset, verbose=verbose)
365 end_date = end
366 if end_date > dataset.end_date:
367 start = start_date
368 start_date = dataset.end_date
369 self.fill_dataset(start_date=start_date, end_date=end_date, dataset=dataset, verbose=verbose)
370 start_date = start
371 if verbose > 1:
372 print(f"### Finished downloading at {datetime.now(tz=UTC)} (took {datetime.now(tz=UTC) - start_time} seconds)")
373 dataset.save()
374 return dataset
376 def submit_order(
377 self,
378 controller,
379 aggregated_order: dict,
380 testing: bool,
381 ) -> tuple[dict, dict, dict]:
382 receipt = {}
383 receipt[SIDES.BUY], executed_amounts_buy, fees_buy = self.send_order_to_exchange(
384 side=SIDES.BUY,
385 amount=aggregated_order["buy_amount"],
386 controller=controller,
387 min_trade=aggregated_order["min_trade"],
388 price=aggregated_order["price"],
389 testing=testing,
390 )
391 receipt[SIDES.SELL], executed_amounts_sell, feel_sell = self.send_order_to_exchange(
392 side=SIDES.SELL,
393 amount=aggregated_order["sell_amount"],
394 controller=controller,
395 min_trade=aggregated_order["min_trade"],
396 price=aggregated_order["price"],
397 testing=testing,
398 )
399 return receipt, executed_amounts_buy, executed_amounts_sell, fees_buy, feel_sell
401 def send_order_to_exchange(
402 self,
403 controller,
404 side: str,
405 amount: float,
406 min_trade: float,
407 testing: bool,
408 price: float,
409 ) -> tuple[dict, dict]:
410 if amount == 0:
411 return {"error": "Amount too low"}, {}, {}
413 executed_amounts = {}
414 fees = {}
415 if testing:
416 if side == SIDES.BUY:
417 amount /= price
418 amount = round_down(amount, controller.lot_size)
419 if amount > min_trade:
420 receipt = self.test_order(amount, SIDES.BUY, price, quote=controller.quote, base=controller.base)
421 exec_quote = -float(receipt["cummulativeQuoteQty"])
422 exec_base = 0
423 for elem in receipt["fills"]:
424 exec_base += float(elem["qty"]) - float(elem["commission"])
425 fees[elem["commissionAsset"]] = fees.get(elem["commissionAsset"], 0) + float(elem["commission"])
426 executed_amounts[controller.quote] = exec_quote
427 executed_amounts[controller.base] = exec_base
429 else:
430 receipt = {"error": "Amount too low"}
432 elif side == SIDES.SELL:
433 amount = round_down(amount, controller.lot_size)
434 if amount > min_trade:
435 receipt = self.test_order(amount, SIDES.SELL, price, quote=controller.quote, base=controller.base)
436 exec_quote = float(receipt["cummulativeQuoteQty"])
437 exec_base = -float(receipt["origQty"])
438 for elem in receipt["fills"]:
439 exec_quote -= float(elem["commission"])
440 fees[elem["commissionAsset"]] = fees.get(elem["commissionAsset"], 0) + float(elem["commission"])
441 executed_amounts[controller.quote] = exec_quote
442 executed_amounts[controller.base] = exec_base
443 else:
444 receipt = {"error": "Amount too low"}
446 else:
447 # TODO: implement real orders
448 error_msg = "IRL orders are not implemented yet. (failsafe to prevent accidental irl orders)."
449 raise NotImplementedError(error_msg)
450 return receipt, executed_amounts, fees
452 def current_free_assets(self) -> dict:
453 assets = self.get_info()["balances"]
454 current = {}
455 for elem in assets:
456 if float(elem["free"]) > 0:
457 current[elem.get("asset")] = float(elem.get("free"))
458 return current
460 @staticmethod
461 def test_order(amount: float, side: str, price: float, base: str, quote: str) -> dict:
462 if side not in (SIDES.BUY, SIDES.SELL):
463 error_msg = f"Side must be BUY or SELL. Got {side}"
464 raise ValueError(error_msg)
466 pair = base + quote
467 side_buy = side == SIDES.BUY
469 executed_qty = amount
470 cummulative_quote_qty = amount * price
471 commission = executed_qty * DEFAULT_TAX["BINANCE"] / 100 if side_buy else cummulative_quote_qty * DEFAULT_TAX["BINANCE"] / 100
473 commission_asset = base if side_buy else quote
474 return {
475 "symbol": pair,
476 "orderId": None,
477 "orderListId": None,
478 "clientOrderId": None,
479 "transactTime": None,
480 "price": price,
481 "origQty": amount,
482 "executedQty": executed_qty,
483 "cummulativeQuoteQty": cummulative_quote_qty,
484 "status": "FILLED",
485 "timeInForce": "GTC",
486 "type": "MARKET",
487 "side": side,
488 "fills": [
489 {
490 "price": price,
491 "qty": amount,
492 "commission": commission,
493 "commissionAsset": commission_asset,
494 "tradeId": None,
495 },
496 ],
497 }
499 def executed_amounts(self, receipt: dict, current_free_assets_dict: dict, controller, testing: bool) -> dict:
500 executed = {}
501 commission = 0
502 if receipt == {} or receipt is None:
503 return {}
504 if testing:
505 for fill in receipt.get("fills"):
506 commission += float(fill.get("commission"))
507 if receipt.get("side") == SIDES.BUY:
508 base = receipt.get("fills")[0].get("commissionAsset")
509 quote = receipt.get("symbol").replace(base, "")
510 executed[base] = float(receipt.get("executedQty")) * (1 - DEFAULT_TAX["BINANCE"] / 100)
511 executed[quote] = -float(receipt.get("cummulativeQuoteQty"))
512 elif receipt.get("side") == SIDES.SELL:
513 quote = receipt.get("fills")[0].get("commissionAsset")
514 base = receipt.get("symbol").replace(quote, "")
515 executed[base] = -float(receipt.get("executedQty"))
516 executed[quote] = float(receipt.get("cummulativeQuoteQty")) * (1 - DEFAULT_TAX["BINANCE"] / 100)
517 else:
518 while self.current_free_assets(controller) == current_free_assets_dict:
519 print("Waiting for order to be executed...")
520 sleep(0.01)
521 new_free_assets = self.current_free_assets(controller)
522 for asset, amount in new_free_assets.items():
523 if amount != current_free_assets_dict.get(asset):
524 executed[asset] = amount - current_free_assets_dict.get(asset)
525 return executed