diff --git a/docs/bot-basics.md b/docs/bot-basics.md index 44181abfa..e7ff27040 100644 --- a/docs/bot-basics.md +++ b/docs/bot-basics.md @@ -36,11 +36,12 @@ By default, loop runs every few seconds (`internals.process_throttle_secs`) and * Calls `check_sell_timeout()` strategy callback for open sell orders. * Verifies existing positions and eventually places sell orders. * Considers stoploss, ROI and sell-signal, `custom_sell()` and `custom_stoploss()`. - * Determine sell-price based on `ask_strategy` configuration setting. + * Determine sell-price based on `ask_strategy` configuration setting or by using the `custom_exit_price()` callback. * Before a sell order is placed, `confirm_trade_exit()` strategy callback is called. * Check if trade-slots are still available (if `max_open_trades` is reached). * Verifies buy signal trying to enter new positions. - * Determine buy-price based on `bid_strategy` configuration setting. + * Determine buy-price based on `bid_strategy` configuration setting, or by using the `custom_entry_price()` callback. + * Determine stake size by calling the `custom_stake_amount()` callback. * Before a buy order is placed, `confirm_trade_entry()` strategy callback is called. This loop will be repeated again and again until the bot is stopped. diff --git a/docs/configuration.md b/docs/configuration.md index fab3004a5..09198e019 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -110,6 +110,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `ignore_buying_expired_candle_after` | Specifies the number of seconds until a buy signal is no longer used.
**Datatype:** Integer | `order_types` | Configure order-types depending on the action (`"buy"`, `"sell"`, `"stoploss"`, `"stoploss_on_exchange"`). [More information below](#understand-order_types). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict | `order_time_in_force` | Configure time in force for buy and sell orders. [More information below](#understand-order_time_in_force). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict +| `custom_price_max_distance_ratio` | Configure maximum distance ratio between current and custom entry or exit price.
*Defaults to `0.02` 2%).*
**Datatype:** Positive float | `exchange.name` | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename).
**Datatype:** String | `exchange.sandbox` | Use the 'sandbox' version of the exchange, where the exchange provides a sandbox for risk-free integration. See [here](sandbox-testing.md) in more details.
**Datatype:** Boolean | `exchange.key` | API key to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index 0704473fb..b039f542f 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -357,6 +357,55 @@ See [Dataframe access](#dataframe-access) for more information about dataframe u --- +## Custom order price rules + +By default, freqtrade use the orderbook to automatically set an order price([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy. + +You can use this feature by creating a `custom_entry_price()` function in your strategy file to customize entry prices and custom_exit_price for exits. + +!!! Note + If your custom pricing function return None or an invalid value, price will fall back to `proposed_rate`, which is based on the regular pricing configuration. + +### Custom order entry and exit price example + +``` python +from datetime import datetime, timedelta, timezone +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + def custom_entry_price(self, pair: str, current_time: datetime, + proposed_rate, **kwargs) -> float: + + dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=pair, + timeframe=self.timeframe) + new_entryprice = dataframe['bollinger_10_lowerband'].iat[-1] + + return new_entryprice + + def custom_exit_price(self, pair: str, trade: Trade, + current_time: datetime, proposed_rate: float, + current_profit: float, **kwargs) -> float: + + dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=pair, + timeframe=self.timeframe) + new_exitprice = dataframe['bollinger_10_upperband'].iat[-1] + + return new_exitprice + +``` + +!!! Warning + Modifying entry and exit prices will only work for limit orders. Depending on the price chosen, this can result in a lot of unfilled orders. By default the maximum allowed distance between the current price and the custom price is 2%, this value can be changed in config with the `custom_price_max_distance_ratio` parameter. + +!!! Example + If the new_entryprice is 97, the proposed_rate is 100 and the `custom_price_max_distance_ratio` is set to 2%, The retained valid custom entry price will be 98. + +!!! Warning "No backtesting support" + Custom entry-prices are currently not supported during backtesting. + ## Custom order timeout rules Simple, time-based order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section. diff --git a/freqtrade/constants.py b/freqtrade/constants.py index de4bc99b4..cde276ac0 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -191,6 +191,9 @@ CONF_SCHEMA = { }, 'required': ['price_side'] }, + 'custom_price_max_distance_ratio': { + 'type': 'number', 'minimum': 0.0 + }, 'order_types': { 'type': 'object', 'properties': { diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index fb15d6e5c..e7a2a3784 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -479,7 +479,13 @@ class FreqtradeBot(LoggingMixin): buy_limit_requested = price else: # Calculate price - buy_limit_requested = self.exchange.get_rate(pair, refresh=True, side="buy") + proposed_buy_rate = self.exchange.get_rate(pair, refresh=True, side="buy") + custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price, + default_retval=proposed_buy_rate)( + pair=pair, current_time=datetime.now(timezone.utc), + proposed_rate=proposed_buy_rate) + + buy_limit_requested = self.get_valid_price(custom_entry_price, proposed_buy_rate) if not buy_limit_requested: raise PricingError('Could not determine buy price.') @@ -1076,6 +1082,17 @@ class FreqtradeBot(LoggingMixin): and self.strategy.order_types['stoploss_on_exchange']: limit = trade.stop_loss + # set custom_exit_price if available + proposed_limit_rate = limit + current_profit = trade.calc_profit_ratio(limit) + custom_exit_price = strategy_safe_wrapper(self.strategy.custom_exit_price, + default_retval=proposed_limit_rate)( + pair=trade.pair, trade=trade, + current_time=datetime.now(timezone.utc), + proposed_rate=proposed_limit_rate, current_profit=current_profit) + + limit = self.get_valid_price(custom_exit_price, proposed_limit_rate) + # First cancelling stoploss on exchange ... if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id: try: @@ -1375,3 +1392,26 @@ class FreqtradeBot(LoggingMixin): amount=amount, fee_abs=fee_abs) else: return amount + + def get_valid_price(self, custom_price: float, proposed_price: float) -> float: + """ + Return the valid price. + Check if the custom price is of the good type if not return proposed_price + :return: valid price for the order + """ + if custom_price: + try: + valid_custom_price = float(custom_price) + except ValueError: + valid_custom_price = proposed_price + else: + valid_custom_price = proposed_price + + cust_p_max_dist_r = self.config.get('custom_price_max_distance_ratio', 0.02) + min_custom_price_allowed = proposed_price - (proposed_price * cust_p_max_dist_r) + max_custom_price_allowed = proposed_price + (proposed_price * cust_p_max_dist_r) + + # Bracket between min_custom_price_allowed and max_custom_price_allowed + return max( + min(valid_custom_price, max_custom_price_allowed), + min_custom_price_allowed) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index bb8980a53..c51860011 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -280,6 +280,43 @@ class IStrategy(ABC, HyperStrategyMixin): """ return self.stoploss + def custom_entry_price(self, pair: str, current_time: datetime, proposed_rate: float, + **kwargs) -> float: + """ + Custom entry price logic, returning the new entry price. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns None, orderbook is used to set entry price + + :param pair: Pair that's currently analyzed + :param current_time: datetime object, containing the current datetime + :param proposed_rate: Rate, calculated based on pricing settings in ask_strategy. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New entry price value if provided + """ + return proposed_rate + + def custom_exit_price(self, pair: str, trade: Trade, + current_time: datetime, proposed_rate: float, + current_profit: float, **kwargs) -> float: + """ + Custom exit price logic, returning the new exit price. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns None, orderbook is used to set exit price + + :param pair: Pair that's currently analyzed + :param trade: trade object. + :param current_time: datetime object, containing the current datetime + :param proposed_rate: Rate, calculated based on pricing settings in ask_strategy. + :param current_profit: Current profit (as ratio), calculated based on current_rate. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New exit price value if provided + """ + return proposed_rate + def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs) -> Optional[Union[str, bool]]: """ diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index b1e02a99b..a2bb01a4b 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -904,6 +904,40 @@ def test_execute_buy(mocker, default_conf, fee, limit_buy_order, limit_buy_order with pytest.raises(PricingError, match="Could not determine buy price."): freqtrade.execute_buy(pair, stake_amount) + # In case of custom entry price + mocker.patch('freqtrade.exchange.Exchange.get_rate', return_value=0.50) + limit_buy_order['status'] = 'open' + limit_buy_order['id'] = '5566' + freqtrade.strategy.custom_entry_price = lambda **kwargs: 0.508 + assert freqtrade.execute_buy(pair, stake_amount) + trade = Trade.query.all()[6] + assert trade + assert trade.open_rate_requested == 0.508 + + # In case of custom entry price set to None + limit_buy_order['status'] = 'open' + limit_buy_order['id'] = '5567' + freqtrade.strategy.custom_entry_price = lambda **kwargs: None + + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_rate=MagicMock(return_value=10), + ) + + assert freqtrade.execute_buy(pair, stake_amount) + trade = Trade.query.all()[7] + assert trade + assert trade.open_rate_requested == 10 + + # In case of custom entry price not float type + limit_buy_order['status'] = 'open' + limit_buy_order['id'] = '5568' + freqtrade.strategy.custom_entry_price = lambda **kwargs: "string price" + assert freqtrade.execute_buy(pair, stake_amount) + trade = Trade.query.all()[8] + assert trade + assert trade.open_rate_requested == 10 + def test_execute_buy_confirm_error(mocker, default_conf, fee, limit_buy_order) -> None: freqtrade = get_patched_freqtradebot(mocker, default_conf) @@ -2715,6 +2749,70 @@ def test_execute_sell_down(default_conf, ticker, fee, ticker_sell_down, mocker) } == last_msg +def test_execute_sell_custom_exit_price(default_conf, ticker, fee, ticker_sell_up, mocker) -> None: + rpc_mock = patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker, + get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=False), + ) + patch_whitelist(mocker, default_conf) + freqtrade = FreqtradeBot(default_conf) + patch_get_signal(freqtrade) + freqtrade.strategy.confirm_trade_exit = MagicMock(return_value=False) + + # Create some test data + freqtrade.enter_positions() + rpc_mock.reset_mock() + + trade = Trade.query.first() + assert trade + assert freqtrade.strategy.confirm_trade_exit.call_count == 0 + + # Increase the price and sell it + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker_sell_up + ) + + freqtrade.strategy.confirm_trade_exit = MagicMock(return_value=True) + + # Set a custom exit price + freqtrade.strategy.custom_exit_price = lambda **kwargs: 1.170e-05 + + freqtrade.execute_sell(trade=trade, limit=ticker_sell_up()['bid'], + sell_reason=SellCheckTuple(sell_type=SellType.SELL_SIGNAL)) + + # Sell price must be different to default bid price + + assert freqtrade.strategy.confirm_trade_exit.call_count == 1 + + assert rpc_mock.call_count == 1 + last_msg = rpc_mock.call_args_list[-1][0][0] + assert { + 'trade_id': 1, + 'type': RPCMessageType.SELL, + 'exchange': 'Binance', + 'pair': 'ETH/BTC', + 'gain': 'profit', + 'limit': 1.170e-05, + 'amount': 91.07468123, + 'order_type': 'limit', + 'open_rate': 1.098e-05, + 'current_rate': 1.173e-05, + 'profit_amount': 6.041e-05, + 'profit_ratio': 0.06025919, + 'stake_currency': 'BTC', + 'fiat_currency': 'USD', + 'sell_reason': SellType.SELL_SIGNAL.value, + 'open_date': ANY, + 'close_date': ANY, + 'close_rate': ANY, + } == last_msg + + def test_execute_sell_down_stoploss_on_exchange_dry_run(default_conf, ticker, fee, ticker_sell_down, mocker) -> None: rpc_mock = patch_RPCManager(mocker) @@ -4491,3 +4589,43 @@ def test_refind_lost_order(mocker, default_conf, fee, caplog): freqtrade.refind_lost_order(trades[4]) assert log_has(f"Error updating {order['id']}.", caplog) + + +def test_get_valid_price(mocker, default_conf) -> None: + patch_RPCManager(mocker) + patch_exchange(mocker) + freqtrade = FreqtradeBot(default_conf) + freqtrade.config['custom_price_max_distance_ratio'] = 0.02 + + custom_price_string = "10" + custom_price_badstring = "10abc" + custom_price_float = 10.0 + custom_price_int = 10 + + custom_price_over_max_alwd = 11.0 + custom_price_under_min_alwd = 9.0 + proposed_price = 10.1 + + valid_price_from_string = freqtrade.get_valid_price(custom_price_string, proposed_price) + valid_price_from_badstring = freqtrade.get_valid_price(custom_price_badstring, proposed_price) + valid_price_from_int = freqtrade.get_valid_price(custom_price_int, proposed_price) + valid_price_from_float = freqtrade.get_valid_price(custom_price_float, proposed_price) + + valid_price_at_max_alwd = freqtrade.get_valid_price(custom_price_over_max_alwd, proposed_price) + valid_price_at_min_alwd = freqtrade.get_valid_price(custom_price_under_min_alwd, proposed_price) + + assert isinstance(valid_price_from_string, float) + assert isinstance(valid_price_from_badstring, float) + assert isinstance(valid_price_from_int, float) + assert isinstance(valid_price_from_float, float) + + assert valid_price_from_string == custom_price_float + assert valid_price_from_badstring == proposed_price + assert valid_price_from_int == custom_price_int + assert valid_price_from_float == custom_price_float + + assert valid_price_at_max_alwd < custom_price_over_max_alwd + assert valid_price_at_max_alwd > proposed_price + + assert valid_price_at_min_alwd > custom_price_under_min_alwd + assert valid_price_at_min_alwd < proposed_price