diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index f5a674878..75c973b79 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -173,6 +173,9 @@ class Configuration: if 'sd_notify' in self.args and self.args['sd_notify']: config['internals'].update({'sd_notify': True}) + if config.get('position_adjustment_enable', False): + logger.warning('`position_adjustment` has been enabled for strategy.') + def _process_datadir_options(self, config: Dict[str, Any]) -> None: """ Extract information for sys.argv and load directory configurations diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 7bce1d084..bded7a3f2 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -179,7 +179,8 @@ class FreqtradeBot(LoggingMixin): self.exit_positions(trades) # Check if we need to adjust our current positions before attempting to buy new trades. - self.process_open_trade_positions() + if self.config.get('position_adjustment_enable', False): + self.process_open_trade_positions() # Then looking for buy opportunities if self.get_free_open_trades(): @@ -521,7 +522,7 @@ class FreqtradeBot(LoggingMixin): if not stake_amount: logger.info(f'Additional order failed to get stake amount for pair {pair}, amount={amount}, price={enter_limit_requested}') return False - + logger.debug(f'Executing additional order: amount={amount}, stake={stake_amount}, price={enter_limit_requested}') order_type = 'market' diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 9c9f99030..29633b4ed 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -403,8 +403,10 @@ class Backtesting: def _get_sell_trade_entry_for_candle(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]: - - trade = self._get_adjust_trade_entry_for_candle(trade, sell_row) + + # Check if we need to adjust our current positions + if self.config.get('position_adjustment_enable', False): + trade = self._get_adjust_trade_entry_for_candle(trade, sell_row) sell_candle_time = sell_row[DATE_IDX].to_pydatetime() sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], # type: ignore diff --git a/tests/optimize/test_backtesting_adjust_position.py b/tests/optimize/test_backtesting_adjust_position.py new file mode 100644 index 000000000..78bc4758e --- /dev/null +++ b/tests/optimize/test_backtesting_adjust_position.py @@ -0,0 +1,95 @@ +# pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, unused-argument + +import random +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock +import logging + +import numpy as np +import pandas as pd +import pytest +from arrow import Arrow + +from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_backtesting +from freqtrade.configuration import TimeRange +from freqtrade.data import history +from freqtrade.data.btanalysis import BT_DATA_COLUMNS, evaluate_result_multi +from freqtrade.data.converter import clean_ohlcv_dataframe +from freqtrade.data.dataprovider import DataProvider +from freqtrade.data.history import get_timerange +from freqtrade.enums import RunMode, SellType +from freqtrade.exceptions import DependencyException, OperationalException +from freqtrade.optimize.backtesting import Backtesting +from freqtrade.persistence import LocalTrade +from freqtrade.resolvers import StrategyResolver +from tests.conftest import (get_args, log_has, log_has_re, patch_exchange, + patched_configuration_load_config_file) + +def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) -> None: + default_conf['use_sell_signal'] = False + mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) + mocker.patch("freqtrade.exchange.Exchange.get_min_pair_stake_amount", return_value=0.00001) + patch_exchange(mocker) + default_conf.update({ + "position_adjustment_enable": True, + "stake_amount": 100.0, + "dry_run_wallet": 1000.0, + "strategy": "StrategyTestPositionAdjust" + }) + backtesting = Backtesting(default_conf) + backtesting._set_strategy(backtesting.strategylist[0]) + pair = 'UNITTEST/BTC' + timerange = TimeRange('date', None, 1517227800, 0) + data = history.load_data(datadir=testdatadir, timeframe='5m', pairs=['UNITTEST/BTC'], + timerange=timerange) + processed = backtesting.strategy.advise_all_indicators(data) + min_date, max_date = get_timerange(processed) + result = backtesting.backtest( + processed=processed, + start_date=min_date, + end_date=max_date, + max_open_trades=10, + position_stacking=False, + ) + results = result['results'] + assert not results.empty + assert len(results) == 2 + + expected = pd.DataFrame( + {'pair': [pair, pair], + 'stake_amount': [500.0, 100.0], + 'amount': [4806.87657523, 970.63960782], + 'open_date': pd.to_datetime([Arrow(2018, 1, 29, 18, 40, 0).datetime, + Arrow(2018, 1, 30, 3, 30, 0).datetime], utc=True + ), + 'close_date': pd.to_datetime([Arrow(2018, 1, 29, 22, 00, 0).datetime, + Arrow(2018, 1, 30, 4, 10, 0).datetime], utc=True), + 'open_rate': [0.10401764894444211, 0.10302485], + 'close_rate': [0.10453904066847439, 0.103541], + 'fee_open': [0.0025, 0.0025], + 'fee_close': [0.0025, 0.0025], + 'trade_duration': [200, 40], + 'profit_ratio': [0.0, 0.0], + 'profit_abs': [0.0, 0.0], + 'sell_reason': [SellType.ROI.value, SellType.ROI.value], + 'initial_stop_loss_abs': [0.0940005, 0.09272236], + 'initial_stop_loss_ratio': [-0.1, -0.1], + 'stop_loss_abs': [0.0940005, 0.09272236], + 'stop_loss_ratio': [-0.1, -0.1], + 'min_rate': [0.10370188, 0.10300000000000001], + 'max_rate': [0.10481985, 0.1038888], + 'is_open': [False, False], + 'buy_tag': [None, None], + }) + pd.testing.assert_frame_equal(results, expected) + data_pair = processed[pair] + for _, t in results.iterrows(): + ln = data_pair.loc[data_pair["date"] == t["open_date"]] + # Check open trade rate alignes to open rate + assert ln is not None + # check close trade rate alignes to close rate or is between high and low + ln = data_pair.loc[data_pair["date"] == t["close_date"]] + assert (round(ln.iloc[0]["open"], 6) == round(t["close_rate"], 6) or + round(ln.iloc[0]["low"], 6) < round( + t["close_rate"], 6) < round(ln.iloc[0]["high"], 6)) diff --git a/tests/strategy/strats/strategy_test_position_adjust.py b/tests/strategy/strats/strategy_test_position_adjust.py new file mode 100644 index 000000000..cd786ed54 --- /dev/null +++ b/tests/strategy/strats/strategy_test_position_adjust.py @@ -0,0 +1,165 @@ +# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement + +import talib.abstract as ta +from pandas import DataFrame + +import freqtrade.vendor.qtpylib.indicators as qtpylib +from freqtrade.strategy.interface import IStrategy +from freqtrade.persistence import Trade +from datetime import datetime + +class StrategyTestPositionAdjust(IStrategy): + """ + Strategy used by tests freqtrade bot. + Please do not modify this strategy, it's intended for internal use only. + Please look at the SampleStrategy in the user_data/strategy directory + or strategy repository https://github.com/freqtrade/freqtrade-strategies + for samples and inspiration. + """ + INTERFACE_VERSION = 2 + + # Minimal ROI designed for the strategy + minimal_roi = { + "40": 0.0, + "30": 0.01, + "20": 0.02, + "0": 0.04 + } + + # Optimal stoploss designed for the strategy + stoploss = -0.10 + + # Optimal timeframe for the strategy + timeframe = '5m' + + # Optional order type mapping + order_types = { + 'buy': 'limit', + 'sell': 'limit', + 'stoploss': 'limit', + 'stoploss_on_exchange': False + } + + # Number of candles the strategy requires before producing valid signals + startup_candle_count: int = 20 + + # Optional time in force for orders + order_time_in_force = { + 'buy': 'gtc', + 'sell': 'gtc', + } + + def informative_pairs(self): + """ + Define additional, informative pair/interval combinations to be cached from the exchange. + These pair/interval combinations are non-tradeable, unless they are part + of the whitelist as well. + For more information, please consult the documentation + :return: List of tuples in the format (pair, interval) + Sample: return [("ETH/USDT", "5m"), + ("BTC/USDT", "15m"), + ] + """ + return [] + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Adds several different TA indicators to the given DataFrame + + Performance Note: For the best performance be frugal on the number of indicators + you are using. Let uncomment only the indicator you are using in your strategies + or your hyperopt configuration, otherwise you will waste your memory and CPU usage. + :param dataframe: Dataframe with data from the exchange + :param metadata: Additional information, like the currently traded pair + :return: a Dataframe with all mandatory indicators for the strategies + """ + + # Momentum Indicator + # ------------------------------------ + + # ADX + dataframe['adx'] = ta.ADX(dataframe) + + # MACD + macd = ta.MACD(dataframe) + dataframe['macd'] = macd['macd'] + dataframe['macdsignal'] = macd['macdsignal'] + dataframe['macdhist'] = macd['macdhist'] + + # Minus Directional Indicator / Movement + dataframe['minus_di'] = ta.MINUS_DI(dataframe) + + # Plus Directional Indicator / Movement + dataframe['plus_di'] = ta.PLUS_DI(dataframe) + + # RSI + dataframe['rsi'] = ta.RSI(dataframe) + + # Stoch fast + stoch_fast = ta.STOCHF(dataframe) + dataframe['fastd'] = stoch_fast['fastd'] + dataframe['fastk'] = stoch_fast['fastk'] + + # Bollinger bands + bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2) + dataframe['bb_lowerband'] = bollinger['lower'] + dataframe['bb_middleband'] = bollinger['mid'] + dataframe['bb_upperband'] = bollinger['upper'] + + # EMA - Exponential Moving Average + dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10) + + return dataframe + + def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Based on TA indicators, populates the buy signal for the given dataframe + :param dataframe: DataFrame + :param metadata: Additional information, like the currently traded pair + :return: DataFrame with buy column + """ + dataframe.loc[ + ( + (dataframe['rsi'] < 35) & + (dataframe['fastd'] < 35) & + (dataframe['adx'] > 30) & + (dataframe['plus_di'] > 0.5) + ) | + ( + (dataframe['adx'] > 65) & + (dataframe['plus_di'] > 0.5) + ), + 'buy'] = 1 + + return dataframe + + def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Based on TA indicators, populates the sell signal for the given dataframe + :param dataframe: DataFrame + :param metadata: Additional information, like the currently traded pair + :return: DataFrame with buy column + """ + dataframe.loc[ + ( + ( + (qtpylib.crossed_above(dataframe['rsi'], 70)) | + (qtpylib.crossed_above(dataframe['fastd'], 70)) + ) & + (dataframe['adx'] > 10) & + (dataframe['minus_di'] > 0) + ) | + ( + (dataframe['adx'] > 70) & + (dataframe['minus_di'] > 0.5) + ), + 'sell'] = 1 + return dataframe + + def adjust_trade_position(self, pair: str, trade: Trade, current_time: datetime, + current_rate: float, current_profit: float, **kwargs): + + if current_profit < -0.0075: + return self.wallets.get_trade_stake_amount(pair, None) + + return None