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

1import shutil 

2import time 

3from datetime import datetime, timedelta, timezone 

4from time import sleep 

5 

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 

14 

15from django_napse.utils.constants import DEFAULT_TAX, DOWNLOAD_STATUS, SIDES 

16from django_napse.utils.usefull_functions import round_down, round_up 

17 

18 

19class BinanceController: 

20 """Commmunication interface with binance account.""" 

21 

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 

42 

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 

47 

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 

59 

60 return {"error": 408} 

61 

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} 

74 

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() 

82 

83 except ConnectionError: 

84 continue 

85 return [{"error": 408}] 

86 

87 def buy_market(self, pair: str, quantity: float) -> dict: 

88 """Place a spot buy market order. 

89 

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 

96 

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} 

107 

108 def sell_market(self, pair: str, quantity: float) -> dict: 

109 """Place a sell market order. 

110 

111 !!! WARNING: quantity is in quote currency !!! 

112 """ 

113 meter = 0 

114 while meter < self.nb_retry: 

115 meter += 1 

116 

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} 

124 

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 

128 

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 ) 

137 

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) 

150 

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 ) 

160 

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() 

175 

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 ) 

186 

187 dataset.completion = 100 

188 dataset.set_idle() 

189 dataset.save() 

190 return klines 

191 

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") 

204 

205 output_data = [] 

206 

207 timeframe = interval_to_milliseconds(interval) 

208 

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) 

213 

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 

218 

219 idx = 0 

220 start_time = time.time() 

221 last_percentage_saved = 0 

222 

223 dataset.save() 

224 

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 ) 

256 

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="") 

284 

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() 

292 

293 if not len(temp_data) or len(temp_data) < limit: 

294 break 

295 

296 start_ts = temp_data[-1][0] + timeframe 

297 if end_ts and start_ts >= end_ts: 

298 break 

299 

300 if verbose > 0: 

301 print() 

302 return output_data 

303 

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 

322 

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") 

333 

334 start_time = datetime.now(tz=UTC) 

335 

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) 

339 

340 dataset = DataSet.objects.get(controller=controller) 

341 

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) 

351 

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 

375 

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 

400 

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"}, {}, {} 

412 

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 

428 

429 else: 

430 receipt = {"error": "Amount too low"} 

431 

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"} 

445 

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 

451 

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 

459 

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) 

465 

466 pair = base + quote 

467 side_buy = side == SIDES.BUY 

468 

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 

472 

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 } 

498 

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