Skip to content

Commit

Permalink
Merge pull request freqtrade#3531 from freqtrade/exchange_errorhandling
Browse files Browse the repository at this point in the history
Improve exchange errorhandling and API backoff
  • Loading branch information
hroff-1902 authored Jun 30, 2020
2 parents 02c0488 + b95065d commit 8a2f631
Show file tree
Hide file tree
Showing 18 changed files with 306 additions and 183 deletions.
4 changes: 2 additions & 2 deletions freqtrade/data/dataprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pandas import DataFrame

from freqtrade.data.history import load_pair_history
from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.exceptions import ExchangeError, OperationalException
from freqtrade.exchange import Exchange
from freqtrade.state import RunMode
from freqtrade.constants import ListPairsWithTimeframes
Expand Down Expand Up @@ -105,7 +105,7 @@ def ticker(self, pair: str):
"""
try:
return self._exchange.fetch_ticker(pair)
except DependencyException:
except ExchangeError:
return {}

def orderbook(self, pair: str, maximum: int) -> Dict[str, List]:
Expand Down
23 changes: 22 additions & 1 deletion freqtrade/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,35 @@ class InvalidOrderException(FreqtradeException):
"""


class TemporaryError(FreqtradeException):
class RetryableOrderError(InvalidOrderException):
"""
This is returned when the order is not found.
This Error will be repeated with increasing backof (in line with DDosError).
"""


class ExchangeError(DependencyException):
"""
Error raised out of the exchange.
Has multiple Errors to determine the appropriate error.
"""


class TemporaryError(ExchangeError):
"""
Temporary network or exchange related error.
This could happen when an exchange is congested, unavailable, or the user
has networking problems. Usually resolves itself after a time.
"""


class DDosProtection(TemporaryError):
"""
Temporary error caused by DDOS protection.
Bot will wait for a second and then retry.
"""


class StrategyError(FreqtradeException):
"""
Errors with custom user-code deteced.
Expand Down
11 changes: 8 additions & 3 deletions freqtrade/exchange/binance.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import ccxt

from freqtrade.exceptions import (DependencyException, InvalidOrderException,
OperationalException, TemporaryError)
from freqtrade.exceptions import (DDosProtection, ExchangeError,
InvalidOrderException, OperationalException,
TemporaryError)
from freqtrade.exchange import Exchange
from freqtrade.exchange.common import retrier

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -39,6 +41,7 @@ def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool:
"""
return order['type'] == 'stop_loss_limit' and stop_loss > float(order['info']['stopPrice'])

