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