Changed to test to check Date string from last row in DF. Test rolling over a 5min timeframe, with print inside the it block watched it be processed firt loop after the new candle then not after.
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""
|
|
IStrategy interface
|
|
This module defines the interface to apply for strategies
|
|
"""
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Dict, List, NamedTuple, Tuple
|
|
import warnings
|
|
|
|
import arrow
|
|
from pandas import DataFrame
|
|
|
|
from freqtrade import constants
|
|
from freqtrade.exchange.exchange_helpers import parse_ticker_dataframe
|
|
from freqtrade.persistence import Trade
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CandleAnalyzed:
|
|
'''
|
|
Maintains candle_row, the last df ['date'], set by analyze_ticker.
|
|
This allows analyze_ticker to test if analysed the candle row in dataframe prior.
|
|
To not keep testing the same candle data, which is wasteful in CPU and time
|
|
'''
|
|
def __init__(self, candle_row=0):
|
|
self.candle_row = candle_row
|
|
|
|
def get_candle_row(self):
|
|
return self._candle_row
|
|
|
|
def set_candle_row(self, row):
|
|
self._candle_row = row
|
|
|
|
candle_row = property(get_candle_row, set_candle_row)
|
|
|
|
|
|
class SignalType(Enum):
|
|
"""
|
|
Enum to distinguish between buy and sell signals
|
|
"""
|
|
BUY = "buy"
|
|
SELL = "sell"
|
|
|
|
|
|
class SellType(Enum):
|
|
"""
|
|
Enum to distinguish between sell reasons
|
|
"""
|
|
ROI = "roi"
|
|
STOP_LOSS = "stop_loss"
|
|
TRAILING_STOP_LOSS = "trailing_stop_loss"
|
|
SELL_SIGNAL = "sell_signal"
|
|
FORCE_SELL = "force_sell"
|
|
NONE = ""
|
|
|
|
|
|
class SellCheckTuple(NamedTuple):
|
|
"""
|
|
NamedTuple for Sell type + reason
|
|
"""
|
|
sell_flag: bool
|
|
sell_type: SellType
|
|
|
|
|
|
class IStrategy(ABC):
|
|
"""
|
|
Interface for freqtrade strategies
|
|
Defines the mandatory structure must follow any custom strategies
|
|
|
|
Attributes you can use:
|
|
minimal_roi -> Dict: Minimal ROI designed for the strategy
|
|
stoploss -> float: optimal stoploss designed for the strategy
|
|
ticker_interval -> str: value of the ticker interval to use for the strategy
|
|
"""
|
|
|
|
_populate_fun_len: int = 0
|
|
_buy_fun_len: int = 0
|
|
_sell_fun_len: int = 0
|
|
# associated minimal roi
|
|
minimal_roi: Dict
|
|
|
|
# associated stoploss
|
|
stoploss: float
|
|
|
|
# associated ticker interval
|
|
ticker_interval: str
|
|
|
|
def __init__(self, config: dict) -> None:
|
|
self.config = config
|
|
self.r = CandleAnalyzed()
|
|
|
|
@abstractmethod
|
|
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
|
"""
|
|
Populate indicators that will be used in the Buy and Sell strategy
|
|
:param dataframe: Raw data from the exchange and parsed by parse_ticker_dataframe()
|
|
:param metadata: Additional information, like the currently traded pair
|
|
:return: a Dataframe with all mandatory indicators for the strategies
|
|
"""
|
|
|
|
@abstractmethod
|
|
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
|
|
"""
|
|
|
|
@abstractmethod
|
|
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 sell column
|
|
"""
|
|
|
|
def get_strategy_name(self) -> str:
|
|
"""
|
|
Returns strategy class name
|
|
"""
|
|
return self.__class__.__name__
|
|
|
|
def analyze_ticker(self, ticker_history: List[Dict], metadata: dict) -> DataFrame:
|
|
"""
|
|
Parses the given ticker history and returns a populated DataFrame
|
|
add several TA indicators and buy signal to it
|
|
:return DataFrame with ticker data and indicator data
|
|
"""
|
|
|
|
# Get last candle processed and ln of Dataframe
|
|
last_candle_processed = self.r.get_candle_row()
|
|
dataframe = parse_ticker_dataframe(ticker_history)
|
|
|
|
if last_candle_processed != dataframe.iloc[-1]['date']:
|
|
# Defs that only make change on new candle data here
|
|
dataframe = self.advise_indicators(dataframe, metadata)
|
|
dataframe = self.advise_buy(dataframe, metadata)
|
|
dataframe = self.advise_sell(dataframe, metadata)
|
|
self.r.set_candle_row(dataframe.iloc[-1]['date'])
|
|
else:
|
|
dataframe.loc['buy'] = 0
|
|
dataframe.loc['sell'] = 0
|
|
|
|
# Other Defs in strategy that want to be called every loop here
|
|
# twitter_sell = self.watch_twitter_feed(dataframe, metadata)
|
|
|
|
return dataframe
|
|
|
|
def get_signal(self, pair: str, interval: str, ticker_hist: List[Dict]) -> Tuple[bool, bool]:
|
|
"""
|
|
Calculates current signal based several technical analysis indicators
|
|
:param pair: pair in format ANT/BTC
|
|
:param interval: Interval to use (in min)
|
|
:return: (Buy, Sell) A bool-tuple indicating buy/sell signal
|
|
"""
|
|
if not ticker_hist:
|
|
logger.warning('Empty ticker history for pair %s', pair)
|
|
return False, False
|
|
|
|
try:
|
|
dataframe = self.analyze_ticker(ticker_hist, {'pair': pair})
|
|
except ValueError as error:
|
|
logger.warning(
|
|
'Unable to analyze ticker for pair %s: %s',
|
|
pair,
|
|
str(error)
|
|
)
|
|
return False, False
|
|
except Exception as error:
|
|
logger.exception(
|
|
'Unexpected error when analyzing ticker for pair %s: %s',
|
|
pair,
|
|
str(error)
|
|
)
|
|
return False, False
|
|
|
|
if dataframe.empty:
|
|
logger.warning('Empty dataframe for pair %s', pair)
|
|
return False, False
|
|
|
|
latest = dataframe.iloc[-1]
|
|
|
|
# Check if dataframe is out of date
|
|
signal_date = arrow.get(latest['date'])
|
|
interval_minutes = constants.TICKER_INTERVAL_MINUTES[interval]
|
|
if signal_date < (arrow.utcnow().shift(minutes=-(interval_minutes * 2 + 5))):
|
|
logger.warning(
|
|
'Outdated history for pair %s. Last tick is %s minutes old',
|
|
pair,
|
|
(arrow.utcnow() - signal_date).seconds // 60
|
|
)
|
|
return False, False
|
|
|
|
(buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1
|
|
logger.debug(
|
|
'trigger: %s (pair=%s) buy=%s sell=%s',
|
|
latest['date'],
|
|
pair,
|
|
str(buy),
|
|
str(sell)
|
|
)
|
|
return buy, sell
|
|
|
|
def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool,
|
|
sell: bool) -> SellCheckTuple:
|
|
"""
|
|
This function evaluate if on the condition required to trigger a sell has been reached
|
|
if the threshold is reached and updates the trade record.
|
|
:return: True if trade should be sold, False otherwise
|
|
"""
|
|
current_profit = trade.calc_profit_percent(rate)
|
|
stoplossflag = self.stop_loss_reached(current_rate=rate, trade=trade, current_time=date,
|
|
current_profit=current_profit)
|
|
if stoplossflag.sell_flag:
|
|
return stoplossflag
|
|
|
|
experimental = self.config.get('experimental', {})
|
|
|
|
if buy and experimental.get('ignore_roi_if_buy_signal', False):
|
|
logger.debug('Buy signal still active - not selling.')
|
|
return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
|
|
|
|
# Check if minimal roi has been reached and no longer in buy conditions (avoiding a fee)
|
|
if self.min_roi_reached(trade=trade, current_profit=current_profit, current_time=date):
|
|
logger.debug('Required profit reached. Selling..')
|
|
return SellCheckTuple(sell_flag=True, sell_type=SellType.ROI)
|
|
|
|
if experimental.get('sell_profit_only', False):
|
|
logger.debug('Checking if trade is profitable..')
|
|
if trade.calc_profit(rate=rate) <= 0:
|
|
return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
|
|
if sell and not buy and experimental.get('use_sell_signal', False):
|
|
logger.debug('Sell signal received. Selling..')
|
|
return SellCheckTuple(sell_flag=True, sell_type=SellType.SELL_SIGNAL)
|
|
|
|
return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
|
|
|
|
def stop_loss_reached(self, current_rate: float, trade: Trade, current_time: datetime,
|
|
current_profit: float) -> SellCheckTuple:
|
|
"""
|
|
Based on current profit of the trade and configured (trailing) stoploss,
|
|
decides to sell or not
|
|
:param current_profit: current profit in percent
|
|
"""
|
|
|
|
trailing_stop = self.config.get('trailing_stop', False)
|
|
|
|
trade.adjust_stop_loss(trade.open_rate, self.stoploss, initial=True)
|
|
|
|
# evaluate if the stoploss was hit
|
|
if self.stoploss is not None and trade.stop_loss >= current_rate:
|
|
selltype = SellType.STOP_LOSS
|
|
if trailing_stop:
|
|
selltype = SellType.TRAILING_STOP_LOSS
|
|
logger.debug(
|
|
f"HIT STOP: current price at {current_rate:.6f}, "
|
|
f"stop loss is {trade.stop_loss:.6f}, "
|
|
f"initial stop loss was at {trade.initial_stop_loss:.6f}, "
|
|
f"trade opened at {trade.open_rate:.6f}")
|
|
logger.debug(f"trailing stop saved {trade.stop_loss - trade.initial_stop_loss:.6f}")
|
|
|
|
logger.debug('Stop loss hit.')
|
|
return SellCheckTuple(sell_flag=True, sell_type=selltype)
|
|
|
|
# update the stop loss afterwards, after all by definition it's supposed to be hanging
|
|
if trailing_stop:
|
|
|
|
# check if we have a special stop loss for positive condition
|
|
# and if profit is positive
|
|
stop_loss_value = self.stoploss
|
|
sl_offset = self.config.get('trailing_stop_positive_offset', 0.0)
|
|
|
|
if 'trailing_stop_positive' in self.config and current_profit > sl_offset:
|
|
|
|
# Ignore mypy error check in configuration that this is a float
|
|
stop_loss_value = self.config.get('trailing_stop_positive') # type: ignore
|
|
logger.debug(f"using positive stop loss mode: {stop_loss_value} "
|
|
f"with offset {sl_offset:.4g} "
|
|
f"since we have profit {current_profit:.4f}%")
|
|
|
|
trade.adjust_stop_loss(current_rate, stop_loss_value)
|
|
|
|
return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
|
|
|
|
def min_roi_reached(self, trade: Trade, current_profit: float, current_time: datetime) -> bool:
|
|
"""
|
|
Based an earlier trade and current price and ROI configuration, decides whether bot should
|
|
sell
|
|
:return True if bot should sell at current rate
|
|
"""
|
|
|
|
# Check if time matches and current rate is above threshold
|
|
time_diff = (current_time.timestamp() - trade.open_date.timestamp()) / 60
|
|
for duration, threshold in self.minimal_roi.items():
|
|
if time_diff <= duration:
|
|
return False
|
|
if current_profit > threshold:
|
|
return True
|
|
|
|
return False
|
|
|
|
def tickerdata_to_dataframe(self, tickerdata: Dict[str, List]) -> Dict[str, DataFrame]:
|
|
"""
|
|
Creates a dataframe and populates indicators for given ticker data
|
|
"""
|
|
return {pair: self.advise_indicators(parse_ticker_dataframe(pair_data), {'pair': pair})
|
|
for pair, pair_data in tickerdata.items()}
|
|
|
|
def advise_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
|
"""
|
|
Populate indicators that will be used in the Buy and Sell strategy
|
|
This method should not be overridden.
|
|
:param dataframe: Raw data from the exchange and parsed by parse_ticker_dataframe()
|
|
:param metadata: Additional information, like the currently traded pair
|
|
:return: a Dataframe with all mandatory indicators for the strategies
|
|
"""
|
|
if self._populate_fun_len == 2:
|
|
warnings.warn("deprecated - check out the Sample strategy to see "
|
|
"the current function headers!", DeprecationWarning)
|
|
return self.populate_indicators(dataframe) # type: ignore
|
|
else:
|
|
return self.populate_indicators(dataframe, metadata)
|
|
|
|
def advise_buy(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
|
"""
|
|
Based on TA indicators, populates the buy signal for the given dataframe
|
|
This method should not be overridden.
|
|
:param dataframe: DataFrame
|
|
:param pair: Additional information, like the currently traded pair
|
|
:return: DataFrame with buy column
|
|
"""
|
|
if self._buy_fun_len == 2:
|
|
warnings.warn("deprecated - check out the Sample strategy to see "
|
|
"the current function headers!", DeprecationWarning)
|
|
return self.populate_buy_trend(dataframe) # type: ignore
|
|
else:
|
|
return self.populate_buy_trend(dataframe, metadata)
|
|
|
|
def advise_sell(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
|
"""
|
|
Based on TA indicators, populates the sell signal for the given dataframe
|
|
This method should not be overridden.
|
|
:param dataframe: DataFrame
|
|
:param pair: Additional information, like the currently traded pair
|
|
:return: DataFrame with sell column
|
|
"""
|
|
if self._sell_fun_len == 2:
|
|
warnings.warn("deprecated - check out the Sample strategy to see "
|
|
"the current function headers!", DeprecationWarning)
|
|
return self.populate_sell_trend(dataframe) # type: ignore
|
|
else:
|
|
return self.populate_sell_trend(dataframe, metadata)
|