@retrier(retries=0)
def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict:
"""
creates a stoploss limit order.
Expand Down Expand Up @@ -77,7 +80,7 @@ def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dic
'stop price: %s. limit: %s', pair, stop_price, rate)
return order
except ccxt.InsufficientFunds as e:
raise DependencyException(
raise ExchangeError(
f'Insufficient funds to create {ordertype} sell order on market {pair}.'
f'Tried to sell amount {amount} at rate {rate}. '
f'Message: {e}') from e
Expand All @@ -88,6 +91,8 @@ def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dic
f'Could not create {ordertype} sell order on market {pair}. '
f'Tried to sell amount {amount} at rate {rate}. '
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not place sell order due to {e.__class__.__name__}. Message: {e}') from e
Expand Down
61 changes: 44 additions & 17 deletions freqtrade/exchange/common.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import asyncio
import logging
import time
from functools import wraps

from freqtrade.exceptions import TemporaryError
from freqtrade.exceptions import (DDosProtection, RetryableOrderError,
TemporaryError)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -88,6 +92,13 @@
}


def calculate_backoff(retrycount, max_retries):
"""
Calculate backoff
"""
return (max_retries - retrycount) ** 2 + 1


def retrier_async(f):
async def wrapper(*args, **kwargs):
count = kwargs.pop('count', API_RETRY_COUNT)
Expand All @@ -99,26 +110,42 @@ async def wrapper(*args, **kwargs):
count -= 1
kwargs.update({'count': count})
logger.warning('retrying %s() still for %s times', f.__name__, count)
if isinstance(ex, DDosProtection):
backoff_delay = calculate_backoff(count + 1, API_RETRY_COUNT)
logger.debug(f"Applying DDosProtection backoff delay: {backoff_delay}")
await asyncio.sleep(backoff_delay)
return await wrapper(*args, **kwargs)
else:
logger.warning('Giving up retrying: %s()', f.__name__)
raise ex
return wrapper


def retrier(f):
def wrapper(*args, **kwargs):
count = kwargs.pop('count', API_RETRY_COUNT)
try:
return f(*args, **kwargs)
except TemporaryError as ex:
logger.warning('%s() returned exception: "%s"', f.__name__, ex)
if count > 0:
count -= 1
kwargs.update({'count': count})
logger.warning('retrying %s() still for %s times', f.__name__, count)
return wrapper(*args, **kwargs)
else:
logger.warning('Giving up retrying: %s()', f.__name__)
raise ex
return wrapper
def retrier(_func=None, retries=API_RETRY_COUNT):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
count = kwargs.pop('count', retries)
try:
return f(*args, **kwargs)
except (TemporaryError, RetryableOrderError) as ex:
logger.warning('%s() returned exception: "%s"', f.__name__, ex)
if count > 0:
count -= 1
kwargs.update({'count': count})
logger.warning('retrying %s() still for %s times', f.__name__, count)
if isinstance(ex, DDosProtection) or isinstance(ex, RetryableOrderError):
# increasing backoff
backoff_delay = calculate_backoff(count + 1, retries)
logger.debug(f"Applying DDosProtection backoff delay: {backoff_delay}")
time.sleep(backoff_delay)
return wrapper(*args, **kwargs)
else:
logger.warning('Giving up retrying: %s()', f.__name__)
raise ex
return wrapper
# Support both @retrier and @retrier(retries=2) syntax
if _func is None:
return decorator
else:
return decorator(_func)
57 changes: 41 additions & 16 deletions freqtrade/exchange/exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
TRUNCATE, decimal_to_precision)
from pandas import DataFrame

from freqtrade.constants import ListPairsWithTimeframes
from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list
from freqtrade.exceptions import (DependencyException, InvalidOrderException,
OperationalException, TemporaryError)
from freqtrade.exceptions import (DDosProtection, ExchangeError,
InvalidOrderException, OperationalException,
RetryableOrderError, TemporaryError)
from freqtrade.exchange.common import BAD_EXCHANGES, retrier, retrier_async
from freqtrade.misc import deep_merge_dicts, safe_value_fallback
from freqtrade.constants import ListPairsWithTimeframes

CcxtModuleType = Any

Expand Down Expand Up @@ -351,7 +352,7 @@ def get_valid_pair_combination(self, curr_1: str, curr_2: str) -> str:
for pair in [f"{curr_1}/{curr_2}", f"{curr_2}/{curr_1}"]:
if pair in self.markets and self.markets[pair].get('active'):
return pair
raise DependencyException(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")
raise ExchangeError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")

def validate_timeframes(self, timeframe: Optional[str]) -> None:
"""
Expand Down Expand Up @@ -518,15 +519,17 @@ def create_order(self, pair: str, ordertype: str, side: str, amount: float,
amount, rate_for_order, params)

