Merge branch 'develop' into pr/Antreasgr/4838
This commit is contained in:
@@ -7,6 +7,7 @@ from colorama import init as colorama_init
|
||||
from freqtrade.configuration import setup_utils_configuration
|
||||
from freqtrade.data.btanalysis import get_latest_hyperopt_file
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.optimize.optimize_reports import show_backtest_result
|
||||
from freqtrade.state import RunMode
|
||||
|
||||
|
||||
@@ -125,6 +126,12 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None:
|
||||
|
||||
if epochs:
|
||||
val = epochs[n]
|
||||
|
||||
metrics = val['results_metrics']
|
||||
if 'strategy_name' in metrics:
|
||||
show_backtest_result(metrics['strategy_name'], metrics,
|
||||
metrics['stake_currency'])
|
||||
|
||||
HyperoptTools.print_epoch_details(val, total_epochs, print_json, no_header,
|
||||
header_str="Epoch details")
|
||||
|
||||
@@ -132,11 +139,13 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None:
|
||||
def hyperopt_filter_epochs(epochs: List, filteroptions: dict) -> List:
|
||||
"""
|
||||
Filter our items from the list of hyperopt results
|
||||
TODO: after 2021.5 remove all "legacy" mode queries.
|
||||
"""
|
||||
if filteroptions['only_best']:
|
||||
epochs = [x for x in epochs if x['is_best']]
|
||||
if filteroptions['only_profitable']:
|
||||
epochs = [x for x in epochs if x['results_metrics']['profit'] > 0]
|
||||
epochs = [x for x in epochs if x['results_metrics'].get(
|
||||
'profit', x['results_metrics'].get('profit_total', 0)) > 0]
|
||||
|
||||
epochs = _hyperopt_filter_epochs_trade_count(epochs, filteroptions)
|
||||
|
||||
@@ -153,34 +162,55 @@ def hyperopt_filter_epochs(epochs: List, filteroptions: dict) -> List:
|
||||
return epochs
|
||||
|
||||
|
||||
def _hyperopt_filter_epochs_trade(epochs: List, trade_count: int):
|
||||
"""
|
||||
Filter epochs with trade-counts > trades
|
||||
"""
|
||||
return [
|
||||
x for x in epochs
|
||||
if x['results_metrics'].get(
|
||||
'trade_count', x['results_metrics'].get('total_trades', 0)
|
||||
) > trade_count
|
||||
]
|
||||
|
||||
|
||||
def _hyperopt_filter_epochs_trade_count(epochs: List, filteroptions: dict) -> List:
|
||||
|
||||
if filteroptions['filter_min_trades'] > 0:
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['trade_count'] > filteroptions['filter_min_trades']
|
||||
]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, filteroptions['filter_min_trades'])
|
||||
|
||||
if filteroptions['filter_max_trades'] > 0:
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['trade_count'] < filteroptions['filter_max_trades']
|
||||
if x['results_metrics'].get(
|
||||
'trade_count', x['results_metrics'].get('total_trades')
|
||||
) < filteroptions['filter_max_trades']
|
||||
]
|
||||
return epochs
|
||||
|
||||
|
||||
def _hyperopt_filter_epochs_duration(epochs: List, filteroptions: dict) -> List:
|
||||
|
||||
def get_duration_value(x):
|
||||
# Duration in minutes ...
|
||||
if 'duration' in x['results_metrics']:
|
||||
return x['results_metrics']['duration']
|
||||
else:
|
||||
# New mode
|
||||
avg = x['results_metrics']['holding_avg']
|
||||
return avg.total_seconds() // 60
|
||||
|
||||
if filteroptions['filter_min_avg_time'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['duration'] > filteroptions['filter_min_avg_time']
|
||||
if get_duration_value(x) > filteroptions['filter_min_avg_time']
|
||||
]
|
||||
if filteroptions['filter_max_avg_time'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['duration'] < filteroptions['filter_max_avg_time']
|
||||
if get_duration_value(x) < filteroptions['filter_max_avg_time']
|
||||
]
|
||||
|
||||
return epochs
|
||||
@@ -189,28 +219,36 @@ def _hyperopt_filter_epochs_duration(epochs: List, filteroptions: dict) -> List:
|
||||
def _hyperopt_filter_epochs_profit(epochs: List, filteroptions: dict) -> List:
|
||||
|
||||
if filteroptions['filter_min_avg_profit'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['avg_profit'] > filteroptions['filter_min_avg_profit']
|
||||
if x['results_metrics'].get(
|
||||
'avg_profit', x['results_metrics'].get('profit_mean', 0) * 100
|
||||
) > filteroptions['filter_min_avg_profit']
|
||||
]
|
||||
if filteroptions['filter_max_avg_profit'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['avg_profit'] < filteroptions['filter_max_avg_profit']
|
||||
if x['results_metrics'].get(
|
||||
'avg_profit', x['results_metrics'].get('profit_mean', 0) * 100
|
||||
) < filteroptions['filter_max_avg_profit']
|
||||
]
|
||||
if filteroptions['filter_min_total_profit'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['profit'] > filteroptions['filter_min_total_profit']
|
||||
if x['results_metrics'].get(
|
||||
'profit', x['results_metrics'].get('profit_total_abs', 0)
|
||||
) > filteroptions['filter_min_total_profit']
|
||||
]
|
||||
if filteroptions['filter_max_total_profit'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['profit'] < filteroptions['filter_max_total_profit']
|
||||
if x['results_metrics'].get(
|
||||
'profit', x['results_metrics'].get('profit_total_abs', 0)
|
||||
) < filteroptions['filter_max_total_profit']
|
||||
]
|
||||
return epochs
|
||||
|
||||
@@ -218,11 +256,11 @@ def _hyperopt_filter_epochs_profit(epochs: List, filteroptions: dict) -> List:
|
||||
def _hyperopt_filter_epochs_objective(epochs: List, filteroptions: dict) -> List:
|
||||
|
||||
if filteroptions['filter_min_objective'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
|
||||
epochs = [x for x in epochs if x['loss'] < filteroptions['filter_min_objective']]
|
||||
if filteroptions['filter_max_objective'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
|
||||
epochs = [x for x in epochs if x['loss'] > filteroptions['filter_max_objective']]
|
||||
|
||||
|
@@ -3,6 +3,7 @@ This module contains the argument manager class
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import arrow
|
||||
@@ -43,7 +44,7 @@ class TimeRange:
|
||||
self.startts = self.startts - seconds
|
||||
|
||||
def adjust_start_if_necessary(self, timeframe_secs: int, startup_candles: int,
|
||||
min_date: arrow.Arrow) -> None:
|
||||
min_date: datetime) -> None:
|
||||
"""
|
||||
Adjust startts by <startup_candles> candles.
|
||||
Applies only if no startup-candles have been available.
|
||||
@@ -54,11 +55,11 @@ class TimeRange:
|
||||
:return: None (Modifies the object in place)
|
||||
"""
|
||||
if (not self.starttype or (startup_candles
|
||||
and min_date.int_timestamp >= self.startts)):
|
||||
and min_date.timestamp() >= self.startts)):
|
||||
# If no startts was defined, or backtest-data starts at the defined backtest-date
|
||||
logger.warning("Moving start-date by %s candles to account for startup time.",
|
||||
startup_candles)
|
||||
self.startts = (min_date.int_timestamp + timeframe_secs * startup_candles)
|
||||
self.startts = int(min_date.timestamp() + timeframe_secs * startup_candles)
|
||||
self.starttype = 'date'
|
||||
|
||||
@staticmethod
|
||||
|
@@ -11,6 +11,7 @@ DEFAULT_EXCHANGE = 'bittrex'
|
||||
PROCESS_THROTTLE_SECS = 5 # sec
|
||||
HYPEROPT_EPOCH = 100 # epochs
|
||||
RETRY_TIMEOUT = 30 # sec
|
||||
TIMEOUT_UNITS = ['minutes', 'seconds']
|
||||
DEFAULT_DB_PROD_URL = 'sqlite:///tradesv3.sqlite'
|
||||
DEFAULT_DB_DRYRUN_URL = 'sqlite:///tradesv3.dryrun.sqlite'
|
||||
UNLIMITED_STAKE_AMOUNT = 'unlimited'
|
||||
@@ -137,7 +138,8 @@ CONF_SCHEMA = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'buy': {'type': 'number', 'minimum': 1},
|
||||
'sell': {'type': 'number', 'minimum': 1}
|
||||
'sell': {'type': 'number', 'minimum': 1},
|
||||
'unit': {'type': 'string', 'enum': TIMEOUT_UNITS, 'default': 'minutes'}
|
||||
}
|
||||
},
|
||||
'bid_strategy': {
|
||||
@@ -258,7 +260,13 @@ CONF_SCHEMA = {
|
||||
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||
'default': 'off'
|
||||
},
|
||||
'sell': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'sell': {
|
||||
'type': ['string', 'object'],
|
||||
'additionalProperties': {
|
||||
'type': 'string',
|
||||
'enum': TELEGRAM_SETTING_OPTIONS
|
||||
}
|
||||
},
|
||||
'sell_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'sell_fill': {
|
||||
'type': 'string',
|
||||
|
@@ -156,33 +156,35 @@ def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = Non
|
||||
|
||||
data = data['strategy'][strategy]['trades']
|
||||
df = pd.DataFrame(data)
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
if not df.empty:
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
else:
|
||||
# old format - only with lists.
|
||||
df = pd.DataFrame(data, columns=BT_DATA_COLUMNS_OLD)
|
||||
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
# Create compatibility with new format
|
||||
df['profit_abs'] = df['close_rate'] - df['open_rate']
|
||||
if 'profit_ratio' not in df.columns:
|
||||
df['profit_ratio'] = df['profit_percent']
|
||||
df = df.sort_values("open_date").reset_index(drop=True)
|
||||
if not df.empty:
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
# Create compatibility with new format
|
||||
df['profit_abs'] = df['close_rate'] - df['open_rate']
|
||||
if not df.empty:
|
||||
if 'profit_ratio' not in df.columns:
|
||||
df['profit_ratio'] = df['profit_percent']
|
||||
df = df.sort_values("open_date").reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
|
@@ -145,6 +145,27 @@ def trim_dataframe(df: DataFrame, timerange, df_date_col: str = 'date',
|
||||
return df
|
||||
|
||||
|
||||
def trim_dataframes(preprocessed: Dict[str, DataFrame], timerange,
|
||||
startup_candles: int) -> Dict[str, DataFrame]:
|
||||
"""
|
||||
Trim startup period from analyzed dataframes
|
||||
:param preprocessed: Dict of pair: dataframe
|
||||
:param timerange: timerange (use start and end date if available)
|
||||
:param startup_candles: Startup-candles that should be removed
|
||||
:return: Dict of trimmed dataframes
|
||||
"""
|
||||
processed: Dict[str, DataFrame] = {}
|
||||
|
||||
for pair, df in preprocessed.items():
|
||||
trimed_df = trim_dataframe(df, timerange, startup_candles=startup_candles)
|
||||
if not trimed_df.empty:
|
||||
processed[pair] = trimed_df
|
||||
else:
|
||||
logger.warning(f'{pair} has no data left after adjusting for startup candles, '
|
||||
f'skipping.')
|
||||
return processed
|
||||
|
||||
|
||||
def order_book_to_dataframe(bids: list, asks: list) -> DataFrame:
|
||||
"""
|
||||
TODO: This should get a dedicated test
|
||||
|
@@ -19,14 +19,25 @@ from freqtrade.state import RunMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NO_EXCHANGE_EXCEPTION = 'Exchange is not available to DataProvider.'
|
||||
MAX_DATAFRAME_CANDLES = 1000
|
||||
|
||||
|
||||
class DataProvider:
|
||||
|
||||
def __init__(self, config: dict, exchange: Exchange, pairlists=None) -> None:
|
||||
def __init__(self, config: dict, exchange: Optional[Exchange], pairlists=None) -> None:
|
||||
self._config = config
|
||||
self._exchange = exchange
|
||||
self._pairlists = pairlists
|
||||
self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {}
|
||||
self.__slice_index: Optional[int] = None
|
||||
|
||||
def _set_dataframe_max_index(self, limit_index: int):
|
||||
"""
|
||||
Limit analyzed dataframe to max specified index.
|
||||
:param limit_index: dataframe index.
|
||||
"""
|
||||
self.__slice_index = limit_index
|
||||
|
||||
def _set_cached_df(self, pair: str, timeframe: str, dataframe: DataFrame) -> None:
|
||||
"""
|
||||
@@ -45,40 +56,6 @@ class DataProvider:
|
||||
"""
|
||||
self._pairlists = pairlists
|
||||
|
||||
def refresh(self,
|
||||
pairlist: ListPairsWithTimeframes,
|
||||
helping_pairs: ListPairsWithTimeframes = None) -> None:
|
||||
"""
|
||||
Refresh data, called with each cycle
|
||||
"""
|
||||
if helping_pairs:
|
||||
self._exchange.refresh_latest_ohlcv(pairlist + helping_pairs)
|
||||
else:
|
||||
self._exchange.refresh_latest_ohlcv(pairlist)
|
||||
|
||||
@property
|
||||
def available_pairs(self) -> ListPairsWithTimeframes:
|
||||
"""
|
||||
Return a list of tuples containing (pair, timeframe) for which data is currently cached.
|
||||
Should be whitelist + open trades.
|
||||
"""
|
||||
return list(self._exchange._klines.keys())
|
||||
|
||||
def ohlcv(self, pair: str, timeframe: str = None, copy: bool = True) -> DataFrame:
|
||||
"""
|
||||
Get candle (OHLCV) data for the given pair as DataFrame
|
||||
Please use the `available_pairs` method to verify which pairs are currently cached.
|
||||
:param pair: pair to get the data for
|
||||
:param timeframe: Timeframe to get data for
|
||||
:param copy: copy dataframe before returning if True.
|
||||
Use False only for read-only operations (where the dataframe is not modified)
|
||||
"""
|
||||
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
return self._exchange.klines((pair, timeframe or self._config['timeframe']),
|
||||
copy=copy)
|
||||
else:
|
||||
return DataFrame()
|
||||
|
||||
def historic_ohlcv(self, pair: str, timeframe: str = None) -> DataFrame:
|
||||
"""
|
||||
Get stored historical candle (OHLCV) data
|
||||
@@ -111,47 +88,27 @@ class DataProvider:
|
||||
|
||||
def get_analyzed_dataframe(self, pair: str, timeframe: str) -> Tuple[DataFrame, datetime]:
|
||||
"""
|
||||
Retrieve the analyzed dataframe. Returns the full dataframe in trade mode (live / dry),
|
||||
and the last 1000 candles (up to the time evaluated at this moment) in all other modes.
|
||||
:param pair: pair to get the data for
|
||||
:param timeframe: timeframe to get data for
|
||||
:return: Tuple of (Analyzed Dataframe, lastrefreshed) for the requested pair / timeframe
|
||||
combination.
|
||||
Returns empty dataframe and Epoch 0 (1970-01-01) if no dataframe was cached.
|
||||
"""
|
||||
if (pair, timeframe) in self.__cached_pairs:
|
||||
return self.__cached_pairs[(pair, timeframe)]
|
||||
pair_key = (pair, timeframe)
|
||||
if pair_key in self.__cached_pairs:
|
||||
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
df, date = self.__cached_pairs[pair_key]
|
||||
else:
|
||||
df, date = self.__cached_pairs[pair_key]
|
||||
if self.__slice_index is not None:
|
||||
max_index = self.__slice_index
|
||||
df = df.iloc[max(0, max_index - MAX_DATAFRAME_CANDLES):max_index]
|
||||
return df, date
|
||||
else:
|
||||
|
||||
return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc))
|
||||
|
||||
def market(self, pair: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return market data for the pair
|
||||
:param pair: Pair to get the data for
|
||||
:return: Market data dict from ccxt or None if market info is not available for the pair
|
||||
"""
|
||||
return self._exchange.markets.get(pair)
|
||||
|
||||
def ticker(self, pair: str):
|
||||
"""
|
||||
Return last ticker data from exchange
|
||||
:param pair: Pair to get the data for
|
||||
:return: Ticker dict from exchange or empty dict if ticker is not available for the pair
|
||||
"""
|
||||
try:
|
||||
return self._exchange.fetch_ticker(pair)
|
||||
except ExchangeError:
|
||||
return {}
|
||||
|
||||
def orderbook(self, pair: str, maximum: int) -> Dict[str, List]:
|
||||
"""
|
||||
Fetch latest l2 orderbook data
|
||||
Warning: Does a network request - so use with common sense.
|
||||
:param pair: pair to get the data for
|
||||
:param maximum: Maximum number of orderbook entries to query
|
||||
:return: dict including bids/asks with a total of `maximum` entries.
|
||||
"""
|
||||
return self._exchange.fetch_l2_order_book(pair, maximum)
|
||||
|
||||
@property
|
||||
def runmode(self) -> RunMode:
|
||||
"""
|
||||
@@ -173,3 +130,86 @@ class DataProvider:
|
||||
return self._pairlists.whitelist.copy()
|
||||
else:
|
||||
raise OperationalException("Dataprovider was not initialized with a pairlist provider.")
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
Clear pair dataframe cache.
|
||||
"""
|
||||
self.__cached_pairs = {}
|
||||
|
||||
# Exchange functions
|
||||
|
||||
def refresh(self,
|
||||
pairlist: ListPairsWithTimeframes,
|
||||
helping_pairs: ListPairsWithTimeframes = None) -> None:
|
||||
"""
|
||||
Refresh data, called with each cycle
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
if helping_pairs:
|
||||
self._exchange.refresh_latest_ohlcv(pairlist + helping_pairs)
|
||||
else:
|
||||
self._exchange.refresh_latest_ohlcv(pairlist)
|
||||
|
||||
@property
|
||||
def available_pairs(self) -> ListPairsWithTimeframes:
|
||||
"""
|
||||
Return a list of tuples containing (pair, timeframe) for which data is currently cached.
|
||||
Should be whitelist + open trades.
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
return list(self._exchange._klines.keys())
|
||||
|
||||
def ohlcv(self, pair: str, timeframe: str = None, copy: bool = True) -> DataFrame:
|
||||
"""
|
||||
Get candle (OHLCV) data for the given pair as DataFrame
|
||||
Please use the `available_pairs` method to verify which pairs are currently cached.
|
||||
:param pair: pair to get the data for
|
||||
:param timeframe: Timeframe to get data for
|
||||
:param copy: copy dataframe before returning if True.
|
||||
Use False only for read-only operations (where the dataframe is not modified)
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
return self._exchange.klines((pair, timeframe or self._config['timeframe']),
|
||||
copy=copy)
|
||||
else:
|
||||
return DataFrame()
|
||||
|
||||
def market(self, pair: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return market data for the pair
|
||||
:param pair: Pair to get the data for
|
||||
:return: Market data dict from ccxt or None if market info is not available for the pair
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
return self._exchange.markets.get(pair)
|
||||
|
||||
def ticker(self, pair: str):
|
||||
"""
|
||||
Return last ticker data from exchange
|
||||
:param pair: Pair to get the data for
|
||||
:return: Ticker dict from exchange or empty dict if ticker is not available for the pair
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
try:
|
||||
return self._exchange.fetch_ticker(pair)
|
||||
except ExchangeError:
|
||||
return {}
|
||||
|
||||
def orderbook(self, pair: str, maximum: int) -> Dict[str, List]:
|
||||
"""
|
||||
Fetch latest l2 orderbook data
|
||||
Warning: Does a network request - so use with common sense.
|
||||
:param pair: pair to get the data for
|
||||
:param maximum: Maximum number of orderbook entries to query
|
||||
:return: dict including bids/asks with a total of `maximum` entries.
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
return self._exchange.fetch_l2_order_book(pair, maximum)
|
||||
|
@@ -265,9 +265,13 @@ def _download_trades_history(exchange: Exchange,
|
||||
"""
|
||||
try:
|
||||
|
||||
since = timerange.startts * 1000 if \
|
||||
(timerange and timerange.starttype == 'date') else int(arrow.utcnow().shift(
|
||||
days=-new_pairs_days).float_timestamp) * 1000
|
||||
until = None
|
||||
if (timerange and timerange.starttype == 'date'):
|
||||
since = timerange.startts * 1000
|
||||
if timerange.stoptype == 'date':
|
||||
until = timerange.stopts * 1000
|
||||
else:
|
||||
since = int(arrow.utcnow().shift(days=-new_pairs_days).float_timestamp) * 1000
|
||||
|
||||
trades = data_handler.trades_load(pair)
|
||||
|
||||
@@ -295,6 +299,7 @@ def _download_trades_history(exchange: Exchange,
|
||||
# Default since_ms to 30 days if nothing is given
|
||||
new_trades = exchange.get_historic_trades(pair=pair,
|
||||
since=since,
|
||||
until=until,
|
||||
from_id=from_id,
|
||||
)
|
||||
trades.extend(new_trades[1])
|
||||
@@ -367,7 +372,7 @@ def convert_trades_to_ohlcv(pairs: List[str], timeframes: List[str],
|
||||
logger.exception(f'Could not convert {pair} to OHLCV.')
|
||||
|
||||
|
||||
def get_timerange(data: Dict[str, DataFrame]) -> Tuple[arrow.Arrow, arrow.Arrow]:
|
||||
def get_timerange(data: Dict[str, DataFrame]) -> Tuple[datetime, datetime]:
|
||||
"""
|
||||
Get the maximum common timerange for the given backtest data.
|
||||
|
||||
@@ -375,7 +380,7 @@ def get_timerange(data: Dict[str, DataFrame]) -> Tuple[arrow.Arrow, arrow.Arrow]
|
||||
:return: tuple containing min_date, max_date
|
||||
"""
|
||||
timeranges = [
|
||||
(arrow.get(frame['date'].min()), arrow.get(frame['date'].max()))
|
||||
(frame['date'].min().to_pydatetime(), frame['date'].max().to_pydatetime())
|
||||
for frame in data.values()
|
||||
]
|
||||
return (min(timeranges, key=operator.itemgetter(0))[0],
|
||||
|
@@ -1,6 +1,8 @@
|
||||
# pragma pylint: disable=W0603
|
||||
""" Edge positioning package """
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
|
||||
import arrow
|
||||
@@ -12,8 +14,10 @@ from freqtrade.configuration import TimeRange
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, UNLIMITED_STAKE_AMOUNT
|
||||
from freqtrade.data.history import get_timerange, load_data, refresh_data
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange.exchange import timeframe_to_seconds
|
||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||
from freqtrade.strategy.interface import SellType
|
||||
from freqtrade.state import RunMode
|
||||
from freqtrade.strategy.interface import IStrategy, SellType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,7 +49,7 @@ class Edge:
|
||||
|
||||
self.config = config
|
||||
self.exchange = exchange
|
||||
self.strategy = strategy
|
||||
self.strategy: IStrategy = strategy
|
||||
|
||||
self.edge_config = self.config.get('edge', {})
|
||||
self._cached_pairs: Dict[str, Any] = {} # Keeps a list of pairs
|
||||
@@ -102,14 +106,33 @@ class Edge:
|
||||
logger.info('Using local backtesting data (using whitelist in given config) ...')
|
||||
|
||||
if self._refresh_pairs:
|
||||
timerange_startup = deepcopy(self._timerange)
|
||||
timerange_startup.subtract_start(timeframe_to_seconds(
|
||||
self.strategy.timeframe) * self.strategy.startup_candle_count)
|
||||
refresh_data(
|
||||
datadir=self.config['datadir'],
|
||||
pairs=pairs,
|
||||
exchange=self.exchange,
|
||||
timeframe=self.strategy.timeframe,
|
||||
timerange=self._timerange,
|
||||
timerange=timerange_startup,
|
||||
data_format=self.config.get('dataformat_ohlcv', 'json'),
|
||||
)
|
||||
# Download informative pairs too
|
||||
res = defaultdict(list)
|
||||
for p, t in self.strategy.informative_pairs():
|
||||
res[t].append(p)
|
||||
for timeframe, inf_pairs in res.items():
|
||||
timerange_startup = deepcopy(self._timerange)
|
||||
timerange_startup.subtract_start(timeframe_to_seconds(
|
||||
timeframe) * self.strategy.startup_candle_count)
|
||||
refresh_data(
|
||||
datadir=self.config['datadir'],
|
||||
pairs=inf_pairs,
|
||||
exchange=self.exchange,
|
||||
timeframe=timeframe,
|
||||
timerange=timerange_startup,
|
||||
data_format=self.config.get('dataformat_ohlcv', 'json'),
|
||||
)
|
||||
|
||||
data = load_data(
|
||||
datadir=self.config['datadir'],
|
||||
@@ -125,8 +148,11 @@ class Edge:
|
||||
self._cached_pairs = {}
|
||||
logger.critical("No data found. Edge is stopped ...")
|
||||
return False
|
||||
|
||||
# Fake run-mode to Edge
|
||||
prior_rm = self.config['runmode']
|
||||
self.config['runmode'] = RunMode.EDGE
|
||||
preprocessed = self.strategy.ohlcvdata_to_dataframe(data)
|
||||
self.config['runmode'] = prior_rm
|
||||
|
||||
# Print timeframe
|
||||
min_date, max_date = get_timerange(preprocessed)
|
||||
@@ -183,7 +209,7 @@ class Edge:
|
||||
if pair in self._cached_pairs:
|
||||
return self._cached_pairs[pair].stoploss
|
||||
else:
|
||||
logger.warning('tried to access stoploss of a non-existing pair, '
|
||||
logger.warning(f'Tried to access stoploss of non-existing pair {pair}, '
|
||||
'strategy stoploss is returned instead.')
|
||||
return self.strategy.stoploss
|
||||
|
||||
@@ -214,7 +240,7 @@ class Edge:
|
||||
|
||||
return self._final_pairs
|
||||
|
||||
def accepted_pairs(self) -> list:
|
||||
def accepted_pairs(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
return a list of accepted pairs along with their winrate, expectancy and stoploss
|
||||
"""
|
||||
|
@@ -7,6 +7,7 @@ from freqtrade.exchange.bibox import Bibox
|
||||
from freqtrade.exchange.binance import Binance
|
||||
from freqtrade.exchange.bittrex import Bittrex
|
||||
from freqtrade.exchange.bybit import Bybit
|
||||
from freqtrade.exchange.coinbasepro import Coinbasepro
|
||||
from freqtrade.exchange.exchange import (available_exchanges, ccxt_exchanges,
|
||||
is_exchange_known_ccxt, is_exchange_officially_supported,
|
||||
market_is_active, timeframe_to_minutes, timeframe_to_msecs,
|
||||
@@ -14,5 +15,6 @@ from freqtrade.exchange.exchange import (available_exchanges, ccxt_exchanges,
|
||||
timeframe_to_seconds, validate_exchange,
|
||||
validate_exchanges)
|
||||
from freqtrade.exchange.ftx import Ftx
|
||||
from freqtrade.exchange.hitbtc import Hitbtc
|
||||
from freqtrade.exchange.kraken import Kraken
|
||||
from freqtrade.exchange.kucoin import Kucoin
|
||||
|
@@ -18,7 +18,6 @@ class Bybit(Exchange):
|
||||
may still not work as expected.
|
||||
"""
|
||||
|
||||
# fetchCurrencies API point requires authentication for Bybit,
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 200,
|
||||
}
|
||||
|
23
freqtrade/exchange/coinbasepro.py
Normal file
23
freqtrade/exchange/coinbasepro.py
Normal file
@@ -0,0 +1,23 @@
|
||||
""" CoinbasePro exchange subclass """
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from freqtrade.exchange import Exchange
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Coinbasepro(Exchange):
|
||||
"""
|
||||
CoinbasePro exchange class. Contains adjustments needed for Freqtrade to work
|
||||
with this exchange.
|
||||
|
||||
Please note that this exchange is not included in the list of exchanges
|
||||
officially supported by the Freqtrade development team. So some features
|
||||
may still not work as expected.
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 300,
|
||||
}
|
@@ -59,6 +59,7 @@ class Exchange:
|
||||
_ft_has_default: Dict = {
|
||||
"stoploss_on_exchange": False,
|
||||
"order_time_in_force": ["gtc"],
|
||||
"ohlcv_params": {},
|
||||
"ohlcv_candle_limit": 500,
|
||||
"ohlcv_partial_candle": True,
|
||||
"trades_pagination": "time", # Possible are "time" or "id"
|
||||
@@ -465,7 +466,7 @@ class Exchange:
|
||||
def amount_to_precision(self, pair: str, amount: float) -> float:
|
||||
'''
|
||||
Returns the amount to buy or sell to a precision the Exchange accepts
|
||||
Reimplementation of ccxt internal methods - ensuring we can test the result is correct
|
||||
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
|
||||
based on our definitions.
|
||||
'''
|
||||
if self.markets[pair]['precision']['amount']:
|
||||
@@ -479,7 +480,7 @@ class Exchange:
|
||||
def price_to_precision(self, pair: str, price: float) -> float:
|
||||
'''
|
||||
Returns the price rounded up to the precision the Exchange accepts.
|
||||
Partial Reimplementation of ccxt internal method decimal_to_precision(),
|
||||
Partial Re-implementation of ccxt internal method decimal_to_precision(),
|
||||
which does not support rounding up
|
||||
TODO: If ccxt supports ROUND_UP for decimal_to_precision(), we could remove this and
|
||||
align with amount_to_precision().
|
||||
@@ -862,10 +863,11 @@ class Exchange:
|
||||
"Fetching pair %s, interval %s, since %s %s...",
|
||||
pair, timeframe, since_ms, s
|
||||
)
|
||||
|
||||
params = self._ft_has.get('ohlcv_params', {})
|
||||
data = await self._api_async.fetch_ohlcv(pair, timeframe=timeframe,
|
||||
since=since_ms,
|
||||
limit=self.ohlcv_candle_limit(timeframe))
|
||||
limit=self.ohlcv_candle_limit(timeframe),
|
||||
params=params)
|
||||
|
||||
# Some exchanges sort OHLCV in ASC order and others in DESC.
|
||||
# Ex: Bittrex returns the list of OHLCV in ASC order (oldest first, newest last)
|
||||
@@ -1118,6 +1120,27 @@ class Exchange:
|
||||
|
||||
return order
|
||||
|
||||
def cancel_stoploss_order_with_result(self, order_id: str, pair: str, amount: float) -> Dict:
|
||||
"""
|
||||
Cancel stoploss order returning a result.
|
||||
Creates a fake result if cancel order returns a non-usable result
|
||||
and fetch_order does not work (certain exchanges don't return cancelled orders)
|
||||
:param order_id: stoploss-order-id to cancel
|
||||
:param pair: Pair corresponding to order_id
|
||||
:param amount: Amount to use for fake response
|
||||
:return: Result from either cancel_order if usable, or fetch_order
|
||||
"""
|
||||
corder = self.cancel_stoploss_order(order_id, pair)
|
||||
if self.is_cancel_order_result_suitable(corder):
|
||||
return corder
|
||||
try:
|
||||
order = self.fetch_stoploss_order(order_id, pair)
|
||||
except InvalidOrderException:
|
||||
logger.warning(f"Could not fetch cancelled stoploss order {order_id}.")
|
||||
order = {'fee': {}, 'status': 'canceled', 'amount': amount, 'info': {}}
|
||||
|
||||
return order
|
||||
|
||||
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
|
||||
def fetch_order(self, order_id: str, pair: str) -> Dict:
|
||||
if self._config['dry_run']:
|
||||
@@ -1237,6 +1260,9 @@ class Exchange:
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def get_order_id_conditional(self, order: Dict[str, Any]) -> str:
|
||||
return order['id']
|
||||
|
||||
@retrier
|
||||
def get_fee(self, symbol: str, type: str = '', side: str = '', amount: float = 1,
|
||||
price: float = 1, taker_or_maker: str = 'maker') -> float:
|
||||
|
@@ -8,6 +8,7 @@ from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, Invali
|
||||
OperationalException, TemporaryError)
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier
|
||||
from freqtrade.misc import safe_value_fallback2
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -135,3 +136,8 @@ class Ftx(Exchange):
|
||||
f'Could not cancel order due to {e.__class__.__name__}. Message: {e}') from e
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def get_order_id_conditional(self, order: Dict[str, Any]) -> str:
|
||||
if order['type'] == 'stop':
|
||||
return safe_value_fallback2(order['info'], order, 'orderId', 'id')
|
||||
return order['id']
|
||||
|
23
freqtrade/exchange/hitbtc.py
Normal file
23
freqtrade/exchange/hitbtc.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from freqtrade.exchange import Exchange
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Hitbtc(Exchange):
|
||||
"""
|
||||
Hitbtc exchange class. Contains adjustments needed for Freqtrade to work
|
||||
with this exchange.
|
||||
|
||||
Please note that this exchange is not included in the list of exchanges
|
||||
officially supported by the Freqtrade development team. So some features
|
||||
may still not work as expected.
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 1000,
|
||||
"ohlcv_params": {"sort": "DESC"}
|
||||
}
|
@@ -53,6 +53,8 @@ class Kraken(Exchange):
|
||||
# x["side"], x["amount"],
|
||||
) for x in orders]
|
||||
for bal in balances:
|
||||
if not isinstance(balances[bal], dict):
|
||||
continue
|
||||
balances[bal]['used'] = sum(order[1] for order in order_list if order[0] == bal)
|
||||
balances[bal]['free'] = balances[bal]['total'] - balances[bal]['used']
|
||||
|
||||
|
@@ -11,7 +11,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import arrow
|
||||
from cachetools import TTLCache
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade import __version__, constants
|
||||
from freqtrade.configuration import validate_config_consistency
|
||||
@@ -268,7 +267,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
def update_closed_trades_without_assigned_fees(self):
|
||||
"""
|
||||
Update closed trades without close fees assigned.
|
||||
Only acts when Orders are in the database, otherwise the last orderid is unknown.
|
||||
Only acts when Orders are in the database, otherwise the last order-id is unknown.
|
||||
"""
|
||||
if self.config['dry_run']:
|
||||
# Updating open orders in dry-run does not make sense and will fail.
|
||||
@@ -553,7 +552,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
pair=pair, order_type=order_type, amount=amount, rate=buy_limit_requested,
|
||||
time_in_force=time_in_force):
|
||||
time_in_force=time_in_force, current_time=datetime.now(timezone.utc)):
|
||||
logger.info(f"User requested abortion of buying {pair}")
|
||||
return False
|
||||
amount = self.exchange.amount_to_precision(pair, amount)
|
||||
@@ -602,6 +601,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
pair=pair,
|
||||
stake_amount=stake_amount,
|
||||
amount=amount,
|
||||
is_open=True,
|
||||
amount_requested=amount_requested,
|
||||
fee_open=fee,
|
||||
fee_close=fee,
|
||||
@@ -631,7 +631,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def _notify_buy(self, trade: Trade, order_type: str) -> None:
|
||||
"""
|
||||
Sends rpc notification when a buy occured.
|
||||
Sends rpc notification when a buy occurred.
|
||||
"""
|
||||
msg = {
|
||||
'trade_id': trade.id,
|
||||
@@ -653,7 +653,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def _notify_buy_cancel(self, trade: Trade, order_type: str, reason: str) -> None:
|
||||
"""
|
||||
Sends rpc notification when a buy cancel occured.
|
||||
Sends rpc notification when a buy cancel occurred.
|
||||
"""
|
||||
current_rate = self.get_buy_rate(trade.pair, False)
|
||||
|
||||
@@ -714,7 +714,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
except DependencyException as exception:
|
||||
logger.warning('Unable to sell trade %s: %s', trade.pair, exception)
|
||||
|
||||
# Updating wallets if any trade occured
|
||||
# Updating wallets if any trade occurred
|
||||
if trades_closed:
|
||||
self.wallets.update()
|
||||
|
||||
@@ -784,10 +784,10 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
config_ask_strategy = self.config.get('ask_strategy', {})
|
||||
|
||||
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair,
|
||||
self.strategy.timeframe)
|
||||
if (config_ask_strategy.get('use_sell_signal', True) or
|
||||
config_ask_strategy.get('ignore_roi_if_buy_signal', False)):
|
||||
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair,
|
||||
self.strategy.timeframe)
|
||||
|
||||
(buy, sell) = self.strategy.get_signal(trade.pair, self.strategy.timeframe, analyzed_df)
|
||||
|
||||
@@ -814,13 +814,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
# resulting in outdated RPC messages
|
||||
self._sell_rate_cache[trade.pair] = sell_rate
|
||||
|
||||
if self._check_and_execute_sell(analyzed_df, trade, sell_rate, buy, sell):
|
||||
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
|
||||
return True
|
||||
|
||||
else:
|
||||
logger.debug('checking sell')
|
||||
sell_rate = self.get_sell_rate(trade.pair, True)
|
||||
if self._check_and_execute_sell(analyzed_df, trade, sell_rate, buy, sell):
|
||||
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
|
||||
return True
|
||||
|
||||
logger.debug('Found no sell signal for %s.', trade)
|
||||
@@ -933,14 +933,15 @@ class FreqtradeBot(LoggingMixin):
|
||||
:return: None
|
||||
"""
|
||||
if self.exchange.stoploss_adjust(trade.stop_loss, order):
|
||||
# we check if the update is neccesary
|
||||
# we check if the update is necessary
|
||||
update_beat = self.strategy.order_types.get('stoploss_on_exchange_interval', 60)
|
||||
if (datetime.utcnow() - trade.stoploss_last_update).total_seconds() >= update_beat:
|
||||
# cancelling the current stoploss on exchange first
|
||||
logger.info(f"Cancelling current stoploss on exchange for pair {trade.pair} "
|
||||
f"(orderid:{order['id']}) in order to add another one ...")
|
||||
try:
|
||||
co = self.exchange.cancel_stoploss_order(order['id'], trade.pair)
|
||||
co = self.exchange.cancel_stoploss_order_with_result(order['id'], trade.pair,
|
||||
trade.amount)
|
||||
trade.update_order(co)
|
||||
except InvalidOrderException:
|
||||
logger.exception(f"Could not cancel stoploss order {order['id']} "
|
||||
@@ -951,13 +952,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
logger.warning(f"Could not create trailing stoploss order "
|
||||
f"for pair {trade.pair}.")
|
||||
|
||||
def _check_and_execute_sell(self, dataframe: DataFrame, trade: Trade, sell_rate: float,
|
||||
def _check_and_execute_sell(self, trade: Trade, sell_rate: float,
|
||||
buy: bool, sell: bool) -> bool:
|
||||
"""
|
||||
Check and execute sell
|
||||
"""
|
||||
should_sell = self.strategy.should_sell(
|
||||
dataframe, trade, sell_rate, datetime.now(timezone.utc), buy, sell,
|
||||
trade, sell_rate, datetime.now(timezone.utc), buy, sell,
|
||||
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
|
||||
)
|
||||
|
||||
@@ -974,15 +975,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
timeout = self.config.get('unfilledtimeout', {}).get(side)
|
||||
ordertime = arrow.get(order['datetime']).datetime
|
||||
if timeout is not None:
|
||||
timeout_threshold = arrow.utcnow().shift(minutes=-timeout).datetime
|
||||
|
||||
timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes')
|
||||
timeout_kwargs = {timeout_unit: -timeout}
|
||||
timeout_threshold = arrow.utcnow().shift(**timeout_kwargs).datetime
|
||||
return (order['status'] == 'open' and order['side'] == side
|
||||
and ordertime < timeout_threshold)
|
||||
return False
|
||||
|
||||
def check_handle_timedout(self) -> None:
|
||||
"""
|
||||
Check if any orders are timed out and cancel if neccessary
|
||||
Check if any orders are timed out and cancel if necessary
|
||||
:param timeoutvalue: Number of minutes until order is considered timed out
|
||||
:return: None
|
||||
"""
|
||||
@@ -1044,6 +1046,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
# Cancelled orders may have the status of 'canceled' or 'closed'
|
||||
if order['status'] not in ('cancelled', 'canceled', 'closed'):
|
||||
filled_val = order.get('filled', 0.0) or 0.0
|
||||
filled_stake = filled_val * trade.open_rate
|
||||
minstake = self.exchange.get_min_pair_stake_amount(
|
||||
trade.pair, trade.open_rate, self.strategy.stoploss)
|
||||
|
||||
if filled_val > 0 and filled_stake < minstake:
|
||||
logger.warning(
|
||||
f"Order {trade.open_order_id} for {trade.pair} not cancelled, "
|
||||
f"as the filled amount of {filled_val} would result in an unsellable trade.")
|
||||
return False
|
||||
corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair,
|
||||
trade.amount)
|
||||
# Avoid race condition where the order could not be cancelled coz its already filled.
|
||||
@@ -1173,7 +1185,9 @@ class FreqtradeBot(LoggingMixin):
|
||||
# First cancelling stoploss on exchange ...
|
||||
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
|
||||
try:
|
||||
self.exchange.cancel_stoploss_order(trade.stoploss_order_id, trade.pair)
|
||||
co = self.exchange.cancel_stoploss_order_with_result(trade.stoploss_order_id,
|
||||
trade.pair, trade.amount)
|
||||
trade.update_order(co)
|
||||
except InvalidOrderException:
|
||||
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
|
||||
|
||||
@@ -1191,8 +1205,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
|
||||
pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit,
|
||||
time_in_force=time_in_force,
|
||||
sell_reason=sell_reason.sell_reason):
|
||||
time_in_force=time_in_force, sell_reason=sell_reason.sell_reason,
|
||||
current_time=datetime.now(timezone.utc)):
|
||||
logger.info(f"User requested abortion of selling {trade.pair}")
|
||||
return False
|
||||
|
||||
@@ -1221,7 +1235,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
self.update_trade_state(trade, trade.open_order_id, order)
|
||||
Trade.query.session.flush()
|
||||
|
||||
# Lock pair for one candle to prevent immediate rebuys
|
||||
# Lock pair for one candle to prevent immediate re-buys
|
||||
self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc),
|
||||
reason='Auto lock')
|
||||
|
||||
@@ -1231,7 +1245,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def _notify_sell(self, trade: Trade, order_type: str, fill: bool = False) -> None:
|
||||
"""
|
||||
Sends rpc notification when a sell occured.
|
||||
Sends rpc notification when a sell occurred.
|
||||
"""
|
||||
profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested
|
||||
profit_trade = trade.calc_profit(rate=profit_rate)
|
||||
@@ -1272,7 +1286,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def _notify_sell_cancel(self, trade: Trade, order_type: str, reason: str) -> None:
|
||||
"""
|
||||
Sends rpc notification when a sell cancel occured.
|
||||
Sends rpc notification when a sell cancel occurred.
|
||||
"""
|
||||
if trade.sell_order_status == reason:
|
||||
return
|
||||
@@ -1325,7 +1339,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
Handles closing both buy and sell orders.
|
||||
:param trade: Trade object of the trade we're analyzing
|
||||
:param order_id: Order-id of the order we're analyzing
|
||||
:param action_order: Already aquired order object
|
||||
:param action_order: Already acquired order object
|
||||
:return: True if order has been cancelled without being filled partially, False otherwise
|
||||
"""
|
||||
if not order_id:
|
||||
@@ -1395,7 +1409,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
def get_real_amount(self, trade: Trade, order: Dict) -> float:
|
||||
"""
|
||||
Detect and update trade fee.
|
||||
Calls trade.update_fee() uppon correct detection.
|
||||
Calls trade.update_fee() upon correct detection.
|
||||
Returns modified amount if the fee was taken from the destination currency.
|
||||
Necessary for exchanges which charge fees in base currency (e.g. binance)
|
||||
:return: identical (or new) amount for the trade
|
||||
@@ -1428,8 +1442,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
"""
|
||||
fee-detection fallback to Trades. Parses result of fetch_my_trades to get correct fee.
|
||||
"""
|
||||
trades = self.exchange.get_trades_for_order(order['id'], trade.pair,
|
||||
trade.open_date)
|
||||
trades = self.exchange.get_trades_for_order(self.exchange.get_order_id_conditional(order),
|
||||
trade.pair, trade.open_date)
|
||||
|
||||
if len(trades) == 0:
|
||||
logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade)
|
||||
|
@@ -6,7 +6,7 @@ import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Iterator, List
|
||||
from typing.io import IO
|
||||
|
||||
import rapidjson
|
||||
@@ -202,3 +202,14 @@ def render_template_with_fallback(templatefile: str, templatefallbackfile: str,
|
||||
return render_template(templatefile, arguments)
|
||||
except TemplateNotFound:
|
||||
return render_template(templatefallbackfile, arguments)
|
||||
|
||||
|
||||
def chunks(lst: List[Any], n: int) -> Iterator[List[Any]]:
|
||||
"""
|
||||
Split lst into chunks of the size n.
|
||||
:param lst: list to split into chunks
|
||||
:param n: number of max elements per chunk
|
||||
:return: None
|
||||
"""
|
||||
for chunk in range(0, len(lst), n):
|
||||
yield (lst[chunk:chunk + n])
|
||||
|
@@ -15,7 +15,7 @@ from freqtrade.configuration import TimeRange, remove_credentials, validate_conf
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT
|
||||
from freqtrade.data import history
|
||||
from freqtrade.data.btanalysis import trade_list_to_dataframe
|
||||
from freqtrade.data.converter import trim_dataframe
|
||||
from freqtrade.data.converter import trim_dataframes
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
|
||||
@@ -63,9 +63,7 @@ class Backtesting:
|
||||
self.all_results: Dict[str, Dict] = {}
|
||||
|
||||
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
|
||||
|
||||
dataprovider = DataProvider(self.config, self.exchange)
|
||||
IStrategy.dp = dataprovider
|
||||
self.dataprovider = DataProvider(self.config, None)
|
||||
|
||||
if self.config.get('strategy_list', None):
|
||||
for strat in list(self.config['strategy_list']):
|
||||
@@ -96,7 +94,7 @@ class Backtesting:
|
||||
"PrecisionFilter not allowed for backtesting multiple strategies."
|
||||
)
|
||||
|
||||
dataprovider.add_pairlisthandler(self.pairlists)
|
||||
self.dataprovider.add_pairlisthandler(self.pairlists)
|
||||
self.pairlists.refresh_pairlist()
|
||||
|
||||
if len(self.pairlists.whitelist) == 0:
|
||||
@@ -112,15 +110,11 @@ class Backtesting:
|
||||
PairLocks.timeframe = self.config['timeframe']
|
||||
PairLocks.use_db = False
|
||||
PairLocks.reset_locks()
|
||||
if self.config.get('enable_protections', False):
|
||||
self.protections = ProtectionManager(self.config)
|
||||
|
||||
self.wallets = Wallets(self.config, self.exchange, log=False)
|
||||
|
||||
# Get maximum required startup period
|
||||
self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
|
||||
# Load one (first) strategy
|
||||
self._set_strategy(self.strategylist[0])
|
||||
|
||||
def __del__(self):
|
||||
LoggingMixin.show_output = True
|
||||
@@ -132,10 +126,17 @@ class Backtesting:
|
||||
Load strategy into backtesting
|
||||
"""
|
||||
self.strategy: IStrategy = strategy
|
||||
strategy.dp = self.dataprovider
|
||||
# Set stoploss_on_exchange to false for backtesting,
|
||||
# since a "perfect" stoploss-sell is assumed anyway
|
||||
# And the regular "stoploss" function would not apply to that case
|
||||
self.strategy.order_types['stoploss_on_exchange'] = False
|
||||
if self.config.get('enable_protections', False):
|
||||
conf = self.config
|
||||
if hasattr(strategy, 'protections'):
|
||||
conf = deepcopy(conf)
|
||||
conf['protections'] = strategy.protections
|
||||
self.protections = ProtectionManager(conf)
|
||||
|
||||
def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
|
||||
"""
|
||||
@@ -159,7 +160,7 @@ class Backtesting:
|
||||
|
||||
logger.info(f'Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'({(max_date - min_date).days} days)..')
|
||||
f'({(max_date - min_date).days} days).')
|
||||
|
||||
# Adjust startts forward if not enough data is available
|
||||
timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
|
||||
@@ -176,6 +177,8 @@ class Backtesting:
|
||||
Trade.use_db = False
|
||||
PairLocks.reset_locks()
|
||||
Trade.reset_trades()
|
||||
self.rejected_trades = 0
|
||||
self.dataprovider.clear_cache()
|
||||
|
||||
def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
|
||||
"""
|
||||
@@ -189,8 +192,9 @@ class Backtesting:
|
||||
data: Dict = {}
|
||||
# Create dict with data
|
||||
for pair, pair_data in processed.items():
|
||||
pair_data.loc[:, 'buy'] = 0 # cleanup from previous run
|
||||
pair_data.loc[:, 'sell'] = 0 # cleanup from previous run
|
||||
if not pair_data.empty:
|
||||
pair_data.loc[:, 'buy'] = 0 # cleanup if buy_signal is exist
|
||||
pair_data.loc[:, 'sell'] = 0 # cleanup if sell_signal is exist
|
||||
|
||||
df_analyzed = self.strategy.advise_sell(
|
||||
self.strategy.advise_buy(pair_data, {'pair': pair}), {'pair': pair})[headers].copy()
|
||||
@@ -214,6 +218,12 @@ class Backtesting:
|
||||
"""
|
||||
# Special handling if high or low hit STOP_LOSS or ROI
|
||||
if sell.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
|
||||
if trade.stop_loss > sell_row[HIGH_IDX]:
|
||||
# our stoploss was already higher than candle high,
|
||||
# possibly due to a cancelled trade exit.
|
||||
# sell at open price.
|
||||
return sell_row[OPEN_IDX]
|
||||
|
||||
# Set close_rate to stoploss
|
||||
return trade.stop_loss
|
||||
elif sell.sell_type == (SellType.ROI):
|
||||
@@ -247,10 +257,9 @@ class Backtesting:
|
||||
else:
|
||||
return sell_row[OPEN_IDX]
|
||||
|
||||
def _get_sell_trade_entry(self, dataframe: DataFrame, trade: LocalTrade,
|
||||
sell_row: Tuple) -> Optional[LocalTrade]:
|
||||
def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]:
|
||||
|
||||
sell = self.strategy.should_sell(dataframe, trade, sell_row[OPEN_IDX], # type: ignore
|
||||
sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], # type: ignore
|
||||
sell_row[DATE_IDX].to_pydatetime(), sell_row[BUY_IDX],
|
||||
sell_row[SELL_IDX],
|
||||
low=sell_row[LOW_IDX], high=sell_row[HIGH_IDX])
|
||||
@@ -267,7 +276,8 @@ class Backtesting:
|
||||
pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount,
|
||||
rate=closerate,
|
||||
time_in_force=time_in_force,
|
||||
sell_reason=sell.sell_reason):
|
||||
sell_reason=sell.sell_reason,
|
||||
current_time=sell_row[DATE_IDX].to_pydatetime()):
|
||||
return None
|
||||
|
||||
trade.close(closerate, show_msg=False)
|
||||
@@ -287,7 +297,7 @@ class Backtesting:
|
||||
# Confirm trade entry:
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
pair=pair, order_type=order_type, amount=stake_amount, rate=row[OPEN_IDX],
|
||||
time_in_force=time_in_force):
|
||||
time_in_force=time_in_force, current_time=row[DATE_IDX].to_pydatetime()):
|
||||
return None
|
||||
|
||||
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
|
||||
@@ -327,10 +337,18 @@ class Backtesting:
|
||||
trades.append(trade1)
|
||||
return trades
|
||||
|
||||
def trade_slot_available(self, max_open_trades: int, open_trade_count: int) -> bool:
|
||||
# Always allow trades when max_open_trades is enabled.
|
||||
if max_open_trades <= 0 or open_trade_count < max_open_trades:
|
||||
return True
|
||||
# Rejected trade
|
||||
self.rejected_trades += 1
|
||||
return False
|
||||
|
||||
def backtest(self, processed: Dict,
|
||||
start_date: datetime, end_date: datetime,
|
||||
max_open_trades: int = 0, position_stacking: bool = False,
|
||||
enable_protections: bool = False) -> DataFrame:
|
||||
enable_protections: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Implement backtesting functionality
|
||||
|
||||
@@ -349,6 +367,10 @@ class Backtesting:
|
||||
trades: List[LocalTrade] = []
|
||||
self.prepare_backtest(enable_protections)
|
||||
|
||||
# Update dataprovider cache
|
||||
for pair, dataframe in processed.items():
|
||||
self.dataprovider._set_cached_df(pair, self.timeframe, dataframe)
|
||||
|
||||
# Use dict of lists with data for performance
|
||||
# (looping lists is a lot faster than pandas DataFrames)
|
||||
data: Dict = self._get_ohlcv_as_lists(processed)
|
||||
@@ -365,8 +387,9 @@ class Backtesting:
|
||||
open_trade_count_start = open_trade_count
|
||||
|
||||
for i, pair in enumerate(data):
|
||||
row_index = indexes[pair]
|
||||
try:
|
||||
row = data[pair][indexes[pair]]
|
||||
row = data[pair][row_index]
|
||||
except IndexError:
|
||||
# missing Data for one pair at the end.
|
||||
# Warnings for this are shown during data loading
|
||||
@@ -375,16 +398,22 @@ class Backtesting:
|
||||
# Waits until the time-counter reaches the start of the data for this pair.
|
||||
if row[DATE_IDX] > tmp:
|
||||
continue
|
||||
indexes[pair] += 1
|
||||
|
||||
row_index += 1
|
||||
self.dataprovider._set_dataframe_max_index(row_index)
|
||||
indexes[pair] = row_index
|
||||
|
||||
# without positionstacking, we can only have one open trade per pair.
|
||||
# max_open_trades must be respected
|
||||
# don't open on the last row
|
||||
if ((position_stacking or len(open_trades[pair]) == 0)
|
||||
and (max_open_trades <= 0 or open_trade_count_start < max_open_trades)
|
||||
and tmp != end_date
|
||||
and row[BUY_IDX] == 1 and row[SELL_IDX] != 1
|
||||
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])):
|
||||
if (
|
||||
(position_stacking or len(open_trades[pair]) == 0)
|
||||
and self.trade_slot_available(max_open_trades, open_trade_count_start)
|
||||
and tmp != end_date
|
||||
and row[BUY_IDX] == 1
|
||||
and row[SELL_IDX] != 1
|
||||
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])
|
||||
):
|
||||
trade = self._enter_trade(pair, row)
|
||||
if trade:
|
||||
# TODO: hacky workaround to avoid opening > max_open_trades
|
||||
@@ -398,7 +427,7 @@ class Backtesting:
|
||||
|
||||
for trade in open_trades[pair]:
|
||||
# also check the buying candle for sell conditions.
|
||||
trade_entry = self._get_sell_trade_entry(processed[pair], trade, row)
|
||||
trade_entry = self._get_sell_trade_entry(trade, row)
|
||||
# Sell occured
|
||||
if trade_entry:
|
||||
# logger.debug(f"{pair} - Backtesting sell {trade}")
|
||||
@@ -417,7 +446,14 @@ class Backtesting:
|
||||
trades += self.handle_left_open(open_trades, data=data)
|
||||
self.wallets.update()
|
||||
|
||||
return trade_list_to_dataframe(trades)
|
||||
results = trade_list_to_dataframe(trades)
|
||||
return {
|
||||
'results': results,
|
||||
'config': self.strategy.config,
|
||||
'locks': PairLocks.get_all_locks(),
|
||||
'rejected_signals': self.rejected_trades,
|
||||
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
|
||||
}
|
||||
|
||||
def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, Any], timerange: TimeRange):
|
||||
logger.info("Running backtesting for Strategy %s", strat.get_strategy_name())
|
||||
@@ -439,32 +475,32 @@ class Backtesting:
|
||||
preprocessed = self.strategy.ohlcvdata_to_dataframe(data)
|
||||
|
||||
# Trim startup period from analyzed dataframe
|
||||
for pair, df in preprocessed.items():
|
||||
preprocessed[pair] = trim_dataframe(df, timerange,
|
||||
startup_candles=self.required_startup)
|
||||
min_date, max_date = history.get_timerange(preprocessed)
|
||||
preprocessed = trim_dataframes(preprocessed, timerange, self.required_startup)
|
||||
|
||||
if not preprocessed:
|
||||
raise OperationalException(
|
||||
"No data left after adjusting for startup candles.")
|
||||
|
||||
min_date, max_date = history.get_timerange(preprocessed)
|
||||
logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'({(max_date - min_date).days} days)..')
|
||||
f'({(max_date - min_date).days} days).')
|
||||
# Execute backtest and store results
|
||||
results = self.backtest(
|
||||
processed=preprocessed,
|
||||
start_date=min_date.datetime,
|
||||
end_date=max_date.datetime,
|
||||
start_date=min_date,
|
||||
end_date=max_date,
|
||||
max_open_trades=max_open_trades,
|
||||
position_stacking=self.config.get('position_stacking', False),
|
||||
enable_protections=self.config.get('enable_protections', False),
|
||||
)
|
||||
backtest_end_time = datetime.now(timezone.utc)
|
||||
self.all_results[self.strategy.get_strategy_name()] = {
|
||||
'results': results,
|
||||
'config': self.strategy.config,
|
||||
'locks': PairLocks.get_all_locks(),
|
||||
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
|
||||
results.update({
|
||||
'backtest_start_time': int(backtest_start_time.timestamp()),
|
||||
'backtest_end_time': int(backtest_end_time.timestamp()),
|
||||
}
|
||||
})
|
||||
self.all_results[self.strategy.get_strategy_name()] = results
|
||||
|
||||
return min_date, max_date
|
||||
|
||||
def start(self) -> None:
|
||||
|
@@ -7,20 +7,21 @@ This module contains the hyperopt logic
|
||||
import logging
|
||||
import random
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from math import ceil
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import progressbar
|
||||
import rapidjson
|
||||
from colorama import Fore, Style
|
||||
from colorama import init as colorama_init
|
||||
from joblib import Parallel, cpu_count, delayed, dump, load, wrap_non_picklable_objects
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN
|
||||
from freqtrade.data.converter import trim_dataframe
|
||||
from freqtrade.data.converter import trim_dataframes
|
||||
from freqtrade.data.history import get_timerange
|
||||
from freqtrade.misc import file_dump_json, plural
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
@@ -29,8 +30,8 @@ from freqtrade.optimize.hyperopt_auto import HyperOptAuto
|
||||
from freqtrade.optimize.hyperopt_interface import IHyperOpt # noqa: F401
|
||||
from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss # noqa: F401
|
||||
from freqtrade.optimize.hyperopt_tools import HyperoptTools
|
||||
from freqtrade.optimize.optimize_reports import generate_strategy_stats
|
||||
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver, HyperOptResolver
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
|
||||
# Suppress scikit-learn FutureWarnings from skopt
|
||||
@@ -64,6 +65,13 @@ class Hyperopt:
|
||||
custom_hyperopt: IHyperOpt
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
self.buy_space: List[Dimension] = []
|
||||
self.sell_space: List[Dimension] = []
|
||||
self.roi_space: List[Dimension] = []
|
||||
self.stoploss_space: List[Dimension] = []
|
||||
self.trailing_space: List[Dimension] = []
|
||||
self.dimensions: List[Dimension] = []
|
||||
|
||||
self.config = config
|
||||
|
||||
self.backtesting = Backtesting(self.config)
|
||||
@@ -72,15 +80,15 @@ class Hyperopt:
|
||||
self.custom_hyperopt = HyperOptAuto(self.config)
|
||||
else:
|
||||
self.custom_hyperopt = HyperOptResolver.load_hyperopt(self.config)
|
||||
self.backtesting._set_strategy(self.backtesting.strategylist[0])
|
||||
self.custom_hyperopt.strategy = self.backtesting.strategy
|
||||
|
||||
self.custom_hyperoptloss = HyperOptLossResolver.load_hyperoptloss(self.config)
|
||||
self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function
|
||||
time_now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
strategy = str(self.config['strategy'])
|
||||
self.results_file = (self.config['user_data_dir'] /
|
||||
'hyperopt_results' /
|
||||
f'strategy_{strategy}_hyperopt_results_{time_now}.pickle')
|
||||
self.results_file: Path = (self.config['user_data_dir'] / 'hyperopt_results' /
|
||||
f'strategy_{strategy}_{time_now}.fthypt')
|
||||
self.data_pickle_file = (self.config['user_data_dir'] /
|
||||
'hyperopt_results' / 'hyperopt_tickerdata.pkl')
|
||||
self.total_epochs = config.get('epochs', 0)
|
||||
@@ -90,9 +98,7 @@ class Hyperopt:
|
||||
self.clean_hyperopt()
|
||||
|
||||
self.num_epochs_saved = 0
|
||||
|
||||
# Previous evaluations
|
||||
self.epochs: List = []
|
||||
self.current_best_epoch: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Populate functions here (hasattr is slow so should not be run during "regular" operations)
|
||||
if hasattr(self.custom_hyperopt, 'populate_indicators'):
|
||||
@@ -113,7 +119,7 @@ class Hyperopt:
|
||||
self.max_open_trades = 0
|
||||
self.position_stacking = self.config.get('position_stacking', False)
|
||||
|
||||
if self.has_space('sell'):
|
||||
if HyperoptTools.has_space(self.config, 'sell'):
|
||||
# Make sure use_sell_signal is enabled
|
||||
if 'ask_strategy' not in self.config:
|
||||
self.config['ask_strategy'] = {}
|
||||
@@ -139,9 +145,7 @@ class Hyperopt:
|
||||
logger.info(f"Removing `{p}`.")
|
||||
p.unlink()
|
||||
|
||||
def _get_params_dict(self, raw_params: List[Any]) -> Dict:
|
||||
|
||||
dimensions: List[Dimension] = self.dimensions
|
||||
def _get_params_dict(self, dimensions: List[Dimension], raw_params: List[Any]) -> Dict:
|
||||
|
||||
# Ensure the number of dimensions match
|
||||
# the number of parameters in the list.
|
||||
@@ -152,21 +156,30 @@ class Hyperopt:
|
||||
# and the values are taken from the list of parameters.
|
||||
return {d.name: v for d, v in zip(dimensions, raw_params)}
|
||||
|
||||
def _save_results(self) -> None:
|
||||
def _save_result(self, epoch: Dict) -> None:
|
||||
"""
|
||||
Save hyperopt results to file
|
||||
Store one line per epoch.
|
||||
While not a valid json object - this allows appending easily.
|
||||
:param epoch: result dictionary for this epoch.
|
||||
"""
|
||||
num_epochs = len(self.epochs)
|
||||
if num_epochs > self.num_epochs_saved:
|
||||
logger.debug(f"Saving {num_epochs} {plural(num_epochs, 'epoch')}.")
|
||||
dump(self.epochs, self.results_file)
|
||||
self.num_epochs_saved = num_epochs
|
||||
logger.debug(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
|
||||
f"saved to '{self.results_file}'.")
|
||||
# Store hyperopt filename
|
||||
latest_filename = Path.joinpath(self.results_file.parent, LAST_BT_RESULT_FN)
|
||||
file_dump_json(latest_filename, {'latest_hyperopt': str(self.results_file.name)},
|
||||
log=False)
|
||||
def default_parser(x):
|
||||
if isinstance(x, np.integer):
|
||||
return int(x)
|
||||
return str(x)
|
||||
|
||||
with self.results_file.open('a') as f:
|
||||
rapidjson.dump(epoch, f, default=default_parser,
|
||||
number_mode=rapidjson.NM_NATIVE | rapidjson.NM_NAN)
|
||||
f.write("\n")
|
||||
|
||||
self.num_epochs_saved += 1
|
||||
logger.debug(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
|
||||
f"saved to '{self.results_file}'.")
|
||||
# Store hyperopt filename
|
||||
latest_filename = Path.joinpath(self.results_file.parent, LAST_BT_RESULT_FN)
|
||||
file_dump_json(latest_filename, {'latest_hyperopt': str(self.results_file.name)},
|
||||
log=False)
|
||||
|
||||
def _get_params_details(self, params: Dict) -> Dict:
|
||||
"""
|
||||
@@ -174,18 +187,16 @@ class Hyperopt:
|
||||
"""
|
||||
result: Dict = {}
|
||||
|
||||
if self.has_space('buy'):
|
||||
result['buy'] = {p.name: params.get(p.name)
|
||||
for p in self.hyperopt_space('buy')}
|
||||
if self.has_space('sell'):
|
||||
result['sell'] = {p.name: params.get(p.name)
|
||||
for p in self.hyperopt_space('sell')}
|
||||
if self.has_space('roi'):
|
||||
result['roi'] = self.custom_hyperopt.generate_roi_table(params)
|
||||
if self.has_space('stoploss'):
|
||||
result['stoploss'] = {p.name: params.get(p.name)
|
||||
for p in self.hyperopt_space('stoploss')}
|
||||
if self.has_space('trailing'):
|
||||
if HyperoptTools.has_space(self.config, 'buy'):
|
||||
result['buy'] = {p.name: params.get(p.name) for p in self.buy_space}
|
||||
if HyperoptTools.has_space(self.config, 'sell'):
|
||||
result['sell'] = {p.name: params.get(p.name) for p in self.sell_space}
|
||||
if HyperoptTools.has_space(self.config, 'roi'):
|
||||
result['roi'] = {str(k): v for k, v in
|
||||
self.custom_hyperopt.generate_roi_table(params).items()}
|
||||
if HyperoptTools.has_space(self.config, 'stoploss'):
|
||||
result['stoploss'] = {p.name: params.get(p.name) for p in self.stoploss_space}
|
||||
if HyperoptTools.has_space(self.config, 'trailing'):
|
||||
result['trailing'] = self.custom_hyperopt.generate_trailing_params(params)
|
||||
|
||||
return result
|
||||
@@ -207,71 +218,58 @@ class Hyperopt:
|
||||
)
|
||||
self.hyperopt_table_header = 2
|
||||
|
||||
def has_space(self, space: str) -> bool:
|
||||
def init_spaces(self):
|
||||
"""
|
||||
Tell if the space value is contained in the configuration
|
||||
Assign the dimensions in the hyperoptimization space.
|
||||
"""
|
||||
# The 'trailing' space is not included in the 'default' set of spaces
|
||||
if space == 'trailing':
|
||||
return any(s in self.config['spaces'] for s in [space, 'all'])
|
||||
else:
|
||||
return any(s in self.config['spaces'] for s in [space, 'all', 'default'])
|
||||
|
||||
def hyperopt_space(self, space: Optional[str] = None) -> List[Dimension]:
|
||||
"""
|
||||
Return the dimensions in the hyperoptimization space.
|
||||
:param space: Defines hyperspace to return dimensions for.
|
||||
If None, then the self.has_space() will be used to return dimensions
|
||||
for all hyperspaces used.
|
||||
"""
|
||||
spaces: List[Dimension] = []
|
||||
|
||||
if space == 'buy' or (space is None and self.has_space('buy')):
|
||||
if HyperoptTools.has_space(self.config, 'buy'):
|
||||
logger.debug("Hyperopt has 'buy' space")
|
||||
spaces += self.custom_hyperopt.indicator_space()
|
||||
self.buy_space = self.custom_hyperopt.indicator_space()
|
||||
|
||||
if space == 'sell' or (space is None and self.has_space('sell')):
|
||||
if HyperoptTools.has_space(self.config, 'sell'):
|
||||
logger.debug("Hyperopt has 'sell' space")
|
||||
spaces += self.custom_hyperopt.sell_indicator_space()
|
||||
self.sell_space = self.custom_hyperopt.sell_indicator_space()
|
||||
|
||||
if space == 'roi' or (space is None and self.has_space('roi')):
|
||||
if HyperoptTools.has_space(self.config, 'roi'):
|
||||
logger.debug("Hyperopt has 'roi' space")
|
||||
spaces += self.custom_hyperopt.roi_space()
|
||||
self.roi_space = self.custom_hyperopt.roi_space()
|
||||
|
||||
if space == 'stoploss' or (space is None and self.has_space('stoploss')):
|
||||
if HyperoptTools.has_space(self.config, 'stoploss'):
|
||||
logger.debug("Hyperopt has 'stoploss' space")
|
||||
spaces += self.custom_hyperopt.stoploss_space()
|
||||
self.stoploss_space = self.custom_hyperopt.stoploss_space()
|
||||
|
||||
if space == 'trailing' or (space is None and self.has_space('trailing')):
|
||||
if HyperoptTools.has_space(self.config, 'trailing'):
|
||||
logger.debug("Hyperopt has 'trailing' space")
|
||||
spaces += self.custom_hyperopt.trailing_space()
|
||||
|
||||
return spaces
|
||||
self.trailing_space = self.custom_hyperopt.trailing_space()
|
||||
self.dimensions = (self.buy_space + self.sell_space + self.roi_space +
|
||||
self.stoploss_space + self.trailing_space)
|
||||
|
||||
def generate_optimizer(self, raw_params: List[Any], iteration=None) -> Dict:
|
||||
"""
|
||||
Used Optimize function. Called once per epoch to optimize whatever is configured.
|
||||
Keep this function as optimized as possible!
|
||||
"""
|
||||
params_dict = self._get_params_dict(raw_params)
|
||||
params_details = self._get_params_details(params_dict)
|
||||
backtest_start_time = datetime.now(timezone.utc)
|
||||
params_dict = self._get_params_dict(self.dimensions, raw_params)
|
||||
|
||||
if self.has_space('roi'):
|
||||
# Apply parameters
|
||||
if HyperoptTools.has_space(self.config, 'roi'):
|
||||
self.backtesting.strategy.minimal_roi = ( # type: ignore
|
||||
self.custom_hyperopt.generate_roi_table(params_dict))
|
||||
|
||||
if self.has_space('buy'):
|
||||
if HyperoptTools.has_space(self.config, 'buy'):
|
||||
self.backtesting.strategy.advise_buy = ( # type: ignore
|
||||
self.custom_hyperopt.buy_strategy_generator(params_dict))
|
||||
|
||||
if self.has_space('sell'):
|
||||
if HyperoptTools.has_space(self.config, 'sell'):
|
||||
self.backtesting.strategy.advise_sell = ( # type: ignore
|
||||
self.custom_hyperopt.sell_strategy_generator(params_dict))
|
||||
|
||||
if self.has_space('stoploss'):
|
||||
if HyperoptTools.has_space(self.config, 'stoploss'):
|
||||
self.backtesting.strategy.stoploss = params_dict['stoploss']
|
||||
|
||||
if self.has_space('trailing'):
|
||||
if HyperoptTools.has_space(self.config, 'trailing'):
|
||||
d = self.custom_hyperopt.generate_trailing_params(params_dict)
|
||||
self.backtesting.strategy.trailing_stop = d['trailing_stop']
|
||||
self.backtesting.strategy.trailing_stop_positive = d['trailing_stop_positive']
|
||||
@@ -280,30 +278,42 @@ class Hyperopt:
|
||||
self.backtesting.strategy.trailing_only_offset_is_reached = \
|
||||
d['trailing_only_offset_is_reached']
|
||||
|
||||
processed = load(self.data_pickle_file)
|
||||
|
||||
min_date, max_date = get_timerange(processed)
|
||||
|
||||
backtesting_results = self.backtesting.backtest(
|
||||
with self.data_pickle_file.open('rb') as f:
|
||||
processed = load(f, mmap_mode='r')
|
||||
bt_results = self.backtesting.backtest(
|
||||
processed=processed,
|
||||
start_date=min_date.datetime,
|
||||
end_date=max_date.datetime,
|
||||
start_date=self.min_date,
|
||||
end_date=self.max_date,
|
||||
max_open_trades=self.max_open_trades,
|
||||
position_stacking=self.position_stacking,
|
||||
enable_protections=self.config.get('enable_protections', False),
|
||||
|
||||
)
|
||||
return self._get_results_dict(backtesting_results, min_date, max_date,
|
||||
params_dict, params_details,
|
||||
backtest_end_time = datetime.now(timezone.utc)
|
||||
bt_results.update({
|
||||
'backtest_start_time': int(backtest_start_time.timestamp()),
|
||||
'backtest_end_time': int(backtest_end_time.timestamp()),
|
||||
})
|
||||
|
||||
return self._get_results_dict(bt_results, self.min_date, self.max_date,
|
||||
params_dict,
|
||||
processed=processed)
|
||||
|
||||
def _get_results_dict(self, backtesting_results, min_date, max_date,
|
||||
params_dict, params_details, processed: Dict[str, DataFrame]):
|
||||
results_metrics = self._calculate_results_metrics(backtesting_results)
|
||||
results_explanation = self._format_results_explanation_string(results_metrics)
|
||||
params_dict, processed: Dict[str, DataFrame]
|
||||
) -> Dict[str, Any]:
|
||||
params_details = self._get_params_details(params_dict)
|
||||
|
||||
trade_count = results_metrics['trade_count']
|
||||
total_profit = results_metrics['total_profit']
|
||||
strat_stats = generate_strategy_stats(
|
||||
processed, self.backtesting.strategy.get_strategy_name(),
|
||||
backtesting_results, min_date, max_date, market_change=0
|
||||
)
|
||||
results_explanation = HyperoptTools.format_results_explanation_string(
|
||||
strat_stats, self.config['stake_currency'])
|
||||
|
||||
not_optimized = self.backtesting.strategy.get_params_dict()
|
||||
|
||||
trade_count = strat_stats['total_trades']
|
||||
total_profit = strat_stats['profit_total']
|
||||
|
||||
# If this evaluation contains too short amount of trades to be
|
||||
# interesting -- consider it as 'bad' (assigned max. loss value)
|
||||
@@ -311,50 +321,20 @@ class Hyperopt:
|
||||
# path. We do not want to optimize 'hodl' strategies.
|
||||
loss: float = MAX_LOSS
|
||||
if trade_count >= self.config['hyperopt_min_trades']:
|
||||
loss = self.calculate_loss(results=backtesting_results, trade_count=trade_count,
|
||||
min_date=min_date.datetime, max_date=max_date.datetime,
|
||||
loss = self.calculate_loss(results=backtesting_results['results'],
|
||||
trade_count=trade_count,
|
||||
min_date=min_date, max_date=max_date,
|
||||
config=self.config, processed=processed)
|
||||
return {
|
||||
'loss': loss,
|
||||
'params_dict': params_dict,
|
||||
'params_details': params_details,
|
||||
'results_metrics': results_metrics,
|
||||
'params_not_optimized': not_optimized,
|
||||
'results_metrics': strat_stats,
|
||||
'results_explanation': results_explanation,
|
||||
'total_profit': total_profit,
|
||||
}
|
||||
|
||||
def _calculate_results_metrics(self, backtesting_results: DataFrame) -> Dict:
|
||||
wins = len(backtesting_results[backtesting_results['profit_ratio'] > 0])
|
||||
draws = len(backtesting_results[backtesting_results['profit_ratio'] == 0])
|
||||
losses = len(backtesting_results[backtesting_results['profit_ratio'] < 0])
|
||||
return {
|
||||
'trade_count': len(backtesting_results.index),
|
||||
'wins': wins,
|
||||
'draws': draws,
|
||||
'losses': losses,
|
||||
'winsdrawslosses': f"{wins:>4} {draws:>4} {losses:>4}",
|
||||
'avg_profit': backtesting_results['profit_ratio'].mean() * 100.0,
|
||||
'median_profit': backtesting_results['profit_ratio'].median() * 100.0,
|
||||
'total_profit': backtesting_results['profit_abs'].sum(),
|
||||
'profit': backtesting_results['profit_ratio'].sum() * 100.0,
|
||||
'duration': backtesting_results['trade_duration'].mean(),
|
||||
}
|
||||
|
||||
def _format_results_explanation_string(self, results_metrics: Dict) -> str:
|
||||
"""
|
||||
Return the formatted results explanation in a string
|
||||
"""
|
||||
stake_cur = self.config['stake_currency']
|
||||
return (f"{results_metrics['trade_count']:6d} trades. "
|
||||
f"{results_metrics['wins']}/{results_metrics['draws']}"
|
||||
f"/{results_metrics['losses']} Wins/Draws/Losses. "
|
||||
f"Avg profit {results_metrics['avg_profit']: 6.2f}%. "
|
||||
f"Median profit {results_metrics['median_profit']: 6.2f}%. "
|
||||
f"Total profit {results_metrics['total_profit']: 11.8f} {stake_cur} "
|
||||
f"({results_metrics['profit']: 7.2f}%). "
|
||||
f"Avg duration {results_metrics['duration']:5.1f} min."
|
||||
)
|
||||
|
||||
def get_optimizer(self, dimensions: List[Dimension], cpu_count) -> Optimizer:
|
||||
return Optimizer(
|
||||
dimensions,
|
||||
@@ -373,25 +353,31 @@ class Hyperopt:
|
||||
def _set_random_state(self, random_state: Optional[int]) -> int:
|
||||
return random_state or random.randint(1, 2**16 - 1)
|
||||
|
||||
def prepare_hyperopt_data(self) -> None:
|
||||
data, timerange = self.backtesting.load_bt_data()
|
||||
logger.info("Dataload complete. Calculating indicators")
|
||||
|
||||
preprocessed = self.backtesting.strategy.ohlcvdata_to_dataframe(data)
|
||||
|
||||
# Trim startup period from analyzed dataframe
|
||||
processed = trim_dataframes(preprocessed, timerange, self.backtesting.required_startup)
|
||||
|
||||
self.min_date, self.max_date = get_timerange(processed)
|
||||
|
||||
logger.info(f'Hyperopting with data from {self.min_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'up to {self.max_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'({(self.max_date - self.min_date).days} days)..')
|
||||
|
||||
dump(processed, self.data_pickle_file)
|
||||
|
||||
def start(self) -> None:
|
||||
self.random_state = self._set_random_state(self.config.get('hyperopt_random_state', None))
|
||||
logger.info(f"Using optimizer random state: {self.random_state}")
|
||||
self.hyperopt_table_header = -1
|
||||
data, timerange = self.backtesting.load_bt_data()
|
||||
logger.info("Dataload complete. Calculating indicators")
|
||||
preprocessed = self.backtesting.strategy.ohlcvdata_to_dataframe(data)
|
||||
# Initialize spaces ...
|
||||
self.init_spaces()
|
||||
|
||||
# Trim startup period from analyzed dataframe
|
||||
for pair, df in preprocessed.items():
|
||||
preprocessed[pair] = trim_dataframe(df, timerange,
|
||||
startup_candles=self.backtesting.required_startup)
|
||||
min_date, max_date = get_timerange(preprocessed)
|
||||
|
||||
logger.info(f'Hyperopting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'({(max_date - min_date).days} days)..')
|
||||
|
||||
dump(preprocessed, self.data_pickle_file)
|
||||
self.prepare_hyperopt_data()
|
||||
|
||||
# We don't need exchange instance anymore while running hyperopt
|
||||
self.backtesting.exchange.close()
|
||||
@@ -399,15 +385,12 @@ class Hyperopt:
|
||||
self.backtesting.exchange._api_async = None # type: ignore
|
||||
# self.backtesting.exchange = None # type: ignore
|
||||
self.backtesting.pairlists = None # type: ignore
|
||||
self.backtesting.strategy.dp = None # type: ignore
|
||||
IStrategy.dp = None # type: ignore
|
||||
|
||||
cpus = cpu_count()
|
||||
logger.info(f"Found {cpus} CPU cores. Let's make them scream!")
|
||||
config_jobs = self.config.get('hyperopt_jobs', -1)
|
||||
logger.info(f'Number of parallel jobs set as: {config_jobs}')
|
||||
|
||||
self.dimensions: List[Dimension] = self.hyperopt_space()
|
||||
self.opt = self.get_optimizer(self.dimensions, config_jobs)
|
||||
|
||||
if self.print_colorized:
|
||||
@@ -473,25 +456,21 @@ class Hyperopt:
|
||||
|
||||
if is_best:
|
||||
self.current_best_loss = val['loss']
|
||||
self.epochs.append(val)
|
||||
self.current_best_epoch = val
|
||||
|
||||
# Save results after each best epoch and every 100 epochs
|
||||
if is_best or current % 100 == 0:
|
||||
self._save_results()
|
||||
self._save_result(val)
|
||||
|
||||
pbar.update(current)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print('User interrupted..')
|
||||
|
||||
self._save_results()
|
||||
logger.info(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
|
||||
f"saved to '{self.results_file}'.")
|
||||
|
||||
if self.epochs:
|
||||
sorted_epochs = sorted(self.epochs, key=itemgetter('loss'))
|
||||
best_epoch = sorted_epochs[0]
|
||||
HyperoptTools.print_epoch_details(best_epoch, self.total_epochs, self.print_json)
|
||||
if self.current_best_epoch:
|
||||
HyperoptTools.print_epoch_details(self.current_best_epoch, self.total_epochs,
|
||||
self.print_json)
|
||||
else:
|
||||
# This is printed when Ctrl+C is pressed quickly, before first epochs have
|
||||
# a chance to be evaluated.
|
||||
|
@@ -3,17 +3,15 @@ import io
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from pprint import pformat
|
||||
from typing import Dict, List
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import rapidjson
|
||||
import tabulate
|
||||
from colorama import Fore, Style
|
||||
from joblib import load
|
||||
from pandas import isna, json_normalize
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import round_dict
|
||||
from freqtrade.misc import round_coin_value, round_dict
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,13 +19,38 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class HyperoptTools():
|
||||
|
||||
@staticmethod
|
||||
def has_space(config: Dict[str, Any], space: str) -> bool:
|
||||
"""
|
||||
Tell if the space value is contained in the configuration
|
||||
"""
|
||||
# The 'trailing' space is not included in the 'default' set of spaces
|
||||
if space == 'trailing':
|
||||
return any(s in config['spaces'] for s in [space, 'all'])
|
||||
else:
|
||||
return any(s in config['spaces'] for s in [space, 'all', 'default'])
|
||||
|
||||
@staticmethod
|
||||
def _read_results_pickle(results_file: Path) -> List:
|
||||
"""
|
||||
Read hyperopt results from pickle file
|
||||
LEGACY method - new files are written as json and cannot be read with this method.
|
||||
"""
|
||||
from joblib import load
|
||||
|
||||
logger.info(f"Reading pickled epochs from '{results_file}'")
|
||||
data = load(results_file)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _read_results(results_file: Path) -> List:
|
||||
"""
|
||||
Read hyperopt results from file
|
||||
"""
|
||||
logger.info("Reading epochs from '%s'", results_file)
|
||||
data = load(results_file)
|
||||
import rapidjson
|
||||
logger.info(f"Reading epochs from '{results_file}'")
|
||||
with results_file.open('r') as f:
|
||||
data = [rapidjson.loads(line) for line in f]
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
@@ -37,7 +60,10 @@ class HyperoptTools():
|
||||
"""
|
||||
epochs: List = []
|
||||
if results_file.is_file() and results_file.stat().st_size > 0:
|
||||
epochs = HyperoptTools._read_results(results_file)
|
||||
if results_file.suffix == '.pickle':
|
||||
epochs = HyperoptTools._read_results_pickle(results_file)
|
||||
else:
|
||||
epochs = HyperoptTools._read_results(results_file)
|
||||
# Detection of some old format, without 'is_best' field saved
|
||||
if epochs[0].get('is_best') is None:
|
||||
raise OperationalException(
|
||||
@@ -53,6 +79,7 @@ class HyperoptTools():
|
||||
Display details of the hyperopt result
|
||||
"""
|
||||
params = results.get('params_details', {})
|
||||
non_optimized = results.get('params_not_optimized', {})
|
||||
|
||||
# Default header string
|
||||
if header_str is None:
|
||||
@@ -69,8 +96,10 @@ class HyperoptTools():
|
||||
print(rapidjson.dumps(result_dict, default=str, number_mode=rapidjson.NM_NATIVE))
|
||||
|
||||
else:
|
||||
HyperoptTools._params_pretty_print(params, 'buy', "Buy hyperspace params:")
|
||||
HyperoptTools._params_pretty_print(params, 'sell', "Sell hyperspace params:")
|
||||
HyperoptTools._params_pretty_print(params, 'buy', "Buy hyperspace params:",
|
||||
non_optimized)
|
||||
HyperoptTools._params_pretty_print(params, 'sell', "Sell hyperspace params:",
|
||||
non_optimized)
|
||||
HyperoptTools._params_pretty_print(params, 'roi', "ROI table:")
|
||||
HyperoptTools._params_pretty_print(params, 'stoploss', "Stoploss:")
|
||||
HyperoptTools._params_pretty_print(params, 'trailing', "Trailing stop:")
|
||||
@@ -96,12 +125,12 @@ class HyperoptTools():
|
||||
result_dict.update(space_params)
|
||||
|
||||
@staticmethod
|
||||
def _params_pretty_print(params, space: str, header: str) -> None:
|
||||
if space in params:
|
||||
def _params_pretty_print(params, space: str, header: str, non_optimized={}) -> None:
|
||||
if space in params or space in non_optimized:
|
||||
space_params = HyperoptTools._space_params(params, space, 5)
|
||||
params_result = f"\n# {header}\n"
|
||||
result = f"\n# {header}\n"
|
||||
if space == 'stoploss':
|
||||
params_result += f"stoploss = {space_params.get('stoploss')}"
|
||||
result += f"stoploss = {space_params.get('stoploss')}"
|
||||
elif space == 'roi':
|
||||
# TODO: get rid of OrderedDict when support for python 3.6 will be
|
||||
# dropped (dicts keep the order as the language feature)
|
||||
@@ -110,28 +139,64 @@ class HyperoptTools():
|
||||
(str(k), v) for k, v in space_params.items()
|
||||
),
|
||||
default=str, indent=4, number_mode=rapidjson.NM_NATIVE)
|
||||
params_result += f"minimal_roi = {minimal_roi_result}"
|
||||
result += f"minimal_roi = {minimal_roi_result}"
|
||||
elif space == 'trailing':
|
||||
|
||||
for k, v in space_params.items():
|
||||
params_result += f'{k} = {v}\n'
|
||||
result += f'{k} = {v}\n'
|
||||
|
||||
else:
|
||||
params_result += f"{space}_params = {pformat(space_params, indent=4)}"
|
||||
params_result = params_result.replace("}", "\n}").replace("{", "{\n ")
|
||||
no_params = HyperoptTools._space_params(non_optimized, space, 5)
|
||||
|
||||
params_result = params_result.replace("\n", "\n ")
|
||||
print(params_result)
|
||||
result += f"{space}_params = {HyperoptTools._pprint(space_params, no_params)}"
|
||||
|
||||
result = result.replace("\n", "\n ")
|
||||
print(result)
|
||||
|
||||
@staticmethod
|
||||
def _space_params(params, space: str, r: int = None) -> Dict:
|
||||
d = params[space]
|
||||
# Round floats to `r` digits after the decimal point if requested
|
||||
return round_dict(d, r) if r else d
|
||||
d = params.get(space)
|
||||
if d:
|
||||
# Round floats to `r` digits after the decimal point if requested
|
||||
return round_dict(d, r) if r else d
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _pprint(params, non_optimized, indent: int = 4):
|
||||
"""
|
||||
Pretty-print hyperopt results (based on 2 dicts - with add. comment)
|
||||
"""
|
||||
p = params.copy()
|
||||
p.update(non_optimized)
|
||||
result = '{\n'
|
||||
|
||||
for k, param in p.items():
|
||||
result += " " * indent + f'"{k}": '
|
||||
result += f'"{param}",' if isinstance(param, str) else f'{param},'
|
||||
if k in non_optimized:
|
||||
result += " # value loaded from strategy"
|
||||
result += "\n"
|
||||
result += '}'
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def is_best_loss(results, current_best_loss: float) -> bool:
|
||||
return results['loss'] < current_best_loss
|
||||
return bool(results['loss'] < current_best_loss)
|
||||
|
||||
@staticmethod
|
||||
def format_results_explanation_string(results_metrics: Dict, stake_currency: str) -> str:
|
||||
"""
|
||||
Return the formatted results explanation in a string
|
||||
"""
|
||||
return (f"{results_metrics['total_trades']:6d} trades. "
|
||||
f"{results_metrics['wins']}/{results_metrics['draws']}"
|
||||
f"/{results_metrics['losses']} Wins/Draws/Losses. "
|
||||
f"Avg profit {results_metrics['profit_mean'] * 100: 6.2f}%. "
|
||||
f"Median profit {results_metrics['profit_median'] * 100: 6.2f}%. "
|
||||
f"Total profit {results_metrics['profit_total_abs']: 11.8f} {stake_currency} "
|
||||
f"({results_metrics['profit_total'] * 100: 7.2f}%). "
|
||||
f"Avg duration {results_metrics['holding_avg']} min."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_explanation_string(results, total_epochs) -> str:
|
||||
@@ -156,12 +221,27 @@ class HyperoptTools():
|
||||
if 'results_metrics.winsdrawslosses' not in trials.columns:
|
||||
# Ensure compatibility with older versions of hyperopt results
|
||||
trials['results_metrics.winsdrawslosses'] = 'N/A'
|
||||
legacy_mode = True
|
||||
|
||||
if 'results_metrics.total_trades' in trials:
|
||||
legacy_mode = False
|
||||
# New mode, using backtest result for metrics
|
||||
trials['results_metrics.winsdrawslosses'] = trials.apply(
|
||||
lambda x: f"{x['results_metrics.wins']} {x['results_metrics.draws']:>4} "
|
||||
f"{x['results_metrics.losses']:>4}", axis=1)
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.total_trades',
|
||||
'results_metrics.winsdrawslosses',
|
||||
'results_metrics.profit_mean', 'results_metrics.profit_total_abs',
|
||||
'results_metrics.profit_total', 'results_metrics.holding_avg',
|
||||
'loss', 'is_initial_point', 'is_best']]
|
||||
else:
|
||||
# Legacy mode
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count',
|
||||
'results_metrics.winsdrawslosses',
|
||||
'results_metrics.avg_profit', 'results_metrics.total_profit',
|
||||
'results_metrics.profit', 'results_metrics.duration',
|
||||
'loss', 'is_initial_point', 'is_best']]
|
||||
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count',
|
||||
'results_metrics.winsdrawslosses',
|
||||
'results_metrics.avg_profit', 'results_metrics.total_profit',
|
||||
'results_metrics.profit', 'results_metrics.duration',
|
||||
'loss', 'is_initial_point', 'is_best']]
|
||||
trials.columns = ['Best', 'Epoch', 'Trades', ' Win Draw Loss', 'Avg profit',
|
||||
'Total profit', 'Profit', 'Avg duration', 'Objective',
|
||||
'is_initial_point', 'is_best']
|
||||
@@ -171,26 +251,28 @@ class HyperoptTools():
|
||||
trials.loc[trials['is_initial_point'] & trials['is_best'], 'Best'] = '* Best'
|
||||
trials.loc[trials['Total profit'] > 0, 'is_profit'] = True
|
||||
trials['Trades'] = trials['Trades'].astype(str)
|
||||
|
||||
perc_multi = 1 if legacy_mode else 100
|
||||
trials['Epoch'] = trials['Epoch'].apply(
|
||||
lambda x: '{}/{}'.format(str(x).rjust(len(str(total_epochs)), ' '), total_epochs)
|
||||
)
|
||||
trials['Avg profit'] = trials['Avg profit'].apply(
|
||||
lambda x: '{:,.2f}%'.format(x).rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
|
||||
lambda x: f'{x * perc_multi:,.2f}%'.rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
|
||||
)
|
||||
trials['Avg duration'] = trials['Avg duration'].apply(
|
||||
lambda x: '{:,.1f} m'.format(x).rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
|
||||
lambda x: f'{x:,.1f} m'.rjust(7, ' ') if isinstance(x, float) else f"{x}"
|
||||
if not isna(x) else "--".rjust(7, ' ')
|
||||
)
|
||||
trials['Objective'] = trials['Objective'].apply(
|
||||
lambda x: '{:,.5f}'.format(x).rjust(8, ' ') if x != 100000 else "N/A".rjust(8, ' ')
|
||||
lambda x: f'{x:,.5f}'.rjust(8, ' ') if x != 100000 else "N/A".rjust(8, ' ')
|
||||
)
|
||||
|
||||
stake_currency = config['stake_currency']
|
||||
trials['Profit'] = trials.apply(
|
||||
lambda x: '{:,.8f} {} {}'.format(
|
||||
x['Total profit'], config['stake_currency'],
|
||||
'({:,.2f}%)'.format(x['Profit']).rjust(10, ' ')
|
||||
).rjust(25+len(config['stake_currency']))
|
||||
if x['Total profit'] != 0.0 else '--'.rjust(25+len(config['stake_currency'])),
|
||||
lambda x: '{} {}'.format(
|
||||
round_coin_value(x['Total profit'], stake_currency),
|
||||
'({:,.2f}%)'.format(x['Profit'] * perc_multi).rjust(10, ' ')
|
||||
).rjust(25+len(stake_currency))
|
||||
if x['Total profit'] != 0.0 else '--'.rjust(25+len(stake_currency)),
|
||||
axis=1
|
||||
)
|
||||
trials = trials.drop(columns=['Total profit'])
|
||||
@@ -251,11 +333,21 @@ class HyperoptTools():
|
||||
trials['Best'] = ''
|
||||
trials['Stake currency'] = config['stake_currency']
|
||||
|
||||
base_metrics = ['Best', 'current_epoch', 'results_metrics.trade_count',
|
||||
'results_metrics.avg_profit', 'results_metrics.median_profit',
|
||||
'results_metrics.total_profit',
|
||||
'Stake currency', 'results_metrics.profit', 'results_metrics.duration',
|
||||
'loss', 'is_initial_point', 'is_best']
|
||||
if 'results_metrics.total_trades' in trials:
|
||||
base_metrics = ['Best', 'current_epoch', 'results_metrics.total_trades',
|
||||
'results_metrics.profit_mean', 'results_metrics.profit_median',
|
||||
'results_metrics.profit_total',
|
||||
'Stake currency',
|
||||
'results_metrics.profit_total_abs', 'results_metrics.holding_avg',
|
||||
'loss', 'is_initial_point', 'is_best']
|
||||
perc_multi = 100
|
||||
else:
|
||||
perc_multi = 1
|
||||
base_metrics = ['Best', 'current_epoch', 'results_metrics.trade_count',
|
||||
'results_metrics.avg_profit', 'results_metrics.median_profit',
|
||||
'results_metrics.total_profit',
|
||||
'Stake currency', 'results_metrics.profit', 'results_metrics.duration',
|
||||
'loss', 'is_initial_point', 'is_best']
|
||||
param_metrics = [("params_dict."+param) for param in results[0]['params_dict'].keys()]
|
||||
trials = trials[base_metrics + param_metrics]
|
||||
|
||||
@@ -272,21 +364,24 @@ class HyperoptTools():
|
||||
trials.loc[trials['Total profit'] > 0, 'is_profit'] = True
|
||||
trials['Epoch'] = trials['Epoch'].astype(str)
|
||||
trials['Trades'] = trials['Trades'].astype(str)
|
||||
trials['Median profit'] = trials['Median profit'] * perc_multi
|
||||
|
||||
trials['Total profit'] = trials['Total profit'].apply(
|
||||
lambda x: '{:,.8f}'.format(x) if x != 0.0 else ""
|
||||
lambda x: f'{x:,.8f}' if x != 0.0 else ""
|
||||
)
|
||||
trials['Profit'] = trials['Profit'].apply(
|
||||
lambda x: '{:,.2f}'.format(x) if not isna(x) else ""
|
||||
lambda x: f'{x:,.2f}' if not isna(x) else ""
|
||||
)
|
||||
trials['Avg profit'] = trials['Avg profit'].apply(
|
||||
lambda x: '{:,.2f}%'.format(x) if not isna(x) else ""
|
||||
)
|
||||
trials['Avg duration'] = trials['Avg duration'].apply(
|
||||
lambda x: '{:,.1f} m'.format(x) if not isna(x) else ""
|
||||
lambda x: f'{x * perc_multi:,.2f}%' if not isna(x) else ""
|
||||
)
|
||||
if perc_multi == 1:
|
||||
trials['Avg duration'] = trials['Avg duration'].apply(
|
||||
lambda x: f'{x:,.1f} m' if isinstance(
|
||||
x, float) else f"{x.total_seconds() // 60:,.1f} m" if not isna(x) else ""
|
||||
)
|
||||
trials['Objective'] = trials['Objective'].apply(
|
||||
lambda x: '{:,.5f}'.format(x) if x != 100000 else ""
|
||||
lambda x: f'{x:,.5f}' if x != 100000 else ""
|
||||
)
|
||||
|
||||
trials = trials.drop(columns=['is_initial_point', 'is_best', 'is_profit'])
|
||||
|
@@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from arrow import Arrow
|
||||
from numpy import int64
|
||||
from pandas import DataFrame
|
||||
from tabulate import tabulate
|
||||
@@ -44,7 +43,7 @@ def _get_line_floatfmt(stake_currency: str) -> List[str]:
|
||||
Generate floatformat (goes in line with _generate_result_line())
|
||||
"""
|
||||
return ['s', 'd', '.2f', '.2f', f'.{decimals_per_coin(stake_currency)}f',
|
||||
'.2f', 'd', 'd', 'd', 'd']
|
||||
'.2f', 'd', 's', 's']
|
||||
|
||||
|
||||
def _get_line_header(first_column: str, stake_currency: str) -> List[str]:
|
||||
@@ -53,7 +52,17 @@ def _get_line_header(first_column: str, stake_currency: str) -> List[str]:
|
||||
"""
|
||||
return [first_column, 'Buys', 'Avg Profit %', 'Cum Profit %',
|
||||
f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration',
|
||||
'Wins', 'Draws', 'Losses']
|
||||
'Win Draw Loss Win%']
|
||||
|
||||
|
||||
def _generate_wins_draws_losses(wins, draws, losses):
|
||||
if wins > 0 and losses == 0:
|
||||
wl_ratio = '100'
|
||||
elif wins == 0:
|
||||
wl_ratio = '0'
|
||||
else:
|
||||
wl_ratio = f'{100.0 / (wins + draws + losses) * wins:.1f}' if losses > 0 else '100'
|
||||
return f'{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}'
|
||||
|
||||
|
||||
def _generate_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict:
|
||||
@@ -153,7 +162,7 @@ def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List
|
||||
return tabular_data
|
||||
|
||||
|
||||
def generate_strategy_metrics(all_results: Dict) -> List[Dict]:
|
||||
def generate_strategy_comparison(all_results: Dict) -> List[Dict]:
|
||||
"""
|
||||
Generate summary per strategy
|
||||
:param all_results: Dict of <Strategyname: DataFrame> containing results for all strategies
|
||||
@@ -165,6 +174,17 @@ def generate_strategy_metrics(all_results: Dict) -> List[Dict]:
|
||||
tabular_data.append(_generate_result_line(
|
||||
results['results'], results['config']['dry_run_wallet'], strategy)
|
||||
)
|
||||
try:
|
||||
max_drawdown_per, _, _, _, _ = calculate_max_drawdown(results['results'],
|
||||
value_col='profit_ratio')
|
||||
max_drawdown_abs, _, _, _, _ = calculate_max_drawdown(results['results'],
|
||||
value_col='profit_abs')
|
||||
except ValueError:
|
||||
max_drawdown_per = 0
|
||||
max_drawdown_abs = 0
|
||||
tabular_data[-1]['max_drawdown_per'] = round(max_drawdown_per * 100, 2)
|
||||
tabular_data[-1]['max_drawdown_abs'] = \
|
||||
round_coin_value(max_drawdown_abs, results['config']['stake_currency'], False)
|
||||
return tabular_data
|
||||
|
||||
|
||||
@@ -194,7 +214,40 @@ def generate_edge_table(results: dict) -> str:
|
||||
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore
|
||||
|
||||
|
||||
def generate_trading_stats(results: DataFrame) -> Dict[str, Any]:
|
||||
""" Generate overall trade statistics """
|
||||
if len(results) == 0:
|
||||
return {
|
||||
'wins': 0,
|
||||
'losses': 0,
|
||||
'draws': 0,
|
||||
'holding_avg': timedelta(),
|
||||
'winner_holding_avg': timedelta(),
|
||||
'loser_holding_avg': timedelta(),
|
||||
}
|
||||
|
||||
winning_trades = results.loc[results['profit_ratio'] > 0]
|
||||
draw_trades = results.loc[results['profit_ratio'] == 0]
|
||||
losing_trades = results.loc[results['profit_ratio'] < 0]
|
||||
zero_duration_trades = len(results.loc[(results['trade_duration'] == 0) &
|
||||
(results['sell_reason'] == 'trailing_stop_loss')])
|
||||
|
||||
return {
|
||||
'wins': len(winning_trades),
|
||||
'losses': len(losing_trades),
|
||||
'draws': len(draw_trades),
|
||||
'holding_avg': (timedelta(minutes=round(results['trade_duration'].mean()))
|
||||
if not results.empty else timedelta()),
|
||||
'winner_holding_avg': (timedelta(minutes=round(winning_trades['trade_duration'].mean()))
|
||||
if not winning_trades.empty else timedelta()),
|
||||
'loser_holding_avg': (timedelta(minutes=round(losing_trades['trade_duration'].mean()))
|
||||
if not losing_trades.empty else timedelta()),
|
||||
'zero_duration_trades': zero_duration_trades,
|
||||
}
|
||||
|
||||
|
||||
def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
|
||||
""" Generate daily statistics """
|
||||
if len(results) == 0:
|
||||
return {
|
||||
'backtest_best_day': 0,
|
||||
@@ -204,8 +257,6 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
|
||||
'winning_days': 0,
|
||||
'draw_days': 0,
|
||||
'losing_days': 0,
|
||||
'winner_holding_avg': timedelta(),
|
||||
'loser_holding_avg': timedelta(),
|
||||
}
|
||||
daily_profit_rel = results.resample('1d', on='close_date')['profit_ratio'].sum()
|
||||
daily_profit = results.resample('1d', on='close_date')['profit_abs'].sum().round(10)
|
||||
@@ -217,9 +268,6 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
|
||||
draw_days = sum(daily_profit == 0)
|
||||
losing_days = sum(daily_profit < 0)
|
||||
|
||||
winning_trades = results.loc[results['profit_ratio'] > 0]
|
||||
losing_trades = results.loc[results['profit_ratio'] < 0]
|
||||
|
||||
return {
|
||||
'backtest_best_day': best_rel,
|
||||
'backtest_worst_day': worst_rel,
|
||||
@@ -228,16 +276,152 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
|
||||
'winning_days': winning_days,
|
||||
'draw_days': draw_days,
|
||||
'losing_days': losing_days,
|
||||
'winner_holding_avg': (timedelta(minutes=round(winning_trades['trade_duration'].mean()))
|
||||
if not winning_trades.empty else timedelta()),
|
||||
'loser_holding_avg': (timedelta(minutes=round(losing_trades['trade_duration'].mean()))
|
||||
if not losing_trades.empty else timedelta()),
|
||||
}
|
||||
|
||||
|
||||
def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
strategy: str,
|
||||
content: Dict[str, Any],
|
||||
min_date: datetime, max_date: datetime,
|
||||
market_change: float
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
:param btdata: Backtest data
|
||||
:param strategy: Strategy name
|
||||
:param content: Backtest result data in the format:
|
||||
{'results: results, 'config: config}}.
|
||||
:param min_date: Backtest start date
|
||||
:param max_date: Backtest end date
|
||||
:param market_change: float indicating the market change
|
||||
:return: Dictionary containing results per strategy and a stratgy summary.
|
||||
"""
|
||||
results: Dict[str, DataFrame] = content['results']
|
||||
if not isinstance(results, DataFrame):
|
||||
return {}
|
||||
config = content['config']
|
||||
max_open_trades = min(config['max_open_trades'], len(btdata.keys()))
|
||||
starting_balance = config['dry_run_wallet']
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
|
||||
starting_balance=starting_balance,
|
||||
results=results, skip_nan=False)
|
||||
sell_reason_stats = generate_sell_reason_stats(max_open_trades=max_open_trades,
|
||||
results=results)
|
||||
left_open_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
|
||||
starting_balance=starting_balance,
|
||||
results=results.loc[results['is_open']],
|
||||
skip_nan=True)
|
||||
daily_stats = generate_daily_stats(results)
|
||||
trade_stats = generate_trading_stats(results)
|
||||
best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'],
|
||||
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
|
||||
worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'],
|
||||
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
|
||||
results['open_timestamp'] = results['open_date'].astype(int64) // 1e6
|
||||
results['close_timestamp'] = results['close_date'].astype(int64) // 1e6
|
||||
|
||||
backtest_days = (max_date - min_date).days
|
||||
strat_stats = {
|
||||
'trades': results.to_dict(orient='records'),
|
||||
'locks': [lock.to_json() for lock in content['locks']],
|
||||
'best_pair': best_pair,
|
||||
'worst_pair': worst_pair,
|
||||
'results_per_pair': pair_results,
|
||||
'sell_reason_summary': sell_reason_stats,
|
||||
'left_open_trades': left_open_results,
|
||||
'total_trades': len(results),
|
||||
'total_volume': float(results['stake_amount'].sum()),
|
||||
'avg_stake_amount': results['stake_amount'].mean() if len(results) > 0 else 0,
|
||||
'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0,
|
||||
'profit_median': results['profit_ratio'].median() if len(results) > 0 else 0,
|
||||
'profit_total': results['profit_abs'].sum() / starting_balance,
|
||||
'profit_total_abs': results['profit_abs'].sum(),
|
||||
'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT),
|
||||
'backtest_start_ts': int(min_date.timestamp() * 1000),
|
||||
'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT),
|
||||
'backtest_end_ts': int(max_date.timestamp() * 1000),
|
||||
'backtest_days': backtest_days,
|
||||
|
||||
'backtest_run_start_ts': content['backtest_start_time'],
|
||||
'backtest_run_end_ts': content['backtest_end_time'],
|
||||
|
||||
'trades_per_day': round(len(results) / backtest_days, 2) if backtest_days > 0 else 0,
|
||||
'market_change': market_change,
|
||||
'pairlist': list(btdata.keys()),
|
||||
'stake_amount': config['stake_amount'],
|
||||
'stake_currency': config['stake_currency'],
|
||||
'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
|
||||
'starting_balance': starting_balance,
|
||||
'dry_run_wallet': starting_balance,
|
||||
'final_balance': content['final_balance'],
|
||||
'rejected_signals': content['rejected_signals'],
|
||||
'max_open_trades': max_open_trades,
|
||||
'max_open_trades_setting': (config['max_open_trades']
|
||||
if config['max_open_trades'] != float('inf') else -1),
|
||||
'timeframe': config['timeframe'],
|
||||
'timerange': config.get('timerange', ''),
|
||||
'enable_protections': config.get('enable_protections', False),
|
||||
'strategy_name': strategy,
|
||||
# Parameters relevant for backtesting
|
||||
'stoploss': config['stoploss'],
|
||||
'trailing_stop': config.get('trailing_stop', False),
|
||||
'trailing_stop_positive': config.get('trailing_stop_positive'),
|
||||
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0),
|
||||
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False),
|
||||
'use_custom_stoploss': config.get('use_custom_stoploss', False),
|
||||
'minimal_roi': config['minimal_roi'],
|
||||
'use_sell_signal': config['ask_strategy']['use_sell_signal'],
|
||||
'sell_profit_only': config['ask_strategy']['sell_profit_only'],
|
||||
'sell_profit_offset': config['ask_strategy']['sell_profit_offset'],
|
||||
'ignore_roi_if_buy_signal': config['ask_strategy']['ignore_roi_if_buy_signal'],
|
||||
**daily_stats,
|
||||
**trade_stats
|
||||
}
|
||||
|
||||
try:
|
||||
max_drawdown, _, _, _, _ = calculate_max_drawdown(
|
||||
results, value_col='profit_ratio')
|
||||
drawdown_abs, drawdown_start, drawdown_end, high_val, low_val = calculate_max_drawdown(
|
||||
results, value_col='profit_abs')
|
||||
strat_stats.update({
|
||||
'max_drawdown': max_drawdown,
|
||||
'max_drawdown_abs': drawdown_abs,
|
||||
'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT),
|
||||
'drawdown_start_ts': drawdown_start.timestamp() * 1000,
|
||||
'drawdown_end': drawdown_end.strftime(DATETIME_PRINT_FORMAT),
|
||||
'drawdown_end_ts': drawdown_end.timestamp() * 1000,
|
||||
|
||||
'max_drawdown_low': low_val,
|
||||
'max_drawdown_high': high_val,
|
||||
})
|
||||
|
||||
csum_min, csum_max = calculate_csum(results, starting_balance)
|
||||
strat_stats.update({
|
||||
'csum_min': csum_min,
|
||||
'csum_max': csum_max
|
||||
})
|
||||
|
||||
except ValueError:
|
||||
strat_stats.update({
|
||||
'max_drawdown': 0.0,
|
||||
'max_drawdown_abs': 0.0,
|
||||
'max_drawdown_low': 0.0,
|
||||
'max_drawdown_high': 0.0,
|
||||
'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc),
|
||||
'drawdown_start_ts': 0,
|
||||
'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc),
|
||||
'drawdown_end_ts': 0,
|
||||
'csum_min': 0,
|
||||
'csum_max': 0
|
||||
})
|
||||
|
||||
return strat_stats
|
||||
|
||||
|
||||
def generate_backtest_stats(btdata: Dict[str, DataFrame],
|
||||
all_results: Dict[str, Dict[str, Union[DataFrame, Dict]]],
|
||||
min_date: Arrow, max_date: Arrow
|
||||
min_date: datetime, max_date: datetime
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
:param btdata: Backtest data
|
||||
@@ -245,132 +429,17 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame],
|
||||
{ Strategy: {'results: results, 'config: config}}.
|
||||
:param min_date: Backtest start date
|
||||
:param max_date: Backtest end date
|
||||
:return:
|
||||
Dictionary containing results per strategy and a stratgy summary.
|
||||
:return: Dictionary containing results per strategy and a stratgy summary.
|
||||
"""
|
||||
result: Dict[str, Any] = {'strategy': {}}
|
||||
market_change = calculate_market_change(btdata, 'close')
|
||||
|
||||
for strategy, content in all_results.items():
|
||||
results: Dict[str, DataFrame] = content['results']
|
||||
if not isinstance(results, DataFrame):
|
||||
continue
|
||||
config = content['config']
|
||||
max_open_trades = min(config['max_open_trades'], len(btdata.keys()))
|
||||
starting_balance = config['dry_run_wallet']
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
|
||||
starting_balance=starting_balance,
|
||||
results=results, skip_nan=False)
|
||||
sell_reason_stats = generate_sell_reason_stats(max_open_trades=max_open_trades,
|
||||
results=results)
|
||||
left_open_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
|
||||
starting_balance=starting_balance,
|
||||
results=results.loc[results['is_open']],
|
||||
skip_nan=True)
|
||||
daily_stats = generate_daily_stats(results)
|
||||
best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'],
|
||||
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
|
||||
worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'],
|
||||
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
|
||||
results['open_timestamp'] = results['open_date'].astype(int64) // 1e6
|
||||
results['close_timestamp'] = results['close_date'].astype(int64) // 1e6
|
||||
|
||||
backtest_days = (max_date - min_date).days
|
||||
strat_stats = {
|
||||
'trades': results.to_dict(orient='records'),
|
||||
'locks': [lock.to_json() for lock in content['locks']],
|
||||
'best_pair': best_pair,
|
||||
'worst_pair': worst_pair,
|
||||
'results_per_pair': pair_results,
|
||||
'sell_reason_summary': sell_reason_stats,
|
||||
'left_open_trades': left_open_results,
|
||||
'total_trades': len(results),
|
||||
'total_volume': float(results['stake_amount'].sum()),
|
||||
'avg_stake_amount': results['stake_amount'].mean() if len(results) > 0 else 0,
|
||||
'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0,
|
||||
'profit_total': results['profit_abs'].sum() / starting_balance,
|
||||
'profit_total_abs': results['profit_abs'].sum(),
|
||||
'backtest_start': min_date.datetime,
|
||||
'backtest_start_ts': min_date.int_timestamp * 1000,
|
||||
'backtest_end': max_date.datetime,
|
||||
'backtest_end_ts': max_date.int_timestamp * 1000,
|
||||
'backtest_days': backtest_days,
|
||||
|
||||
'backtest_run_start_ts': content['backtest_start_time'],
|
||||
'backtest_run_end_ts': content['backtest_end_time'],
|
||||
|
||||
'trades_per_day': round(len(results) / backtest_days, 2) if backtest_days > 0 else 0,
|
||||
'market_change': market_change,
|
||||
'pairlist': list(btdata.keys()),
|
||||
'stake_amount': config['stake_amount'],
|
||||
'stake_currency': config['stake_currency'],
|
||||
'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
|
||||
'starting_balance': starting_balance,
|
||||
'dry_run_wallet': starting_balance,
|
||||
'final_balance': content['final_balance'],
|
||||
'max_open_trades': max_open_trades,
|
||||
'max_open_trades_setting': (config['max_open_trades']
|
||||
if config['max_open_trades'] != float('inf') else -1),
|
||||
'timeframe': config['timeframe'],
|
||||
'timerange': config.get('timerange', ''),
|
||||
'enable_protections': config.get('enable_protections', False),
|
||||
'strategy_name': strategy,
|
||||
# Parameters relevant for backtesting
|
||||
'stoploss': config['stoploss'],
|
||||
'trailing_stop': config.get('trailing_stop', False),
|
||||
'trailing_stop_positive': config.get('trailing_stop_positive'),
|
||||
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0),
|
||||
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False),
|
||||
'use_custom_stoploss': config.get('use_custom_stoploss', False),
|
||||
'minimal_roi': config['minimal_roi'],
|
||||
'use_sell_signal': config['ask_strategy']['use_sell_signal'],
|
||||
'sell_profit_only': config['ask_strategy']['sell_profit_only'],
|
||||
'sell_profit_offset': config['ask_strategy']['sell_profit_offset'],
|
||||
'ignore_roi_if_buy_signal': config['ask_strategy']['ignore_roi_if_buy_signal'],
|
||||
**daily_stats,
|
||||
}
|
||||
strat_stats = generate_strategy_stats(btdata, strategy, content,
|
||||
min_date, max_date, market_change=market_change)
|
||||
result['strategy'][strategy] = strat_stats
|
||||
|
||||
try:
|
||||
max_drawdown, _, _, _, _ = calculate_max_drawdown(
|
||||
results, value_col='profit_ratio')
|
||||
drawdown_abs, drawdown_start, drawdown_end, high_val, low_val = calculate_max_drawdown(
|
||||
results, value_col='profit_abs')
|
||||
strat_stats.update({
|
||||
'max_drawdown': max_drawdown,
|
||||
'max_drawdown_abs': drawdown_abs,
|
||||
'drawdown_start': drawdown_start,
|
||||
'drawdown_start_ts': drawdown_start.timestamp() * 1000,
|
||||
'drawdown_end': drawdown_end,
|
||||
'drawdown_end_ts': drawdown_end.timestamp() * 1000,
|
||||
|
||||
'max_drawdown_low': low_val,
|
||||
'max_drawdown_high': high_val,
|
||||
})
|
||||
|
||||
csum_min, csum_max = calculate_csum(results, starting_balance)
|
||||
strat_stats.update({
|
||||
'csum_min': csum_min,
|
||||
'csum_max': csum_max
|
||||
})
|
||||
|
||||
except ValueError:
|
||||
strat_stats.update({
|
||||
'max_drawdown': 0.0,
|
||||
'max_drawdown_abs': 0.0,
|
||||
'max_drawdown_low': 0.0,
|
||||
'max_drawdown_high': 0.0,
|
||||
'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc),
|
||||
'drawdown_start_ts': 0,
|
||||
'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc),
|
||||
'drawdown_end_ts': 0,
|
||||
'csum_min': 0,
|
||||
'csum_max': 0
|
||||
})
|
||||
|
||||
strategy_results = generate_strategy_metrics(all_results=all_results)
|
||||
strategy_results = generate_strategy_comparison(all_results=all_results)
|
||||
|
||||
result['strategy_comparison'] = strategy_results
|
||||
|
||||
@@ -393,7 +462,8 @@ def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: st
|
||||
floatfmt = _get_line_floatfmt(stake_currency)
|
||||
output = [[
|
||||
t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
|
||||
t['profit_total_pct'], t['duration_avg'], t['wins'], t['draws'], t['losses']
|
||||
t['profit_total_pct'], t['duration_avg'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses'])
|
||||
] for t in pair_results]
|
||||
# Ignore type as floatfmt does allow tuples but mypy does not know that
|
||||
return tabulate(output, headers=headers,
|
||||
@@ -410,9 +480,7 @@ def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_curren
|
||||
headers = [
|
||||
'Sell Reason',
|
||||
'Sells',
|
||||
'Wins',
|
||||
'Draws',
|
||||
'Losses',
|
||||
'Win Draws Loss Win%',
|
||||
'Avg Profit %',
|
||||
'Cum Profit %',
|
||||
f'Tot Profit {stake_currency}',
|
||||
@@ -420,7 +488,8 @@ def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_curren
|
||||
]
|
||||
|
||||
output = [[
|
||||
t['sell_reason'], t['trades'], t['wins'], t['draws'], t['losses'],
|
||||
t['sell_reason'], t['trades'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses']),
|
||||
t['profit_mean_pct'], t['profit_sum_pct'],
|
||||
round_coin_value(t['profit_total_abs'], stake_currency, False),
|
||||
t['profit_total_pct'],
|
||||
@@ -438,11 +507,22 @@ def text_table_strategy(strategy_results, stake_currency: str) -> str:
|
||||
"""
|
||||
floatfmt = _get_line_floatfmt(stake_currency)
|
||||
headers = _get_line_header('Strategy', stake_currency)
|
||||
# _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless
|
||||
# therefore we slip this column in only for strategy summary here.
|
||||
headers.append('Drawdown')
|
||||
|
||||
# Align drawdown string on the center two space separator.
|
||||
drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results]
|
||||
dd_pad_abs = max([len(t['max_drawdown_abs']) for t in strategy_results])
|
||||
dd_pad_per = max([len(dd) for dd in drawdown])
|
||||
drawdown = [f'{t["max_drawdown_abs"]:>{dd_pad_abs}} {stake_currency} {dd:>{dd_pad_per}}%'
|
||||
for t, dd in zip(strategy_results, drawdown)]
|
||||
|
||||
output = [[
|
||||
t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
|
||||
t['profit_total_pct'], t['duration_avg'], t['wins'], t['draws'], t['losses']
|
||||
] for t in strategy_results]
|
||||
t['profit_total_pct'], t['duration_avg'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), drawdown]
|
||||
for t, drawdown in zip(strategy_results, drawdown)]
|
||||
# Ignore type as floatfmt does allow tuples but mypy does not know that
|
||||
return tabulate(output, headers=headers,
|
||||
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
|
||||
@@ -452,9 +532,21 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
if len(strat_results['trades']) > 0:
|
||||
best_trade = max(strat_results['trades'], key=lambda x: x['profit_ratio'])
|
||||
worst_trade = min(strat_results['trades'], key=lambda x: x['profit_ratio'])
|
||||
|
||||
# Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
|
||||
# command stores these results and newer version of freqtrade must be able to handle old
|
||||
# results with missing new fields.
|
||||
zero_duration_trades = '--'
|
||||
|
||||
if 'zero_duration_trades' in strat_results:
|
||||
zero_duration_trades_per = \
|
||||
100.0 / strat_results['total_trades'] * strat_results['zero_duration_trades']
|
||||
zero_duration_trades = f'{zero_duration_trades_per:.2f}% ' \
|
||||
f'({strat_results["zero_duration_trades"]})'
|
||||
|
||||
metrics = [
|
||||
('Backtesting from', strat_results['backtest_start'].strftime(DATETIME_PRINT_FORMAT)),
|
||||
('Backtesting to', strat_results['backtest_end'].strftime(DATETIME_PRINT_FORMAT)),
|
||||
('Backtesting from', strat_results['backtest_start']),
|
||||
('Backtesting to', strat_results['backtest_end']),
|
||||
('Max open trades', strat_results['max_open_trades']),
|
||||
('', ''), # Empty line to improve readability
|
||||
('Total trades', strat_results['total_trades']),
|
||||
@@ -464,13 +556,12 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
strat_results['stake_currency'])),
|
||||
('Absolute profit ', round_coin_value(strat_results['profit_total_abs'],
|
||||
strat_results['stake_currency'])),
|
||||
('Total profit %', f"{round(strat_results['profit_total'] * 100, 2)}%"),
|
||||
('Total profit %', f"{round(strat_results['profit_total'] * 100, 2):}%"),
|
||||
('Trades per day', strat_results['trades_per_day']),
|
||||
('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'],
|
||||
strat_results['stake_currency'])),
|
||||
('Total trade volume', round_coin_value(strat_results['total_volume'],
|
||||
strat_results['stake_currency'])),
|
||||
|
||||
('', ''), # Empty line to improve readability
|
||||
('Best Pair', f"{strat_results['best_pair']['key']} "
|
||||
f"{round(strat_results['best_pair']['profit_sum_pct'], 2)}%"),
|
||||
@@ -488,6 +579,8 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
f"{strat_results['draw_days']} / {strat_results['losing_days']}"),
|
||||
('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"),
|
||||
('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"),
|
||||
('Zero Duration Trades', zero_duration_trades),
|
||||
('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')),
|
||||
('', ''), # Empty line to improve readability
|
||||
|
||||
('Min balance', round_coin_value(strat_results['csum_min'],
|
||||
@@ -502,8 +595,8 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
strat_results['stake_currency'])),
|
||||
('Drawdown low', round_coin_value(strat_results['max_drawdown_low'],
|
||||
strat_results['stake_currency'])),
|
||||
('Drawdown Start', strat_results['drawdown_start'].strftime(DATETIME_PRINT_FORMAT)),
|
||||
('Drawdown End', strat_results['drawdown_end'].strftime(DATETIME_PRINT_FORMAT)),
|
||||
('Drawdown Start', strat_results['drawdown_start']),
|
||||
('Drawdown End', strat_results['drawdown_end']),
|
||||
('Market change', f"{round(strat_results['market_change'] * 100, 2)}%"),
|
||||
]
|
||||
|
||||
@@ -522,37 +615,43 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
return message
|
||||
|
||||
|
||||
def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: str):
|
||||
"""
|
||||
Print results for one strategy
|
||||
"""
|
||||
# Print results
|
||||
print(f"Result for strategy {strategy}")
|
||||
table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency)
|
||||
if isinstance(table, str):
|
||||
print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'],
|
||||
stake_currency=stake_currency)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' SELL REASON STATS '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_add_metrics(results)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print('=' * len(table.splitlines()[0]))
|
||||
print()
|
||||
|
||||
|
||||
def show_backtest_results(config: Dict, backtest_stats: Dict):
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
for strategy, results in backtest_stats['strategy'].items():
|
||||
|
||||
# Print results
|
||||
print(f"Result for strategy {strategy}")
|
||||
table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency)
|
||||
if isinstance(table, str):
|
||||
print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'],
|
||||
stake_currency=stake_currency)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' SELL REASON STATS '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_add_metrics(results)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print('=' * len(table.splitlines()[0]))
|
||||
print()
|
||||
show_backtest_result(strategy, results, stake_currency)
|
||||
|
||||
if len(backtest_stats['strategy']) > 1:
|
||||
# Print Strategy summary table
|
||||
|
@@ -123,6 +123,27 @@ def migrate_open_orders_to_trades(engine):
|
||||
""")
|
||||
|
||||
|
||||
def migrate_orders_table(decl_base, inspector, engine, table_back_name: str, cols: List):
|
||||
# Schema migration necessary
|
||||
engine.execute(f"alter table orders rename to {table_back_name}")
|
||||
# drop indexes on backup table
|
||||
for index in inspector.get_indexes(table_back_name):
|
||||
engine.execute(f"drop index {index['name']}")
|
||||
|
||||
# let SQLAlchemy create the schema as required
|
||||
decl_base.metadata.create_all(engine)
|
||||
|
||||
engine.execute(f"""
|
||||
insert into orders ( id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status,
|
||||
symbol, order_type, side, price, amount, filled, average, remaining, cost, order_date,
|
||||
order_filled_date, order_update_date)
|
||||
select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status,
|
||||
symbol, order_type, side, price, amount, filled, null average, remaining, cost, order_date,
|
||||
order_filled_date, order_update_date
|
||||
from {table_back_name}
|
||||
""")
|
||||
|
||||
|
||||
def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
"""
|
||||
Checks if migration is necessary and migrates if necessary
|
||||
@@ -145,6 +166,11 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
logger.info('Moving open orders to Orders table.')
|
||||
migrate_open_orders_to_trades(engine)
|
||||
else:
|
||||
pass
|
||||
# Empty for now - as there is only one iteration of the orders table so far.
|
||||
# table_back_name = get_backup_name(tabs, 'orders_bak')
|
||||
cols_order = inspector.get_columns('orders')
|
||||
|
||||
if not has_column(cols_order, 'average'):
|
||||
tabs = get_table_names_for_table(inspector, 'orders')
|
||||
# Empty for now - as there is only one iteration of the orders table so far.
|
||||
table_back_name = get_backup_name(tabs, 'orders_bak')
|
||||
|
||||
migrate_orders_table(decl_base, inspector, engine, table_back_name, cols)
|
||||
|
@@ -112,16 +112,17 @@ class Order(_DECL_BASE):
|
||||
|
||||
trade = relationship("Trade", back_populates="orders")
|
||||
|
||||
ft_order_side = Column(String, nullable=False)
|
||||
ft_pair = Column(String, nullable=False)
|
||||
ft_order_side = Column(String(25), nullable=False)
|
||||
ft_pair = Column(String(25), nullable=False)
|
||||
ft_is_open = Column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
order_id = Column(String, nullable=False, index=True)
|
||||
status = Column(String, nullable=True)
|
||||
symbol = Column(String, nullable=True)
|
||||
order_type = Column(String, nullable=True)
|
||||
side = Column(String, nullable=True)
|
||||
order_id = Column(String(255), nullable=False, index=True)
|
||||
status = Column(String(255), nullable=True)
|
||||
symbol = Column(String(25), nullable=True)
|
||||
order_type = Column(String(50), nullable=True)
|
||||
side = Column(String(25), nullable=True)
|
||||
price = Column(Float, nullable=True)
|
||||
average = Column(Float, nullable=True)
|
||||
amount = Column(Float, nullable=True)
|
||||
filled = Column(Float, nullable=True)
|
||||
remaining = Column(Float, nullable=True)
|
||||
@@ -150,6 +151,7 @@ class Order(_DECL_BASE):
|
||||
self.price = order.get('price', self.price)
|
||||
self.amount = order.get('amount', self.amount)
|
||||
self.filled = order.get('filled', self.filled)
|
||||
self.average = order.get('average', self.average)
|
||||
self.remaining = order.get('remaining', self.remaining)
|
||||
self.cost = order.get('cost', self.cost)
|
||||
if 'timestamp' in order and order['timestamp'] is not None:
|
||||
@@ -656,15 +658,15 @@ class Trade(_DECL_BASE, LocalTrade):
|
||||
|
||||
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan")
|
||||
|
||||
exchange = Column(String, nullable=False)
|
||||
pair = Column(String, nullable=False, index=True)
|
||||
exchange = Column(String(25), nullable=False)
|
||||
pair = Column(String(25), nullable=False, index=True)
|
||||
is_open = Column(Boolean, nullable=False, default=True, index=True)
|
||||
fee_open = Column(Float, nullable=False, default=0.0)
|
||||
fee_open_cost = Column(Float, nullable=True)
|
||||
fee_open_currency = Column(String, nullable=True)
|
||||
fee_open_currency = Column(String(25), nullable=True)
|
||||
fee_close = Column(Float, nullable=False, default=0.0)
|
||||
fee_close_cost = Column(Float, nullable=True)
|
||||
fee_close_currency = Column(String, nullable=True)
|
||||
fee_close_currency = Column(String(25), nullable=True)
|
||||
open_rate = Column(Float)
|
||||
open_rate_requested = Column(Float)
|
||||
# open_trade_value - calculated via _calc_open_trade_value
|
||||
@@ -678,7 +680,7 @@ class Trade(_DECL_BASE, LocalTrade):
|
||||
amount_requested = Column(Float)
|
||||
open_date = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
close_date = Column(DateTime)
|
||||
open_order_id = Column(String)
|
||||
open_order_id = Column(String(255))
|
||||
# absolute value of the stop loss
|
||||
stop_loss = Column(Float, nullable=True, default=0.0)
|
||||
# percentage value of the stop loss
|
||||
@@ -688,16 +690,16 @@ class Trade(_DECL_BASE, LocalTrade):
|
||||
# percentage value of the initial stop loss
|
||||
initial_stop_loss_pct = Column(Float, nullable=True)
|
||||
# stoploss order id which is on exchange
|
||||
stoploss_order_id = Column(String, nullable=True, index=True)
|
||||
stoploss_order_id = Column(String(255), nullable=True, index=True)
|
||||
# last update time of the stoploss order on exchange
|
||||
stoploss_last_update = Column(DateTime, nullable=True)
|
||||
# absolute value of the highest reached price
|
||||
max_rate = Column(Float, nullable=True, default=0.0)
|
||||
# Lowest price reached
|
||||
min_rate = Column(Float, nullable=True)
|
||||
sell_reason = Column(String, nullable=True)
|
||||
sell_order_status = Column(String, nullable=True)
|
||||
strategy = Column(String, nullable=True)
|
||||
sell_reason = Column(String(100), nullable=True)
|
||||
sell_order_status = Column(String(100), nullable=True)
|
||||
strategy = Column(String(100), nullable=True)
|
||||
timeframe = Column(Integer, nullable=True)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -815,18 +817,20 @@ class Trade(_DECL_BASE, LocalTrade):
|
||||
pair_rates = Trade.query.with_entities(
|
||||
Trade.pair,
|
||||
func.sum(Trade.close_profit).label('profit_sum'),
|
||||
func.sum(Trade.close_profit_abs).label('profit_sum_abs'),
|
||||
func.count(Trade.pair).label('count')
|
||||
).filter(Trade.is_open.is_(False))\
|
||||
.group_by(Trade.pair) \
|
||||
.order_by(desc('profit_sum')) \
|
||||
.order_by(desc('profit_sum_abs')) \
|
||||
.all()
|
||||
return [
|
||||
{
|
||||
'pair': pair,
|
||||
'profit': rate,
|
||||
'profit': profit,
|
||||
'profit_abs': profit_abs,
|
||||
'count': count
|
||||
}
|
||||
for pair, rate, count in pair_rates
|
||||
for pair, profit, profit_abs, count in pair_rates
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -852,8 +856,8 @@ class PairLock(_DECL_BASE):
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
pair = Column(String, nullable=False, index=True)
|
||||
reason = Column(String, nullable=True)
|
||||
pair = Column(String(25), nullable=False, index=True)
|
||||
reason = Column(String(255), nullable=True)
|
||||
# Time the pair was locked (start time)
|
||||
lock_time = Column(DateTime, nullable=False)
|
||||
# Time until the pair is locked (end time)
|
||||
|
@@ -77,7 +77,8 @@ def init_plotscript(config, markets: List, startup_candles: int = 0):
|
||||
)
|
||||
except ValueError as e:
|
||||
raise OperationalException(e) from e
|
||||
trades = trim_dataframe(trades, timerange, 'open_date')
|
||||
if not trades.empty:
|
||||
trades = trim_dataframe(trades, timerange, 'open_date')
|
||||
|
||||
return {"ohlcv": data,
|
||||
"trades": trades,
|
||||
@@ -540,8 +541,11 @@ def load_and_plot_trades(config: Dict[str, Any]):
|
||||
|
||||
df_analyzed = strategy.analyze_ticker(data, {'pair': pair})
|
||||
df_analyzed = trim_dataframe(df_analyzed, timerange)
|
||||
trades_pair = trades.loc[trades['pair'] == pair]
|
||||
trades_pair = extract_trades_of_period(df_analyzed, trades_pair)
|
||||
if not trades.empty:
|
||||
trades_pair = trades.loc[trades['pair'] == pair]
|
||||
trades_pair = extract_trades_of_period(df_analyzed, trades_pair)
|
||||
else:
|
||||
trades_pair = trades
|
||||
|
||||
fig = generate_candlestick_graph(
|
||||
pair=pair,
|
||||
|
@@ -71,14 +71,14 @@ class AgeFilter(IPairList):
|
||||
daily_candles = candles[(p, '1d')] if (p, '1d') in candles else None
|
||||
if not self._validate_pair_loc(p, daily_candles):
|
||||
pairlist.remove(p)
|
||||
logger.info(f"Validated {len(pairlist)} pairs.")
|
||||
self.log_once(f"Validated {len(pairlist)} pairs.", logger.info)
|
||||
return pairlist
|
||||
|
||||
def _validate_pair_loc(self, pair: str, daily_candles: Optional[DataFrame]) -> bool:
|
||||
"""
|
||||
Validate age for the ticker
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
# Check symbol in cache
|
||||
@@ -86,7 +86,7 @@ class AgeFilter(IPairList):
|
||||
return True
|
||||
|
||||
if daily_candles is not None:
|
||||
if len(daily_candles) > self._min_days_listed:
|
||||
if len(daily_candles) >= self._min_days_listed:
|
||||
# We have fetched at least the minimum required number of daily candles
|
||||
# Add to cache, store the time we last checked this symbol
|
||||
self._symbolsChecked[pair] = int(arrow.utcnow().float_timestamp) * 1000
|
||||
|
@@ -7,7 +7,7 @@ from copy import deepcopy
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import market_is_active
|
||||
from freqtrade.exchange import Exchange, market_is_active
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class IPairList(LoggingMixin, ABC):
|
||||
|
||||
def __init__(self, exchange, pairlistmanager,
|
||||
def __init__(self, exchange: Exchange, pairlistmanager,
|
||||
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
|
||||
pairlist_pos: int) -> None:
|
||||
"""
|
||||
@@ -28,7 +28,7 @@ class IPairList(LoggingMixin, ABC):
|
||||
"""
|
||||
self._enabled = True
|
||||
|
||||
self._exchange = exchange
|
||||
self._exchange: Exchange = exchange
|
||||
self._pairlistmanager = pairlistmanager
|
||||
self._config = config
|
||||
self._pairlistconfig = pairlistconfig
|
||||
@@ -68,7 +68,7 @@ class IPairList(LoggingMixin, ABC):
|
||||
filter_pairlist() method.
|
||||
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
@@ -39,7 +39,12 @@ class PerformanceFilter(IPairList):
|
||||
:return: new allowlist
|
||||
"""
|
||||
# Get the trading performance for pairs from database
|
||||
performance = pd.DataFrame(Trade.get_overall_performance())
|
||||
try:
|
||||
performance = pd.DataFrame(Trade.get_overall_performance())
|
||||
except AttributeError:
|
||||
# Performancefilter does not work in backtesting.
|
||||
self.log_once("PerformanceFilter is not available in this mode.", logger.warning)
|
||||
return pairlist
|
||||
|
||||
# Skip performance-based sorting if no performance data is available
|
||||
if len(performance) == 0:
|
||||
|
@@ -48,7 +48,7 @@ class PrecisionFilter(IPairList):
|
||||
Check if pair has enough room to add a stoploss to avoid "unsellable" buys of very
|
||||
low value pairs.
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
stop_price = ticker['ask'] * self._stoploss
|
||||
|
@@ -27,9 +27,13 @@ class PriceFilter(IPairList):
|
||||
self._max_price = pairlistconfig.get('max_price', 0)
|
||||
if self._max_price < 0:
|
||||
raise OperationalException("PriceFilter requires max_price to be >= 0")
|
||||
self._max_value = pairlistconfig.get('max_value', 0)
|
||||
if self._max_value < 0:
|
||||
raise OperationalException("PriceFilter requires max_value to be >= 0")
|
||||
self._enabled = ((self._low_price_ratio > 0) or
|
||||
(self._min_price > 0) or
|
||||
(self._max_price > 0))
|
||||
(self._max_price > 0) or
|
||||
(self._max_value > 0))
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
@@ -51,6 +55,8 @@ class PriceFilter(IPairList):
|
||||
active_price_filters.append(f"below {self._min_price:.8f}")
|
||||
if self._max_price != 0:
|
||||
active_price_filters.append(f"above {self._max_price:.8f}")
|
||||
if self._max_value != 0:
|
||||
active_price_filters.append(f"Value above {self._max_value:.8f}")
|
||||
|
||||
if len(active_price_filters):
|
||||
return f"{self.name} - Filtering pairs priced {' or '.join(active_price_filters)}."
|
||||
@@ -61,7 +67,7 @@ class PriceFilter(IPairList):
|
||||
"""
|
||||
Check if if one price-step (pip) is > than a certain barrier.
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
if ticker.get('last', None) is None or ticker.get('last') == 0:
|
||||
@@ -79,6 +85,32 @@ class PriceFilter(IPairList):
|
||||
f"because 1 unit is {changeperc * 100:.3f}%", logger.info)
|
||||
return False
|
||||
|
||||
# Perform low_amount check
|
||||
if self._max_value != 0:
|
||||
price = ticker['last']
|
||||
market = self._exchange.markets[pair]
|
||||
limits = market['limits']
|
||||
if ('amount' in limits and 'min' in limits['amount']
|
||||
and limits['amount']['min'] is not None):
|
||||
min_amount = limits['amount']['min']
|
||||
min_precision = market['precision']['amount']
|
||||
|
||||
min_value = min_amount * price
|
||||
if self._exchange.precisionMode == 4:
|
||||
# tick size
|
||||
next_value = (min_amount + min_precision) * price
|
||||
else:
|
||||
# Decimal places
|
||||
min_precision = pow(0.1, min_precision)
|
||||
next_value = (min_amount + min_precision) * price
|
||||
diff = next_value - min_value
|
||||
|
||||
if diff > self._max_value:
|
||||
self.log_once(f"Removed {pair} from whitelist, "
|
||||
f"because min value change of {diff} > {self._max_value}.",
|
||||
logger.info)
|
||||
return False
|
||||
|
||||
# Perform min_price check.
|
||||
if self._min_price != 0:
|
||||
if ticker['last'] < self._min_price:
|
||||
@@ -89,7 +121,7 @@ class PriceFilter(IPairList):
|
||||
# Perform max_price check.
|
||||
if self._max_price != 0:
|
||||
if ticker['last'] > self._max_price:
|
||||
self.log_once(f"Removed {ticker['symbol']} from whitelist, "
|
||||
self.log_once(f"Removed {pair} from whitelist, "
|
||||
f"because last price > {self._max_price:.8f}", logger.info)
|
||||
return False
|
||||
|
||||
|
@@ -40,7 +40,7 @@ class SpreadFilter(IPairList):
|
||||
"""
|
||||
Validate spread for the ticker
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
if 'bid' in ticker and 'ask' in ticker and ticker['ask']:
|
||||
|
@@ -90,7 +90,7 @@ class VolatilityFilter(IPairList):
|
||||
"""
|
||||
Validate trading range
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
# Check symbol in cache
|
||||
|
@@ -83,7 +83,7 @@ class RangeStabilityFilter(IPairList):
|
||||
"""
|
||||
Validate trading range
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
# Check symbol in cache
|
||||
|
@@ -57,6 +57,7 @@ class Count(BaseModel):
|
||||
class PerformanceEntry(BaseModel):
|
||||
pair: str
|
||||
profit: float
|
||||
profit_abs: float
|
||||
count: int
|
||||
|
||||
|
||||
@@ -268,7 +269,7 @@ class DeleteTrade(BaseModel):
|
||||
|
||||
class PlotConfig_(BaseModel):
|
||||
main_plot: Dict[str, Any]
|
||||
subplots: Optional[Dict[str, Any]]
|
||||
subplots: Dict[str, Any]
|
||||
|
||||
|
||||
class PlotConfig(BaseModel):
|
||||
|
@@ -3,11 +3,13 @@ Module that define classes to convert Crypto-currency to FIAT
|
||||
e.g BTC to USD
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from cachetools.ttl import TTLCache
|
||||
from pycoingecko import CoinGeckoAPI
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from freqtrade.constants import SUPPORTED_FIAT
|
||||
|
||||
@@ -25,6 +27,7 @@ class CryptoToFiatConverter:
|
||||
_coingekko: CoinGeckoAPI = None
|
||||
|
||||
_cryptomap: Dict = {}
|
||||
_backoff: float = 0.0
|
||||
|
||||
def __new__(cls):
|
||||
"""
|
||||
@@ -47,8 +50,21 @@ class CryptoToFiatConverter:
|
||||
def _load_cryptomap(self) -> None:
|
||||
try:
|
||||
coinlistings = self._coingekko.get_coins_list()
|
||||
# Create mapping table from synbol to coingekko_id
|
||||
# Create mapping table from symbol to coingekko_id
|
||||
self._cryptomap = {x['symbol']: x['id'] for x in coinlistings}
|
||||
except RequestException as request_exception:
|
||||
if "429" in str(request_exception):
|
||||
logger.warning(
|
||||
"Too many requests for Coingecko API, backing off and trying again later.")
|
||||
# Set backoff timestamp to 60 seconds in the future
|
||||
self._backoff = datetime.datetime.now().timestamp() + 60
|
||||
return
|
||||
# If the request is not a 429 error we want to raise the normal error
|
||||
logger.error(
|
||||
"Could not load FIAT Cryptocurrency map for the following problem: {}".format(
|
||||
request_exception
|
||||
)
|
||||
)
|
||||
except (Exception) as exception:
|
||||
logger.error(
|
||||
f"Could not load FIAT Cryptocurrency map for the following problem: {exception}")
|
||||
@@ -127,6 +143,15 @@ class CryptoToFiatConverter:
|
||||
if crypto_symbol == fiat_symbol:
|
||||
return 1.0
|
||||
|
||||
if self._cryptomap == {}:
|
||||
if self._backoff <= datetime.datetime.now().timestamp():
|
||||
self._load_cryptomap()
|
||||
# return 0.0 if we still dont have data to check, no reason to proceed
|
||||
if self._cryptomap == {}:
|
||||
return 0.0
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
if crypto_symbol not in self._cryptomap:
|
||||
# return 0 for unsupported stake currencies (fiat-convert should not break the bot)
|
||||
logger.warning("unsupported crypto-symbol %s - returning 0.0", crypto_symbol)
|
||||
|
@@ -178,7 +178,7 @@ class RPC:
|
||||
current_rate = trade.close_rate
|
||||
current_profit = trade.calc_profit_ratio(current_rate)
|
||||
current_profit_abs = trade.calc_profit(current_rate)
|
||||
|
||||
current_profit_fiat: Optional[float] = None
|
||||
# Calculate fiat profit
|
||||
if self._fiat_converter:
|
||||
current_profit_fiat = self._fiat_converter.convert_amount(
|
||||
@@ -220,12 +220,13 @@ class RPC:
|
||||
return results
|
||||
|
||||
def _rpc_status_table(self, stake_currency: str,
|
||||
fiat_display_currency: str) -> Tuple[List, List]:
|
||||
fiat_display_currency: str) -> Tuple[List, List, float]:
|
||||
trades = Trade.get_open_trades()
|
||||
if not trades:
|
||||
raise RPCException('no active trade')
|
||||
else:
|
||||
trades_list = []
|
||||
fiat_profit_sum = NAN
|
||||
for trade in trades:
|
||||
# calculate profit and send message to user
|
||||
try:
|
||||
@@ -243,6 +244,8 @@ class RPC:
|
||||
)
|
||||
if fiat_profit and not isnan(fiat_profit):
|
||||
profit_str += f" ({fiat_profit:.2f})"
|
||||
fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \
|
||||
else fiat_profit_sum + fiat_profit
|
||||
trades_list.append([
|
||||
trade.id,
|
||||
trade.pair + ('*' if (trade.open_order_id is not None
|
||||
@@ -256,7 +259,7 @@ class RPC:
|
||||
profitcol += " (" + fiat_display_currency + ")"
|
||||
|
||||
columns = ['ID', 'Pair', 'Since', profitcol]
|
||||
return trades_list, columns
|
||||
return trades_list, columns, fiat_profit_sum
|
||||
|
||||
def _rpc_daily_profit(
|
||||
self, timescale: int,
|
||||
@@ -845,5 +848,7 @@ class RPC:
|
||||
df_analyzed, arrow.Arrow.utcnow().datetime)
|
||||
|
||||
def _rpc_plot_config(self) -> Dict[str, Any]:
|
||||
|
||||
if (self._freqtrade.strategy.plot_config and
|
||||
'subplots' not in self._freqtrade.strategy.plot_config):
|
||||
self._freqtrade.strategy.plot_config['subplots'] = {}
|
||||
return self._freqtrade.strategy.plot_config
|
||||
|
@@ -8,19 +8,21 @@ import logging
|
||||
from datetime import timedelta
|
||||
from html import escape
|
||||
from itertools import chain
|
||||
from typing import Any, Callable, Dict, List, Union
|
||||
from math import isnan
|
||||
from typing import Any, Callable, Dict, List, Optional, Union, cast
|
||||
|
||||
import arrow
|
||||
from tabulate import tabulate
|
||||
from telegram import KeyboardButton, ParseMode, ReplyKeyboardMarkup, Update
|
||||
from telegram import (InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ParseMode,
|
||||
ReplyKeyboardMarkup, Update)
|
||||
from telegram.error import NetworkError, TelegramError
|
||||
from telegram.ext import CallbackContext, CommandHandler, Updater
|
||||
from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater
|
||||
from telegram.utils.helpers import escape_markdown
|
||||
|
||||
from freqtrade.__init__ import __version__
|
||||
from freqtrade.constants import DUST_PER_COIN
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import round_coin_value
|
||||
from freqtrade.misc import chunks, round_coin_value
|
||||
from freqtrade.rpc import RPC, RPCException, RPCHandler, RPCMessageType
|
||||
|
||||
|
||||
@@ -87,7 +89,7 @@ class Telegram(RPCHandler):
|
||||
Validates the keyboard configuration from telegram config
|
||||
section.
|
||||
"""
|
||||
self._keyboard: List[List[Union[str, KeyboardButton]]] = [
|
||||
self._keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = [
|
||||
['/daily', '/profit', '/balance'],
|
||||
['/status', '/status table', '/performance'],
|
||||
['/count', '/start', '/stop', '/help']
|
||||
@@ -169,6 +171,11 @@ class Telegram(RPCHandler):
|
||||
[h.command for h in handles]
|
||||
)
|
||||
|
||||
self._current_callback_query_handler: Optional[CallbackQueryHandler] = None
|
||||
self._callback_query_handlers = {
|
||||
'forcebuy': CallbackQueryHandler(self._forcebuy_inline)
|
||||
}
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Stops all running telegram threads.
|
||||
@@ -226,44 +233,58 @@ class Telegram(RPCHandler):
|
||||
def send_msg(self, msg: Dict[str, Any]) -> None:
|
||||
""" Send a message to telegram channel """
|
||||
|
||||
noti = self._config['telegram'].get('notification_settings', {}
|
||||
).get(str(msg['type']), 'on')
|
||||
default_noti = 'on'
|
||||
|
||||
msg_type = msg['type']
|
||||
noti = ''
|
||||
if msg_type == RPCMessageType.SELL:
|
||||
sell_noti = self._config['telegram'] \
|
||||
.get('notification_settings', {}).get(str(msg_type), {})
|
||||
# For backward compatibility sell still be string
|
||||
if isinstance(noti, str):
|
||||
noti = sell_noti
|
||||
else:
|
||||
noti = sell_noti.get(str(msg['sell_reason']), default_noti)
|
||||
else:
|
||||
noti = self._config['telegram'] \
|
||||
.get('notification_settings', {}).get(str(msg_type), default_noti)
|
||||
|
||||
if noti == 'off':
|
||||
logger.info(f"Notification '{msg['type']}' not sent.")
|
||||
logger.info(f"Notification '{msg_type}' not sent.")
|
||||
# Notification disabled
|
||||
return
|
||||
|
||||
if msg['type'] == RPCMessageType.BUY:
|
||||
if msg_type == RPCMessageType.BUY:
|
||||
message = self._format_buy_msg(msg)
|
||||
|
||||
elif msg['type'] in (RPCMessageType.BUY_CANCEL, RPCMessageType.SELL_CANCEL):
|
||||
msg['message_side'] = 'buy' if msg['type'] == RPCMessageType.BUY_CANCEL else 'sell'
|
||||
elif msg_type in (RPCMessageType.BUY_CANCEL, RPCMessageType.SELL_CANCEL):
|
||||
msg['message_side'] = 'buy' if msg_type == RPCMessageType.BUY_CANCEL else 'sell'
|
||||
message = ("\N{WARNING SIGN} *{exchange}:* "
|
||||
"Cancelling open {message_side} Order for {pair} (#{trade_id}). "
|
||||
"Reason: {reason}.".format(**msg))
|
||||
|
||||
elif msg['type'] == RPCMessageType.BUY_FILL:
|
||||
elif msg_type == RPCMessageType.BUY_FILL:
|
||||
message = ("\N{LARGE CIRCLE} *{exchange}:* "
|
||||
"Buy order for {pair} (#{trade_id}) filled "
|
||||
"for {open_rate}.".format(**msg))
|
||||
elif msg['type'] == RPCMessageType.SELL_FILL:
|
||||
elif msg_type == RPCMessageType.SELL_FILL:
|
||||
message = ("\N{LARGE CIRCLE} *{exchange}:* "
|
||||
"Sell order for {pair} (#{trade_id}) filled "
|
||||
"for {close_rate}.".format(**msg))
|
||||
elif msg['type'] == RPCMessageType.SELL:
|
||||
elif msg_type == RPCMessageType.SELL:
|
||||
message = self._format_sell_msg(msg)
|
||||
|
||||
elif msg['type'] == RPCMessageType.STATUS:
|
||||
elif msg_type == RPCMessageType.STATUS:
|
||||
message = '*Status:* `{status}`'.format(**msg)
|
||||
|
||||
elif msg['type'] == RPCMessageType.WARNING:
|
||||
elif msg_type == RPCMessageType.WARNING:
|
||||
message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg)
|
||||
|
||||
elif msg['type'] == RPCMessageType.STARTUP:
|
||||
elif msg_type == RPCMessageType.STARTUP:
|
||||
message = '{status}'.format(**msg)
|
||||
|
||||
else:
|
||||
raise NotImplementedError('Unknown message type: {}'.format(msg['type']))
|
||||
raise NotImplementedError('Unknown message type: {}'.format(msg_type))
|
||||
|
||||
self._send_msg(message, disable_notification=(noti == 'silent'))
|
||||
|
||||
@@ -354,19 +375,31 @@ class Telegram(RPCHandler):
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
statlist, head = self._rpc._rpc_status_table(
|
||||
self._config['stake_currency'], self._config.get('fiat_display_currency', ''))
|
||||
fiat_currency = self._config.get('fiat_display_currency', '')
|
||||
statlist, head, fiat_profit_sum = self._rpc._rpc_status_table(
|
||||
self._config['stake_currency'], fiat_currency)
|
||||
|
||||
show_total = not isnan(fiat_profit_sum) and len(statlist) > 1
|
||||
max_trades_per_msg = 50
|
||||
"""
|
||||
Calculate the number of messages of 50 trades per message
|
||||
0.99 is used to make sure that there are no extra (empty) messages
|
||||
As an example with 50 trades, there will be int(50/50 + 0.99) = 1 message
|
||||
"""
|
||||
for i in range(0, max(int(len(statlist) / max_trades_per_msg + 0.99), 1)):
|
||||
message = tabulate(statlist[i * max_trades_per_msg:(i + 1) * max_trades_per_msg],
|
||||
messages_count = max(int(len(statlist) / max_trades_per_msg + 0.99), 1)
|
||||
for i in range(0, messages_count):
|
||||
trades = statlist[i * max_trades_per_msg:(i + 1) * max_trades_per_msg]
|
||||
if show_total and i == messages_count - 1:
|
||||
# append total line
|
||||
trades.append(["Total", "", "", f"{fiat_profit_sum:.2f} {fiat_currency}"])
|
||||
|
||||
message = tabulate(trades,
|
||||
headers=head,
|
||||
tablefmt='simple')
|
||||
if show_total and i == messages_count - 1:
|
||||
# insert separators line between Total
|
||||
lines = message.split("\n")
|
||||
message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
|
||||
self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
@@ -624,6 +657,25 @@ class Telegram(RPCHandler):
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
def _forcebuy_action(self, pair, price=None):
|
||||
try:
|
||||
self._rpc._rpc_forcebuy(pair, price)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
def _forcebuy_inline(self, update: Update, _: CallbackContext) -> None:
|
||||
if update.callback_query:
|
||||
query = update.callback_query
|
||||
pair = query.data
|
||||
query.answer()
|
||||
query.edit_message_text(text=f"Force Buying: {pair}")
|
||||
self._forcebuy_action(pair)
|
||||
|
||||
@staticmethod
|
||||
def _layout_inline_keyboard(buttons: List[InlineKeyboardButton],
|
||||
cols=3) -> List[List[InlineKeyboardButton]]:
|
||||
return [buttons[i:i + cols] for i in range(0, len(buttons), cols)]
|
||||
|
||||
@authorized_only
|
||||
def _forcebuy(self, update: Update, context: CallbackContext) -> None:
|
||||
"""
|
||||
@@ -636,10 +688,13 @@ class Telegram(RPCHandler):
|
||||
if context.args:
|
||||
pair = context.args[0]
|
||||
price = float(context.args[1]) if len(context.args) > 1 else None
|
||||
try:
|
||||
self._rpc._rpc_forcebuy(pair, price)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._forcebuy_action(pair, price)
|
||||
else:
|
||||
whitelist = self._rpc._rpc_whitelist()['whitelist']
|
||||
pairs = [InlineKeyboardButton(pair, callback_data=pair) for pair in whitelist]
|
||||
self._send_inline_msg("Which pair?",
|
||||
keyboard=self._layout_inline_keyboard(pairs),
|
||||
callback_query_handler='forcebuy')
|
||||
|
||||
@authorized_only
|
||||
def _trades(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -711,8 +766,11 @@ class Telegram(RPCHandler):
|
||||
trades = self._rpc._rpc_performance()
|
||||
output = "<b>Performance:</b>\n"
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (f"{i+1}.\t <code>{trade['pair']}\t{trade['profit']:.2f}% "
|
||||
f"({trade['count']})</code>\n")
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['pair']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit']:.2f}%) "
|
||||
f"({trade['count']})</code>\n")
|
||||
|
||||
if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
@@ -750,17 +808,21 @@ class Telegram(RPCHandler):
|
||||
Handler for /locks.
|
||||
Returns the currently active locks
|
||||
"""
|
||||
locks = self._rpc._rpc_locks()
|
||||
message = tabulate([[
|
||||
lock['id'],
|
||||
lock['pair'],
|
||||
lock['lock_end_time'],
|
||||
lock['reason']] for lock in locks['locks']],
|
||||
headers=['ID', 'Pair', 'Until', 'Reason'],
|
||||
tablefmt='simple')
|
||||
message = f"<pre>{escape(message)}</pre>"
|
||||
logger.debug(message)
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
rpc_locks = self._rpc._rpc_locks()
|
||||
if not rpc_locks['locks']:
|
||||
self._send_msg('No active locks.', parse_mode=ParseMode.HTML)
|
||||
|
||||
for locks in chunks(rpc_locks['locks'], 25):
|
||||
message = tabulate([[
|
||||
lock['id'],
|
||||
lock['pair'],
|
||||
lock['lock_end_time'],
|
||||
lock['reason']] for lock in locks],
|
||||
headers=['ID', 'Pair', 'Until', 'Reason'],
|
||||
tablefmt='simple')
|
||||
message = f"<pre>{escape(message)}</pre>"
|
||||
logger.debug(message)
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
@authorized_only
|
||||
def _delete_locks(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -860,9 +922,17 @@ class Telegram(RPCHandler):
|
||||
"""
|
||||
try:
|
||||
edge_pairs = self._rpc._rpc_edge()
|
||||
edge_pairs_tab = tabulate(edge_pairs, headers='keys', tablefmt='simple')
|
||||
message = f'<b>Edge only validated following pairs:</b>\n<pre>{edge_pairs_tab}</pre>'
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
if not edge_pairs:
|
||||
message = '<b>Edge only validated following pairs:</b>'
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
for chunk in chunks(edge_pairs, 25):
|
||||
edge_pairs_tab = tabulate(chunk, headers='keys', tablefmt='simple')
|
||||
message = (f'<b>Edge only validated following pairs:</b>\n'
|
||||
f'<pre>{edge_pairs_tab}</pre>')
|
||||
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@@ -959,8 +1029,9 @@ class Telegram(RPCHandler):
|
||||
f"*Current state:* `{val['state']}`"
|
||||
)
|
||||
|
||||
def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN,
|
||||
disable_notification: bool = False) -> None:
|
||||
def _send_inline_msg(self, msg: str, callback_query_handler,
|
||||
parse_mode: str = ParseMode.MARKDOWN, disable_notification: bool = False,
|
||||
keyboard: List[List[InlineKeyboardButton]] = None, ) -> None:
|
||||
"""
|
||||
Send given markdown message
|
||||
:param msg: message
|
||||
@@ -968,7 +1039,29 @@ class Telegram(RPCHandler):
|
||||
:param parse_mode: telegram parse mode
|
||||
:return: None
|
||||
"""
|
||||
reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True)
|
||||
if self._current_callback_query_handler:
|
||||
self._updater.dispatcher.remove_handler(self._current_callback_query_handler)
|
||||
self._current_callback_query_handler = self._callback_query_handlers[callback_query_handler]
|
||||
self._updater.dispatcher.add_handler(self._current_callback_query_handler)
|
||||
|
||||
self._send_msg(msg, parse_mode, disable_notification,
|
||||
cast(List[List[Union[str, KeyboardButton, InlineKeyboardButton]]], keyboard),
|
||||
reply_markup=InlineKeyboardMarkup)
|
||||
|
||||
def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN,
|
||||
disable_notification: bool = False,
|
||||
keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = None,
|
||||
reply_markup=ReplyKeyboardMarkup) -> None:
|
||||
"""
|
||||
Send given markdown message
|
||||
:param msg: message
|
||||
:param bot: alternative bot
|
||||
:param parse_mode: telegram parse mode
|
||||
:return: None
|
||||
"""
|
||||
if keyboard is None:
|
||||
keyboard = self._keyboard
|
||||
reply_markup = reply_markup(keyboard, resize_keyboard=True)
|
||||
try:
|
||||
try:
|
||||
self._updater.bot.send_message(
|
||||
|
@@ -5,7 +5,9 @@ This module defines a base class for auto-hyperoptable strategies.
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import suppress
|
||||
from typing import Any, Dict, Iterator, Optional, Sequence, Tuple, Union
|
||||
from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
from freqtrade.optimize.hyperopt_tools import HyperoptTools
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
@@ -26,7 +28,8 @@ class BaseParameter(ABC):
|
||||
category: Optional[str]
|
||||
default: Any
|
||||
value: Any
|
||||
hyperopt: bool = False
|
||||
in_space: bool = False
|
||||
name: str
|
||||
|
||||
def __init__(self, *, default: Any, space: Optional[str] = None,
|
||||
optimize: bool = True, load: bool = True, **kwargs):
|
||||
@@ -131,7 +134,7 @@ class IntParameter(NumericParameter):
|
||||
Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid
|
||||
calculating 100ds of indicators.
|
||||
"""
|
||||
if self.hyperopt:
|
||||
if self.in_space and self.optimize:
|
||||
# Scikit-optimize ranges are "inclusive", while python's "range" is exclusive
|
||||
return range(self.low, self.high + 1)
|
||||
else:
|
||||
@@ -247,6 +250,10 @@ class HyperStrategyMixin(object):
|
||||
"""
|
||||
Initialize hyperoptable strategy mixin.
|
||||
"""
|
||||
self.config = config
|
||||
self.ft_buy_params: List[BaseParameter] = []
|
||||
self.ft_sell_params: List[BaseParameter] = []
|
||||
|
||||
self._load_hyper_params(config.get('runmode') == RunMode.HYPEROPT)
|
||||
|
||||
def enumerate_parameters(self, category: str = None) -> Iterator[Tuple[str, BaseParameter]]:
|
||||
@@ -257,15 +264,26 @@ class HyperStrategyMixin(object):
|
||||
"""
|
||||
if category not in ('buy', 'sell', None):
|
||||
raise OperationalException('Category must be one of: "buy", "sell", None.')
|
||||
|
||||
if category is None:
|
||||
params = self.ft_buy_params + self.ft_sell_params
|
||||
else:
|
||||
params = getattr(self, f"ft_{category}_params")
|
||||
|
||||
for par in params:
|
||||
yield par.name, par
|
||||
|
||||
def _detect_parameters(self, category: str) -> Iterator[Tuple[str, BaseParameter]]:
|
||||
""" Detect all parameters for 'category' """
|
||||
for attr_name in dir(self):
|
||||
if not attr_name.startswith('__'): # Ignore internals, not strictly necessary.
|
||||
attr = getattr(self, attr_name)
|
||||
if issubclass(attr.__class__, BaseParameter):
|
||||
if (category and attr_name.startswith(category + '_')
|
||||
if (attr_name.startswith(category + '_')
|
||||
and attr.category is not None and attr.category != category):
|
||||
raise OperationalException(
|
||||
f'Inconclusive parameter name {attr_name}, category: {attr.category}.')
|
||||
if (category is None or category == attr.category or
|
||||
if (category == attr.category or
|
||||
(attr_name.startswith(category + '_') and attr.category is None)):
|
||||
yield attr_name, attr
|
||||
|
||||
@@ -283,9 +301,16 @@ class HyperStrategyMixin(object):
|
||||
"""
|
||||
if not params:
|
||||
logger.info(f"No params for {space} found, using default values.")
|
||||
param_container: List[BaseParameter] = getattr(self, f"ft_{space}_params")
|
||||
|
||||
for attr_name, attr in self._detect_parameters(space):
|
||||
attr.name = attr_name
|
||||
attr.in_space = hyperopt and HyperoptTools.has_space(self.config, space)
|
||||
if not attr.category:
|
||||
attr.category = space
|
||||
|
||||
param_container.append(attr)
|
||||
|
||||
for attr_name, attr in self.enumerate_parameters():
|
||||
attr.hyperopt = hyperopt
|
||||
if params and attr_name in params:
|
||||
if attr.load:
|
||||
attr.value = params[attr_name]
|
||||
@@ -295,3 +320,16 @@ class HyperStrategyMixin(object):
|
||||
f'Default value "{attr.value}" used.')
|
||||
else:
|
||||
logger.info(f'Strategy Parameter(default): {attr_name} = {attr.value}')
|
||||
|
||||
def get_params_dict(self):
|
||||
"""
|
||||
Returns list of Parameters that are not part of the current optimize job
|
||||
"""
|
||||
params = {
|
||||
'buy': {},
|
||||
'sell': {}
|
||||
}
|
||||
for name, p in self.enumerate_parameters():
|
||||
if not p.optimize or not p.in_space:
|
||||
params[p.category][name] = p.value
|
||||
return params
|
||||
|
@@ -161,6 +161,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param metadata: Additional information, like the currently traded pair
|
||||
:return: a Dataframe with all mandatory indicators for the strategies
|
||||
"""
|
||||
return dataframe
|
||||
|
||||
@abstractmethod
|
||||
def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
@@ -170,6 +171,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param metadata: Additional information, like the currently traded pair
|
||||
:return: DataFrame with buy column
|
||||
"""
|
||||
return dataframe
|
||||
|
||||
@abstractmethod
|
||||
def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
@@ -179,6 +181,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param metadata: Additional information, like the currently traded pair
|
||||
:return: DataFrame with sell column
|
||||
"""
|
||||
return dataframe
|
||||
|
||||
def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
|
||||
"""
|
||||
@@ -226,7 +229,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
pass
|
||||
|
||||
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
time_in_force: str, **kwargs) -> bool:
|
||||
time_in_force: str, current_time: datetime, **kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a buy order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -241,6 +244,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param amount: Amount in target (quote) currency that's going to be traded.
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the buy-order is placed on the exchange.
|
||||
False aborts the process
|
||||
@@ -248,7 +252,8 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
return True
|
||||
|
||||
def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float,
|
||||
rate: float, time_in_force: str, sell_reason: str, **kwargs) -> bool:
|
||||
rate: float, time_in_force: str, sell_reason: str,
|
||||
current_time: datetime, **kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a regular sell order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -267,6 +272,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param sell_reason: Sell reason.
|
||||
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
|
||||
'sell_signal', 'force_sell', 'emergency_sell']
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the sell-order is placed on the exchange.
|
||||
False aborts the process
|
||||
@@ -274,7 +280,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
return True
|
||||
|
||||
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
|
||||
current_profit: float, dataframe: DataFrame, **kwargs) -> float:
|
||||
current_profit: float, **kwargs) -> float:
|
||||
"""
|
||||
Custom stoploss logic, returning the new distance relative to current_rate (as ratio).
|
||||
e.g. returning -0.05 would create a stoploss 5% below current_rate.
|
||||
@@ -290,15 +296,13 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param current_rate: Rate, calculated based on pricing settings in ask_strategy.
|
||||
:param current_profit: Current profit (as ratio), calculated based on current_rate.
|
||||
:param dataframe: Analyzed dataframe for this pair. Can contain future data in backtesting.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: New stoploss value, relative to the currentrate
|
||||
"""
|
||||
return self.stoploss
|
||||
|
||||
def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
|
||||
current_profit: float, dataframe: DataFrame,
|
||||
**kwargs) -> Optional[Union[str, bool]]:
|
||||
current_profit: float, **kwargs) -> Optional[Union[str, bool]]:
|
||||
"""
|
||||
Custom sell signal logic indicating that specified position should be sold. Returning a
|
||||
string or True from this method is equal to setting sell signal on a candle at specified
|
||||
@@ -536,8 +540,8 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
else:
|
||||
return False
|
||||
|
||||
def should_sell(self, dataframe: DataFrame, trade: Trade, rate: float, date: datetime,
|
||||
buy: bool, sell: bool, low: float = None, high: float = None,
|
||||
def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool,
|
||||
sell: bool, low: float = None, high: float = None,
|
||||
force_stoploss: float = 0) -> SellCheckTuple:
|
||||
"""
|
||||
This function evaluates if one of the conditions required to trigger a sell
|
||||
@@ -553,9 +557,8 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
|
||||
trade.adjust_min_max_rates(high or current_rate)
|
||||
|
||||
stoplossflag = self.stop_loss_reached(dataframe=dataframe, current_rate=current_rate,
|
||||
trade=trade, current_time=date,
|
||||
current_profit=current_profit,
|
||||
stoplossflag = self.stop_loss_reached(current_rate=current_rate, trade=trade,
|
||||
current_time=date, current_profit=current_profit,
|
||||
force_stoploss=force_stoploss, high=high)
|
||||
|
||||
# Set current rate to high for backtesting sell
|
||||
@@ -570,6 +573,10 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
|
||||
sell_signal = SellType.NONE
|
||||
custom_reason = ''
|
||||
# use provided rate in backtesting, not high/low.
|
||||
current_rate = rate
|
||||
current_profit = trade.calc_profit_ratio(current_rate)
|
||||
|
||||
if (ask_strategy.get('sell_profit_only', False)
|
||||
and current_profit <= ask_strategy.get('sell_profit_offset', 0)):
|
||||
# sell_profit_only and profit doesn't reach the offset - ignore sell signal
|
||||
@@ -580,7 +587,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
else:
|
||||
custom_reason = strategy_safe_wrapper(self.custom_sell, default_retval=False)(
|
||||
pair=trade.pair, trade=trade, current_time=date, current_rate=current_rate,
|
||||
current_profit=current_profit, dataframe=dataframe)
|
||||
current_profit=current_profit)
|
||||
if custom_reason:
|
||||
sell_signal = SellType.CUSTOM_SELL
|
||||
if isinstance(custom_reason, str):
|
||||
@@ -617,7 +624,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
# logger.debug(f"{trade.pair} - No sell signal.")
|
||||
return SellCheckTuple(sell_type=SellType.NONE)
|
||||
|
||||
def stop_loss_reached(self, dataframe: DataFrame, current_rate: float, trade: Trade,
|
||||
def stop_loss_reached(self, current_rate: float, trade: Trade,
|
||||
current_time: datetime, current_profit: float,
|
||||
force_stoploss: float, high: float = None) -> SellCheckTuple:
|
||||
"""
|
||||
@@ -635,8 +642,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
)(pair=trade.pair, trade=trade,
|
||||
current_time=current_time,
|
||||
current_rate=current_rate,
|
||||
current_profit=current_profit,
|
||||
dataframe=dataframe)
|
||||
current_profit=current_profit)
|
||||
# Sanity check - error cases will return None
|
||||
if stop_loss_value:
|
||||
# logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}")
|
||||
|
@@ -9,7 +9,8 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 30,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"price_side": "bid",
|
||||
|
@@ -39,7 +39,7 @@ def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime',
|
||||
return self.stoploss
|
||||
|
||||
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
time_in_force: str, **kwargs) -> bool:
|
||||
time_in_force: str, current_time: 'datetime', **kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a buy order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -54,6 +54,7 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f
|
||||
:param amount: Amount in target (quote) currency that's going to be traded.
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the buy-order is placed on the exchange.
|
||||
False aborts the process
|
||||
@@ -61,7 +62,8 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f
|
||||
return True
|
||||
|
||||
def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount: float,
|
||||
rate: float, time_in_force: str, sell_reason: str, **kwargs) -> bool:
|
||||
rate: float, time_in_force: str, sell_reason: str,
|
||||
current_time: 'datetime', **kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a regular sell order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -80,6 +82,7 @@ def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount:
|
||||
:param sell_reason: Sell reason.
|
||||
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
|
||||
'sell_signal', 'force_sell', 'emergency_sell']
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the sell-order is placed on the exchange.
|
||||
False aborts the process
|
||||
|
Reference in New Issue
Block a user