Merge branch 'develop' into pr/stash86/6458

This commit is contained in:
Matthias
2022-03-01 19:30:48 +01:00
33 changed files with 519 additions and 154 deletions

View File

@@ -108,10 +108,11 @@ def ask_user_config() -> Dict[str, Any]:
"binance",
"binanceus",
"bittrex",
"kraken",
"ftx",
"kucoin",
"gateio",
"huobi",
"kraken",
"kucoin",
"okx",
Separator(),
"other",

View File

@@ -18,6 +18,7 @@ from freqtrade.exchange.exchange import (available_exchanges, ccxt_exchanges,
from freqtrade.exchange.ftx import Ftx
from freqtrade.exchange.gateio import Gateio
from freqtrade.exchange.hitbtc import Hitbtc
from freqtrade.exchange.huobi import Huobi
from freqtrade.exchange.kraken import Kraken
from freqtrade.exchange.kucoin import Kucoin
from freqtrade.exchange.okx import Okx

View File

@@ -3,12 +3,8 @@ import logging
from typing import Dict, List, Tuple
import arrow
import ccxt
from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, InvalidOrderException,
OperationalException, TemporaryError)
from freqtrade.exchange import Exchange
from freqtrade.exchange.common import retrier
logger = logging.getLogger(__name__)
@@ -18,6 +14,7 @@ class Binance(Exchange):
_ft_has: Dict = {
"stoploss_on_exchange": True,
"stoploss_order_types": {"limit": "stop_loss_limit"},
"order_time_in_force": ['gtc', 'fok', 'ioc'],
"time_in_force_parameter": "timeInForce",
"ohlcv_candle_limit": 1000,
@@ -33,65 +30,6 @@ class Binance(Exchange):
"""
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.
this stoploss-limit is binance-specific.
It may work with a limited number of other exchanges, but this has not been tested yet.
"""
# Limit price threshold: As limit price should always be below stop-price
limit_price_pct = order_types.get('stoploss_on_exchange_limit_ratio', 0.99)
rate = stop_price * limit_price_pct
ordertype = "stop_loss_limit"
stop_price = self.price_to_precision(pair, stop_price)
# Ensure rate is less than stop price
if stop_price <= rate:
raise OperationalException(
'In stoploss limit order, stop price should be more than limit price')
if self._config['dry_run']:
dry_order = self.create_dry_run_order(
pair, ordertype, "sell", amount, stop_price)
return dry_order
try:
params = self._params.copy()
params.update({'stopPrice': stop_price})
amount = self.amount_to_precision(pair, amount)
rate = self.price_to_precision(pair, rate)
order = self._api.create_order(symbol=pair, type=ordertype, side='sell',
amount=amount, price=rate, params=params)
logger.info('stoploss limit order added for %s. '
'stop price: %s. limit: %s', pair, stop_price, rate)
self._log_exchange_response('create_stoploss_order', order)
return order
except ccxt.InsufficientFunds as e:
raise InsufficientFundsError(
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
except ccxt.InvalidOrder as e:
# Errors:
# `binance Order would trigger immediately.`
raise InvalidOrderException(
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
except ccxt.BaseError as e:
raise OperationalException(e) from e
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
since_ms: int, is_new_pair: bool = False,
raise_: bool = False

View File

@@ -600,7 +600,8 @@ class Exchange:
# Dry-run methods
def create_dry_run_order(self, pair: str, ordertype: str, side: str, amount: float,
rate: float, params: Dict = {}) -> Dict[str, Any]:
rate: float, params: Dict = {},
stop_loss: bool = False) -> Dict[str, Any]:
order_id = f'dry_run_{side}_{datetime.now().timestamp()}'
_amount = self.amount_to_precision(pair, amount)
dry_order: Dict[str, Any] = {
@@ -616,14 +617,17 @@ class Exchange:
'remaining': _amount,
'datetime': arrow.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'timestamp': arrow.utcnow().int_timestamp * 1000,
'status': "closed" if ordertype == "market" else "open",
'status': "closed" if ordertype == "market" and not stop_loss else "open",
'fee': None,
'info': {}
}
if dry_order["type"] in ["stop_loss_limit", "stop-loss-limit"]:
if stop_loss:
dry_order["info"] = {"stopPrice": dry_order["price"]}
dry_order["stopPrice"] = dry_order["price"]
# Workaround to avoid filling stoploss orders immediately
dry_order["ft_order_type"] = "stoploss"
if dry_order["type"] == "market":
if dry_order["type"] == "market" and not dry_order.get("ft_order_type"):
# Update market order pricing
average = self.get_dry_market_fill_price(pair, side, amount, rate)
dry_order.update({
@@ -714,7 +718,9 @@ class Exchange:
"""
Check dry-run limit order fill and update fee (if it filled).
"""
if order['status'] != "closed" and order['type'] in ["limit"]:
if (order['status'] != "closed"
and order['type'] in ["limit"]
and not order.get('ft_order_type')):
pair = order['symbol']
if self._is_dry_limit_order_filled(pair, order['side'], order['price']):
order.update({
@@ -791,18 +797,89 @@ class Exchange:
"""
raise OperationalException(f"stoploss is not implemented for {self.name}.")
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
params = self._params.copy()
# Verify if stopPrice works for your exchange!
params.update({'stopPrice': stop_price})
return params
@retrier(retries=0)
def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict:
"""
creates a stoploss order.
requires `_ft_has['stoploss_order_types']` to be set as a dict mapping limit and market
to the corresponding exchange type.
The precise ordertype is determined by the order_types dict or exchange default.
Since ccxt does not unify stoploss-limit orders yet, this needs to be implemented in each
exchange's subclass.
The exception below should never raise, since we disallow
starting the bot in validate_ordertypes()
Note: Changes to this interface need to be applied to all sub-classes too.
"""
raise OperationalException(f"stoploss is not implemented for {self.name}.")
This may work with a limited number of other exchanges, but correct working
needs to be tested individually.
WARNING: setting `stoploss_on_exchange` to True will NOT auto-enable stoploss on exchange.
`stoploss_adjust` must still be implemented for this to work.
"""
if not self._ft_has['stoploss_on_exchange']:
raise OperationalException(f"stoploss is not implemented for {self.name}.")
user_order_type = order_types.get('stoploss', 'market')
if user_order_type in self._ft_has["stoploss_order_types"].keys():
ordertype = self._ft_has["stoploss_order_types"][user_order_type]
else:
# Otherwise pick only one available
ordertype = list(self._ft_has["stoploss_order_types"].values())[0]
user_order_type = list(self._ft_has["stoploss_order_types"].keys())[0]
stop_price_norm = self.price_to_precision(pair, stop_price)
rate = None
if user_order_type == 'limit':
# Limit price threshold: As limit price should always be below stop-price
limit_price_pct = order_types.get('stoploss_on_exchange_limit_ratio', 0.99)
rate = stop_price * limit_price_pct
# Ensure rate is less than stop price
if stop_price_norm <= rate:
raise OperationalException(
'In stoploss limit order, stop price should be more than limit price')
rate = self.price_to_precision(pair, rate)
if self._config['dry_run']:
dry_order = self.create_dry_run_order(
pair, ordertype, "sell", amount, stop_price_norm, stop_loss=True)
return dry_order
try:
params = self._get_stop_params(ordertype=ordertype, stop_price=stop_price_norm)
amount = self.amount_to_precision(pair, amount)
order = self._api.create_order(symbol=pair, type=ordertype, side='sell',
amount=amount, price=rate, params=params)
logger.info(f"stoploss {user_order_type} order added for {pair}. "
f"stop price: {stop_price}. limit: {rate}")
self._log_exchange_response('create_stoploss_order', order)
return order
except ccxt.InsufficientFunds as e:
raise InsufficientFundsError(
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
except ccxt.InvalidOrder as e:
# Errors:
# `Order would trigger immediately.`
raise InvalidOrderException(
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 stoploss order due to {e.__class__.__name__}. "
f"Message: {e}") from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
def fetch_order(self, order_id: str, pair: str) -> Dict:
@@ -1587,7 +1664,7 @@ def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = Non
def is_exchange_officially_supported(exchange_name: str) -> bool:
return exchange_name in ['bittrex', 'binance', 'kraken', 'ftx', 'gateio', 'okx']
return exchange_name in ['binance', 'bittrex', 'ftx', 'gateio', 'huobi', 'kraken', 'okx']
def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:

View File

@@ -56,7 +56,7 @@ class Ftx(Exchange):
if self._config['dry_run']:
dry_order = self.create_dry_run_order(
pair, ordertype, "sell", amount, stop_price)
pair, ordertype, "sell", amount, stop_price, stop_loss=True)
return dry_order
try:

View File

@@ -0,0 +1,39 @@
""" Huobi exchange subclass """
import logging
from typing import Dict
from freqtrade.exchange import Exchange
logger = logging.getLogger(__name__)
class Huobi(Exchange):
"""
Huobi exchange class. Contains adjustments needed for Freqtrade to work
with this exchange.
"""
_ft_has: Dict = {
"stoploss_on_exchange": True,
"stoploss_order_types": {"limit": "stop-limit"},
"ohlcv_candle_limit": 1000,
"l2_limit_range": [5, 10, 20],
"l2_limit_range_required": False,
}
def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool:
"""
Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary.
"""
return order['type'] == 'stop' and stop_loss > float(order['stopPrice'])
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
params = self._params.copy()
params.update({
"stopPrice": stop_price,
"operator": "lte",
})
return params

View File

@@ -86,6 +86,8 @@ class Kraken(Exchange):
"""
Creates a stoploss market order.
Stoploss market orders is the only stoploss type supported by kraken.
TODO: investigate if this can be combined with generic implementation
(careful, prices are reversed)
"""
params = self._params.copy()
@@ -101,7 +103,7 @@ class Kraken(Exchange):
if self._config['dry_run']:
dry_order = self.create_dry_run_order(
pair, ordertype, "sell", amount, stop_price)
pair, ordertype, "sell", amount, stop_price, stop_loss=True)
return dry_order
try:

View File

@@ -19,8 +19,26 @@ class Kucoin(Exchange):
"""
_ft_has: Dict = {
"stoploss_on_exchange": True,
"stoploss_order_types": {"limit": "limit", "market": "market"},
"l2_limit_range": [20, 100],
"l2_limit_range_required": False,
"order_time_in_force": ['gtc', 'fok', 'ioc'],
"time_in_force_parameter": "timeInForce",
}
def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool:
"""
Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary.
"""
return order['info'].get('stop') is not None and stop_loss > float(order['stopPrice'])
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
params = self._params.copy()
params.update({
'stopPrice': stop_price,
'stop': 'loss'
})
return params

View File

@@ -542,7 +542,6 @@ class FreqtradeBot(LoggingMixin):
entry_tag=buy_tag):
logger.info(f"User requested abortion of buying {pair}")
return False
amount = self.exchange.amount_to_precision(pair, amount)
order = self.exchange.create_order(pair=pair, ordertype=order_type, side="buy",
amount=amount, rate=enter_limit_requested,
time_in_force=time_in_force)
@@ -900,7 +899,7 @@ class FreqtradeBot(LoggingMixin):
return False
def handle_trailing_stoploss_on_exchange(self, trade: Trade, order: dict) -> None:
def handle_trailing_stoploss_on_exchange(self, trade: Trade, order: Dict) -> None:
"""
Check to see if stoploss on exchange should be updated
in case of trailing stoploss on exchange
@@ -979,10 +978,10 @@ class FreqtradeBot(LoggingMixin):
or (order_obj and self.strategy.ft_check_timed_out(
'sell', trade, order_obj, datetime.now(timezone.utc))
))):
self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT'])
canceled = self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT'])
canceled_count = trade.get_exit_order_count()
max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0)
if max_timeouts > 0 and canceled_count >= max_timeouts:
if canceled and max_timeouts > 0 and canceled_count >= max_timeouts:
logger.warning(f'Emergencyselling trade {trade}, as the sell order '
f'timed out {max_timeouts} times.')
try:
@@ -1079,11 +1078,12 @@ class FreqtradeBot(LoggingMixin):
reason=reason)
return was_trade_fully_canceled
def handle_cancel_exit(self, trade: Trade, order: Dict, reason: str) -> str:
def handle_cancel_exit(self, trade: Trade, order: Dict, reason: str) -> bool:
"""
Sell cancel - cancel order and update trade
:return: Reason for cancel
:return: True if exit order was cancelled, false otherwise
"""
cancelled = False
# if trade is not partially completed, just cancel the order
if order['remaining'] == order['amount'] or order.get('filled') == 0.0:
if not self.exchange.check_order_canceled_empty(order):
@@ -1094,7 +1094,7 @@ class FreqtradeBot(LoggingMixin):
trade.update_order(co)
except InvalidOrderException:
logger.exception(f"Could not cancel sell order {trade.open_order_id}")
return 'error cancelling order'
return False
logger.info('Sell order %s for %s.', reason, trade)
else:
reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
@@ -1109,9 +1109,11 @@ class FreqtradeBot(LoggingMixin):
trade.is_open = True
trade.open_order_id = None
trade.sell_reason = None
cancelled = True
else:
# TODO: figure out how to handle partially complete sell orders
reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN']
cancelled = False
self.wallets.update()
self._notify_exit_cancel(
@@ -1119,7 +1121,7 @@ class FreqtradeBot(LoggingMixin):
order_type=self.strategy.order_types['sell'],
reason=reason
)
return reason
return cancelled
def _safe_exit_amount(self, pair: str, amount: float) -> float:
"""
@@ -1168,8 +1170,8 @@ class FreqtradeBot(LoggingMixin):
# if stoploss is on exchange and we are on dry_run mode,
# we consider the sell price stop price
if self.config['dry_run'] and sell_type == 'stoploss' \
and self.strategy.order_types['stoploss_on_exchange']:
if (self.config['dry_run'] and sell_type == 'stoploss'
and self.strategy.order_types['stoploss_on_exchange']):
limit = trade.stop_loss
# set custom_exit_price if available

View File

@@ -195,6 +195,7 @@ class Order(_DECL_BASE):
return {
'amount': self.amount,
'average': round(self.average, 8) if self.average else 0,
'safe_price': self.safe_price,
'cost': self.cost if self.cost else 0,
'filled': self.filled,
'ft_order_side': self.ft_order_side,

View File

@@ -8,7 +8,7 @@ from freqtrade.configuration.config_validation import validate_config_consistenc
from freqtrade.enums import BacktestState
from freqtrade.exceptions import DependencyException
from freqtrade.rpc.api_server.api_schemas import BacktestRequest, BacktestResponse
from freqtrade.rpc.api_server.deps import get_config
from freqtrade.rpc.api_server.deps import get_config, is_webserver_mode
from freqtrade.rpc.api_server.webserver import ApiServer
from freqtrade.rpc.rpc import RPCException
@@ -22,7 +22,7 @@ router = APIRouter()
@router.post('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest'])
# flake8: noqa: C901
async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: BackgroundTasks,
config=Depends(get_config)):
config=Depends(get_config), ws_mode=Depends(is_webserver_mode)):
"""Start backtesting if not done so already"""
if ApiServer._bgtask_running:
raise RPCException('Bot Background task already running')
@@ -121,7 +121,7 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
@router.get('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest'])
def api_get_backtest():
def api_get_backtest(ws_mode=Depends(is_webserver_mode)):
"""
Get backtesting result.
Returns Result after backtesting has been ran.
@@ -157,7 +157,7 @@ def api_get_backtest():
@router.delete('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest'])
def api_delete_backtest():
def api_delete_backtest(ws_mode=Depends(is_webserver_mode)):
"""Reset backtesting"""
if ApiServer._bgtask_running:
return {
@@ -183,7 +183,7 @@ def api_delete_backtest():
@router.get('/backtest/abort', response_model=BacktestResponse, tags=['webserver', 'backtest'])
def api_backtest_abort():
def api_backtest_abort(ws_mode=Depends(is_webserver_mode)):
if not ApiServer._bgtask_running:
return {
"status": "not_running",

View File

@@ -2,6 +2,7 @@ from typing import Any, Dict, Iterator, Optional
from fastapi import Depends
from freqtrade.enums import RunMode
from freqtrade.persistence import Trade
from freqtrade.rpc.rpc import RPC, RPCException
@@ -38,3 +39,9 @@ def get_exchange(config=Depends(get_config)):
ApiServer._exchange = ExchangeResolver.load_exchange(
config['exchange']['name'], config)
return ApiServer._exchange
def is_webserver_mode(config=Depends(get_config)):
if config['runmode'] != RunMode.WEBSERVER:
raise RPCException('Bot is not in the correct state')
return None

View File

@@ -370,46 +370,50 @@ class Telegram(RPCHandler):
else:
return "\N{CROSS MARK}"
def _prepare_entry_details(self, filled_orders, base_currency, is_open):
def _prepare_entry_details(self, filled_orders: List, base_currency: str, is_open: bool):
"""
Prepare details of trade with entry adjustment enabled
"""
lines = []
lines: List[str] = []
if len(filled_orders) > 0:
first_avg = filled_orders[0]["safe_price"]
for x, order in enumerate(filled_orders):
cur_entry_datetime = arrow.get(order["order_filled_date"])
cur_entry_amount = order["amount"]
cur_entry_average = order["average"]
cur_entry_average = order["safe_price"]
lines.append(" ")
if x == 0:
lines.append("*Entry #{}:*".format(x+1))
lines.append("*Entry Amount:* {} ({:.8f} {})"
.format(cur_entry_amount, order["cost"], base_currency))
lines.append("*Average Entry Price:* {}".format(cur_entry_average))
lines.append(f"*Entry #{x+1}:*")
lines.append(
f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {base_currency})")
lines.append(f"*Average Entry Price:* {cur_entry_average}")
else:
sumA = 0
sumB = 0
for y in range(x):
sumA += (filled_orders[y]["amount"] * filled_orders[y]["average"])
sumA += (filled_orders[y]["amount"] * filled_orders[y]["safe_price"])
sumB += filled_orders[y]["amount"]
prev_avg_price = sumA/sumB
price_to_1st_entry = ((cur_entry_average - filled_orders[0]["average"])
/ filled_orders[0]["average"])
minus_on_entry = (cur_entry_average - prev_avg_price)/prev_avg_price
prev_avg_price = sumA / sumB
price_to_1st_entry = ((cur_entry_average - first_avg) / first_avg)
minus_on_entry = 0
if prev_avg_price:
minus_on_entry = (cur_entry_average - prev_avg_price) / prev_avg_price
dur_entry = cur_entry_datetime - arrow.get(filled_orders[x-1]["order_filled_date"])
days = dur_entry.days
hours, remainder = divmod(dur_entry.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
lines.append("*Entry #{}:* at {:.2%} avg profit".format(x+1, minus_on_entry))
lines.append(f"*Entry #{x+1}:* at {minus_on_entry:.2%} avg profit")
if is_open:
lines.append("({})".format(cur_entry_datetime
.humanize(granularity=["day", "hour", "minute"])))
lines.append("*Entry Amount:* {} ({:.8f} {})"
.format(cur_entry_amount, order["cost"], base_currency))
lines.append("*Average Entry Price:* {} ({:.2%} from 1st entry rate)"
.format(cur_entry_average, price_to_1st_entry))
lines.append("*Order filled at:* {}".format(order["order_filled_date"]))
lines.append("({}d {}h {}m {}s from previous entry)"
.format(days, hours, minutes, seconds))
lines.append(
f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {base_currency})")
lines.append(f"*Average Entry Price:* {cur_entry_average} "
f"({price_to_1st_entry:.2%} from 1st entry rate)")
lines.append(f"*Order filled at:* {order['order_filled_date']}")
lines.append(f"({days}d {hours}h {minutes}m {seconds}s from previous entry)")
return lines
@authorized_only

View File

@@ -0,0 +1,12 @@
"exchange": {
"name": "{{ exchange_name | lower }}",
"key": "{{ exchange_key }}",
"secret": "{{ exchange_secret }}",
"ccxt_config": {},
"ccxt_async_config": {},
"pair_whitelist": [
],
"pair_blacklist": [
"HT/.*"
]
}