add feature custom entry price for live

This commit is contained in:
axel 2021-07-31 00:05:45 -04:00
parent dfc17f2bd1
commit f11f5d17e9
9 changed files with 222 additions and 1 deletions

View File

@ -357,6 +357,33 @@ See [Dataframe access](#dataframe-access) for more information about dataframe u
--- ---
## Custom order entry price rules
By default, freqtrade use the orderbook to automatically set an order price, you also have the option to create custom order prices based on your strategy.
You can use this feature by setting the `use_custom_entry_price` option to `true` in config and creating a custom_entry_price function.
### Custom order entry price exemple
``` 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,
current_rate, **kwargs) -> float:
dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=pair,
timeframe=self.timeframe)
entryprice = dataframe['bollinger_10_lowerband'].iat[-1]
return entryprice
```
## Custom order timeout rules ## Custom order timeout rules
Simple, time-based order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section. Simple, time-based order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section.
@ -366,7 +393,7 @@ However, freqtrade also offers a custom callback for both order types, which all
!!! Note !!! Note
Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances. Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances.
### Custom order timeout example ## Custom order timeout example
A simple example, which applies different unfilled-timeouts depending on the price of the asset can be seen below. A simple example, which applies different unfilled-timeouts depending on the price of the asset can be seen below.
It applies a tight timeout for higher priced assets, while allowing more time to fill on cheap coins. It applies a tight timeout for higher priced assets, while allowing more time to fill on cheap coins.

View File

@ -433,6 +433,8 @@ SCHEMA_MINIMAL_REQUIRED = [
CANCEL_REASON = { CANCEL_REASON = {
"TIMEOUT": "cancelled due to timeout", "TIMEOUT": "cancelled due to timeout",
"ENTRYPRICECHANGED": "Custom entry price changed",
"EXITPRICECHANGED": "Custom exit price changed",
"PARTIALLY_FILLED_KEEP_OPEN": "partially filled - keeping order open", "PARTIALLY_FILLED_KEEP_OPEN": "partially filled - keeping order open",
"PARTIALLY_FILLED": "partially filled", "PARTIALLY_FILLED": "partially filled",
"FULLY_CANCELLED": "fully cancelled", "FULLY_CANCELLED": "fully cancelled",

View File

@ -169,6 +169,7 @@ class FreqtradeBot(LoggingMixin):
with self._sell_lock: with self._sell_lock:
# Check and handle any timed out open orders # Check and handle any timed out open orders
self.check_handle_timedout() self.check_handle_timedout()
self.check_handle_custom_entryprice_outdated()
# Protect from collisions with forcesell. # Protect from collisions with forcesell.
# Without this, freqtrade my try to recreate stoploss_on_exchange orders # Without this, freqtrade my try to recreate stoploss_on_exchange orders
@ -480,6 +481,14 @@ class FreqtradeBot(LoggingMixin):
else: else:
# Calculate price # Calculate price
buy_limit_requested = self.exchange.get_rate(pair, refresh=True, side="buy") buy_limit_requested = self.exchange.get_rate(pair, refresh=True, side="buy")
if self.config.get('use_custom_entry_price', False):
buy_rate = self.exchange.get_rate(pair, refresh=True, side="buy")
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=stake_amount)(
pair=pair, current_time=datetime.now(timezone.utc),
current_rate=buy_rate)
buy_limit_requested = custom_entry_price
if not buy_limit_requested: if not buy_limit_requested:
raise PricingError('Could not determine buy price.') raise PricingError('Could not determine buy price.')
@ -911,6 +920,70 @@ class FreqtradeBot(LoggingMixin):
order=order))): order=order))):
self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['TIMEOUT']) self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['TIMEOUT'])
def _check_entryprice_outdated(self, side: str, order: dict) -> bool:
"""
Check if entry price is outdated by comparing it to the new prefered entry price
, and if the order is still open and price outdated
"""
#print("check_entryprice_outdated")
if self.config.get('use_custom_entry_price', False):
order_prefered_entry_price = order['price'] # order['trade']
#print(order)
#order_open_rate_requested = order.trade['open_rate_requested']
#print("order_trade_object : {}".format(order['trade']))
# get pep from strategy data provider
pair = order['symbol']
old_prefered_entry_price = order_prefered_entry_price
#new_prefered_entry_price = self.strategy.custom_info[pair]['pep_long'].iloc[-1] #buy_limit_requested
new_prefered_entry_price = self.strategy.entryprice
old_prefered_entry_price_rounded = self.exchange.price_to_precision(pair, order_prefered_entry_price)
new_prefered_entry_price_rounded = self.exchange.price_to_precision(pair, new_prefered_entry_price)
if old_prefered_entry_price_rounded != new_prefered_entry_price_rounded:
print("order['symbol']: {}".format(order['symbol']))
print("new_prefered_entry_price: {}, old_prefered_entry_price: {}".format(new_prefered_entry_price, old_prefered_entry_price))
print("rounded new pep: {}, rounded old pep: {}".format(new_prefered_entry_price_rounded, old_prefered_entry_price_rounded))
print("Delta in prefered entry price, order to cancel")
return True
else:
return False
else:
return False
def check_handle_custom_entryprice_outdated(self) -> None:
"""
Check if any orders prefered entryprice change and cancel if necessary
:return: None
"""
for trade in Trade.get_open_order_trades():
try:
if not trade.open_order_id:
continue
order = self.exchange.fetch_order(trade.open_order_id, trade.pair)
except (ExchangeError):
logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc())
continue
fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order)
# Refresh entryprice value if order is open
if (order['status'] == 'open'):
self.strategy.entryprice = strategy_safe_wrapper(self.strategy.custom_entry_price)(
pair=trade.pair, current_time=datetime.now(timezone.utc),
current_rate=trade.open_rate_requested)
if (order['side'] == 'buy' and (order['status'] == 'open') and (
self._check_entryprice_outdated('buy', order))):
self.handle_cancel_buy(trade, order, constants.CANCEL_REASON['ENTRYPRICECHANGED'])
elif (order['side'] == 'sell' and (order['status'] == 'open') and (
self._check_entryprice_outdated('sell', order))):
self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['EXITPRICECHANGED'])
def cancel_all_open_orders(self) -> None: def cancel_all_open_orders(self) -> None:
""" """
Cancel all orders that are currently open Cancel all orders that are currently open

