Merge branch 'feat/short' into pr/samgermain/5378

This commit is contained in:
Matthias
2021-09-04 19:54:34 +02:00
59 changed files with 566 additions and 276 deletions

View File

@@ -22,7 +22,7 @@ ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
"max_open_trades", "stake_amount", "fee", "pairs"]
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
"enable_protections", "dry_run_wallet",
"enable_protections", "dry_run_wallet", "timeframe_detail",
"strategy_list", "export", "exportfilename"]
ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",

View File

@@ -66,16 +66,22 @@ def ask_user_config() -> Dict[str, Any]:
{
"type": "text",
"name": "stake_amount",
"message": "Please insert your stake amount:",
"message": f"Please insert your stake amount (Number or '{UNLIMITED_STAKE_AMOUNT}'):",
"default": "0.01",
"validate": lambda val: val == UNLIMITED_STAKE_AMOUNT or validate_is_float(val),
"filter": lambda val: '"' + UNLIMITED_STAKE_AMOUNT + '"'
if val == UNLIMITED_STAKE_AMOUNT
else val
},
{
"type": "text",
"name": "max_open_trades",
"message": f"Please insert max_open_trades (Integer or '{UNLIMITED_STAKE_AMOUNT}'):",
"default": "3",
"validate": lambda val: val == UNLIMITED_STAKE_AMOUNT or validate_is_int(val)
"validate": lambda val: val == UNLIMITED_STAKE_AMOUNT or validate_is_int(val),
"filter": lambda val: '"' + UNLIMITED_STAKE_AMOUNT + '"'
if val == UNLIMITED_STAKE_AMOUNT
else val
},
{
"type": "text",

View File

@@ -135,6 +135,10 @@ AVAILABLE_CLI_OPTIONS = {
help='Override the value of the `stake_amount` configuration setting.',
),
# Backtesting
"timeframe_detail": Arg(
'--timeframe-detail',
help='Specify detail timeframe for backtesting (`1m`, `5m`, `30m`, `1h`, `1d`).',
),
"position_stacking": Arg(
'--eps', '--enable-position-stacking',
help='Allow buying the same pair multiple times (position stacking).',
@@ -162,7 +166,7 @@ AVAILABLE_CLI_OPTIONS = {
'Please note that ticker-interval needs to be set either in config '
'or via command line. When using this together with `--export trades`, '
'the strategy-name is injected into the filename '
'(so `backtest-data.json` becomes `backtest-data-DefaultStrategy.json`',
'(so `backtest-data.json` becomes `backtest-data-SampleStrategy.json`',
nargs='+',
),
"export": Arg(

View File

@@ -74,8 +74,6 @@ def start_new_strategy(args: Dict[str, Any]) -> None:
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
if "strategy" in args and args["strategy"]:
if args["strategy"] == "DefaultStrategy":
raise OperationalException("DefaultStrategy is not allowed as name.")
new_path = config['user_data_dir'] / USERPATH_STRATEGIES / (args['strategy'] + '.py')
@@ -128,8 +126,6 @@ def start_new_hyperopt(args: Dict[str, Any]) -> None:
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
if 'hyperopt' in args and args['hyperopt']:
if args['hyperopt'] == 'DefaultHyperopt':
raise OperationalException("DefaultHyperopt is not allowed as name.")
new_path = config['user_data_dir'] / USERPATH_HYPEROPTS / (args['hyperopt'] + '.py')

View File

@@ -242,6 +242,9 @@ class Configuration:
except ValueError:
pass
self._args_to_config(config, argname='timeframe_detail',
logstring='Parameter --timeframe-detail detected, '
'using {} for intra-candle backtesting ...')
self._args_to_config(config, argname='stake_amount',
logstring='Parameter --stake-amount detected, '
'overriding stake_amount to: {} ...')

View File

@@ -49,6 +49,8 @@ USERPATH_NOTEBOOKS = 'notebooks'
TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent']
ENV_VAR_PREFIX = 'FREQTRADE__'
NON_OPEN_EXCHANGE_STATES = ('cancelled', 'canceled', 'closed', 'expired')
# Define decimals per coin for outputs
# Only used for outputs.

View File

@@ -19,7 +19,8 @@ from ccxt.base.decimal_to_precision import (ROUND_DOWN, ROUND_UP, TICK_SIZE, TRU
decimal_to_precision)
from pandas import DataFrame
from freqtrade.constants import DEFAULT_AMOUNT_RESERVE_PERCENT, ListPairsWithTimeframes
from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES,
ListPairsWithTimeframes)
from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list
from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError,
InvalidOrderException, OperationalException, PricingError,
@@ -351,9 +352,16 @@ class Exchange:
def validate_stakecurrency(self, stake_currency: str) -> None:
"""
Checks stake-currency against available currencies on the exchange.
Only runs on startup. If markets have not been loaded, there's been a problem with
the connection to the exchange.
:param stake_currency: Stake-currency to validate
:raise: OperationalException if stake-currency is not available.
"""
if not self._markets:
raise OperationalException(
'Could not load markets, therefore cannot start. '
'Please investigate the above error for more details.'
)
quote_currencies = self.get_quote_currencies()
if stake_currency not in quote_currencies:
raise OperationalException(
@@ -804,7 +812,7 @@ class Exchange:
: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', 'cancelled')
return (order.get('status') in NON_OPEN_EXCHANGE_STATES
and order.get('filled') == 0.0)
@retrier

View File

@@ -433,11 +433,12 @@ class FreqtradeBot(LoggingMixin):
# TODO-lev: Does the below need to be adjusted for shorts?
if self._check_depth_of_market_buy(pair, bid_check_dom):
# TODO-lev: pass in "enter" as side.
return self.execute_buy(pair, stake_amount, enter_tag=enter_tag)
return self.execute_entry(pair, stake_amount, buy_tag=enter_tag)
else:
return False
return self.execute_buy(pair, stake_amount, enter_tag=enter_tag)
return self.execute_entry(pair, stake_amount, buy_tag=enter_tag)
else:
return False
@@ -465,8 +466,8 @@ class FreqtradeBot(LoggingMixin):
logger.info(f"Bids to asks delta for {pair} does not satisfy condition.")
return False
def execute_buy(self, pair: str, stake_amount: float, price: Optional[float] = None,
forcebuy: bool = False, enter_tag: Optional[str] = None) -> bool:
def execute_entry(self, pair: str, stake_amount: float, price: Optional[float] = None,
forcebuy: bool = False, enter_tag: Optional[str] = None) -> bool:
"""
Executes a limit buy for the given pair
:param pair: pair for which we want to create a LIMIT_BUY
@@ -746,7 +747,7 @@ class FreqtradeBot(LoggingMixin):
trade.stoploss_order_id = None
logger.error(f'Unable to place a stoploss order on exchange. {e}')
logger.warning('Selling the trade forcefully')
self.execute_sell(trade, trade.stop_loss, sell_reason=SellCheckTuple(
self.execute_trade_exit(trade, trade.stop_loss, sell_reason=SellCheckTuple(
sell_type=SellType.EMERGENCY_SELL))
except ExchangeError:
@@ -864,7 +865,7 @@ class FreqtradeBot(LoggingMixin):
if should_exit.sell_flag:
logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.sell_type}')
self.execute_sell(trade, sell_rate, should_exit)
self.execute_trade_exit(trade, sell_rate, should_exit)
return True
return False
@@ -946,7 +947,7 @@ class FreqtradeBot(LoggingMixin):
was_trade_fully_canceled = False
# Cancelled orders may have the status of 'canceled' or 'closed'
if order['status'] not in ('cancelled', 'canceled', 'closed'):
if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES:
filled_val = order.get('filled', 0.0) or 0.0
filled_stake = filled_val * trade.open_rate
minstake = self.exchange.get_min_pair_stake_amount(
@@ -962,7 +963,7 @@ class FreqtradeBot(LoggingMixin):
# Avoid race condition where the order could not be cancelled coz its already filled.
# Simply bailing here is the only safe way - as this order will then be
# handled in the next iteration.
if corder.get('status') not in ('cancelled', 'canceled', 'closed'):
if corder.get('status') not in constants.NON_OPEN_EXCHANGE_STATES:
logger.warning(f"Order {trade.open_order_id} for {trade.pair} not cancelled.")
return False
else:
@@ -1065,9 +1066,9 @@ class FreqtradeBot(LoggingMixin):
raise DependencyException(
f"Not enough amount to sell. Trade-amount: {amount}, Wallet: {wallet_amount}")
def execute_sell(self, trade: Trade, limit: float, sell_reason: SellCheckTuple) -> bool:
def execute_trade_exit(self, trade: Trade, limit: float, sell_reason: SellCheckTuple) -> bool:
"""
Executes a limit sell for the given trade and limit
Executes a trade exit for the given trade and limit
:param trade: Trade instance
:param limit: limit rate for the sell order
:param sell_reason: Reason the sell was triggered
@@ -1143,7 +1144,7 @@ class FreqtradeBot(LoggingMixin):
trade.close_rate_requested = limit
trade.sell_reason = sell_reason.sell_reason
# In case of market sell orders the order can be closed immediately
if order.get('status', 'unknown') == 'closed':
if order.get('status', 'unknown') in ('closed', 'expired'):
self.update_trade_state(trade, trade.open_order_id, order)
Trade.commit()

View File

@@ -89,6 +89,17 @@ class Backtesting:
"configuration or as cli argument `--timeframe 5m`")
self.timeframe = str(self.config.get('timeframe'))
self.timeframe_min = timeframe_to_minutes(self.timeframe)
# Load detail timeframe if specified
self.timeframe_detail = str(self.config.get('timeframe_detail', ''))
if self.timeframe_detail:
self.timeframe_detail_min = timeframe_to_minutes(self.timeframe_detail)
if self.timeframe_min <= self.timeframe_detail_min:
raise OperationalException(
"Detail timeframe must be smaller than strategy timeframe.")
else:
self.timeframe_detail_min = 0
self.detail_data: Dict[str, DataFrame] = {}
self.pairlists = PairListManager(self.exchange, self.config)
if 'VolumePairList' in self.pairlists.name_list:
@@ -191,6 +202,23 @@ class Backtesting:
self.progress.set_new_value(1)
return data, self.timerange
def load_bt_data_detail(self) -> None:
"""
Loads backtest detail data (smaller timeframe) if necessary.
"""
if self.timeframe_detail:
self.detail_data = history.load_data(
datadir=self.config['datadir'],
pairs=self.pairlists.whitelist,
timeframe=self.timeframe_detail,
timerange=self.timerange,
startup_candles=0,
fail_without_data=True,
data_format=self.config.get('dataformat_ohlcv', 'json'),
)
else:
self.detail_data = {}
def prepare_backtest(self, enable_protections):
"""
Backtesting setup method - called once for every call to "backtest()".
@@ -334,7 +362,8 @@ class Backtesting:
else:
return sell_row[OPEN_IDX]
def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]:
def _get_sell_trade_entry_for_candle(self, trade: LocalTrade,
sell_row: Tuple) -> Optional[LocalTrade]:
sell_candle_time = sell_row[DATE_IDX].to_pydatetime()
enter = sell_row[LONG_IDX] if trade.is_short else sell_row[SHORT_IDX]
exit_ = sell_row[ELONG_IDX] if trade.is_short else sell_row[ESHORT_IDX]
@@ -365,6 +394,35 @@ class Backtesting:
return None
def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]:
if self.timeframe_detail and trade.pair in self.detail_data:
sell_candle_time = sell_row[DATE_IDX].to_pydatetime()
sell_candle_end = sell_candle_time + timedelta(minutes=self.timeframe_min)
detail_data = self.detail_data[trade.pair]
detail_data = detail_data.loc[
(detail_data['date'] >= sell_candle_time) &
(detail_data['date'] < sell_candle_end)
]
if len(detail_data) == 0:
# Fall back to "regular" data if no detail data was found for this candle
return self._get_sell_trade_entry_for_candle(trade, sell_row)
detail_data['enter_long'] = sell_row[LONG_IDX]
detail_data['exit_long'] = sell_row[ELONG_IDX]
detail_data['enter_short'] = sell_row[SHORT_IDX]
detail_data['exit_short'] = sell_row[ESHORT_IDX]
headers = ['date', 'open', 'high', 'low', 'close', 'enter_long', 'exit_long',
'enter_short', 'exit_short']
for det_row in detail_data[headers].values.tolist():
res = self._get_sell_trade_entry_for_candle(trade, det_row)
if res:
return res
return None
else:
return self._get_sell_trade_entry_for_candle(trade, sell_row)
def _enter_trade(self, pair: str, row: List, direction: str) -> Optional[LocalTrade]:
try:
stake_amount = self.wallets.get_trade_stake_amount(pair, None)
@@ -626,6 +684,7 @@ class Backtesting:
data: Dict[str, Any] = {}
data, timerange = self.load_bt_data()
self.load_bt_data_detail()
logger.info("Dataload complete. Calculating indicators")
for strat in self.strategylist:

View File

@@ -368,6 +368,7 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame],
'max_open_trades_setting': (config['max_open_trades']
if config['max_open_trades'] != float('inf') else -1),
'timeframe': config['timeframe'],
'timeframe_detail': config.get('timeframe_detail', ''),
'timerange': config.get('timerange', ''),
'enable_protections': config.get('enable_protections', False),
'strategy_name': strategy,

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session
from sqlalchemy.pool import StaticPool
from sqlalchemy.sql.schema import UniqueConstraint
from freqtrade.constants import DATETIME_PRINT_FORMAT
from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES
from freqtrade.enums import SellType
from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.leverage import interest
@@ -164,7 +164,7 @@ class Order(_DECL_BASE):
self.order_date = datetime.fromtimestamp(order['timestamp'] / 1000, tz=timezone.utc)
self.ft_is_open = True
if self.status in ('closed', 'canceled', 'cancelled'):
if self.status in NON_OPEN_EXCHANGE_STATES:
self.ft_is_open = False
if (order.get('filled', 0.0) or 0.0) > 0:
self.order_filled_date = datetime.now(timezone.utc)

View File

@@ -46,11 +46,14 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
if (
not ApiServer._bt
or lastconfig.get('timeframe') != strat.timeframe
or lastconfig.get('timeframe_detail') != btconfig.get('timeframe_detail')
or lastconfig.get('dry_run_wallet') != btconfig.get('dry_run_wallet', 0)
or lastconfig.get('timerange') != btconfig['timerange']
):
from freqtrade.optimize.backtesting import Backtesting
ApiServer._bt = Backtesting(btconfig)
if ApiServer._bt.timeframe_detail:
ApiServer._bt.load_bt_data_detail()
# Only reload data if timeframe changed.
if (

View File

@@ -324,6 +324,7 @@ class PairHistory(BaseModel):
class BacktestRequest(BaseModel):
strategy: str
timeframe: Optional[str]
timeframe_detail: Optional[str]
timerange: Optional[str]
max_open_trades: Optional[int]
stake_amount: Optional[Union[float, str]]

View File

@@ -557,7 +557,7 @@ class RPC:
current_rate = self._freqtrade.exchange.get_rate(
trade.pair, refresh=False, side="sell")
sell_reason = SellCheckTuple(sell_type=SellType.FORCE_SELL)
self._freqtrade.execute_sell(trade, current_rate, sell_reason)
self._freqtrade.execute_trade_exit(trade, current_rate, sell_reason)
# ---- EOF def _exec_forcesell ----
if self._freqtrade.state != State.RUNNING:
@@ -613,7 +613,7 @@ class RPC:
stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair)
# execute buy
if self._freqtrade.execute_buy(pair, stakeamount, price, forcebuy=True):
if self._freqtrade.execute_entry(pair, stakeamount, price, forcebuy=True):
Trade.commit()
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
return trade

View File

@@ -120,6 +120,8 @@ class IStrategy(ABC, HyperStrategyMixin):
# and wallets - access to the current balance.
dp: Optional[DataProvider] = None
wallets: Optional[Wallets] = None
# Filled from configuration
stake_currency: str
# container variable for strategy source code
__source__: str = ''

View File

@@ -36,6 +36,6 @@
"BNB/TUSD",
"BNB/USDC",
"BNB/USDS",
"BNB/USDT",
"BNB/USDT"
]
}