except ccxt.InsufficientFunds as e:
raise DependencyException(
raise ExchangeError(
f'Insufficient funds to create {ordertype} {side} order on market {pair}.'
f'Tried to {side} amount {amount} at rate {rate}.'
f'Message: {e}') from e
except ccxt.InvalidOrder as e:
raise DependencyException(
raise ExchangeError(
f'Could not create {ordertype} {side} order on market {pair}.'
f'Tried to {side} amount {amount} at rate {rate}.'
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not place {side} order due to {e.__class__.__name__}. Message: {e}') from e
Expand Down Expand Up @@ -606,6 +609,8 @@ def get_balances(self) -> dict:
balances.pop("used", None)

return balances
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get balance due to {e.__class__.__name__}. Message: {e}') from e
Expand All @@ -620,6 +625,8 @@ def get_tickers(self) -> Dict:
raise OperationalException(
f'Exchange {self._api.name} does not support fetching tickers in batch. '
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not load tickers due to {e.__class__.__name__}. Message: {e}') from e
Expand All @@ -630,9 +637,11 @@ def get_tickers(self) -> Dict:
def fetch_ticker(self, pair: str) -> dict:
try:
if pair not in self._api.markets or not self._api.markets[pair].get('active'):
raise DependencyException(f"Pair {pair} not available")
raise ExchangeError(f"Pair {pair} not available")
data = self._api.fetch_ticker(pair)
return data
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not load ticker due to {e.__class__.__name__}. Message: {e}') from e
Expand Down Expand Up @@ -766,6 +775,8 @@ async def _async_get_candle_history(self, pair: str, timeframe: str,
raise OperationalException(
f'Exchange {self._api.name} does not support fetching historical '
f'candle (OHLCV) data. Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(f'Could not fetch historical candle (OHLCV) data '
f'for pair {pair} due to {e.__class__.__name__}. '
Expand Down Expand Up @@ -802,6 +813,8 @@ async def _async_fetch_trades(self, pair: str,
raise OperationalException(
f'Exchange {self._api.name} does not support fetching historical trade data.'
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(f'Could not load trade history due to {e.__class__.__name__}. '
f'Message: {e}') from e
Expand Down Expand Up @@ -933,7 +946,7 @@ def get_historic_trades(self, pair: str,
def check_order_canceled_empty(self, order: Dict) -> bool:
"""
Verify if an order has been cancelled without being partially filled
:param order: Order dict as returned from get_order()
:param order: Order dict as returned from fetch_order()
:return: True if order has been cancelled without being filled, False otherwise.
"""
return order.get('status') in ('closed', 'canceled') and order.get('filled') == 0.0
Expand All @@ -948,13 +961,15 @@ def cancel_order(self, order_id: str, pair: str) -> Dict:
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Could not cancel order. Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not cancel order due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e

# Assign method to get_stoploss_order to allow easy overriding in other classes
# Assign method to fetch_stoploss_order to allow easy overriding in other classes
cancel_stoploss_order = cancel_order

def is_cancel_order_result_suitable(self, corder) -> bool:
Expand All @@ -968,7 +983,7 @@ def cancel_order_with_result(self, order_id: str, pair: str, amount: float) -> D
"""
Cancel order returning a result.
Creates a fake result if cancel order returns a non-usable result
and get_order does not work (certain exchanges don't return cancelled orders)
and fetch_order does not work (certain exchanges don't return cancelled orders)
:param order_id: Orderid to cancel
:param pair: Pair corresponding to order_id
:param amount: Amount to use for fake response
Expand All @@ -981,15 +996,15 @@ def cancel_order_with_result(self, order_id: str, pair: str, amount: float) -> D
except InvalidOrderException:
logger.warning(f"Could not cancel order {order_id}.")
try:
order = self.get_order(order_id, pair)
order = self.fetch_order(order_id, pair)
except InvalidOrderException:
logger.warning(f"Could not fetch cancelled order {order_id}.")
order = {'fee': {}, 'status': 'canceled', 'amount': amount, 'info': {}}

return order

@retrier
def get_order(self, order_id: str, pair: str) -> Dict:
def fetch_order(self, order_id: str, pair: str) -> Dict:
if self._config['dry_run']:
try:
order = self._dry_run_open_orders[order_id]
Expand All @@ -1000,17 +1015,22 @@ def get_order(self, order_id: str, pair: str) -> Dict:
f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e
try:
return self._api.fetch_order(order_id, pair)
except ccxt.OrderNotFound as e:
raise RetryableOrderError(
f'Order not found (id: {order_id}). Message: {e}') from e
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Tried to get an invalid order (id: {order_id}). Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get order due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e

# Assign method to get_stoploss_order to allow easy overriding in other classes
get_stoploss_order = get_order
# Assign method to fetch_stoploss_order to allow easy overriding in other classes
fetch_stoploss_order = fetch_order

@retrier
def fetch_l2_order_book(self, pair: str, limit: int = 100) -> dict:
Expand All @@ -1027,6 +1047,8 @@ def fetch_l2_order_book(self, pair: str, limit: int = 100) -> dict:
raise OperationalException(
f'Exchange {self._api.name} does not support fetching order book.'
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get order book due to {e.__class__.__name__}. Message: {e}') from e
Expand Down Expand Up @@ -1063,7 +1085,8 @@ def get_trades_for_order(self, order_id: str, pair: str, since: datetime) -> Lis
matched_trades = [trade for trade in my_trades if trade['order'] == order_id]

return matched_trades

except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get trades due to {e.__class__.__name__}. Message: {e}') from e
Expand All @@ -1080,6 +1103,8 @@ def get_fee(self, symbol: str, type: str = '', side: str = '', amount: float = 1

return self._api.calculate_fee(symbol=symbol, type=type, side=side, amount=amount,
price=price, takerOrMaker=taker_or_maker)['rate']
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get fee info due to {e.__class__.__name__}. Message: {e}') from e
Expand Down Expand Up @@ -1129,7 +1154,7 @@ def calculate_fee_rate(self, order: Dict) -> Optional[float]:

fee_to_quote_rate = safe_value_fallback(tick, tick, 'last', 'ask')
return round((order['fee']['cost'] * fee_to_quote_rate) / order['cost'], 8)
except DependencyException:
except ExchangeError:
return None

def extract_cost_curr_rate(self, order: Dict) -> Tuple[float, str, Optional[float]]:
Expand Down
Loading

0 comments on commit 8a2f631

Please sign in to comment.