View File

@ -79,6 +79,7 @@ class StrategyResolver(IResolver):
("trailing_stop_positive_offset", 0.0), ("trailing_stop_positive_offset", 0.0),
("trailing_only_offset_is_reached", None), ("trailing_only_offset_is_reached", None),
("use_custom_stoploss", None), ("use_custom_stoploss", None),
("use_custom_entry_price", None),
("process_only_new_candles", None), ("process_only_new_candles", None),
("order_types", None), ("order_types", None),
("order_time_in_force", None), ("order_time_in_force", None),

View File

@ -129,6 +129,7 @@ class ShowConfig(BaseModel):
trailing_stop_positive_offset: Optional[float] trailing_stop_positive_offset: Optional[float]
trailing_only_offset_is_reached: Optional[bool] trailing_only_offset_is_reached: Optional[bool]
use_custom_stoploss: Optional[bool] use_custom_stoploss: Optional[bool]
use_custom_entry_price: Optional[bool]
timeframe: Optional[str] timeframe: Optional[str]
timeframe_ms: int timeframe_ms: int
timeframe_min: int timeframe_min: int

View File

@ -116,6 +116,7 @@ class RPC:
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset'), 'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset'),
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached'), 'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached'),
'use_custom_stoploss': config.get('use_custom_stoploss'), 'use_custom_stoploss': config.get('use_custom_stoploss'),
'use_custom_entry_price': config.get('use_custom_entry_price'),
'bot_name': config.get('bot_name', 'freqtrade'), 'bot_name': config.get('bot_name', 'freqtrade'),
'timeframe': config.get('timeframe'), 'timeframe': config.get('timeframe'),
'timeframe_ms': timeframe_to_msecs(config['timeframe'] 'timeframe_ms': timeframe_to_msecs(config['timeframe']

View File

@ -69,6 +69,10 @@ class IStrategy(ABC, HyperStrategyMixin):
# associated stoploss # associated stoploss
stoploss: float stoploss: float
# custom order price
entryprice: Optional[float] = None
exitprice: Optional[float] = None
# trailing stoploss # trailing stoploss
trailing_stop: bool = False trailing_stop: bool = False
trailing_stop_positive: Optional[float] = None trailing_stop_positive: Optional[float] = None
@ -280,6 +284,24 @@ class IStrategy(ABC, HyperStrategyMixin):
""" """
return self.stoploss return self.stoploss
def custom_entry_price(self, pair: str, current_time: datetime, current_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
Only called when use_custom_entry_price is set to True.
:param pair: Pair that's currently analyzed
:param current_time: datetime object, containing the current datetime
:param current_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 self.entryprice
def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, **kwargs) -> Optional[Union[str, bool]]: current_profit: float, **kwargs) -> Optional[Union[str, bool]]:
""" """
@ -635,6 +657,42 @@ class IStrategy(ABC, HyperStrategyMixin):
# logger.debug(f"{trade.pair} - No sell signal.") # logger.debug(f"{trade.pair} - No sell signal.")
return SellCheckTuple(sell_type=SellType.NONE) return SellCheckTuple(sell_type=SellType.NONE)
def entry_price_reached(self, pair: str, current_rate: float,
current_time: datetime, low: float = None,
high: float = None, side: str = "long") -> bool:
"""
Based on current candle low ,decides if entry price was reached
:param current_rate: current rate
:param low: Low value of this candle, only set in backtesting
:param high: High value of this candle, only set in backtesting
"""
if self.use_custom_entry_price:
entry_price_value = strategy_safe_wrapper(self.custom_entry_price, default_retval=None
)(pair=pair,
current_time=current_time,
current_rate=current_rate)
# Sanity check - error cases will return None
if side == "long":
if entry_price_value > low:
return True
else:
logger.info(f"Entry failed because entry price {entry_price_value} \
higher than candle low in long side")
return False
elif side == "short":
if entry_price_value < high:
return True
else:
logger.info(f"Entry failed because entry price {entry_price_value} \
higher than candle high in short side")
return False
else:
logger.warning("CustomEntryPrice function did not return valid entry price")
return False
def stop_loss_reached(self, current_rate: float, trade: Trade, def stop_loss_reached(self, current_rate: float, trade: Trade,
current_time: datetime, current_profit: float, current_time: datetime, current_profit: float,
force_stoploss: float, low: float = None, force_stoploss: float, low: float = None,

View File

@ -431,6 +431,34 @@ def test_stop_loss_reached(default_conf, fee, profit, adjusted, expected, traili
strategy.custom_stoploss = original_stopvalue strategy.custom_stoploss = original_stopvalue
@pytest.mark.parametrize(
'current_rate, exp_custom_entry', 'expected_result', 'use_custom_entry_price', 'custom_entry' [
# Profit, adjusted stoploss(absolute), profit for 2nd call, enable trailing,
# enable custom stoploss, expected after 1st call, expected after 2nd call
(99, 98, False, True, lambda current_rate, **kwargs: current_rate - (current_rate * 0.01)), # custom_entry_price pice - (price * 0.01)
(97.8, 98, True, True, lambda current_rate, **kwargs: current_rate - (current_rate * 0.01)), # price stayed under entry price
(97.8, 98, True, True, lambda current_rate, **kwargs: current_rate + (current_rate * 0.01)), # entry price over current price
(99.9, 98, True, False, None), # feature not activated
])
def test_entry_price_reached(default_conf, current_rate, exp_custom_entry, candle_ohlc,
expected_result, use_custom_entry_price, custom_entry) -> None:
default_conf.update({'strategy': 'DefaultStrategy'})
strategy = StrategyResolver.load_strategy(default_conf)
strategy.use_custom_entry_price = use_custom_entry_price
custom_entry_price = custom_entry
if use_custom_entry_price:
strategy.custom_entry_price = custom_entry(current_rate)
now = arrow.utcnow().datetime
entry_flag = strategy.entry_price_reached(current_rate=current_rate, low= None, high=None)
pass
def test_custom_sell(default_conf, fee, caplog) -> None: def test_custom_sell(default_conf, fee, caplog) -> None:
default_conf.update({'strategy': 'DefaultStrategy'}) default_conf.update({'strategy': 'DefaultStrategy'})

View File

@ -904,6 +904,36 @@ def test_execute_buy(mocker, default_conf, fee, limit_buy_order, limit_buy_order
with pytest.raises(PricingError, match="Could not determine buy price."): with pytest.raises(PricingError, match="Could not determine buy price."):
freqtrade.execute_buy(pair, stake_amount) freqtrade.execute_buy(pair, stake_amount)
def test_execute_buy_custom_entry_price(mocker, default_conf, fee, limit_buy_order, limit_buy_order_open) -> None:
patch_RPCManager(mocker)
patch_exchange(mocker)
default_conf.update({'use_custom_entry_price': True})
freqtrade = FreqtradeBot(default_conf)
freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=False)
stake_amount = 3
bid = 2304
buy_rate_mock = MagicMock(return_value=bid)
buy_mm = MagicMock(return_value=limit_buy_order_open)
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
get_rate=buy_rate_mock,
fetch_ticker=MagicMock(return_value={
'bid': 2304,
'ask': 0.00001173,
'last': 2304
}),
buy=buy_mm,
get_min_pair_stake_amount=MagicMock(return_value=1),
get_fee=fee,
)
pair = 'ETH/USDT'
# Test calling with custom entry price option activated
limit_buy_order_open['id'] = '55'
assert freqtrade.execute_buy(pair, stake_amount)
# Make sure get_rate called to provide current_rate param to custom_entry_price
assert buy_rate_mock.call_count == 1
def test_execute_buy_confirm_error(mocker, default_conf, fee, limit_buy_order) -> None: def test_execute_buy_confirm_error(mocker, default_conf, fee, limit_buy_order) -> None:
freqtrade = get_patched_freqtradebot(mocker, default_conf) freqtrade = get_patched_freqtradebot(mocker, default_conf)