diff --git a/docs/backtesting.md b/docs/backtesting.md index 68782bb9c..017289905 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -194,7 +194,10 @@ Since backtesting lacks some detailed information about what happens within a ca - Buys happen at open-price - Sell signal sells happen at open-price of the following candle - Low happens before high for stoploss, protecting capital first. -- ROI sells are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the sell will be at 2%) +- ROI + - sells are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the sell will be at 2%) + - sells are never "below the candle", so a ROI of 2% may result in a sell at 2.4% if low was at 2.4% profit + - Forcesells caused by `=-1` ROI entries use low as sell value, unless N falls on the candle open (e.g. `120: -1` for 1h candles) - Stoploss sells happen exactly at stoploss price, even if low was lower - Trailing stoploss - High happens first - adjusting stoploss diff --git a/docs/configuration.md b/docs/configuration.md index 5ad1a886e..5bebbcfcd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -169,6 +169,9 @@ This parameter can be set in either Strategy or Configuration file. If you use i `minimal_roi` value from the strategy file. If it is not set in either Strategy or Configuration, a default of 1000% `{"0": 10}` is used, and minimal roi is disabled unless your trade generates 1000% profit. +!!! Note "Special case to forcesell after a specific time" + A special case presents using `"": -1` as ROI. This forces the bot to sell a trade after N Minutes, no matter if it's positive or negative, so represents a time-limited force-sell. + ### Understand stoploss Go to the [stoploss documentation](stoploss.md) for more details. diff --git a/docs/hyperopt.md b/docs/hyperopt.md index 9c9e9fdef..f399fe816 100644 --- a/docs/hyperopt.md +++ b/docs/hyperopt.md @@ -6,8 +6,12 @@ algorithms included in the `scikit-optimize` package to accomplish this. The search will burn all your CPU cores, make your laptop sound like a fighter jet and still take a long time. +In general, the search for best parameters starts with a few random combinations and then uses Bayesian search with a +ML regressor algorithm (currently ExtraTreesRegressor) to quickly find a combination of parameters in the search hyperspace +that minimizes the value of the [loss function](#loss-functions). + Hyperopt requires historic data to be available, just as backtesting does. -To learn how to get data for the pairs and exchange you're interrested in, head over to the [Data Downloading](data-download.md) section of the documentation. +To learn how to get data for the pairs and exchange you're interested in, head over to the [Data Downloading](data-download.md) section of the documentation. !!! Bug Hyperopt can crash when used with only 1 CPU Core as found out in [Issue #1133](https://github.com/freqtrade/freqtrade/issues/1133) @@ -170,10 +174,6 @@ with different value combinations. It will then use the given historical data an buys based on the buy signals generated with the above function and based on the results it will end with telling you which paramter combination produced the best profits. -The search for best parameters starts with a few random combinations and then uses a -regressor algorithm (currently ExtraTreesRegressor) to quickly find a parameter combination -that minimizes the value of the [loss function](#loss-functions). - The above setup expects to find ADX, RSI and Bollinger Bands in the populated indicators. When you want to test an indicator that isn't used by the bot currently, remember to add it to the `populate_indicators()` method in your custom hyperopt file. @@ -284,6 +284,16 @@ number). You can also enable position stacking in the configuration file by explicitly setting `"position_stacking"=true`. +### Reproducible results + +The search for optimal parameters starts with a few (currently 30) random combinations in the hyperspace of parameters, random Hyperopt epochs. These random epochs are marked with a leading asterisk sign at the Hyperopt output. + +The initial state for generation of these random values (random state) is controlled by the value of the `--random-state` command line option. You can set it to some arbitrary value of your choice to obtain reproducible results. + +If you have not set this value explicitly in the command line options, Hyperopt seeds the random state with some random value for you. The random state value for each Hyperopt run is shown in the log, so you can copy and paste it into the `--random-state` command line option to repeat the set of the initial random epochs used. + +If you have not changed anything in the command line options, configuration, timerange, Strategy and Hyperopt classes, historical data and the Loss Function -- you should obtain same hyperoptimization results with same random state value used. + ## Understand the Hyperopt Result Once Hyperopt is completed you can use the result to create a new strategy. diff --git a/freqtrade/edge/__init__.py b/freqtrade/edge/__init__.py index afd20cf61..4bc3023a4 100644 --- a/freqtrade/edge/__init__.py +++ b/freqtrade/edge/__init__.py @@ -80,7 +80,7 @@ class Edge: if config.get('fee'): self.fee = config['fee'] else: - self.fee = self.exchange.get_fee() + self.fee = self.exchange.get_fee(symbol=self.config['exchange']['pair_whitelist'][0]) def calculate(self) -> bool: pairs = self.config['exchange']['pair_whitelist'] diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index fbe7cd29a..860f59fba 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -921,7 +921,7 @@ class Exchange: raise OperationalException(e) from e @retrier - def get_fee(self, symbol='ETH/BTC', type='', side='', amount=1, + def get_fee(self, symbol, type='', side='', amount=1, price=1, taker_or_maker='maker') -> float: try: # validate that markets are loaded before trying to get fee diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index c32d4c9fa..8d4d62d9e 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -65,7 +65,7 @@ class Backtesting: if config.get('fee'): self.fee = config['fee'] else: - self.fee = self.exchange.get_fee() + self.fee = self.exchange.get_fee(symbol=self.config['exchange']['pair_whitelist'][0]) if self.config.get('runmode') != RunMode.HYPEROPT: self.dataprovider = DataProvider(self.config, self.exchange) @@ -278,6 +278,45 @@ class Backtesting: ticker[pair] = [x for x in pair_data[headers].itertuples()] return ticker + def _get_close_rate(self, sell_row, trade: Trade, sell, trade_dur) -> float: + """ + Get close rate for backtesting result + """ + # Special handling if high or low hit STOP_LOSS or ROI + if sell.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS): + # Set close_rate to stoploss + return trade.stop_loss + elif sell.sell_type == (SellType.ROI): + roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur) + if roi is not None: + if roi == -1 and roi_entry % self.timeframe_mins == 0: + # When forceselling with ROI=-1, the roi time will always be equal to trade_dur. + # If that entry is a multiple of the timeframe (so on candle open) + # - we'll use open instead of close + return sell_row.open + + # - (Expected abs profit + open_rate + open_fee) / (fee_close -1) + close_rate = - (trade.open_rate * roi + trade.open_rate * + (1 + trade.fee_open)) / (trade.fee_close - 1) + + if (trade_dur > 0 and trade_dur == roi_entry + and roi_entry % self.timeframe_mins == 0 + and sell_row.open > close_rate): + # new ROI entry came into effect. + # use Open rate if open_rate > calculated sell rate + return sell_row.open + + # Use the maximum between close_rate and low as we + # cannot sell outside of a candle. + # Applies when a new ROI setting comes in place and the whole candle is above that. + return max(close_rate, sell_row.low) + + else: + # This should not be reached... + return sell_row.open + else: + return sell_row.open + def _get_sell_trade_entry( self, pair: str, buy_row: DataFrame, partial_ticker: List, trade_count_lock: Dict, @@ -304,26 +343,7 @@ class Backtesting: sell_row.sell, low=sell_row.low, high=sell_row.high) if sell.sell_flag: trade_dur = int((sell_row.date - buy_row.date).total_seconds() // 60) - # Special handling if high or low hit STOP_LOSS or ROI - if sell.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS): - # Set close_rate to stoploss - closerate = trade.stop_loss - elif sell.sell_type == (SellType.ROI): - roi = self.strategy.min_roi_reached_entry(trade_dur) - if roi is not None: - # - (Expected abs profit + open_rate + open_fee) / (fee_close -1) - closerate = - (trade.open_rate * roi + trade.open_rate * - (1 + trade.fee_open)) / (trade.fee_close - 1) - - # Use the maximum between closerate and low as we - # cannot sell outside of a candle. - # Applies when using {"xx": -1} as roi to force sells after xx minutes - closerate = max(closerate, sell_row.low) - else: - # This should not be reached... - closerate = sell_row.open - else: - closerate = sell_row.open + closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) return BacktestResult(pair=pair, profit_percent=trade.calc_profit_percent(rate=closerate), diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index 6d39c083f..578b09bd2 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -6,6 +6,7 @@ This module contains the hyperopt logic import locale import logging +import random import sys import warnings from collections import OrderedDict @@ -434,7 +435,7 @@ class Hyperopt: acq_optimizer="auto", n_initial_points=INITIAL_POINTS, acq_optimizer_kwargs={'n_jobs': cpu_count}, - random_state=self.config.get('hyperopt_random_state', None), + random_state=self.random_state, ) def fix_optimizer_models_list(self): @@ -473,7 +474,13 @@ class Hyperopt: logger.info(f"Loaded {len(trials)} previous evaluations from disk.") return trials + def _set_random_state(self, random_state: Optional[int]) -> int: + return random_state or random.randint(1, 2**16 - 1) + def start(self) -> None: + self.random_state = self._set_random_state(self.config.get('hyperopt_random_state', None)) + logger.info(f"Using optimizer random state: {self.random_state}") + data, timerange = self.backtesting.load_bt_data() preprocessed = self.backtesting.strategy.tickerdata_to_dataframe(data) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 51736968b..a3f88003a 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -587,14 +587,25 @@ class Telegram(RPC): :return: None """ val = self._rpc_show_config() + if val['trailing_stop']: + sl_info = ( + f"*Initial Stoploss:* `{val['stoploss']}`\n" + f"*Trailing stop positive:* `{val['trailing_stop_positive']}`\n" + f"*Trailing stop offset:* `{val['trailing_stop_positive_offset']}`\n" + f"*Only trail above offset:* `{val['trailing_only_offset_is_reached']}`\n" + ) + + else: + sl_info = f"*Stoploss:* `{val['stoploss']}`\n" + self._send_msg( f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n" f"*Exchange:* `{val['exchange']}`\n" f"*Stake per trade:* `{val['stake_amount']} {val['stake_currency']}`\n" f"*Minimum ROI:* `{val['minimal_roi']}`\n" - f"*{'Trailing ' if val['trailing_stop'] else ''}Stoploss:* `{val['stoploss']}`\n" + f"{sl_info}" f"*Ticker Interval:* `{val['ticker_interval']}`\n" - f"*Strategy:* `{val['strategy']}`'" + f"*Strategy:* `{val['strategy']}`" ) def _send_msg(self, msg: str, parse_mode: ParseMode = ParseMode.MARKDOWN) -> None: diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 050181472..cf0e760f2 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -400,7 +400,7 @@ class IStrategy(ABC): return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE) - def min_roi_reached_entry(self, trade_dur: int) -> Optional[float]: + def min_roi_reached_entry(self, trade_dur: int) -> Tuple[Optional[int], Optional[float]]: """ Based on trade duration defines the ROI entry that may have been reached. :param trade_dur: trade duration in minutes @@ -409,9 +409,9 @@ class IStrategy(ABC): # Get highest entry in ROI dict where key <= trade-duration roi_list = list(filter(lambda x: x <= trade_dur, self.minimal_roi.keys())) if not roi_list: - return None + return None, None roi_entry = max(roi_list) - return self.minimal_roi[roi_entry] + return roi_entry, self.minimal_roi[roi_entry] def min_roi_reached(self, trade: Trade, current_profit: float, current_time: datetime) -> bool: """ @@ -421,7 +421,7 @@ class IStrategy(ABC): """ # Check if time matches and current rate is above threshold trade_dur = int((current_time.timestamp() - trade.open_date.timestamp()) // 60) - roi = self.min_roi_reached_entry(trade_dur) + _, roi = self.min_roi_reached_entry(trade_dur) if roi is None: return False else: diff --git a/tests/edge/test_edge.py b/tests/edge/test_edge.py index 001dc9591..bdb986d6d 100644 --- a/tests/edge/test_edge.py +++ b/tests/edge/test_edge.py @@ -334,7 +334,7 @@ def test_process_expectancy(mocker, edge_conf): edge_conf['edge']['min_trade_number'] = 2 freqtrade = get_patched_freqtradebot(mocker, edge_conf) - def get_fee(): + def get_fee(*args, **kwargs): return 0.001 freqtrade.exchange.get_fee = get_fee diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index a21a5f3ac..82b62d5b8 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1646,10 +1646,10 @@ def test_get_fee(default_conf, mocker, exchange_name): }) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - assert exchange.get_fee() == 0.025 + assert exchange.get_fee('ETH/BTC') == 0.025 ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, - 'get_fee', 'calculate_fee') + 'get_fee', 'calculate_fee', symbol="ETH/BTC") def test_stoploss_limit_order_unsupported_exchange(default_conf, mocker): diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 9914eea23..9a8350c3d 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -265,6 +265,69 @@ tc16 = BTContainer(data=[ trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)] ) +# Test 17: Buy, hold for 120 mins, then forcesell using roi=-1 +# Causes negative profit even though sell-reason is ROI. +# stop-loss: 10%, ROI: 10% (should not apply), -100% after 100 minutes (limits trade duration) +# Uses open as sell-rate (special case) - since the roi-time is a multiple of the ticker interval. +tc17 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5025, 4975, 4987, 6172, 1, 0], + [1, 5000, 5025, 4975, 4987, 6172, 0, 0], + [2, 4987, 5300, 4950, 5050, 6172, 0, 0], + [3, 4980, 5000, 4940, 4962, 6172, 0, 0], # ForceSell on ROI (roi=-1) + [4, 4962, 4987, 4972, 4950, 6172, 0, 0], + [5, 4950, 4975, 4925, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10, "120": -1}, profit_perc=-0.004, + trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)] +) + + +# Test 18: Buy, hold for 120 mins, then drop ROI to 1%, causing a sell in candle 3. +# stop-loss: 10%, ROI: 10% (should not apply), -100% after 100 minutes (limits trade duration) +# uses open_rate as sell-price +tc18 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5025, 4975, 4987, 6172, 1, 0], + [1, 5000, 5025, 4975, 4987, 6172, 0, 0], + [2, 4987, 5300, 4950, 5200, 6172, 0, 0], + [3, 5200, 5220, 4940, 4962, 6172, 0, 0], # Sell on ROI (sells on open) + [4, 4962, 4987, 4972, 4950, 6172, 0, 0], + [5, 4950, 4975, 4925, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10, "120": 0.01}, profit_perc=0.04, + trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)] +) + +# Test 19: Buy, hold for 119 mins, then drop ROI to 1%, causing a sell in candle 3. +# stop-loss: 10%, ROI: 10% (should not apply), -100% after 100 minutes (limits trade duration) +# uses calculated ROI (1%) as sell rate, otherwise identical to tc18 +tc19 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5025, 4975, 4987, 6172, 1, 0], + [1, 5000, 5025, 4975, 4987, 6172, 0, 0], + [2, 4987, 5300, 4950, 5200, 6172, 0, 0], + [3, 5000, 5300, 4940, 4962, 6172, 0, 0], # Sell on ROI + [4, 4962, 4987, 4972, 4950, 6172, 0, 0], + [5, 4550, 4975, 4925, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10, "120": 0.01}, profit_perc=0.01, + trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)] +) + +# Test 20: Buy, hold for 119 mins, then drop ROI to 1%, causing a sell in candle 3. +# stop-loss: 10%, ROI: 10% (should not apply), -100% after 100 minutes (limits trade duration) +# uses calculated ROI (1%) as sell rate, otherwise identical to tc18 +tc20 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5025, 4975, 4987, 6172, 1, 0], + [1, 5000, 5025, 4975, 4987, 6172, 0, 0], + [2, 4987, 5300, 4950, 5200, 6172, 0, 0], + [3, 5200, 5300, 4940, 4962, 6172, 0, 0], # Sell on ROI + [4, 4962, 4987, 4972, 4950, 6172, 0, 0], + [5, 4550, 4975, 4925, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10, "119": 0.01}, profit_perc=0.01, + trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)] +) + + TESTS = [ tc0, tc1, @@ -283,6 +346,10 @@ TESTS = [ tc14, tc15, tc16, + tc17, + tc18, + tc19, + tc20, ] diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 7c4a8f0d6..2ba1ccf4b 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -1177,6 +1177,16 @@ def test_show_config_handle(default_conf, update, mocker) -> None: assert '*Mode:* `{}`'.format('Dry-run') in msg_mock.call_args_list[0][0][0] assert '*Exchange:* `bittrex`' in msg_mock.call_args_list[0][0][0] assert '*Strategy:* `DefaultStrategy`' in msg_mock.call_args_list[0][0][0] + assert '*Stoploss:* `-0.1`' in msg_mock.call_args_list[0][0][0] + + msg_mock.reset_mock() + freqtradebot.config['trailing_stop'] = True + telegram._show_config(update=update, context=MagicMock()) + assert msg_mock.call_count == 1 + assert '*Mode:* `{}`'.format('Dry-run') in msg_mock.call_args_list[0][0][0] + assert '*Exchange:* `bittrex`' in msg_mock.call_args_list[0][0][0] + assert '*Strategy:* `DefaultStrategy`' in msg_mock.call_args_list[0][0][0] + assert '*Initial Stoploss:* `-0.1`' in msg_mock.call_args_list[0][0][0] def test_send_msg_buy_notification(default_conf, mocker) -> None: