From c438441eda0977d2076499d04d23694d1f79e108 Mon Sep 17 00:00:00 2001 From: Carlos Wu Date: Mon, 11 Sep 2023 12:15:43 +0100 Subject: [PATCH] Stable maintenance (#495) * Trigger bot save and activation sequentially in the update deal action * Update streaming logging * Refactor create deal streaming into a separate spot streaming updates class * Add long bot autoswitch to margin short bot * Fix trade price division * Fix transfer qty to isolated in order to cover stop loss * Retry loan repayment logic * Fix bots not updating after deal is opened (update deal) * Fix login * Use current price for switch to long bot, so that out of date bots don't show bloated profits * Fix long bots streaming updates * Remove short buy from UI * Add isolated balance to db.balance * Add benchmark graph data endpoint * Build benchmark component and redux data * Profit and Loss data and visualization * Adjustments for real time balance calculations * Improve errors on failure * Clean up Web application --------- Co-authored-by: Carlos Wu Fei --- .gitignore | 1 + .vscode/launch.json | 32 +- api/account/account.py | 1 + api/account/assets.py | 109 ++++- api/account/routes.py | 20 +- api/account/schemas.py | 16 + api/apis.py | 23 +- api/autotrade/controller.py | 7 +- api/bots/controllers.py | 9 +- api/charts/models.py | 45 +- api/charts/routes.py | 4 +- api/deals/base.py | 17 +- api/deals/controllers.py | 68 +-- api/deals/margin.py | 434 ++++++++++++------ api/deals/spot.py | 41 +- api/market_updates.py | 14 + api/orders/controller.py | 40 +- api/streaming/socket_manager.py | 13 +- api/streaming/streaming_controller.py | 54 +-- api/tools/handle_error.py | 22 +- binquant | 1 + docs/binance-teapot-errors.md | 2 +- web/.env | 1 + web/package.json | 2 +- web/public/index.html | 2 +- web/src/assets/scss/paper-dashboard.scss | 1 - .../scss/paper-dashboard/_fixed-plugin.scss | 339 -------------- .../scss/paper-dashboard/_typography.scss | 26 +- .../scss/paper-dashboard/_utilities.scss | 20 +- web/src/components/BotCard.jsx | 14 +- web/src/components/GainersLosers.jsx | 4 +- web/src/components/MainTab.jsx | 49 -- web/src/components/SettingsInput.jsx | 8 +- web/src/pages/bots/Autotrade.jsx | 2 +- web/src/pages/bots/BotForm.jsx | 24 +- web/src/pages/bots/actions.js | 12 +- web/src/pages/bots/reducer.js | 86 ++-- web/src/pages/bots/saga.js | 12 +- web/src/pages/dashboard/Dashboard.jsx | 250 +++++----- .../dashboard/PortfolioBenchmarkChart.jsx | 67 ++- web/src/pages/dashboard/reducer.js | 58 +++ web/src/pages/dashboard/saga.js | 54 ++- web/src/pages/paper-trading/TestAutotrade.jsx | 1 - web/src/pages/paper-trading/TestBotForm.jsx | 15 +- web/src/pages/users/UserForm.jsx | 143 ------ web/src/pages/users/Users.jsx | 154 ------- web/src/pages/users/actions.js | 184 -------- web/src/pages/users/reducer.js | 103 ----- web/src/pages/users/saga.js | 85 ---- web/src/reducer.js | 5 +- web/src/request.js | 4 +- web/src/router/routes.js | 10 - web/src/sagas.js | 12 +- web/src/state/bots/actions.js | 3 + web/yarn.lock | 8 +- 55 files changed, 1091 insertions(+), 1640 deletions(-) create mode 160000 binquant delete mode 100644 web/src/assets/scss/paper-dashboard/_fixed-plugin.scss delete mode 100644 web/src/pages/users/UserForm.jsx delete mode 100644 web/src/pages/users/Users.jsx delete mode 100644 web/src/pages/users/actions.js delete mode 100644 web/src/pages/users/reducer.js delete mode 100644 web/src/pages/users/saga.js diff --git a/.gitignore b/.gitignore index b6efa1ddc..6957b102e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .vscode/settings.json .venv/ binbot-research/ +binquant/ ## # diff --git a/.vscode/launch.json b/.vscode/launch.json index 4899cf2ac..ed344a7da 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -42,16 +42,28 @@ "console": "internalConsole" }, { - "type": "pwa-chrome", + "name": "Python: Producer", + "type": "python", "request": "launch", - "name": "React: Web", - "url": "http://localhost:3000", - "webRoot": "${workspaceFolder}/web/src", - "preLaunchTask": "startApp", - "postDebugTask": "stopApp", - "sourceMapPathOverrides": { - "webpack:///src/*": "${webRoot}/*" - } - } + "program": "binquant/producer.py", + "console": "internalConsole", + "justMyCode": true + }, + { + "name": "Python: Consumer", + "type": "python", + "request": "launch", + "program": "binquant/consumer/__init__.py", + "console": "internalConsole", + "justMyCode": true + }, + { + "name": "Python: Test Producer", + "type": "python", + "request": "launch", + "program": "binquant/kafka_producer.py", + "console": "internalConsole", + "justMyCode": true + }, ] } diff --git a/api/account/account.py b/api/account/account.py index 6e442cc93..03cb3560d 100644 --- a/api/account/account.py +++ b/api/account/account.py @@ -12,6 +12,7 @@ from pymongo import MongoClient import os import pandas + class Account(BinbotApi): def __init__(self): self.db = setup_db() diff --git a/api/account/assets.py b/api/account/assets.py index 4195d1932..5eeddd657 100644 --- a/api/account/assets.py +++ b/api/account/assets.py @@ -5,6 +5,8 @@ from account.account import Account from account.schemas import BalanceSchema from bson.objectid import ObjectId +from charts.models import CandlestickParams +from charts.models import Candlestick from db import setup_db from tools.handle_error import InvalidSymbol, json_response, json_response_message from tools.round_numbers import round_numbers @@ -74,7 +76,7 @@ def store_balance(self) -> dict: for b in bin_balance["data"]: # Only tether coins for hedging if b["asset"] == "NFT": - break + continue elif b["asset"] in ["USD", "USDT"]: qty = self._check_locked(b) total_usdt += qty @@ -88,6 +90,10 @@ def store_balance(self) -> dict: # Some coins like NFT are air dropped and cannot be traded break + isolated_balance_total = self.get_isolated_balance_total() + rate = self.get_ticker_price("BTCUSDT") + total_usdt += float(isolated_balance_total) * float(rate) + total_balance = { "time": current_time.strftime("%Y-%m-%d"), "balances": bin_balance["data"], @@ -115,8 +121,10 @@ def balance_estimate(self, fiat="USDT"): balances_response = self.get_raw_balance() # Isolated m isolated_margin = self.signed_request(url=self.isolated_account_url) - get_usdt_btc_rate = self.ticker(symbol=f'BTC{fiat}', json=False) - total_isolated_margin = float(isolated_margin["totalNetAssetOfBtc"]) * float(get_usdt_btc_rate["price"]) + get_usdt_btc_rate = self.ticker(symbol=f"BTC{fiat}", json=False) + total_isolated_margin = float(isolated_margin["totalNetAssetOfBtc"]) * float( + get_usdt_btc_rate["price"] + ) balances = json.loads(balances_response.body) total_fiat = 0 @@ -144,7 +152,7 @@ def balance_estimate(self, fiat="USDT"): "total_fiat": total_fiat + total_isolated_margin, "total_isolated_margin": total_isolated_margin, "fiat_left": left_to_allocate, - "asset": fiat + "asset": fiat, } if balance: resp = json_response({"data": balance}) @@ -164,7 +172,6 @@ def balance_series(self, fiat="GBP", start_time=None, end_time=None, limit=5): ) balances = [] for datapoint in snapshot_account_data["snapshotVos"]: - fiat_rate = self.get_ticker_price(f"BTC{fiat}") total_fiat = float(datapoint["data"]["totalAssetOfBtc"]) * float(fiat_rate) balance = { @@ -238,3 +245,95 @@ async def retrieve_gainers_losers(self, market_asset="USDT"): "data": gainers_losers_list, } ) + + def match_series_dates( + self, dates, balance_date, i: int = 0, count=0 + ) -> int | None: + if i == len(dates): + return None + + + for idx, d in enumerate(dates): + dt_obj = datetime.fromtimestamp(d / 1000) + str_date = datetime.strftime(dt_obj, "%Y-%m-%d") + + # Match balance store dates with btc price dates + if str_date == balance_date: + return idx + else: + print("Not able to match any BTC dates for this balance store date") + return None + + async def get_balance_series(self, end_date, start_date): + params = {} + + if start_date: + start_date = start_date * 1000 + try: + float(start_date) + except ValueError: + resp = json_response( + {"message": f"start_date must be a timestamp float", "data": []} + ) + return resp + + obj_start_date = datetime.fromtimestamp(int(float(start_date) / 1000)) + gte_tp_id = ObjectId.from_datetime(obj_start_date) + try: + params["_id"]["$gte"] = gte_tp_id + except KeyError: + params["_id"] = {"$gte": gte_tp_id} + + if end_date: + end_date = end_date * 1000 + try: + float(end_date) + except ValueError as e: + resp = json_response( + {"message": f"end_date must be a timestamp float: {e}", "data": []} + ) + return resp + + obj_end_date = datetime.fromtimestamp(int(float(end_date) / 1000)) + lte_tp_id = ObjectId.from_datetime(obj_end_date) + params["_id"]["$lte"] = lte_tp_id + + balance_series = list(self.db.balances.find(params).sort([("_id", -1)])) + + # btc candlestick data series + params = CandlestickParams( + limit=31, # One month - 1 (calculating percentages) worth of data to display + symbol="BTCUSDT", + interval="1d", + ) + + cs = Candlestick() + df, dates = cs.get_klines(params) + trace = cs.candlestick_trace(df, dates) + + balances_series_diff = [] + balances_series_dates = [] + balance_btc_diff = [] + balance_series.sort(key=lambda item: item["_id"], reverse=False) + + for index, item in enumerate(balance_series): + btc_index = self.match_series_dates(dates, item["time"], index) + if btc_index: + balances_series_diff.append(float(balance_series[index]["estimated_total_usdt"])) + balances_series_dates.append(item["time"]) + balance_btc_diff.append(float(trace["close"][btc_index])) + else: + continue + + resp = json_response( + { + "message": "Sucessfully rendered benchmark data.", + "data": { + "usdt": balances_series_diff, + "btc": balance_btc_diff, + "dates": balances_series_dates, + }, + "error": 0, + } + ) + return resp diff --git a/api/account/routes.py b/api/account/routes.py index 89106a42c..891d910bb 100644 --- a/api/account/routes.py +++ b/api/account/routes.py @@ -1,8 +1,8 @@ from fastapi import APIRouter - +from datetime import datetime, timedelta from account.account import Account from account.assets import Assets -from account.schemas import BalanceResponse, GainersLosersResponse +from account.schemas import BalanceResponse, GainersLosersResponse, BalanceSeriesResponse account_blueprint = APIRouter() @@ -50,26 +50,32 @@ def ticker_24(pair=None): return Account().ticker_24(symbol=pair) -@account_blueprint.get("/balance/estimate", tags=["account"]) +@account_blueprint.get("/balance/estimate", tags=["assets"]) async def balance_estimated(): return Assets().balance_estimate() -@account_blueprint.get("/balance/series", tags=["account"]) +@account_blueprint.get("/balance/series", tags=["assets"]) def balance_series(): return Assets().balance_series() -@account_blueprint.get("/pnl", tags=["account"]) +@account_blueprint.get("/pnl", tags=["assets"]) def get_pnl(): return Assets().get_pnl() -@account_blueprint.get("/store-balance", tags=["account"]) +@account_blueprint.get("/store-balance", tags=["assets"]) def store_balance(): return Assets().store_balance() -@account_blueprint.get("/gainers-losers", response_model=GainersLosersResponse, tags=["account"]) +@account_blueprint.get("/gainers-losers", response_model=GainersLosersResponse, tags=["assets"]) async def retrieve_gainers_losers(): return await Assets().retrieve_gainers_losers() + +@account_blueprint.get("/balance-series", response_model=BalanceSeriesResponse, tags=["assets"]) +async def get_balance_series(): + today = datetime.now() + month_ago = today - timedelta(30) + return await Assets().get_balance_series(start_date=datetime.timestamp(month_ago), end_date=datetime.timestamp(today)) diff --git a/api/account/schemas.py b/api/account/schemas.py index 5ed4e0a79..d48deb853 100644 --- a/api/account/schemas.py +++ b/api/account/schemas.py @@ -53,6 +53,12 @@ class Binance24Ticker(BaseModel): count: int +class BinanceBalance(BaseModel): + asset: str + free: float + locked: float + + class GainersLosersResponse(StandardResponse): data: list[Binance24Ticker] @@ -65,3 +71,13 @@ class EstimatedBalance(BaseModel): class EstimatedBalancesResponse(StandardResponse): data: EstimatedBalance + + +class BalanceSeries(StandardResponse): + usdt: list[float] + btc: list[float] + dates: list[str] + + +class BalanceSeriesResponse(StandardResponse): + data: list[BalanceSeries] diff --git a/api/apis.py b/api/apis.py index 7f67d18ca..dda4418d8 100644 --- a/api/apis.py +++ b/api/apis.py @@ -4,10 +4,9 @@ from urllib.parse import urlencode from time import time from requests import request -from tools.handle_error import handle_binance_errors, json_response, json_response_error +from tools.handle_error import handle_binance_errors, json_response, json_response_error, IsolateBalanceError from py3cw.request import Py3CW - class BinanceApi: """ Binance API URLs @@ -128,8 +127,26 @@ def get_isolated_balance(self, symbol=None): Use isolated margin account is preferrable, because this is the one that supports the most assets """ - info = self.signed_request(url=self.isolated_account_url, payload={"symbols": symbol}) + payload = {} + if symbol: + payload["symbols"] = [symbol] + info = self.signed_request(url=self.isolated_account_url, payload=payload) assets = info["assets"] + if len(assets) == 0: + raise IsolateBalanceError("Hit symbol 24hr restriction or not available (requires transfer in)") + return assets + + def get_isolated_balance_total(self): + """ + Get balance of Isolated Margin account + + Use isolated margin account is preferrable, + because this is the one that supports the most assets + """ + info = self.signed_request(url=self.isolated_account_url, payload={}) + assets = info['totalNetAssetOfBtc'] + if len(assets) == 0: + raise IsolateBalanceError("Hit symbol 24hr restriction or not available (requires transfer in)") return assets class BinbotApi(BinanceApi): diff --git a/api/autotrade/controller.py b/api/autotrade/controller.py index 43fecfcfd..d7b5de55d 100644 --- a/api/autotrade/controller.py +++ b/api/autotrade/controller.py @@ -19,11 +19,12 @@ def __init__( self, document_id: Literal["test_autotrade_settings", "settings"] = "settings" ): self.document_id = document_id - self.db = setup_db().research_controller + self.db = setup_db() + self.db_collection = self.db.research_controller def get_settings(self): try: - settings = self.db.find_one({"_id": self.document_id}) + settings = self.db_collection.find_one({"_id": self.document_id}) resp = json_response( {"message": "Successfully retrieved settings", "data": settings} ) @@ -40,7 +41,7 @@ def edit_settings(self, data): if "update_required" in settings: settings["update_required"] = time() - self.db.update_one({"_id": self.document_id}, {"$set": settings}) + self.db_collection.update_one({"_id": self.document_id}, {"$set": settings}) resp = json_response_message("Successfully updated settings") except TypeError as e: diff --git a/api/bots/controllers.py b/api/bots/controllers.py index 655723e18..282b1951e 100644 --- a/api/bots/controllers.py +++ b/api/bots/controllers.py @@ -7,13 +7,13 @@ from account.account import Account from deals.margin import MarginShortError -from db import setup_db from deals.controllers import CreateDealController from tools.enum_definitions import BinbotEnums from tools.exceptions import OpenDealError from tools.handle_error import ( NotEnoughFunds, QuantityTooLow, + IsolateBalanceError, handle_binance_errors, json_response, json_response_message, @@ -26,7 +26,7 @@ class Bot(Account): def __init__(self, collection_name="paper_trading"): - self.db = setup_db() + super().__init__() self.db_collection = self.db[collection_name] def _update_required(self): @@ -154,11 +154,12 @@ def edit(self, botId, data: BotSchema): resp = json_response( {"message": "Successfully updated bot", "botId": str(botId)} ) - self._update_required() except RequestValidationError as e: resp = json_response_error(f"Failed validation: {e}") except Exception as e: resp = json_response_error(f"Failed to create new bot: {e}") + + self._update_required() return resp def delete(self, bot_ids: List[str] = Query(...)): @@ -193,6 +194,8 @@ def activate(self, botId: str): except MarginShortError as error: message = str("Unable to create margin_short bot: ".join(error.args)) return json_response_error(message) + except IsolateBalanceError as error: + return json_response_error(error.message) except Exception as error: resp = json_response_error(f"Unable to activate bot: {error}") return resp diff --git a/api/charts/models.py b/api/charts/models.py index 6a7239ed1..b7e693c01 100644 --- a/api/charts/models.py +++ b/api/charts/models.py @@ -29,11 +29,9 @@ class CandlestickItemRequest(BaseModel): class CandlestickParams(BaseModel): symbol: str - interval: str # See EnumDefitions + interval: str # See EnumDefinitions limit: int = 600 - startTime: float | None = ( - None # starTime and endTime must be camel cased for the API - ) + startTime: float | None = None # starTime and endTime must be camel cased for the API endTime: float | None = None @@ -65,7 +63,7 @@ def replace_klines(self, data): def update_data(self, data, timestamp=None): """ - Function that specifically updates candlesticks. + Function that specifically updates candlesticks from websockets Finds existence of candlesticks and then updates with new stream kline data or adds new data """ new_data = data # This is not existent data but stream data from API @@ -98,7 +96,7 @@ def update_data(self, data, timestamp=None): return update_kline except Exception as e: return e - + def delete_klines(self): result = self.db.klines.delete_one({"symbol": self._id}) return result.acknowledged @@ -121,8 +119,12 @@ def delete_and_create_klines(self, params: CandlestickParams): df [Pandas dataframe] """ logging.info("Requesting and Cleaning db of incomplete data...") + if params.limit: + # Avoid inconsistencies in data + params.limit = 600 + data = self.request(url=self.candlestick_url, params=vars(params)) - klines_schema = KlinesSchema(params.symbol, params.interval, params.limit) + klines_schema = KlinesSchema(params.symbol, params.interval) klines = klines_schema.replace_klines(data) kline_df = pd.DataFrame(klines[params.interval]) return kline_df @@ -134,7 +136,7 @@ def check_gaps(self, df, params: CandlestickParams): @params - df [Pandas dataframe] """ - logging.info(f"Checking gaps in the kline data for {params.symbol}") + logging.warning(f"Checking gaps in the kline data for {params.symbol}") kline_df = df.copy(deep=True) df["gaps_check"] = df[0].diff()[1:] df = df.dropna() @@ -182,7 +184,6 @@ def get_klines(self, params): klines = None pass - klines_schema = KlinesSchema(params.symbol, params.interval) if not klines or not isinstance(klines[params.interval], list): if params.startTime: # Calculate diff start_time and end_time @@ -192,8 +193,7 @@ def get_klines(self, params): params.limit = ceil(diff_time / interval_to_millisecs(params.interval)) # Store more data for db to fill up candlestick charts - data = self.request(url=self.candlestick_url, params=jsonable_encoder(params)) - klines_schema.replace_klines(data) + self.delete_and_create_klines(params) df, dates = self.get_klines(params) return df, dates else: @@ -359,6 +359,28 @@ def get(self, symbol, interval="15m", limit=500, start_time: float | None=None, all_time_low = pd.to_numeric(df[3]).min() volumes = df[5].tolist() + + btc_params = CandlestickParams( + symbol="BTCUSDT", + interval="15m", + ) + btc_df, btc_dates = self.get_klines(btc_params) + df[1].astype(float) + open_price_r = df[1].astype(float).corr(btc_df[1].astype(float)) + high_price_r = df[2].astype(float).corr(btc_df[2].astype(float)) + low_price_r = df[3].astype(float).corr(btc_df[3].astype(float)) + close_price_r = df[4].astype(float).corr(btc_df[4].astype(float)) + volume_r = df[5].astype(float).corr(btc_df[5].astype(float)) + + # collection of correlations + p_btc = { + "open_price": open_price_r, + "high_price": high_price_r, + "low_price": low_price_r, + "close_price": close_price_r, + "volume": volume_r + } + resp = json_response( { "trace": [trace, ma_100, ma_25, ma_7], @@ -366,6 +388,7 @@ def get(self, symbol, interval="15m", limit=500, start_time: float | None=None, "volumes": volumes, "amplitude": amplitude, "all_time_low": all_time_low, + "btc_correlation": p_btc, } ) return resp diff --git a/api/charts/routes.py b/api/charts/routes.py index e2fdb75c1..884907984 100644 --- a/api/charts/routes.py +++ b/api/charts/routes.py @@ -30,8 +30,8 @@ def delete_klines(symbol): @charts_blueprint.get( "/candlestick", summary="Retrieved klines stored in DB", tags=["charts"] ) -def get(symbol: str, interval: str="15m", limit: int=500, start_time: float | None=None, end_time: float | None=None): +def get(symbol: str, interval: str="15m", limit: int=500, start_time: float | None=None, end_time: float | None=None, stats: bool = False): """ Retrieve existing candlestick data stored in DB from Binance """ - return Candlestick().get(symbol, interval, limit, start_time, end_time) + return Candlestick().get(symbol, interval, limit, start_time, end_time, stats) diff --git a/api/deals/base.py b/api/deals/base.py index e76ea7976..e7e4ad848 100644 --- a/api/deals/base.py +++ b/api/deals/base.py @@ -27,23 +27,10 @@ class BaseDeal(OrderController): Base Deal class to share with CreateDealController and MarginDeal """ - def __init__(self, bot, db_collection): + def __init__(self, bot, db_collection_name): self.active_bot = BotSchema.parse_obj(bot) - self.db = setup_db() - self.db_collection = self.db[db_collection] - self.decimal_precision = self.get_quote_asset_precision(self.active_bot.pair) - # PRICE_FILTER decimals - self.price_precision = -1 * ( - Decimal(str(self.price_filter_by_symbol(self.active_bot.pair, "tickSize"))) - .as_tuple() - .exponent - ) - self.qty_precision = -1 * ( - Decimal(str(self.lot_size_by_symbol(self.active_bot.pair, "stepSize"))) - .as_tuple() - .exponent - ) super().__init__() + self.db_collection = self.db[db_collection_name] def __repr__(self) -> str: """ diff --git a/api/deals/controllers.py b/api/deals/controllers.py index 1c0e8db85..84edec953 100644 --- a/api/deals/controllers.py +++ b/api/deals/controllers.py @@ -1,4 +1,3 @@ -import numpy import requests from bots.schemas import BotSchema @@ -6,20 +5,13 @@ from deals.margin import MarginDeal from deals.models import BinanceOrderModel from deals.schema import DealSchema, OrderSchema -from pydantic import ValidationError from pymongo import ReturnDocument -from requests.exceptions import HTTPError -from scipy.stats import linregress from tools.enum_definitions import Status from tools.exceptions import ( - OpenDealError, ShortStrategyError, TakeProfitError, - TraillingProfitError, ) from tools.handle_error import ( - NotEnoughFunds, - QuantityTooLow, encode_json, handle_binance_errors, ) @@ -46,6 +38,7 @@ class CreateDealController(BaseDeal): def __init__(self, bot, db_collection="paper_trading"): # Inherit from parent class super().__init__(bot, db_collection) + self.active_bot = BotSchema.parse_obj(bot) def get_one_balance(self, symbol="BTC"): # Response after request @@ -82,21 +75,16 @@ def base_order(self): # Long position does not need qty in take_profit # initial price with 1 qty should return first match - initial_price = float(self.matching_engine(pair, True)) + price = float(self.matching_engine(pair, True)) qty = round_numbers( - (float(self.active_bot.base_order_size) / float(initial_price)), + (float(self.active_bot.base_order_size) / float(price)), self.qty_precision, ) - price = float(self.matching_engine(pair, True, qty)) - # setup stop_loss_price stop_loss_price = 0 if float(self.active_bot.stop_loss) > 0: stop_loss_price = price - (price * (float(self.active_bot.stop_loss) / 100)) - if not price: - price = initial_price - if self.db_collection.name == "paper_trading": res = self.simulate_order( pair, supress_notation(price, self.price_precision), qty, "BUY" @@ -332,50 +320,6 @@ def update_take_profit(self, order_id): else: self.update_deal_logs("Error: Bot does not contain a base order deal") - - - - def execute_short_buy(self): - """ - Short strategy, buy after hitting a certain short_buy_price - - 1. Set parameters for short_buy - 2. Open new deal as usual - """ - self.active_bot.short_buy_price = 0 - self.active_bot.strategy = "long" - - try: - self.open_deal() - self.update_deal_logs("Successfully activated bot!") - - bot = encode_json(self.active_bot) - if "_id" in bot: - bot.pop("_id") - - self.db_collection.update_one( - {"id": self.active_bot.id}, - {"$set": bot}, - ) - - except ValidationError as error: - self.update_deal_logs(f"Short buy error: {error}") - return - except (TypeError, AttributeError) as error: - message = str(";".join(error.args)) - self.update_deal_logs(f"Short buy error: {message}") - return - except OpenDealError as error: - message = str(";".join(error.args)) - self.update_deal_logs(f"Short buy error: {message}") - except NotEnoughFunds as e: - message = str(";".join(e.args)) - self.update_deal_logs(f"Short buy error: {message}") - except Exception as error: - self.update_deal_logs(f"Short buy error: {error}") - return - return - def open_deal(self): """ @@ -408,7 +352,7 @@ def open_deal(self): if not base_order_deal: if self.active_bot.strategy == "margin_short": self.active_bot = MarginDeal( - bot=self.active_bot, db_collection=self.db_collection.name + bot=self.active_bot, db_collection_name=self.db_collection.name ).margin_short_base_order() else: bot = self.base_order() @@ -427,7 +371,7 @@ def open_deal(self): ): if self.active_bot.strategy == "margin_short": self.active_bot = MarginDeal( - bot=self.active_bot, db_collection=self.db_collection.name + bot=self.active_bot, db_collection_name=self.db_collection.name ).set_margin_short_stop_loss() else: buy_price = float(self.active_bot.deal.buy_price) @@ -445,7 +389,7 @@ def open_deal(self): and self.active_bot.strategy == "margin_short" ): self.active_bot = MarginDeal( - bot=self.active_bot, db_collection=self.db_collection.name + bot=self.active_bot, db_collection_name=self.db_collection.name ).set_margin_take_profit() # Keep trailling_stop_loss_price up to date in case of failure to update in autotrade diff --git a/api/deals/margin.py b/api/deals/margin.py index f8ce44988..207f6fcbd 100644 --- a/api/deals/margin.py +++ b/api/deals/margin.py @@ -13,13 +13,8 @@ from deals.base import BaseDeal from deals.schema import MarginOrderSchema from pydantic import ValidationError -from tools.handle_error import QuantityTooLow, encode_json, BinanceErrors -from tools.round_numbers import ( - round_numbers, - supress_notation, - supress_trailling, - round_numbers_ceiling, -) +from tools.handle_error import QuantityTooLow, IsolateBalanceError, BinanceErrors +from tools.round_numbers import round_numbers, supress_notation, round_numbers_ceiling class MarginShortError(Exception): @@ -27,11 +22,20 @@ class MarginShortError(Exception): class MarginDeal(BaseDeal): - def __init__(self, bot, db_collection: str) -> None: + def __init__(self, bot, db_collection_name: str) -> None: # Inherit from parent class - super().__init__(bot, db_collection) + super().__init__(bot, db_collection_name) self.isolated_balance = self.get_isolated_balance(self.active_bot.pair) + def _append_errors(self, error): + """ + Sets errors to be stored later with save_bot + as opposed to update_deal_errors which immediately saves + + This option consumes less memory, as we don't make a DB transaction + """ + self.active_bot.errors.append(error) + def simulate_margin_order(self, qty, side): price = float(self.matching_engine(self.active_bot.pair, True, qty)) order = { @@ -81,15 +85,32 @@ def compute_margin_buy_back( self.isolated_balance[0]["baseAsset"]["free"] ) - def get_remaining_quote_asset(self): - if ( - self.isolated_balance[0]["quoteAsset"]["free"] == 0 - or self.isolated_balance[0]["baseAsset"]["borrowed"] == 0 - ): - return None + def get_remaining_assets(self) -> tuple[float, float]: + """ + Get remaining isolated account assets + given current isolated balance of isolated pair + + if account has borrowed assets yet, it should also return the amount borrowed + + """ + if float(self.isolated_balance[0]["quoteAsset"]["borrowed"]) > 0: + self._append_errors( + f'Borrowed {self.isolated_balance[0]["quoteAsset"]["asset"]} still remaining, please clear out manually' + ) + self.active_bot.status = Status.error + + if float(self.isolated_balance[0]["baseAsset"]["borrowed"]) > 0: + self._append_errors( + f'Borrowed {self.isolated_balance[0]["baseAsset"]["asset"]} still remaining, please clear out manually' + ) + self.active_bot.status = Status.error + + quote_asset = float(self.isolated_balance[0]["quoteAsset"]["free"]) + base_asset = float(self.isolated_balance[0]["baseAsset"]["free"]) - qty = float(self.isolated_balance[0]["quoteAsset"]["free"]) - return round_numbers(qty, self.qty_precision) + return round_numbers(quote_asset, self.qty_precision), round_numbers( + base_asset, self.qty_precision + ) def cancel_open_orders(self, deal_type): """ @@ -107,15 +128,43 @@ def cancel_open_orders(self, deal_type): if order_id: try: # First cancel old order to unlock balance - self.cancel_margin_order(symbol=self.active_bot.pair, orderId=order_id) - self.update_deal_logs("Old take profit order cancelled") + self.cancel_margin_order(symbol=self.active_bot.pair, order_id=order_id) + self._append_errors("Old take profit order cancelled") except HTTPError as error: - self.update_deal_logs("Take profit order not found, no need to cancel") + self._append_errors("Take profit order not found, no need to cancel") return + except Exception as error: + # Most likely old error out of date orderId + if error.args[1] == -2011: + return + return - def init_margin_short(self, qty): + def terminate_failed_transactions(self): + """ + Transfer back from isolated account to spot account + Disable isolated pair (so we don't reach the limit) + """ + self.isolated_balance = self.get_isolated_balance(self.active_bot.pair) + qty = self.isolated_balance[0]["quoteAsset"]["free"] + self.transfer_isolated_margin_to_spot( + asset=self.active_bot.balance_to_use, + symbol=self.active_bot.pair, + amount=qty, + ) + try: + self.disable_isolated_margin_account(symbol=self.active_bot.pair) + except BinanceErrors as error: + if error.code == -1003: + self._append_errors("Isolated margin account can't be disabled within 24hrs, please disable manually") + all_errors = ". ".join(self.active_bot.errors) + # save error so it's available in the bot logs + self.save_bot_streaming() + # raise error so that it returns to the JSON response + raise MarginShortError(all_errors) + + def init_margin_short(self, initial_price): """ Pre-tasks for db_collection = bots These tasks are not necessary for paper_trading @@ -128,45 +177,154 @@ def init_margin_short(self, qty): # Check margin account balance first balance = float(self.isolated_balance[0]["quoteAsset"]["free"]) # always enable, it doesn't cause errors - self.enable_isolated_margin_account(symbol=self.active_bot.pair) - if balance == 0: + try: + self.enable_isolated_margin_account(symbol=self.active_bot.pair) + except BinanceErrors as error: + if error.code == -11001: + # Isolated margin account needs to be activated with a transfer + self.transfer_spot_to_isolated_margin( + asset=self.active_bot.balance_to_use, + symbol=self.active_bot.pair, + amount="1", + ) + + # Given USDT amount we want to buy, + # how much can we buy? + qty = round_numbers_ceiling( + (float(self.active_bot.base_order_size) / float(initial_price)), + self.qty_precision, + ) + if qty == 0: + raise QuantityTooLow("Margin short quantity is too low") + + # transfer quantity is base order size (what we want to invest) + stop loss to cover losses + stop_loss_price_inc = float(initial_price) * ( + 1 + (self.active_bot.stop_loss / 100) + ) + final_qty = float(stop_loss_price_inc * qty) + transfer_qty = round_numbers_ceiling( + final_qty, + self.qty_precision, + ) + + # For leftover values + # or transfers to activate isolated pair + # sometimes to activate an isolated pair we need to transfer sth + if balance <= 1: try: # transfer self.transfer_spot_to_isolated_margin( asset=self.active_bot.balance_to_use, symbol=self.active_bot.pair, - amount=self.active_bot.base_order_size, + amount=transfer_qty, ) except BinanceAPIException as error: + if error.code == -3041: + self.terminate_failed_transactions() + raise MarginShortError("Spot balance is not enough") if error.code == -11003: + self.terminate_failed_transactions() raise MarginShortError("Isolated margin not available") asset = self.active_bot.pair.replace(self.active_bot.balance_to_use, "") - # In the future, amount_to_borrow = base + base * (2.5) try: self.create_margin_loan( asset=asset, symbol=self.active_bot.pair, amount=qty ) - loan_details = self.get_margin_loan_details(asset=asset, isolatedSymbol=self.active_bot.pair) + loan_details = self.get_margin_loan_details( + asset=asset, isolatedSymbol=self.active_bot.pair + ) - self.active_bot.deal.margin_short_loan_timestamp = loan_details["rows"][0]["timestamp"] - self.active_bot.deal.margin_short_loan_principal = loan_details["rows"][0]["principal"] + self.active_bot.deal.margin_short_loan_timestamp = loan_details["rows"][0][ + "timestamp" + ] + self.active_bot.deal.margin_short_loan_principal = loan_details["rows"][0][ + "principal" + ] self.active_bot.deal.margin_loan_id = loan_details["rows"][0]["txId"] + self.active_bot.deal.margin_short_base_order = qty # Estimate interest to add to total cost asset = self.active_bot.pair.replace(self.active_bot.balance_to_use, "") # This interest rate is much more accurate than any of the others - hourly_fees = self.signed_request(url=self.isolated_hourly_interest, payload={"assets": asset, "isIsolated": "TRUE"}) - self.active_bot.deal.hourly_interest_rate = float(hourly_fees[0]["nextHourlyInterestRate"]) + hourly_fees = self.signed_request( + url=self.isolated_hourly_interest, + payload={"assets": asset, "isIsolated": "TRUE"}, + ) + self.active_bot.deal.hourly_interest_rate = float( + hourly_fees[0]["nextHourlyInterestRate"] + ) except BinanceErrors as error: - logging.error(error) - return + if error.args[1] == -3045: + msg = "Binance doesn't have any money to lend" + self._append_errors(msg) + self.terminate_failed_transactions() + raise MarginShortError(msg) except Exception as error: logging.error(error) return + def retry_repayment(self, query_loan, buy_back_fiat): + """ + Retry repayment for failed isolated transactions + """ + + balance = float(self.isolated_balance[0]["quoteAsset"]["free"]) + required_qty_quote = float(query_loan["rows"][0]["principal"]) - balance + current_price = float(self.matching_engine(self.active_bot.pair, False)) + total_base_qty = round_numbers_ceiling( + current_price * required_qty_quote, self.qty_precision + ) + qty = round_numbers_ceiling( + float(query_loan["rows"][0]["principal"]) + + float(self.isolated_balance[0]["baseAsset"]["interest"]), + self.qty_precision, + ) + try: + res = self.buy_margin_order( + symbol=self.active_bot.pair, qty=qty, price=current_price + ) + repay_order = MarginOrderSchema( + timestamp=res["transactTime"], + deal_type="stop_loss", + order_id=res["orderId"], + pair=res["symbol"], + order_side=res["side"], + order_type=res["type"], + price=res["price"], + qty=res["origQty"], + fills=res["fills"], + time_in_force=res["timeInForce"], + status=res["status"], + is_isolated=res["isIsolated"], + ) + + for chunk in res["fills"]: + self.active_bot.total_commission += float(chunk["commission"]) + + self.active_bot.orders.append(repay_order) + # Retrieve updated isolated balance again + self.isolated_balance = self.get_isolated_balance(self.active_bot.pair) + self.save_bot_streaming() + self.terminate_margin_short(buy_back_fiat) + except Exception as error: + print(error) + try: + self.transfer_spot_to_isolated_margin( + asset=self.active_bot.balance_to_use, + symbol=self.active_bot.pair, + amount=total_base_qty, + ) + self.retry_repayment(query_loan, buy_back_fiat) + except Exception as error: + print(error) + self._append_errors( + "Not enough SPOT balance to repay loan, need to liquidate manually" + ) + return + def terminate_margin_short(self, buy_back_fiat: bool = True): """ @@ -180,7 +338,9 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): 2. Exchange asset to quote asset (USDT) 3. Transfer back to spot """ - logging.info("Terminating margin_short tasks for real bots trading") + logging.info( + f"Terminating margin_short {self.active_bot.pair} for real bots trading" + ) # Check margin account balance first balance = float(self.isolated_balance[0]["quoteAsset"]["free"]) @@ -199,7 +359,7 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): ) if query_loan["total"] > 0 and repay_amount > 0: # Only supress trailling 0s, so that everything is paid - # repay_amount = supress_trailling(repay_amount) + repay_amount = round_numbers_ceiling(repay_amount, self.qty_precision) try: self.repay_margin_loan( asset=asset, @@ -207,15 +367,23 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): amount=repay_amount, isIsolated="TRUE", ) - # Complete bot here to avoid unimportant errors blocking completion - # Once margin loan is repaid it is closed profit - self.active_bot.status = Status.completed except BinanceAPIException as error: - if error.code == -3041 or error.code == -3015: + if error.code == -3041: # Most likely not enough funds to pay back # Get fiat (USDT) to pay back self.active_bot.errors.append(error.message) + if error.code == -3015: + # false alarm pass + except BinanceErrors as error: + if error.code == -3041: + self.retry_repayment(query_loan, buy_back_fiat) + pass + except Exception as error: + self.update_deal_logs(error) + # Continue despite errors to avoid losses + # most likely it is still possible to update bot + pass repay_details_res = self.get_margin_repay_details( asset=asset, isolatedSymbol=self.active_bot.pair @@ -232,14 +400,17 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): "timestamp" ] - if buy_back_fiat: + self.isolated_balance = self.get_isolated_balance(self.active_bot.pair) + sell_back_qty = supress_notation( + self.isolated_balance[0]["baseAsset"]["free"], + self.qty_precision, + ) + + if buy_back_fiat and float(sell_back_qty): # Sell quote and get base asset (USDT) # In theory, we should sell self.active_bot.base_order # but this can be out of sync - sell_back_qty = supress_notation( - self.isolated_balance[0]["baseAsset"]["free"], - self.qty_precision, - ) + res = self.sell_margin_order( symbol=self.active_bot.pair, qty=sell_back_qty ) @@ -273,55 +444,55 @@ def terminate_margin_short(self, buy_back_fiat: bool = True): ) else: - self.active_bot.status = Status.error self.active_bot.errors.append("Loan not found for this bot.") # Save in two steps, because it takes time for Binance to process repayments bot = self.save_bot_streaming() self.active_bot: BotSchema = BotSchema.parse_obj(bot) - if float(self.isolated_balance[0]["quoteAsset"]["free"]) != 0: - try: + try: + # get new balance + self.isolated_balance = self.get_isolated_balance(self.active_bot.pair) + print(f"Transfering leftover isolated assets back to Spot") + if float(self.isolated_balance[0]["quoteAsset"]["free"]) != 0: # transfer back to SPOT account self.transfer_isolated_margin_to_spot( asset=self.active_bot.balance_to_use, symbol=self.active_bot.pair, amount=self.isolated_balance[0]["quoteAsset"]["free"], ) - except BinanceAPIException as error: - logging.error(error) - - # if ( - # self.get_remaining_quote_asset() > 0 - # and float(self.isolated_balance[0]["baseAsset"]["free"]) > 0 - # ): - # transfer back any quote asset qty leftovers - print("Transfering base asset back to Spot") - self.transfer_isolated_margin_to_spot( - asset=asset, - symbol=self.active_bot.pair, - amount=self.isolated_balance[0]["baseAsset"]["free"], - ) + if float(self.isolated_balance[0]["baseAsset"]["free"]) != 0: + self.transfer_isolated_margin_to_spot( + asset=asset, + symbol=self.active_bot.pair, + amount=self.isolated_balance[0]["baseAsset"]["free"], + ) + except Exception as error: + error_msg = f"Failed to transfer isolated assets to spot: {error}" + logging.error(error_msg) + self.active_bot.errors.append(error_msg) + return # Disable isolated pair to avoid reaching the 15 pair limit # this is not always possible, sometimes there are small quantities # that can't be cleaned out completely, need to do it manually # this is ok, since this is not a hard requirement to complete the deal try: - self.disable_isolated_margin_account( - symbol=self.active_bot.pair - ) + self.disable_isolated_margin_account(symbol=self.active_bot.pair) except BinanceAPIException as error: logging.error(error) if error.code == -3051: - self.active_bot.errors.append(error.message) + self._append_errors(error.message) pass completion_msg = f"{self.active_bot.pair} ISOLATED margin funds transferred back to SPOT." + self.active_bot.status = Status.completed self.active_bot.errors.append(completion_msg) - bot = self.save_bot_streaming() - self.active_bot: BotSchema = BotSchema.parse_obj(bot) - return self.active_bot + + bot = self.save_bot_streaming() + self.active_bot = BotSchema.parse_obj(bot) + + return self.active_bot def margin_short_base_order(self): """ @@ -333,27 +504,17 @@ def margin_short_base_order(self): """ logging.info(f"Opening margin_short_base_order") initial_price = float(self.matching_engine(self.active_bot.pair, False)) - # Given USDT amount we want to buy, - # how much can we buy? - qty = round_numbers_ceiling( - (float(self.active_bot.base_order_size) / float(initial_price)), - self.qty_precision, - ) - # Prepare for margin sell - # Add markup on top of qty to cover for stop_losses - # and round up so that we don't have leftovers - qty = round_numbers_ceiling( - qty * (1 * self.active_bot.stop_loss), self.qty_precision - ) - if qty == 0: - raise QuantityTooLow("Margin short quantity is too low") if self.db_collection.name == "bots": - self.init_margin_short(qty) - order_res = self.sell_margin_order(symbol=self.active_bot.pair, qty=qty) + self.init_margin_short(initial_price) + order_res = self.sell_margin_order( + symbol=self.active_bot.pair, + qty=self.active_bot.deal.margin_short_base_order, + ) else: # Simulate Margin sell - order_res = self.simulate_margin_order(qty, "SELL") + # qty doesn't matter in paper bots + order_res = self.simulate_margin_order(1, "SELL") order_data = MarginOrderSchema( timestamp=order_res["transactTime"], @@ -462,35 +623,34 @@ def streaming_updates(self, close_price: str): f"Executing margin_short stop_loss reversal after hitting stop_loss_price {self.active_bot.deal.stop_loss_price}" ) self.execute_stop_loss() - if self.db_collection.name == "bots": - if ( - hasattr(self.active_bot, "margin_short_reversal") - and self.active_bot.margin_short_reversal - ): - # If we want to do reversal long bot, there is no point - # incurring in an additional transaction - self.terminate_margin_short(buy_back_fiat=False) - # To profit from reversal, we still need to repay loan and transfer - # assets back to SPOT account, so this means executing stop loss - # and creating a new long bot, this way we can also keep the old bot - # with the corresponding data for profit/loss calculation - self.switch_to_long_bot() - else: - self.terminate_margin_short() + if ( + hasattr(self.active_bot, "margin_short_reversal") + and self.active_bot.margin_short_reversal + ): + # If we want to do reversal long bot, there is no point + # incurring in an additional transaction + self.terminate_margin_short(buy_back_fiat=False) + # To profit from reversal, we still need to repay loan and transfer + # assets back to SPOT account, so this means executing stop loss + # and creating a new long bot, this way we can also keep the old bot + # with the corresponding data for profit/loss calculation + self.switch_to_long_bot(price) + else: + self.terminate_margin_short() try: self.save_bot_streaming() self.update_required() except ValidationError as error: - self.update_deal_logs(f"margin_short steaming update error: {error}") + self._append_errors(f"margin_short steaming update error: {error}") return except (TypeError, AttributeError) as error: message = str(";".join(error.args)) - self.update_deal_logs(f"margin_short steaming update error: {message}") + self._append_errors(f"margin_short steaming update error: {message}") return except Exception as error: - self.update_deal_logs(f"margin_short steaming update error: {error}") + self._append_errors(f"margin_short steaming update error: {error}") return return @@ -539,31 +699,17 @@ def execute_stop_loss(self): # paper_trading doesn't have real orders so no need to check self.cancel_open_orders("stop_loss") - # If for some reason, the bot has been closed already (e.g. transacted on Binance) - # Inactivate bot - # if self.db_collection.name == "bots" and not qty: - # self.update_deal_logs( - # f"Cannot execute update stop limit, quantity is {qty}. Deleting bot" - # ) - # params = {"id": self.active_bot.id} - # self.bb_request(f"{self.bb_bot_url}", "DELETE", params=params) - # return - # Margin buy (buy back) if self.db_collection.name == "paper_trading": res = self.simulate_margin_order(self.active_bot.deal.buy_total_qty, "BUY") else: try: - if qty == 0 or free <= qty: - # Not enough funds probably because already bought before - # correct using quote asset to buy base asset - # we want base asset anyway now, because of long bot - quote_asset_qty = self.get_remaining_quote_asset() - price = self.matching_engine(self.active_bot.pair, True, qty) - qty = round_numbers( - float(quote_asset_qty) / float(price), self.qty_precision - ) - + quote, base = self.get_remaining_assets() + price = self.matching_engine(self.active_bot.pair, True, qty) + # No need to round? + # qty = round_numbers( + # float(quote) / float(price), self.qty_precision + # ) # If still qty = 0, it means everything is clear if qty == 0: return @@ -575,10 +721,15 @@ def execute_stop_loss(self): logging.error(error) if error.code in (-2010, -1013): return + except BinanceErrors as error: + if error.code in (-2010, -1013): + return except Exception as error: - self.update_deal_logs( + self._append_errors( f"Error trying to open new stop_limit order {error}" ) + # Continue in case of error to execute long bot + # to avoid losses return stop_loss_order = MarginOrderSchema( @@ -631,6 +782,10 @@ def execute_take_profit(self, price=None): if qty and not price: price = self.matching_engine(self.active_bot.pair, True, qty) + elif qty == 0: + # Errored order, possible completed order. + # returning will skip directly to terminate_margin + return # Margin buy (buy back) if self.db_collection.name == "paper_trading": @@ -645,7 +800,7 @@ def execute_take_profit(self, price=None): ) except BinanceAPIException as error: if error.code == -2010: - self.update_deal_logs( + self._append_errors( f"{error.message}. Not enough fiat to buy back loaned quantity" ) return @@ -663,11 +818,9 @@ def execute_take_profit(self, price=None): # Not enough funds probably because already bought before # correct using quote asset to buy base asset # we want base asset anyway now, because of long bot - quote_asset_qty = self.get_remaining_quote_asset() + quote, base = self.get_remaining_assets() price = self.matching_engine(self.active_bot.pair, True, qty) - qty = round_numbers( - float(quote_asset_qty) / float(price), self.qty_precision - ) + qty = round_numbers(float(quote) / float(price), self.qty_precision) # If still qty = 0, it means everything is clear if qty == 0: @@ -683,7 +836,7 @@ def execute_take_profit(self, price=None): self.bb_request(f"{self.bb_bot_url}/{self.active_bot.id}", "DELETE") logging.info(f"Deleted obsolete bot {self.active_bot.pair}") except Exception as error: - self.update_deal_logs( + self._append_errors( f"Error trying to open new stop_limit order {error}" ) return @@ -719,7 +872,7 @@ def execute_take_profit(self, price=None): return - def switch_to_long_bot(self): + def switch_to_long_bot(self, current_price): """ Switch to long strategy. Doing some parts of open_deal from scratch @@ -731,7 +884,7 @@ def switch_to_long_bot(self): 2. Calculate take_profit_price and stop_loss_price as usual 3. Create deal """ - + self.update_deal_logs("Resetting bot for long strategy...") # Reset bot to prepare for new activation base_order = next( ( @@ -741,21 +894,22 @@ def switch_to_long_bot(self): ), None, ) - tp_price = float(base_order.price) * 1 + ( - float(self.active_bot.take_profit) / 100 + # start from current stop_loss_price which is where the bot switched to long strategy + new_base_order_price = current_price + tp_price = new_base_order_price * ( + 1 + (float(self.active_bot.take_profit) / 100) ) if float(self.active_bot.stop_loss) > 0: - stop_loss_price = base_order.price - ( - base_order.price * (float(self.active_bot.stop_loss) / 100) + stop_loss_price = new_base_order_price - ( + new_base_order_price * (float(self.active_bot.stop_loss) / 100) ) else: stop_loss_price = 0 self.active_bot.deal = DealSchema( buy_timestamp=base_order.timestamp, - buy_price=base_order.price, + buy_price=new_base_order_price, buy_total_qty=base_order.qty, - current_price=base_order.price, take_profit_price=tp_price, stop_loss_price=stop_loss_price, ) @@ -770,6 +924,9 @@ def switch_to_long_bot(self): return self.active_bot def update_trailling_profit(self, close_price): + # Fix potential bugs in bot updates + if self.active_bot.deal.take_profit_price == 0: + self.margin_short_base_order() # Direction: downward trend (short) # Breaking trailling_stop_loss if self.active_bot.deal.trailling_stop_loss_price == 0: @@ -814,6 +971,9 @@ def update_trailling_profit(self, close_price): self.active_bot.deal.trailling_stop_loss_price = float( self.active_bot.deal.trailling_profit_price ) * (1 + ((self.active_bot.trailling_deviation) / 100)) + + # Reset stop_loss_price to avoid confusion in front-end + self.active_bot.deal.stop_loss_price = 0 logging.info( f"{self.active_bot.pair} Updating after broken first trailling_profit (short)" ) diff --git a/api/deals/spot.py b/api/deals/spot.py index fd86e0907..80b3feba8 100644 --- a/api/deals/spot.py +++ b/api/deals/spot.py @@ -1,9 +1,6 @@ import logging -import requests -import numpy from deals.base import BaseDeal -from pymongo import ReturnDocument from requests.exceptions import HTTPError from deals.models import BinanceOrderModel from tools.handle_error import ( @@ -26,10 +23,19 @@ class SpotLongDeal(BaseDeal): Spot (non-margin, no borrowing) long bot deal updates during streaming """ - def __init__(self, bot, db_collection: str) -> None: + def __init__(self, bot, db_collection_name: str) -> None: # Inherit from parent class - super().__init__(bot, db_collection) + super().__init__(bot, db_collection_name) + def switch_margin_short(self, close_price): + msg = "Resetting bot for margin_short strategy..." + print(msg) + self.update_deal_logs(msg) + + margin_deal = MarginDeal(self.active_bot, db_collection_name="bots") + margin_deal.margin_short_base_order() + self.save_bot_streaming() + def execute_stop_loss(self, price): """ @@ -89,7 +95,7 @@ def execute_stop_loss(self, price): timestamp=res["transactTime"], deal_type="stop_loss", order_id=res["orderId"], - pair=res["self.active_bot.pair"], + pair=res["symbol"], order_side=res["side"], order_type=res["type"], price=res["price"], @@ -333,23 +339,13 @@ def so_update_deal(self, so_index): pass def streaming_updates(self, close_price, open_price): - # Update Current price only for active bots - # This is to keep historical profit intact - bot = self.db_collection.find_one_and_update( - {"id": self.active_bot.id}, - {"$set": {"deal.current_price": close_price}}, - return_document=ReturnDocument.AFTER, - ) - + self.active_bot.deal.current_price = close_price + self.save_bot_streaming() # Stop loss - if (float(self.active_bot.stop_loss) > 0 - and "stop_loss_price" in self.active_bot.deal - and float(self.active_bot.deal["stop_loss_price"]) > float(close_price) - ): + if (float(self.active_bot.stop_loss) > 0 and float(self.active_bot.deal.stop_loss_price) > float(close_price)): self.execute_stop_loss(close_price) if self.active_bot.margin_short_reversal: - margin_deal = MarginDeal(self.active_bot, db_collection=self.db_collection) - margin_deal.margin_short_base_order() + self.switch_margin_short(close_price) return @@ -368,7 +364,7 @@ def streaming_updates(self, close_price, open_price): self.active_bot.deal.trailling_profit_price = trailling_price # Direction 1 (upward): breaking the current trailling - if bot and float(close_price) >= float(trailling_price): + if float(close_price) >= float(trailling_price): new_take_profit = float(trailling_price) * ( 1 + (float(self.active_bot.take_profit) / 100) ) @@ -402,9 +398,6 @@ def streaming_updates(self, close_price, open_price): f'{datetime.utcnow()} Updated {self.active_bot.pair} trailling_stop_loss_price {self.active_bot.deal.trailling_stop_loss_price}' ) - if not bot: - self.active_bot.errors.append("Error updating trailling order") - self.save_bot_streaming() # Direction 2 (downward): breaking the trailling_stop_loss diff --git a/api/market_updates.py b/api/market_updates.py index b571ff6f8..04cf7a502 100644 --- a/api/market_updates.py +++ b/api/market_updates.py @@ -6,6 +6,11 @@ from apscheduler.schedulers.background import BackgroundScheduler from streaming.streaming_controller import StreamingController from account.assets import Assets +from websocket import ( + WebSocketException, + WebSocketConnectionClosedException, +) + logging.Formatter.converter = time.gmtime # date time in GMT/UTC @@ -35,6 +40,15 @@ try: mu = StreamingController() mu.get_klines() + +except WebSocketException as e: + if isinstance(e, WebSocketConnectionClosedException): + logging.error("Lost websocket connection") + mu = StreamingController() + mu.get_klines() + else: + logging.error(f"Websocket exception: {e}") + except Exception as error: logging.error(f"Streaming controller error: {error}") mu = StreamingController() diff --git a/api/orders/controller.py b/api/orders/controller.py index 7a35dfba0..7e56573d7 100644 --- a/api/orders/controller.py +++ b/api/orders/controller.py @@ -1,15 +1,28 @@ from account.account import Account from tools.enum_definitions import OrderType, TimeInForce, OrderSide from tools.handle_error import json_response, json_response_error, json_response_message -from db import setup_db +from tools.round_numbers import supress_notation +from decimal import Decimal + poll_percentage = 0 class OrderController(Account): def __init__(self) -> None: + super().__init__() # Always GTC and limit orders # limit/market orders will be decided by matching_engine - self.db = setup_db() + # PRICE_FILTER decimals + self.price_precision = -1 * ( + Decimal(str(self.price_filter_by_symbol(self.active_bot.pair, "tickSize"))) + .as_tuple() + .exponent + ) + self.qty_precision = -1 * ( + Decimal(str(self.lot_size_by_symbol(self.active_bot.pair, "stepSize"))) + .as_tuple() + .exponent + ) pass def sell_order(self, symbol, qty, price=None): @@ -24,15 +37,15 @@ def sell_order(self, symbol, qty, price=None): "side": OrderSide.sell, "type": OrderType.limit, "timeInForce": TimeInForce.gtc, - "price": price, - "quantity": qty, + "price": supress_notation(price, self.price_precision), + "quantity": supress_notation(qty, self.qty_precision), } else: payload = { "symbol": symbol, "side": OrderSide.sell, "type": OrderType.market, - "quantity": qty, + "quantity": supress_notation(qty, self.qty_precision), } data = self.signed_request( url=self.order_url, method="POST", payload=payload @@ -48,8 +61,8 @@ def buy_order(self, symbol, qty, price=None): "side": OrderSide.buy, "type": OrderType.limit, "timeInForce": TimeInForce.gtc, - "price": price, - "quantity": qty, + "price": supress_notation(price, self.price_precision), + "quantity": supress_notation(qty, self.qty_precision), } else: payload = { @@ -134,6 +147,19 @@ def buy_margin_order(self, symbol, qty, price=None): } data = self.signed_request(url=self.margin_order, method="POST", payload=payload) + if data["status"] != "FILLED": + delete_payload = { + "symbol": symbol, + "isIsolated": "TRUE", + "orderId": data["orderId"] + } + try: + self.signed_request(url=self.margin_order, method="DELETE", payload=delete_payload) + except Exception as error: + return + + self.buy_margin_order(symbol, qty) + return data def sell_margin_order(self, symbol, qty, price=None): diff --git a/api/streaming/socket_manager.py b/api/streaming/socket_manager.py index 11f3517ae..7929637f6 100644 --- a/api/streaming/socket_manager.py +++ b/api/streaming/socket_manager.py @@ -59,18 +59,7 @@ def ping(self): def read_data(self): data = "" while True: - try: - op_code, frame = self.ws.recv_data_frame(True) - except WebSocketException as e: - if isinstance(e, WebSocketConnectionClosedException): - self.logger.error("Lost websocket connection") - else: - self.logger.error(f"Websocket exception: {e}") - - except Exception as e: - self.logger.error(f"Exception in read_data: {e}") - raise e - + op_code, frame = self.ws.recv_data_frame(True) if op_code == ABNF.OPCODE_CLOSE: self.logger.warning( "CLOSE frame received, closing websocket connection" diff --git a/api/streaming/streaming_controller.py b/api/streaming/streaming_controller.py index 9c93053b6..743c68097 100644 --- a/api/streaming/streaming_controller.py +++ b/api/streaming/streaming_controller.py @@ -1,11 +1,8 @@ import json import logging from db import setup_db -from deals.controllers import CreateDealController from deals.margin import MarginDeal from deals.spot import SpotLongDeal -from pymongo import ReturnDocument -from datetime import datetime from time import time from streaming.socket_client import SpotWebsocketStreamClient @@ -46,14 +43,13 @@ def _update_required(self): ) return - def execute_strategies( self, current_bot, close_price: str, open_price: str, symbol: str, - db_collection, + db_collection_name, ): """ Processes the deal market websocket price updates @@ -63,26 +59,21 @@ def execute_strategies( """ # Margin short if current_bot["strategy"] == "margin_short": - margin_deal = MarginDeal(current_bot, db_collection=db_collection) - margin_deal.streaming_updates(close_price) + margin_deal = MarginDeal(current_bot, db_collection_name=db_collection_name) + try: + margin_deal.streaming_updates(close_price) + except Exception as error: + logging.info(error) + margin_deal.update_deal_logs(error) + pass return else: - # Short strategy - if ( - "short_buy_price" in current_bot - and float(current_bot["short_buy_price"]) > 0 - and float(current_bot["short_buy_price"]) >= float(close_price) - ): - # If hit short_buy_price, resume long strategy by resetting short_buy_price - CreateDealController( - current_bot, db_collection=db_collection - ).execute_short_buy() # Long strategy starts if current_bot["strategy"] == "long": SpotLongDeal( - current_bot, db_collection=db_collection + current_bot, db_collection_name=db_collection_name ).streaming_updates(close_price, open_price) self._update_required() @@ -115,18 +106,6 @@ def process_klines(self, result): local_settings = self.streaming_db.research_controller.find_one( {"_id": "settings"} ) - # Add margin time to update_required signal to avoid restarting constantly - # About 1000 seconds (16.6 minutes) - similar to candlestick ticks of 15m - if local_settings["update_required"]: - logging.info( - f'Time elapsed for update_required: {time() - local_settings["update_required"]}' - ) - if (time() - local_settings["update_required"]) > 20: - self.streaming_db.research_controller.update_one( - {"_id": "settings"}, {"$set": {"update_required": time()}} - ) - logging.info("Restarting streaming_controller") - self.get_klines() if "k" in result: close_price = result["k"]["c"] @@ -154,7 +133,20 @@ def process_klines(self, result): symbol, "paper_trading", ) - return + + # Add margin time to update_required signal to avoid restarting constantly + # About 1000 seconds (16.6 minutes) - similar to candlestick ticks of 15m + if local_settings["update_required"]: + logging.debug( + f'Time elapsed for update_required: {time() - local_settings["update_required"]}' + ) + if (time() - local_settings["update_required"]) > 20: + self.streaming_db.research_controller.update_one( + {"_id": "settings"}, {"$set": {"update_required": time()}} + ) + logging.info("Restarting streaming_controller") + self.get_klines() + return def get_klines(self): interval = self.settings["candlestick_interval"] diff --git a/api/tools/handle_error.py b/api/tools/handle_error.py index f5f536a26..825a146af 100644 --- a/api/tools/handle_error.py +++ b/api/tools/handle_error.py @@ -3,7 +3,6 @@ from time import sleep from bson import json_util -from bson.objectid import ObjectId from fastapi.responses import JSONResponse from pydantic import BaseModel from requests import Response, put @@ -11,8 +10,19 @@ from fastapi.encoders import jsonable_encoder from copy import deepcopy +class IsolateBalanceError(Exception): + def __init__(self, message) -> None: + self.message = message + class BinanceErrors(Exception): - pass + def __init__(self, msg, code): + self.code = code + self.message = msg + return None + + def __str__(self) -> str: + return f"Binance Error: {self.code} {self.message}" + class InvalidSymbol(BinanceErrors): @@ -98,12 +108,12 @@ def handle_binance_errors(response: Response) -> str | None: # Binbot errors if content and "error" in content and content["error"] == 1: - raise BinanceErrors(content["message"]) + raise BinanceErrors(content["message"], content["error"]) # Binance errors if content and "code" in content: if content["code"] == -1013: - raise QuantityTooLow() + raise QuantityTooLow(content["message"], content["error"]) if content["code"] == 200: return content if ( @@ -113,7 +123,7 @@ def handle_binance_errors(response: Response) -> str | None: ): # Not enough funds. Ignore, send to bot errors # Need to be dealt with at higher levels - raise NotEnoughFunds(content["msg"]) + raise NotEnoughFunds(content["msg"], content["code"]) if content["code"] == -1003: # Too many requests, most likely exceeded API rate limits @@ -122,7 +132,7 @@ def handle_binance_errors(response: Response) -> str | None: sleep(60) if content["code"] == -1121: - raise InvalidSymbol(f'Binance error: {content["msg"]}') + raise InvalidSymbol(f'Binance error: {content["msg"]}', content["msg"]) return content diff --git a/binquant b/binquant new file mode 160000 index 000000000..fc66847bd --- /dev/null +++ b/binquant @@ -0,0 +1 @@ +Subproject commit fc66847bd9cd93e99e4697417dc1e6d537e1152b diff --git a/docs/binance-teapot-errors.md b/docs/binance-teapot-errors.md index beea15009..b138538a6 100644 --- a/docs/binance-teapot-errors.md +++ b/docs/binance-teapot-errors.md @@ -8,4 +8,4 @@ This error happens actually after rate limits violation. The HTTP codes ocurr in 200 -> 429 -> 418 -> 403 (IP banned) -Source: https://dev.binance.vision/t/how-to-deal-with-http-status-code-at-binance-api/712 \ No newline at end of file +Source: https://dev.binance.vision/t/how-to-deal-with-http-status-code-at-binance-api/712 diff --git a/web/.env b/web/.env index 9fae3df0a..af98ee824 100644 --- a/web/.env +++ b/web/.env @@ -33,6 +33,7 @@ REACT_APP_NO_CANNIBALISM_SYMBOLS=/account/symbols/no-cannibal REACT_APP_BINANCE_INFO="https://api.binance.com/api/v3/exchangeInfo" REACT_APP_HEDGE_GBP=/account/hedge-gbp REACT_APP_GAINERS_LOSERS=/account/gainers-losers +REACT_APP_BALANCE_SERIES="/account/balance-series" REACT_APP_TEST_BOT=/paper-trading REACT_APP_ARCHIVE_TEST_BOT=/paper-trading/archive diff --git a/web/package.json b/web/package.json index cee25f68f..cea499197 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,7 @@ "map-sass": "sass src/assets/scss/paper-dashboard.scss src/assets/css/paper-dashboard.css --source-map true" }, "dependencies": { - "binbot-charts": "^0.3.1", + "binbot-charts": "^0.4.1", "bootstrap": "5.2.2", "history": "^5.0.0", "immer": "^9.0.21", diff --git a/web/public/index.html b/web/public/index.html index 518b2c998..b713d4b3b 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -18,7 +18,7 @@ href="%PUBLIC_URL%/apple-icon.png" /> - + %REACT_APP_TITLE% diff --git a/web/src/assets/scss/paper-dashboard.scss b/web/src/assets/scss/paper-dashboard.scss index 411b0a4d0..fd58d7ba9 100644 --- a/web/src/assets/scss/paper-dashboard.scss +++ b/web/src/assets/scss/paper-dashboard.scss @@ -21,7 +21,6 @@ @import "paper-dashboard/tables"; @import "paper-dashboard/sidebar-and-main-panel"; @import "paper-dashboard/footers"; -@import "paper-dashboard/fixed-plugin"; // cards @import "paper-dashboard/cards"; diff --git a/web/src/assets/scss/paper-dashboard/_fixed-plugin.scss b/web/src/assets/scss/paper-dashboard/_fixed-plugin.scss deleted file mode 100644 index 561e7aacf..000000000 --- a/web/src/assets/scss/paper-dashboard/_fixed-plugin.scss +++ /dev/null @@ -1,339 +0,0 @@ -.fixed-plugin { - position: fixed; - right: 0; - width: 64px; - background: rgba(0, 0, 0, 0.3); - z-index: 1031; - border-radius: 8px 0 0 8px; - text-align: center; - top: 120px; - - li > a, - .badge { - transition: all 0.34s; - -webkit-transition: all 0.34s; - -moz-transition: all 0.34s; - } - - .fa-cog { - color: #ffffff; - padding: 10px; - border-radius: 0 0 6px 6px; - width: auto; - } - - .dropdown-menu { - right: 80px; - left: auto !important; - top: -52px !important; - width: 290px; - border-radius: 10px; - padding: 0 10px; - } - - .dropdown .dropdown-menu .nc-icon { - top: 2px; - right: 10px; - font-size: 14px; - } - - .dropdown-menu:after, - .dropdown-menu:before { - right: 10px; - margin-left: auto; - left: auto; - } - - .fa-circle-thin { - color: #ffffff; - } - - .active .fa-circle-thin { - color: #00bbff; - } - - .dropdown-menu > .active > a, - .dropdown-menu > .active > a:hover, - .dropdown-menu > .active > a:focus { - color: #777777; - text-align: center; - } - - img { - border-radius: 0; - width: 100%; - height: 100px; - margin: 0 auto; - } - - .dropdown-menu li > a:hover, - .dropdown-menu li > a:focus { - box-shadow: none; - } - - .badge { - border: 3px solid #ffffff; - border-radius: 50%; - cursor: pointer; - display: inline-block; - height: 23px; - margin-right: 5px; - position: relative; - width: 23px; - - &.badge-light { - border: 1px solid $light-gray; - - &.active, - &:hover { - border: 3px solid #0bf; - } - } - } - - .badge.active, - .badge:hover { - border-color: #00bbff; - } - - .badge-blue { - background-color: $brand-info; - } - .badge-green { - background-color: $brand-success; - } - .badge-orange { - background-color: $brand-primary; - } - .badge-yellow { - background-color: $brand-warning; - } - .badge-red { - background-color: $brand-danger; - } - - h5 { - font-size: 14px; - margin: 10px; - } - - .dropdown-menu li { - display: block; - padding: 15px 2px; - width: 25%; - float: left; - } - - li.adjustments-line, - li.header-title, - li.button-container { - width: 100%; - height: 35px; - min-height: inherit; - } - - li.button-container { - height: auto; - - div { - margin-bottom: 5px; - } - } - - #sharrreTitle { - text-align: center; - padding: 10px 0; - height: 50px; - } - - li.header-title { - height: 30px; - line-height: 25px; - font-size: 12px; - font-weight: 600; - text-align: center; - text-transform: uppercase; - } - - .adjustments-line { - p { - float: left; - display: inline-block; - margin-bottom: 0; - font-size: 1em; - color: #3c4858; - } - - a { - color: transparent; - - .badge-colors { - position: relative; - top: -2px; - } - - a:hover, - a:focus { - color: transparent; - } - } - - .togglebutton { - text-align: center; - - .label-switch { - position: relative; - left: -10px; - font-size: $font-size-mini; - color: $default-color; - - &.label-right { - left: 10px; - } - } - - .toggle { - margin-right: 0; - } - } - - .dropdown-menu > li.adjustments-line > a { - padding-right: 0; - padding-left: 0; - border-bottom: 1px solid #ddd; - border-radius: 0; - margin: 0; - } - } - - .dropdown-menu { - > li { - & > a.img-holder { - font-size: 16px; - text-align: center; - border-radius: 10px; - background-color: #fff; - border: 3px solid #fff; - padding-left: 0; - padding-right: 0; - opacity: 1; - cursor: pointer; - display: block; - max-height: 100px; - overflow: hidden; - padding: 0; - - img { - margin-top: auto; - } - } - - a.switch-trigger:hover, - & > a.switch-trigger:focus { - background-color: transparent; - } - - &:hover, - &:focus { - > a.img-holder { - border-color: rgba(0, 187, 255, 0.53); - } - } - } - - > .active > a.img-holder, - > .active > a.img-holder { - border-color: #00bbff; - background-color: #ffffff; - } - } - - .btn-social { - width: 50%; - display: block; - width: 48%; - float: left; - font-weight: 600; - } - - .btn-social { - i { - margin-right: 5px; - } - - &:first-child { - margin-right: 2%; - } - } - - .dropdown { - .dropdown-menu { - transform-origin: 0 0; - - &:before { - border-bottom: 16px solid rgba(0, 0, 0, 0); - border-left: 16px solid rgba(0, 0, 0, 0.2); - border-top: 16px solid rgba(0, 0, 0, 0); - right: -27px; - bottom: 425px; - } - - &:after { - border-bottom: 16px solid rgba(0, 0, 0, 0); - border-left: 16px solid #ffffff; - border-top: 16px solid rgba(0, 0, 0, 0); - right: -26px; - bottom: 425px; - } - - &:before, - &:after { - content: ""; - display: inline-block; - position: absolute; - width: 16px; - transform: translateY(-50px); - -webkit-transform: translateY(-50px); - -moz-transform: translateY(-50px); - } - } - - &.show-dropdown .show { - .dropdown-menu .show { - transform: translate3d(0, -60px, 0) !important; - bottom: auto !important; - top: 0 !important; - } - } - } - - .bootstrap-switch { - margin: 0; - } -} - -.fixed-plugin { - .show-dropdown { - .dropdown-menu[x-placement="bottom-start"] { - @include transform-translate-y-fixed-plugin(-100px); - - &:before, - &:after { - top: 100px; - } - } - .dropdown-menu[x-placement="top-start"] { - @include transform-translate-y-fixed-plugin(100px); - } - - &.show { - .dropdown-menu.show[x-placement="bottom-start"] { - @include transform-translate-y-fixed-plugin(-60px); - } - - .dropdown-menu.show[x-placement="top-start"] { - @include transform-translate-y-fixed-plugin(470px); - } - } - } -} diff --git a/web/src/assets/scss/paper-dashboard/_typography.scss b/web/src/assets/scss/paper-dashboard/_typography.scss index 9b936f826..25aa95d9c 100644 --- a/web/src/assets/scss/paper-dashboard/_typography.scss +++ b/web/src/assets/scss/paper-dashboard/_typography.scss @@ -1,3 +1,13 @@ +// Typography utilities +.capitalize { + text-transform: capitalize; +} + +.uppercase { + @extend .uppercase; +} + + button, input, optgroup, @@ -29,7 +39,7 @@ h1, small { font-weight: $font-weight-bold; - text-transform: uppercase; + @extend .uppercase; opacity: 0.8; } } @@ -66,7 +76,7 @@ h6, .h6 { font-size: $font-size-h6; font-weight: $font-weight-bold; - text-transform: uppercase; + @extend .uppercase; } p { margin-bottom: $margin-base-vertical; @@ -75,17 +85,11 @@ p { } } -// i.fa{ -// font-size: 18px; -// position: relative; -// top: 1px; -// } - .title { font-weight: $font-weight-bold; &.title-up { - text-transform: uppercase; + @extend .uppercase; a { color: $black-color; @@ -106,10 +110,8 @@ p { } .category, .card-category { - text-transform: capitalize; font-weight: $font-weight-normal; color: $dark-gray; - font-size: $font-size-mini; } .card-category { @@ -158,7 +160,7 @@ a.text-gray:hover { small { color: $default-color; font-size: $font-size-small; - text-transform: uppercase; + @extend .uppercase; } &.blockquote-primary { diff --git a/web/src/assets/scss/paper-dashboard/_utilities.scss b/web/src/assets/scss/paper-dashboard/_utilities.scss index 878aa13a3..5b232027f 100644 --- a/web/src/assets/scss/paper-dashboard/_utilities.scss +++ b/web/src/assets/scss/paper-dashboard/_utilities.scss @@ -6,14 +6,6 @@ z-index: $zindex-modal; } -.u-uppercase { - text-transform: uppercase; -} - -.u-capitalize { - text-transform: capitalize; -} - .u-center { text-align: center; } @@ -106,3 +98,15 @@ .u-text-right { text-align: right; } + +// Align large Fontawesome icons +.u-fa-lg { + position: relative; + display: inline-block; + i { + position: absolute; + top: 50%; + left: 50%; + font-size: 2rem; + } +} \ No newline at end of file diff --git a/web/src/components/BotCard.jsx b/web/src/components/BotCard.jsx index 3b4916813..3fbd747e7 100644 --- a/web/src/components/BotCard.jsx +++ b/web/src/components/BotCard.jsx @@ -62,7 +62,7 @@ export default function BotCard({ - + {!checkValue(x.deal) && ( 0 ? "success" : "danger"}> {getNetProfit(x) + "%"} @@ -74,7 +74,7 @@ export default function BotCard({
-

{x.name}

+

{x.name}

@@ -104,7 +104,7 @@ export default function BotCard({

Mode

-

+

{!checkValue(x.mode) ? x.mode : "Unknown"}

@@ -114,7 +114,7 @@ export default function BotCard({

Strategy

-

{x.strategy}

+

{x.strategy}

@@ -146,7 +146,7 @@ export default function BotCard({ - {x.trailling === "true" && ( + {x.trailling && (

Trailling loss

@@ -232,7 +232,7 @@ export default function BotCard({ history.push(`${history.location.pathname}/edit/${x.id}`) } > - +