Merge branch 'develop' into pr/yazeed/3008

This commit is contained in:
Matthias
2020-08-14 06:58:09 +02:00
160 changed files with 7932 additions and 2670 deletions

View File

@@ -9,7 +9,8 @@ Note: Be careful with file-scoped imports in these subfiles.
from freqtrade.commands.arguments import Arguments
from freqtrade.commands.build_config_commands import start_new_config
from freqtrade.commands.data_commands import (start_convert_data,
start_download_data)
start_download_data,
start_list_data)
from freqtrade.commands.deploy_commands import (start_create_userdir,
start_new_hyperopt,
start_new_strategy)
@@ -19,7 +20,8 @@ from freqtrade.commands.list_commands import (start_list_exchanges,
start_list_hyperopts,
start_list_markets,
start_list_strategies,
start_list_timeframes)
start_list_timeframes,
start_show_trades)
from freqtrade.commands.optimize_commands import (start_backtesting,
start_edge, start_hyperopt)
from freqtrade.commands.pairlist_commands import start_test_pairlist

View File

@@ -15,7 +15,7 @@ ARGS_STRATEGY = ["strategy", "strategy_path"]
ARGS_TRADE = ["db_url", "sd_notify", "dry_run"]
ARGS_COMMON_OPTIMIZE = ["ticker_interval", "timerange",
ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange",
"max_open_trades", "stake_amount", "fee"]
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
@@ -54,21 +54,26 @@ ARGS_BUILD_HYPEROPT = ["user_data_dir", "hyperopt", "template"]
ARGS_CONVERT_DATA = ["pairs", "format_from", "format_to", "erase"]
ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes"]
ARGS_LIST_DATA = ["exchange", "dataformat_ohlcv", "pairs"]
ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "download_trades", "exchange",
"timeframes", "erase", "dataformat_ohlcv", "dataformat_trades"]
ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit",
"db_url", "trade_source", "export", "exportfilename",
"timerange", "ticker_interval", "no_trades"]
"timerange", "timeframe", "no_trades"]
ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url",
"trade_source", "ticker_interval"]
"trade_source", "timeframe"]
ARGS_SHOW_TRADES = ["db_url", "trade_ids", "print_json"]
ARGS_HYPEROPT_LIST = ["hyperopt_list_best", "hyperopt_list_profitable",
"hyperopt_list_min_trades", "hyperopt_list_max_trades",
"hyperopt_list_min_avg_time", "hyperopt_list_max_avg_time",
"hyperopt_list_min_avg_profit", "hyperopt_list_max_avg_profit",
"hyperopt_list_min_total_profit", "hyperopt_list_max_total_profit",
"hyperopt_list_min_objective", "hyperopt_list_max_objective",
"print_colorized", "print_json", "hyperopt_list_no_details",
"export_csv"]
@@ -76,9 +81,9 @@ ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperop
"print_json", "hyperopt_show_no_header"]
NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes",
"list-markets", "list-pairs", "list-strategies",
"list-markets", "list-pairs", "list-strategies", "list-data",
"list-hyperopts", "hyperopt-list", "hyperopt-show",
"plot-dataframe", "plot-profit"]
"plot-dataframe", "plot-profit", "show-trades"]
NO_CONF_ALLOWED = ["create-userdir", "list-exchanges", "new-hyperopt", "new-strategy"]
@@ -157,13 +162,13 @@ class Arguments:
self._build_args(optionlist=['version'], parser=self.parser)
from freqtrade.commands import (start_create_userdir, start_convert_data,
start_download_data,
start_download_data, start_list_data,
start_hyperopt_list, start_hyperopt_show,
start_list_exchanges, start_list_hyperopts,
start_list_markets, start_list_strategies,
start_list_timeframes, start_new_config,
start_new_hyperopt, start_new_strategy,
start_plot_dataframe, start_plot_profit,
start_plot_dataframe, start_plot_profit, start_show_trades,
start_backtesting, start_hyperopt, start_edge,
start_test_pairlist, start_trading)
@@ -179,25 +184,6 @@ class Arguments:
trade_cmd.set_defaults(func=start_trading)
self._build_args(optionlist=ARGS_TRADE, parser=trade_cmd)
# Add backtesting subcommand
backtesting_cmd = subparsers.add_parser('backtesting', help='Backtesting module.',
parents=[_common_parser, _strategy_parser])
backtesting_cmd.set_defaults(func=start_backtesting)
self._build_args(optionlist=ARGS_BACKTEST, parser=backtesting_cmd)
# Add edge subcommand
edge_cmd = subparsers.add_parser('edge', help='Edge module.',
parents=[_common_parser, _strategy_parser])
edge_cmd.set_defaults(func=start_edge)
self._build_args(optionlist=ARGS_EDGE, parser=edge_cmd)
# Add hyperopt subcommand
hyperopt_cmd = subparsers.add_parser('hyperopt', help='Hyperopt module.',
parents=[_common_parser, _strategy_parser],
)
hyperopt_cmd.set_defaults(func=start_hyperopt)
self._build_args(optionlist=ARGS_HYPEROPT, parser=hyperopt_cmd)
# add create-userdir subcommand
create_userdir_cmd = subparsers.add_parser('create-userdir',
help="Create user-data directory.",
@@ -211,79 +197,17 @@ class Arguments:
build_config_cmd.set_defaults(func=start_new_config)
self._build_args(optionlist=ARGS_BUILD_CONFIG, parser=build_config_cmd)
# add new-strategy subcommand
build_strategy_cmd = subparsers.add_parser('new-strategy',
help="Create new strategy")
build_strategy_cmd.set_defaults(func=start_new_strategy)
self._build_args(optionlist=ARGS_BUILD_STRATEGY, parser=build_strategy_cmd)
# add new-hyperopt subcommand
build_hyperopt_cmd = subparsers.add_parser('new-hyperopt',
help="Create new hyperopt")
build_hyperopt_cmd.set_defaults(func=start_new_hyperopt)
self._build_args(optionlist=ARGS_BUILD_HYPEROPT, parser=build_hyperopt_cmd)
# Add list-strategies subcommand
list_strategies_cmd = subparsers.add_parser(
'list-strategies',
help='Print available strategies.',
parents=[_common_parser],
)
list_strategies_cmd.set_defaults(func=start_list_strategies)
self._build_args(optionlist=ARGS_LIST_STRATEGIES, parser=list_strategies_cmd)
# Add list-hyperopts subcommand
list_hyperopts_cmd = subparsers.add_parser(
'list-hyperopts',
help='Print available hyperopt classes.',
parents=[_common_parser],
)
list_hyperopts_cmd.set_defaults(func=start_list_hyperopts)
self._build_args(optionlist=ARGS_LIST_HYPEROPTS, parser=list_hyperopts_cmd)
# Add list-exchanges subcommand
list_exchanges_cmd = subparsers.add_parser(
'list-exchanges',
help='Print available exchanges.',
parents=[_common_parser],
)
list_exchanges_cmd.set_defaults(func=start_list_exchanges)
self._build_args(optionlist=ARGS_LIST_EXCHANGES, parser=list_exchanges_cmd)
# Add list-timeframes subcommand
list_timeframes_cmd = subparsers.add_parser(
'list-timeframes',
help='Print available ticker intervals (timeframes) for the exchange.',
parents=[_common_parser],
)
list_timeframes_cmd.set_defaults(func=start_list_timeframes)
self._build_args(optionlist=ARGS_LIST_TIMEFRAMES, parser=list_timeframes_cmd)
# Add list-markets subcommand
list_markets_cmd = subparsers.add_parser(
'list-markets',
help='Print markets on exchange.',
parents=[_common_parser],
)
list_markets_cmd.set_defaults(func=partial(start_list_markets, pairs_only=False))
self._build_args(optionlist=ARGS_LIST_PAIRS, parser=list_markets_cmd)
# Add list-pairs subcommand
list_pairs_cmd = subparsers.add_parser(
'list-pairs',
help='Print pairs on exchange.',
parents=[_common_parser],
)
list_pairs_cmd.set_defaults(func=partial(start_list_markets, pairs_only=True))
self._build_args(optionlist=ARGS_LIST_PAIRS, parser=list_pairs_cmd)
# Add test-pairlist subcommand
test_pairlist_cmd = subparsers.add_parser(
'test-pairlist',
help='Test your pairlist configuration.',
)
test_pairlist_cmd.set_defaults(func=start_test_pairlist)
self._build_args(optionlist=ARGS_TEST_PAIRLIST, parser=test_pairlist_cmd)
# add new-strategy subcommand
build_strategy_cmd = subparsers.add_parser('new-strategy',
help="Create new strategy")
build_strategy_cmd.set_defaults(func=start_new_strategy)
self._build_args(optionlist=ARGS_BUILD_STRATEGY, parser=build_strategy_cmd)
# Add download-data subcommand
download_data_cmd = subparsers.add_parser(
@@ -312,23 +236,33 @@ class Arguments:
convert_trade_data_cmd.set_defaults(func=partial(start_convert_data, ohlcv=False))
self._build_args(optionlist=ARGS_CONVERT_DATA, parser=convert_trade_data_cmd)
# Add Plotting subcommand
plot_dataframe_cmd = subparsers.add_parser(
'plot-dataframe',
help='Plot candles with indicators.',
parents=[_common_parser, _strategy_parser],
)
plot_dataframe_cmd.set_defaults(func=start_plot_dataframe)
self._build_args(optionlist=ARGS_PLOT_DATAFRAME, parser=plot_dataframe_cmd)
# Plot profit
plot_profit_cmd = subparsers.add_parser(
'plot-profit',
help='Generate plot showing profits.',
# Add list-data subcommand
list_data_cmd = subparsers.add_parser(
'list-data',
help='List downloaded data.',
parents=[_common_parser],
)
plot_profit_cmd.set_defaults(func=start_plot_profit)
self._build_args(optionlist=ARGS_PLOT_PROFIT, parser=plot_profit_cmd)
list_data_cmd.set_defaults(func=start_list_data)
self._build_args(optionlist=ARGS_LIST_DATA, parser=list_data_cmd)
# Add backtesting subcommand
backtesting_cmd = subparsers.add_parser('backtesting', help='Backtesting module.',
parents=[_common_parser, _strategy_parser])
backtesting_cmd.set_defaults(func=start_backtesting)
self._build_args(optionlist=ARGS_BACKTEST, parser=backtesting_cmd)
# Add edge subcommand
edge_cmd = subparsers.add_parser('edge', help='Edge module.',
parents=[_common_parser, _strategy_parser])
edge_cmd.set_defaults(func=start_edge)
self._build_args(optionlist=ARGS_EDGE, parser=edge_cmd)
# Add hyperopt subcommand
hyperopt_cmd = subparsers.add_parser('hyperopt', help='Hyperopt module.',
parents=[_common_parser, _strategy_parser],
)
hyperopt_cmd.set_defaults(func=start_hyperopt)
self._build_args(optionlist=ARGS_HYPEROPT, parser=hyperopt_cmd)
# Add hyperopt-list subcommand
hyperopt_list_cmd = subparsers.add_parser(
@@ -347,3 +281,92 @@ class Arguments:
)
hyperopt_show_cmd.set_defaults(func=start_hyperopt_show)
self._build_args(optionlist=ARGS_HYPEROPT_SHOW, parser=hyperopt_show_cmd)
# Add list-exchanges subcommand
list_exchanges_cmd = subparsers.add_parser(
'list-exchanges',
help='Print available exchanges.',
parents=[_common_parser],
)
list_exchanges_cmd.set_defaults(func=start_list_exchanges)
self._build_args(optionlist=ARGS_LIST_EXCHANGES, parser=list_exchanges_cmd)
# Add list-hyperopts subcommand
list_hyperopts_cmd = subparsers.add_parser(
'list-hyperopts',
help='Print available hyperopt classes.',
parents=[_common_parser],
)
list_hyperopts_cmd.set_defaults(func=start_list_hyperopts)
self._build_args(optionlist=ARGS_LIST_HYPEROPTS, parser=list_hyperopts_cmd)
# Add list-markets subcommand
list_markets_cmd = subparsers.add_parser(
'list-markets',
help='Print markets on exchange.',
parents=[_common_parser],
)
list_markets_cmd.set_defaults(func=partial(start_list_markets, pairs_only=False))
self._build_args(optionlist=ARGS_LIST_PAIRS, parser=list_markets_cmd)
# Add list-pairs subcommand
list_pairs_cmd = subparsers.add_parser(
'list-pairs',
help='Print pairs on exchange.',
parents=[_common_parser],
)
list_pairs_cmd.set_defaults(func=partial(start_list_markets, pairs_only=True))
self._build_args(optionlist=ARGS_LIST_PAIRS, parser=list_pairs_cmd)
# Add list-strategies subcommand
list_strategies_cmd = subparsers.add_parser(
'list-strategies',
help='Print available strategies.',
parents=[_common_parser],
)
list_strategies_cmd.set_defaults(func=start_list_strategies)
self._build_args(optionlist=ARGS_LIST_STRATEGIES, parser=list_strategies_cmd)
# Add list-timeframes subcommand
list_timeframes_cmd = subparsers.add_parser(
'list-timeframes',
help='Print available timeframes for the exchange.',
parents=[_common_parser],
)
list_timeframes_cmd.set_defaults(func=start_list_timeframes)
self._build_args(optionlist=ARGS_LIST_TIMEFRAMES, parser=list_timeframes_cmd)
# Add show-trades subcommand
show_trades = subparsers.add_parser(
'show-trades',
help='Show trades.',
parents=[_common_parser],
)
show_trades.set_defaults(func=start_show_trades)
self._build_args(optionlist=ARGS_SHOW_TRADES, parser=show_trades)
# Add test-pairlist subcommand
test_pairlist_cmd = subparsers.add_parser(
'test-pairlist',
help='Test your pairlist configuration.',
)
test_pairlist_cmd.set_defaults(func=start_test_pairlist)
self._build_args(optionlist=ARGS_TEST_PAIRLIST, parser=test_pairlist_cmd)
# Add Plotting subcommand
plot_dataframe_cmd = subparsers.add_parser(
'plot-dataframe',
help='Plot candles with indicators.',
parents=[_common_parser, _strategy_parser],
)
plot_dataframe_cmd.set_defaults(func=start_plot_dataframe)
self._build_args(optionlist=ARGS_PLOT_DATAFRAME, parser=plot_dataframe_cmd)
# Plot profit
plot_profit_cmd = subparsers.add_parser(
'plot-profit',
help='Generate plot showing profits.',
parents=[_common_parser],
)
plot_profit_cmd.set_defaults(func=start_plot_profit)
self._build_args(optionlist=ARGS_PLOT_PROFIT, parser=plot_profit_cmd)

View File

@@ -75,8 +75,8 @@ def ask_user_config() -> Dict[str, Any]:
},
{
"type": "text",
"name": "ticker_interval",
"message": "Please insert your timeframe (ticker interval):",
"name": "timeframe",
"message": "Please insert your desired timeframe (e.g. 5m):",
"default": "5m",
},
{
@@ -163,7 +163,7 @@ def deploy_new_config(config_path: Path, selections: Dict[str, Any]) -> None:
)
except TemplateNotFound:
selections['exchange'] = render_template(
templatefile=f"subtemplates/exchange_generic.j2",
templatefile="subtemplates/exchange_generic.j2",
arguments=selections
)

View File

@@ -110,8 +110,8 @@ AVAILABLE_CLI_OPTIONS = {
action='store_true',
),
# Optimize common
"ticker_interval": Arg(
'-i', '--ticker-interval',
"timeframe": Arg(
'-i', '--timeframe', '--ticker-interval',
help='Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`).',
),
"timerange": Arg(
@@ -217,7 +217,7 @@ AVAILABLE_CLI_OPTIONS = {
),
"print_json": Arg(
'--print-json',
help='Print best result detailization in JSON format.',
help='Print output in JSON format.',
action='store_true',
default=False,
),
@@ -372,8 +372,8 @@ AVAILABLE_CLI_OPTIONS = {
),
"timeframes": Arg(
'-t', '--timeframes',
help=f'Specify which tickers to download. Space-separated list. '
f'Default: `1m 5m`.',
help='Specify which tickers to download. Space-separated list. '
'Default: `1m 5m`.',
choices=['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h',
'6h', '8h', '12h', '1d', '3d', '1w'],
default=['1m', '5m'],
@@ -387,9 +387,9 @@ AVAILABLE_CLI_OPTIONS = {
# Templating options
"template": Arg(
'--template',
help='Use a template which is either `minimal` or '
'`full` (containing multiple sample indicators). Default: `%(default)s`.',
choices=['full', 'minimal'],
help='Use a template which is either `minimal`, '
'`full` (containing multiple sample indicators) or `advanced`. Default: `%(default)s`.',
choices=['full', 'minimal', 'advanced'],
default='full',
),
# Plot dataframe
@@ -425,6 +425,11 @@ AVAILABLE_CLI_OPTIONS = {
choices=["DB", "file"],
default="file",
),
"trade_ids": Arg(
'--trade-ids',
help='Specify the list of trade ids.',
nargs='+',
),
# hyperopt-list, hyperopt-show
"hyperopt_list_profitable": Arg(
'--profitable',
@@ -450,37 +455,49 @@ AVAILABLE_CLI_OPTIONS = {
),
"hyperopt_list_min_avg_time": Arg(
'--min-avg-time',
help='Select epochs on above average time.',
help='Select epochs above average time.',
type=float,
metavar='FLOAT',
),
"hyperopt_list_max_avg_time": Arg(
'--max-avg-time',
help='Select epochs on under average time.',
help='Select epochs below average time.',
type=float,
metavar='FLOAT',
),
"hyperopt_list_min_avg_profit": Arg(
'--min-avg-profit',
help='Select epochs on above average profit.',
help='Select epochs above average profit.',
type=float,
metavar='FLOAT',
),
"hyperopt_list_max_avg_profit": Arg(
'--max-avg-profit',
help='Select epochs on below average profit.',
help='Select epochs below average profit.',
type=float,
metavar='FLOAT',
),
"hyperopt_list_min_total_profit": Arg(
'--min-total-profit',
help='Select epochs on above total profit.',
help='Select epochs above total profit.',
type=float,
metavar='FLOAT',
),
"hyperopt_list_max_total_profit": Arg(
'--max-total-profit',
help='Select epochs on below total profit.',
help='Select epochs below total profit.',
type=float,
metavar='FLOAT',
),
"hyperopt_list_min_objective": Arg(
'--min-objective',
help='Select epochs above objective.',
type=float,
metavar='FLOAT',
),
"hyperopt_list_max_objective": Arg(
'--max-objective',
help='Select epochs below objective.',
type=float,
metavar='FLOAT',
),

View File

@@ -1,5 +1,6 @@
import logging
import sys
from collections import defaultdict
from typing import Any, Dict, List
import arrow
@@ -11,6 +12,7 @@ from freqtrade.data.history import (convert_trades_to_ohlcv,
refresh_backtest_ohlcv_data,
refresh_backtest_trades_data)
from freqtrade.exceptions import OperationalException
from freqtrade.exchange import timeframe_to_minutes
from freqtrade.resolvers import ExchangeResolver
from freqtrade.state import RunMode
@@ -88,3 +90,30 @@ def start_convert_data(args: Dict[str, Any], ohlcv: bool = True) -> None:
convert_trades_format(config,
convert_from=args['format_from'], convert_to=args['format_to'],
erase=args['erase'])
def start_list_data(args: Dict[str, Any]) -> None:
"""
List available backtest data
"""
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
from freqtrade.data.history.idatahandler import get_datahandler
from tabulate import tabulate
dhc = get_datahandler(config['datadir'], config['dataformat_ohlcv'])
paircombs = dhc.ohlcv_get_available_data(config['datadir'])
if args['pairs']:
paircombs = [comb for comb in paircombs if comb[0] in args['pairs']]
print(f"Found {len(paircombs)} pair / timeframe combinations.")
groupedpair = defaultdict(list)
for pair, timeframe in sorted(paircombs, key=lambda x: (x[0], timeframe_to_minutes(x[1]))):
groupedpair[pair].append(timeframe)
if groupedpair:
print(tabulate([(pair, ', '.join(timeframes)) for pair, timeframes in groupedpair.items()],
headers=("Pair", "Timeframe"),
tablefmt='psql', stralign='right'))

View File

@@ -8,7 +8,7 @@ from freqtrade.configuration.directory_operations import (copy_sample_files,
create_userdata_dir)
from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGIES
from freqtrade.exceptions import OperationalException
from freqtrade.misc import render_template
from freqtrade.misc import render_template, render_template_with_fallback
from freqtrade.state import RunMode
logger = logging.getLogger(__name__)
@@ -32,10 +32,27 @@ def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: st
"""
Deploy new strategy from template to strategy_path
"""
indicators = render_template(templatefile=f"subtemplates/indicators_{subtemplate}.j2",)
buy_trend = render_template(templatefile=f"subtemplates/buy_trend_{subtemplate}.j2",)
sell_trend = render_template(templatefile=f"subtemplates/sell_trend_{subtemplate}.j2",)
plot_config = render_template(templatefile=f"subtemplates/plot_config_{subtemplate}.j2",)
fallback = 'full'
indicators = render_template_with_fallback(
templatefile=f"subtemplates/indicators_{subtemplate}.j2",
templatefallbackfile=f"subtemplates/indicators_{fallback}.j2",
)
buy_trend = render_template_with_fallback(
templatefile=f"subtemplates/buy_trend_{subtemplate}.j2",
templatefallbackfile=f"subtemplates/buy_trend_{fallback}.j2",
)
sell_trend = render_template_with_fallback(
templatefile=f"subtemplates/sell_trend_{subtemplate}.j2",
templatefallbackfile=f"subtemplates/sell_trend_{fallback}.j2",
)
plot_config = render_template_with_fallback(
templatefile=f"subtemplates/plot_config_{subtemplate}.j2",
templatefallbackfile=f"subtemplates/plot_config_{fallback}.j2",
)
additional_methods = render_template_with_fallback(
templatefile=f"subtemplates/strategy_methods_{subtemplate}.j2",
templatefallbackfile="subtemplates/strategy_methods_empty.j2",
)
strategy_text = render_template(templatefile='base_strategy.py.j2',
arguments={"strategy": strategy_name,
@@ -43,6 +60,7 @@ def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: st
"buy_trend": buy_trend,
"sell_trend": sell_trend,
"plot_config": plot_config,
"additional_methods": additional_methods,
})
logger.info(f"Writing strategy to `{strategy_path}`.")
@@ -73,14 +91,23 @@ def deploy_new_hyperopt(hyperopt_name: str, hyperopt_path: Path, subtemplate: st
"""
Deploys a new hyperopt template to hyperopt_path
"""
buy_guards = render_template(
templatefile=f"subtemplates/hyperopt_buy_guards_{subtemplate}.j2",)
sell_guards = render_template(
templatefile=f"subtemplates/hyperopt_sell_guards_{subtemplate}.j2",)
buy_space = render_template(
templatefile=f"subtemplates/hyperopt_buy_space_{subtemplate}.j2",)
sell_space = render_template(
templatefile=f"subtemplates/hyperopt_sell_space_{subtemplate}.j2",)
fallback = 'full'
buy_guards = render_template_with_fallback(
templatefile=f"subtemplates/hyperopt_buy_guards_{subtemplate}.j2",
templatefallbackfile=f"subtemplates/hyperopt_buy_guards_{fallback}.j2",
)
sell_guards = render_template_with_fallback(
templatefile=f"subtemplates/hyperopt_sell_guards_{subtemplate}.j2",
templatefallbackfile=f"subtemplates/hyperopt_sell_guards_{fallback}.j2",
)
buy_space = render_template_with_fallback(
templatefile=f"subtemplates/hyperopt_buy_space_{subtemplate}.j2",
templatefallbackfile=f"subtemplates/hyperopt_buy_space_{fallback}.j2",
)
sell_space = render_template_with_fallback(
templatefile=f"subtemplates/hyperopt_sell_space_{subtemplate}.j2",
templatefallbackfile=f"subtemplates/hyperopt_sell_space_{fallback}.j2",
)
strategy_text = render_template(templatefile='base_hyperopt.py.j2',
arguments={"hyperopt": hyperopt_name,

View File

@@ -35,36 +35,38 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None:
'filter_min_avg_profit': config.get('hyperopt_list_min_avg_profit', None),
'filter_max_avg_profit': config.get('hyperopt_list_max_avg_profit', None),
'filter_min_total_profit': config.get('hyperopt_list_min_total_profit', None),
'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None)
'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None),
'filter_min_objective': config.get('hyperopt_list_min_objective', None),
'filter_max_objective': config.get('hyperopt_list_max_objective', None),
}
trials_file = (config['user_data_dir'] /
'hyperopt_results' / 'hyperopt_results.pickle')
results_file = (config['user_data_dir'] /
'hyperopt_results' / 'hyperopt_results.pickle')
# Previous evaluations
trials = Hyperopt.load_previous_results(trials_file)
total_epochs = len(trials)
epochs = Hyperopt.load_previous_results(results_file)
total_epochs = len(epochs)
trials = _hyperopt_filter_trials(trials, filteroptions)
epochs = hyperopt_filter_epochs(epochs, filteroptions)
if print_colorized:
colorama_init(autoreset=True)
if not export_csv:
try:
print(Hyperopt.get_result_table(config, trials, total_epochs,
print(Hyperopt.get_result_table(config, epochs, total_epochs,
not filteroptions['only_best'], print_colorized, 0))
except KeyboardInterrupt:
print('User interrupted..')
if trials and not no_details:
sorted_trials = sorted(trials, key=itemgetter('loss'))
results = sorted_trials[0]
if epochs and not no_details:
sorted_epochs = sorted(epochs, key=itemgetter('loss'))
results = sorted_epochs[0]
Hyperopt.print_epoch_details(results, total_epochs, print_json, no_header)
if trials and export_csv:
if epochs and export_csv:
Hyperopt.export_csv_file(
config, trials, total_epochs, not filteroptions['only_best'], export_csv
config, epochs, total_epochs, not filteroptions['only_best'], export_csv
)
@@ -78,8 +80,8 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None:
print_json = config.get('print_json', False)
no_header = config.get('hyperopt_show_no_header', False)
trials_file = (config['user_data_dir'] /
'hyperopt_results' / 'hyperopt_results.pickle')
results_file = (config['user_data_dir'] /
'hyperopt_results' / 'hyperopt_results.pickle')
n = config.get('hyperopt_show_index', -1)
filteroptions = {
@@ -92,93 +94,130 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None:
'filter_min_avg_profit': config.get('hyperopt_list_min_avg_profit', None),
'filter_max_avg_profit': config.get('hyperopt_list_max_avg_profit', None),
'filter_min_total_profit': config.get('hyperopt_list_min_total_profit', None),
'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None)
'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None),
'filter_min_objective': config.get('hyperopt_list_min_objective', None),
'filter_max_objective': config.get('hyperopt_list_max_objective', None)
}
# Previous evaluations
trials = Hyperopt.load_previous_results(trials_file)
total_epochs = len(trials)
epochs = Hyperopt.load_previous_results(results_file)
total_epochs = len(epochs)
trials = _hyperopt_filter_trials(trials, filteroptions)
trials_epochs = len(trials)
epochs = hyperopt_filter_epochs(epochs, filteroptions)
filtered_epochs = len(epochs)
if n > trials_epochs:
if n > filtered_epochs:
raise OperationalException(
f"The index of the epoch to show should be less than {trials_epochs + 1}.")
if n < -trials_epochs:
f"The index of the epoch to show should be less than {filtered_epochs + 1}.")
if n < -filtered_epochs:
raise OperationalException(
f"The index of the epoch to show should be greater than {-trials_epochs - 1}.")
f"The index of the epoch to show should be greater than {-filtered_epochs - 1}.")
# Translate epoch index from human-readable format to pythonic
if n > 0:
n -= 1
if trials:
val = trials[n]
if epochs:
val = epochs[n]
Hyperopt.print_epoch_details(val, total_epochs, print_json, no_header,
header_str="Epoch details")
def _hyperopt_filter_trials(trials: List, filteroptions: dict) -> List:
def hyperopt_filter_epochs(epochs: List, filteroptions: dict) -> List:
"""
Filter our items from the list of hyperopt results
"""
if filteroptions['only_best']:
trials = [x for x in trials if x['is_best']]
epochs = [x for x in epochs if x['is_best']]
if filteroptions['only_profitable']:
trials = [x for x in trials if x['results_metrics']['profit'] > 0]
if filteroptions['filter_min_trades'] > 0:
trials = [
x for x in trials
if x['results_metrics']['trade_count'] > filteroptions['filter_min_trades']
]
if filteroptions['filter_max_trades'] > 0:
trials = [
x for x in trials
if x['results_metrics']['trade_count'] < filteroptions['filter_max_trades']
]
if filteroptions['filter_min_avg_time'] is not None:
trials = [x for x in trials if x['results_metrics']['trade_count'] > 0]
trials = [
x for x in trials
if x['results_metrics']['duration'] > filteroptions['filter_min_avg_time']
]
if filteroptions['filter_max_avg_time'] is not None:
trials = [x for x in trials if x['results_metrics']['trade_count'] > 0]
trials = [
x for x in trials
if x['results_metrics']['duration'] < filteroptions['filter_max_avg_time']
]
if filteroptions['filter_min_avg_profit'] is not None:
trials = [x for x in trials if x['results_metrics']['trade_count'] > 0]
trials = [
x for x in trials
if x['results_metrics']['avg_profit']
> filteroptions['filter_min_avg_profit']
]
if filteroptions['filter_max_avg_profit'] is not None:
trials = [x for x in trials if x['results_metrics']['trade_count'] > 0]
trials = [
x for x in trials
if x['results_metrics']['avg_profit']
< filteroptions['filter_max_avg_profit']
]
if filteroptions['filter_min_total_profit'] is not None:
trials = [x for x in trials if x['results_metrics']['trade_count'] > 0]
trials = [
x for x in trials
if x['results_metrics']['profit'] > filteroptions['filter_min_total_profit']
]
if filteroptions['filter_max_total_profit'] is not None:
trials = [x for x in trials if x['results_metrics']['trade_count'] > 0]
trials = [
x for x in trials
if x['results_metrics']['profit'] < filteroptions['filter_max_total_profit']
]
epochs = [x for x in epochs if x['results_metrics']['profit'] > 0]
logger.info(f"{len(trials)} " +
epochs = _hyperopt_filter_epochs_trade_count(epochs, filteroptions)
epochs = _hyperopt_filter_epochs_duration(epochs, filteroptions)
epochs = _hyperopt_filter_epochs_profit(epochs, filteroptions)
epochs = _hyperopt_filter_epochs_objective(epochs, filteroptions)
logger.info(f"{len(epochs)} " +
("best " if filteroptions['only_best'] else "") +
("profitable " if filteroptions['only_profitable'] else "") +
"epochs found.")
return epochs
return trials
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']
]
if filteroptions['filter_max_trades'] > 0:
epochs = [
x for x in epochs
if x['results_metrics']['trade_count'] < filteroptions['filter_max_trades']
]
return epochs
def _hyperopt_filter_epochs_duration(epochs: List, filteroptions: dict) -> List:
if filteroptions['filter_min_avg_time'] is not None:
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
epochs = [
x for x in epochs
if x['results_metrics']['duration'] > 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 = [
x for x in epochs
if x['results_metrics']['duration'] < filteroptions['filter_max_avg_time']
]
return epochs
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 = [
x for x in epochs
if x['results_metrics']['avg_profit'] > 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 = [
x for x in epochs
if x['results_metrics']['avg_profit'] < 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 = [
x for x in epochs
if x['results_metrics']['profit'] > 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 = [
x for x in epochs
if x['results_metrics']['profit'] < filteroptions['filter_max_total_profit']
]
return epochs
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 = [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 = [x for x in epochs if x['loss'] > filteroptions['filter_max_objective']]
return epochs

View File

@@ -102,8 +102,8 @@ def start_list_timeframes(args: Dict[str, Any]) -> None:
Print ticker intervals (timeframes) available on Exchange
"""
config = setup_utils_configuration(args, RunMode.UTIL_EXCHANGE)
# Do not use ticker_interval set in the config
config['ticker_interval'] = None
# Do not use timeframe set in the config
config['timeframe'] = None
# Init exchange
exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False)
@@ -197,3 +197,30 @@ def start_list_markets(args: Dict[str, Any], pairs_only: bool = False) -> None:
args.get('list_pairs_print_json', False) or
args.get('print_csv', False)):
print(f"{summary_str}.")
def start_show_trades(args: Dict[str, Any]) -> None:
"""
Show trades
"""
from freqtrade.persistence import init, Trade
import json
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
if 'db_url' not in config:
raise OperationalException("--db-url is required for this command.")
logger.info(f'Using DB: "{config["db_url"]}"')
init(config['db_url'], clean_open_orders=False)
tfilter = []
if config.get('trade_ids'):
tfilter.append(Trade.id.in_(config['trade_ids']))
trades = Trade.get_trades(tfilter).all()
logger.info(f"Printing {len(trades)} Trades: ")
if config.get('print_json', False):
print(json.dumps([trade.to_json() for trade in trades], indent=4))
else:
for trade in trades:
print(trade)

View File

@@ -25,7 +25,6 @@ def start_test_pairlist(args: Dict[str, Any]) -> None:
results = {}
for curr in quote_currencies:
config['stake_currency'] = curr
# Do not use ticker_interval set in the config
pairlists = PairListManager(exchange, config)
pairlists.refresh_pairlist()
results[curr] = pairlists.whitelist

View File

@@ -18,6 +18,9 @@ def start_trading(args: Dict[str, Any]) -> int:
try:
worker = Worker(args)
worker.run()
except Exception as e:
logger.error(str(e))
logger.exception("Fatal exception!")
except KeyboardInterrupt:
logger.info('SIGINT received, aborting ...')
finally:

View File

@@ -204,9 +204,9 @@ class Configuration:
def _process_optimize_options(self, config: Dict[str, Any]) -> None:
# This will override the strategy configuration
self._args_to_config(config, argname='ticker_interval',
logstring='Parameter -i/--ticker-interval detected ... '
'Using ticker_interval: {} ...')
self._args_to_config(config, argname='timeframe',
logstring='Parameter -i/--timeframe detected ... '
'Using timeframe: {} ...')
self._args_to_config(config, argname='position_stacking',
logstring='Parameter --enable-position-stacking detected ...')
@@ -242,8 +242,8 @@ class Configuration:
self._args_to_config(config, argname='strategy_list',
logstring='Using strategy list of {} strategies', logfun=len)
self._args_to_config(config, argname='ticker_interval',
logstring='Overriding ticker interval with Command line argument')
self._args_to_config(config, argname='timeframe',
logstring='Overriding timeframe with Command line argument')
self._args_to_config(config, argname='export',
logstring='Parameter --export detected: {} ...')
@@ -334,6 +334,12 @@ class Configuration:
self._args_to_config(config, argname='hyperopt_list_max_total_profit',
logstring='Parameter --max-total-profit detected: {}')
self._args_to_config(config, argname='hyperopt_list_min_objective',
logstring='Parameter --min-objective detected: {}')
self._args_to_config(config, argname='hyperopt_list_max_objective',
logstring='Parameter --max-objective detected: {}')
self._args_to_config(config, argname='hyperopt_list_no_details',
logstring='Parameter --no-details detected: {}')
@@ -351,8 +357,12 @@ class Configuration:
self._args_to_config(config, argname='indicators2',
logstring='Using indicators2: {}')
self._args_to_config(config, argname='trade_ids',
logstring='Filtering on trade_ids: {}')
self._args_to_config(config, argname='plot_limit',
logstring='Limiting plot to: {}')
self._args_to_config(config, argname='trade_source',
logstring='Using trades from: {}')

View File

@@ -58,35 +58,23 @@ def process_temporary_deprecated_settings(config: Dict[str, Any]) -> None:
process_deprecated_setting(config, 'ask_strategy', 'ignore_roi_if_buy_signal',
'experimental', 'ignore_roi_if_buy_signal')
if not config.get('pairlists') and not config.get('pairlists'):
config['pairlists'] = [{'method': 'StaticPairList'}]
logger.warning(
"DEPRECATED: "
"Pairlists must be defined explicitly in the future."
"Defaulting to StaticPairList for now.")
if config.get('pairlist', {}).get("method") == 'VolumePairList':
logger.warning(
"DEPRECATED: "
f"Using VolumePairList in pairlist is deprecated and must be moved to pairlists. "
"Please refer to the docs on configuration details")
pl = {'method': 'VolumePairList'}
pl.update(config.get('pairlist', {}).get('config'))
config['pairlists'].append(pl)
if config.get('pairlist', {}).get('config', {}).get('precision_filter'):
logger.warning(
"DEPRECATED: "
f"Using precision_filter setting is deprecated and has been replaced by"
"PrecisionFilter. Please refer to the docs on configuration details")
config['pairlists'].append({'method': 'PrecisionFilter'})
if (config.get('edge', {}).get('enabled', False)
and 'capital_available_percentage' in config.get('edge', {})):
logger.warning(
raise OperationalException(
"DEPRECATED: "
"Using 'edge.capital_available_percentage' has been deprecated in favor of "
"'tradable_balance_ratio'. Please migrate your configuration to "
"'tradable_balance_ratio' and remove 'capital_available_percentage' "
"from the edge configuration."
)
if 'ticker_interval' in config:
logger.warning(
"DEPRECATED: "
"Please use 'timeframe' instead of 'ticker_interval."
)
if 'timeframe' in config:
raise OperationalException(
"Both 'timeframe' and 'ticker_interval' detected."
"Please remove 'ticker_interval' from your configuration to continue operating."
)
config['timeframe'] = config['ticker_interval']

View File

@@ -3,6 +3,9 @@
"""
bot constants
"""
from typing import List, Tuple
DEFAULT_CONFIG = 'config.json'
DEFAULT_EXCHANGE = 'bittrex'
PROCESS_THROTTLE_SECS = 5 # sec
@@ -19,11 +22,15 @@ ORDERBOOK_SIDES = ['ask', 'bid']
ORDERTYPE_POSSIBILITIES = ['limit', 'market']
ORDERTIF_POSSIBILITIES = ['gtc', 'fok', 'ioc']
AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
'PrecisionFilter', 'PriceFilter', 'SpreadFilter']
'AgeFilter', 'PrecisionFilter', 'PriceFilter',
'ShuffleFilter', 'SpreadFilter']
AVAILABLE_DATAHANDLERS = ['json', 'jsongz']
DRY_RUN_WALLET = 1000
MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons
DEFAULT_DATAFRAME_COLUMNS = ['date', 'open', 'high', 'low', 'close', 'volume']
# Don't modify sequence of DEFAULT_TRADES_COLUMNS
# it has wide consequences for stored trades files
DEFAULT_TRADES_COLUMNS = ['timestamp', 'id', 'type', 'side', 'price', 'amount', 'cost']
USERPATH_HYPEROPTS = 'hyperopts'
USERPATH_STRATEGIES = 'strategies'
@@ -65,7 +72,7 @@ CONF_SCHEMA = {
'type': 'object',
'properties': {
'max_open_trades': {'type': ['integer', 'number'], 'minimum': -1},
'ticker_interval': {'type': 'string'},
'timeframe': {'type': 'string'},
'stake_currency': {'type': 'string'},
'stake_amount': {
'type': ['number', 'string'],
@@ -85,6 +92,7 @@ CONF_SCHEMA = {
'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT},
'dry_run': {'type': 'boolean'},
'dry_run_wallet': {'type': 'number', 'default': DRY_RUN_WALLET},
'cancel_open_orders_on_exit': {'type': 'boolean', 'default': False},
'process_only_new_candles': {'type': 'boolean'},
'minimal_roi': {
'type': 'object',
@@ -148,7 +156,9 @@ CONF_SCHEMA = {
'emergencysell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
'stoploss_on_exchange': {'type': 'boolean'},
'stoploss_on_exchange_interval': {'type': 'number'}
'stoploss_on_exchange_interval': {'type': 'number'},
'stoploss_on_exchange_limit_ratio': {'type': 'number', 'minimum': 0.0,
'maximum': 1.0}
},
'required': ['buy', 'sell', 'stoploss', 'stoploss_on_exchange']
},
@@ -214,12 +224,16 @@ CONF_SCHEMA = {
},
'username': {'type': 'string'},
'password': {'type': 'string'},
'jwt_secret_key': {'type': 'string'},
'CORS_origins': {'type': 'array', 'items': {'type': 'string'}},
'verbosity': {'type': 'string', 'enum': ['error', 'info']},
},
'required': ['enabled', 'listen_ip_address', 'listen_port', 'username', 'password']
},
'db_url': {'type': 'string'},
'initial_state': {'type': 'string', 'enum': ['running', 'stopped']},
'forcebuy_enable': {'type': 'boolean'},
'disable_dataframe_checks': {'type': 'boolean'},
'internals': {
'type': 'object',
'default': {},
@@ -278,7 +292,6 @@ CONF_SCHEMA = {
'process_throttle_secs': {'type': 'integer', 'minimum': 600},
'calculate_since_number_of_days': {'type': 'integer'},
'allowed_risk': {'type': 'number'},
'capital_available_percentage': {'type': 'number'},
'stoploss_range_min': {'type': 'number'},
'stoploss_range_max': {'type': 'number'},
'stoploss_range_step': {'type': 'number'},
@@ -295,6 +308,7 @@ CONF_SCHEMA = {
SCHEMA_TRADE_REQUIRED = [
'exchange',
'timeframe',
'max_open_trades',
'stake_currency',
'stake_amount',
@@ -318,3 +332,14 @@ SCHEMA_MINIMAL_REQUIRED = [
'dataformat_ohlcv',
'dataformat_trades',
]
CANCEL_REASON = {
"TIMEOUT": "cancelled due to timeout",
"PARTIALLY_FILLED": "partially filled - keeping order open",
"ALL_CANCELLED": "cancelled (all unfilled and partially filled open orders cancelled)",
"CANCELLED_ON_EXCHANGE": "cancelled on exchange",
}
# List of pairs with their timeframes
PairWithTimeframe = Tuple[str, str]
ListPairsWithTimeframes = List[PairWithTimeframe]

View File

@@ -16,7 +16,7 @@ from freqtrade.persistence import Trade
logger = logging.getLogger(__name__)
# must align with columns in backtest.py
BT_DATA_COLUMNS = ["pair", "profitperc", "open_time", "close_time", "index", "duration",
BT_DATA_COLUMNS = ["pair", "profit_percent", "open_time", "close_time", "index", "duration",
"open_rate", "close_rate", "open_at_end", "sell_reason"]
@@ -99,11 +99,11 @@ def load_trades_from_db(db_url: str) -> pd.DataFrame:
trades: pd.DataFrame = pd.DataFrame([], columns=BT_DATA_COLUMNS)
persistence.init(db_url, clean_open_orders=False)
columns = ["pair", "open_time", "close_time", "profit", "profitperc",
columns = ["pair", "open_time", "close_time", "profit", "profit_percent",
"open_rate", "close_rate", "amount", "duration", "sell_reason",
"fee_open", "fee_close", "open_rate_requested", "close_rate_requested",
"stake_amount", "max_rate", "min_rate", "id", "exchange",
"stop_loss", "initial_stop_loss", "strategy", "ticker_interval"]
"stop_loss", "initial_stop_loss", "strategy", "timeframe"]
trades = pd.DataFrame([(t.pair,
t.open_date.replace(tzinfo=timezone.utc),
@@ -121,7 +121,7 @@ def load_trades_from_db(db_url: str) -> pd.DataFrame:
t.min_rate,
t.id, t.exchange,
t.stop_loss, t.initial_stop_loss,
t.strategy, t.ticker_interval
t.strategy, t.timeframe
)
for t in Trade.get_trades().all()],
columns=columns)
@@ -190,15 +190,19 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str,
"""
Adds a column `col_name` with the cumulative profit for the given trades array.
:param df: DataFrame with date index
:param trades: DataFrame containing trades (requires columns close_time and profitperc)
:param trades: DataFrame containing trades (requires columns close_time and profit_percent)
:param col_name: Column name that will be assigned the results
:param timeframe: Timeframe used during the operations
:return: Returns df with one additional column, col_name, containing the cumulative profit.
:raise: ValueError if trade-dataframe was found empty.
"""
if len(trades) == 0:
raise ValueError("Trade dataframe empty.")
from freqtrade.exchange import timeframe_to_minutes
timeframe_minutes = timeframe_to_minutes(timeframe)
# Resample to timeframe to make sure trades match candles
_trades_sum = trades.resample(f'{timeframe_minutes}min', on='close_time')[['profitperc']].sum()
_trades_sum = trades.resample(f'{timeframe_minutes}min', on='close_time'
)[['profit_percent']].sum()
df.loc[:, col_name] = _trades_sum.cumsum()
# Set first value to 0
df.loc[df.iloc[0].name, col_name] = 0
@@ -208,13 +212,13 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str,
def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_time',
value_col: str = 'profitperc'
value_col: str = 'profit_percent'
) -> Tuple[float, pd.Timestamp, pd.Timestamp]:
"""
Calculate max drawdown and the corresponding close dates
:param trades: DataFrame containing trades (requires columns close_time and profitperc)
:param trades: DataFrame containing trades (requires columns close_time and profit_percent)
:param date_col: Column in DataFrame to use for dates (defaults to 'close_time')
:param value_col: Column in DataFrame to use for values (defaults to 'profitperc')
:param value_col: Column in DataFrame to use for values (defaults to 'profit_percent')
:return: Tuple (float, highdate, lowdate) with absolute max drawdown, high and low time
:raise: ValueError if trade-dataframe was found empty.
"""

View File

@@ -1,14 +1,17 @@
"""
Functions to convert data from one format to another
"""
import itertools
import logging
from datetime import datetime, timezone
from typing import Any, Dict
from operator import itemgetter
from typing import Any, Dict, List
import pandas as pd
from pandas import DataFrame, to_datetime
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
from freqtrade.constants import (DEFAULT_DATAFRAME_COLUMNS,
DEFAULT_TRADES_COLUMNS)
logger = logging.getLogger(__name__)
@@ -154,7 +157,27 @@ def order_book_to_dataframe(bids: list, asks: list) -> DataFrame:
return frame
def trades_to_ohlcv(trades: list, timeframe: str) -> DataFrame:
def trades_remove_duplicates(trades: List[List]) -> List[List]:
"""
Removes duplicates from the trades list.
Uses itertools.groupby to avoid converting to pandas.
Tests show it as being pretty efficient on lists of 4M Lists.
:param trades: List of Lists with constants.DEFAULT_TRADES_COLUMNS as columns
:return: same format as above, but with duplicates removed
"""
return [i for i, _ in itertools.groupby(sorted(trades, key=itemgetter(0)))]
def trades_dict_to_list(trades: List[Dict]) -> List[List]:
"""
Convert fetch_trades result into a List (to be more memory efficient).
:param trades: List of trades, as returned by ccxt.fetch_trades.
:return: List of Lists, with constants.DEFAULT_TRADES_COLUMNS as columns
"""
return [[t[col] for col in DEFAULT_TRADES_COLUMNS] for t in trades]
def trades_to_ohlcv(trades: List, timeframe: str) -> DataFrame:
"""
Converts trades list to OHLCV list
TODO: This should get a dedicated test
@@ -164,16 +187,17 @@ def trades_to_ohlcv(trades: list, timeframe: str) -> DataFrame:
"""
from freqtrade.exchange import timeframe_to_minutes
timeframe_minutes = timeframe_to_minutes(timeframe)
df = pd.DataFrame(trades)
df['datetime'] = pd.to_datetime(df['datetime'])
df = df.set_index('datetime')
df = pd.DataFrame(trades, columns=DEFAULT_TRADES_COLUMNS)
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms',
utc=True,)
df = df.set_index('timestamp')
df_new = df['price'].resample(f'{timeframe_minutes}min').ohlc()
df_new['volume'] = df['amount'].resample(f'{timeframe_minutes}min').sum()
df_new['date'] = df_new.index
# Drop 0 volume rows
df_new = df_new.dropna()
return df_new[DEFAULT_DATAFRAME_COLUMNS]
return df_new.loc[:, DEFAULT_DATAFRAME_COLUMNS]
def convert_trades_format(config: Dict[str, Any], convert_from: str, convert_to: str, erase: bool):
@@ -212,12 +236,12 @@ def convert_ohlcv_format(config: Dict[str, Any], convert_from: str, convert_to:
from freqtrade.data.history.idatahandler import get_datahandler
src = get_datahandler(config['datadir'], convert_from)
trg = get_datahandler(config['datadir'], convert_to)
timeframes = config.get('timeframes', [config.get('ticker_interval')])
timeframes = config.get('timeframes', [config.get('timeframe')])
logger.info(f"Converting candle (OHLCV) for timeframe {timeframes}")
if 'pairs' not in config:
config['pairs'] = []
# Check timeframes or fall back to ticker_interval.
# Check timeframes or fall back to timeframe.
for timeframe in timeframes:
config['pairs'].extend(src.ohlcv_get_pairs(config['datadir'],
timeframe))

View File

@@ -5,11 +5,15 @@ including ticker and orderbook data, live and historical candle (OHLCV) data
Common Interface for bot and strategy to access data.
"""
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from arrow import Arrow
from pandas import DataFrame
from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe
from freqtrade.data.history import load_pair_history
from freqtrade.exceptions import ExchangeError, OperationalException
from freqtrade.exchange import Exchange
from freqtrade.state import RunMode
@@ -18,13 +22,26 @@ logger = logging.getLogger(__name__)
class DataProvider:
def __init__(self, config: dict, exchange: Exchange) -> None:
def __init__(self, config: dict, exchange: Exchange, pairlists=None) -> None:
self._config = config
self._exchange = exchange
self._pairlists = pairlists
self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {}
def _set_cached_df(self, pair: str, timeframe: str, dataframe: DataFrame) -> None:
"""
Store cached Dataframe.
Using private method as this should never be used by a user
(but the class is exposed via `self.dp` to the strategy)
:param pair: pair to get the data for
:param timeframe: Timeframe to get data for
:param dataframe: analyzed dataframe
"""
self.__cached_pairs[(pair, timeframe)] = (dataframe, Arrow.utcnow().datetime)
def refresh(self,
pairlist: List[Tuple[str, str]],
helping_pairs: List[Tuple[str, str]] = None) -> None:
pairlist: ListPairsWithTimeframes,
helping_pairs: ListPairsWithTimeframes = None) -> None:
"""
Refresh data, called with each cycle
"""
@@ -34,7 +51,7 @@ class DataProvider:
self._exchange.refresh_latest_ohlcv(pairlist)
@property
def available_pairs(self) -> List[Tuple[str, str]]:
def available_pairs(self) -> ListPairsWithTimeframes:
"""
Return a list of tuples containing (pair, timeframe) for which data is currently cached.
Should be whitelist + open trades.
@@ -51,7 +68,7 @@ class DataProvider:
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['ticker_interval']),
return self._exchange.klines((pair, timeframe or self._config['timeframe']),
copy=copy)
else:
return DataFrame()
@@ -63,7 +80,7 @@ class DataProvider:
:param timeframe: timeframe to get data for
"""
return load_pair_history(pair=pair,
timeframe=timeframe or self._config['ticker_interval'],
timeframe=timeframe or self._config['timeframe'],
datadir=self._config['datadir']
)
@@ -85,6 +102,20 @@ class DataProvider:
logger.warning(f"No data found for ({pair}, {timeframe}).")
return data
def get_analyzed_dataframe(self, pair: str, timeframe: str) -> Tuple[DataFrame, datetime]:
"""
: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)]
else:
return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc))
def market(self, pair: str) -> Optional[Dict[str, Any]]:
"""
Return market data for the pair
@@ -95,19 +126,24 @@ class DataProvider:
def ticker(self, pair: str):
"""
Return last ticker data
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
"""
# TODO: Implement me
pass
try:
return self._exchange.fetch_ticker(pair)
except ExchangeError:
return {}
def orderbook(self, pair: str, maximum: int) -> Dict[str, List]:
"""
fetch latest orderbook data
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.get_order_book(pair, maximum)
return self._exchange.fetch_l2_order_book(pair, maximum)
@property
def runmode(self) -> RunMode:
@@ -116,3 +152,17 @@ class DataProvider:
can be "live", "dry-run", "backtest", "edgecli", "hyperopt" or "other".
"""
return RunMode(self._config.get('runmode', RunMode.OTHER))
def current_whitelist(self) -> List[str]:
"""
fetch latest available whitelist.
Useful when you have a large whitelist and need to call each pair as an informative pair.
As available pairs does not show whitelist until after informative pairs have been cached.
:return: list of pairs in whitelist
"""
if self._pairlists:
return self._pairlists.whitelist
else:
raise OperationalException("Dataprovider was not initialized with a pairlist provider.")

View File

@@ -9,10 +9,13 @@ from pandas import DataFrame
from freqtrade.configuration import TimeRange
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
from freqtrade.data.converter import ohlcv_to_dataframe, trades_to_ohlcv
from freqtrade.data.converter import (ohlcv_to_dataframe,
trades_remove_duplicates,
trades_to_ohlcv)
from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler
from freqtrade.exceptions import OperationalException
from freqtrade.exchange import Exchange
from freqtrade.misc import format_ms_time
logger = logging.getLogger(__name__)
@@ -257,27 +260,45 @@ def _download_trades_history(exchange: Exchange,
"""
try:
since = timerange.startts * 1000 if timerange and timerange.starttype == 'date' else None
since = timerange.startts * 1000 if \
(timerange and timerange.starttype == 'date') else int(arrow.utcnow().shift(
days=-30).float_timestamp) * 1000
trades = data_handler.trades_load(pair)
from_id = trades[-1]['id'] if trades else None
# TradesList columns are defined in constants.DEFAULT_TRADES_COLUMNS
# DEFAULT_TRADES_COLUMNS: 0 -> timestamp
# DEFAULT_TRADES_COLUMNS: 1 -> id
logger.debug("Current Start: %s", trades[0]['datetime'] if trades else 'None')
logger.debug("Current End: %s", trades[-1]['datetime'] if trades else 'None')
if trades and since < trades[0][0]:
# since is before the first trade
logger.info(f"Start earlier than available data. Redownloading trades for {pair}...")
trades = []
from_id = trades[-1][1] if trades else None
if trades and since < trades[-1][0]:
# Reset since to the last available point
# - 5 seconds (to ensure we're getting all trades)
since = trades[-1][0] - (5 * 1000)
logger.info(f"Using last trade date -5s - Downloading trades for {pair} "
f"since: {format_ms_time(since)}.")
logger.debug(f"Current Start: {format_ms_time(trades[0][0]) if trades else 'None'}")
logger.debug(f"Current End: {format_ms_time(trades[-1][0]) if trades else 'None'}")
logger.info(f"Current Amount of trades: {len(trades)}")
# Default since_ms to 30 days if nothing is given
new_trades = exchange.get_historic_trades(pair=pair,
since=since if since else
int(arrow.utcnow().shift(
days=-30).float_timestamp) * 1000,
since=since,
from_id=from_id,
)
trades.extend(new_trades[1])
# Remove duplicates to make sure we're not storing data we don't need
trades = trades_remove_duplicates(trades)
data_handler.trades_store(pair, data=trades)
logger.debug("New Start: %s", trades[0]['datetime'])
logger.debug("New End: %s", trades[-1]['datetime'])
logger.debug(f"New Start: {format_ms_time(trades[0][0])}")
logger.debug(f"New End: {format_ms_time(trades[-1][0])}")
logger.info(f"New Amount of trades: {len(trades)}")
return True

View File

@@ -8,22 +8,35 @@ from abc import ABC, abstractclassmethod, abstractmethod
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Type
from typing import List, Optional, Type
from pandas import DataFrame
from freqtrade.configuration import TimeRange
from freqtrade.data.converter import clean_ohlcv_dataframe, trim_dataframe
from freqtrade.constants import ListPairsWithTimeframes
from freqtrade.data.converter import (clean_ohlcv_dataframe,
trades_remove_duplicates, trim_dataframe)
from freqtrade.exchange import timeframe_to_seconds
logger = logging.getLogger(__name__)
# Type for trades list
TradeList = List[List]
class IDataHandler(ABC):
def __init__(self, datadir: Path) -> None:
self._datadir = datadir
@abstractclassmethod
def ohlcv_get_available_data(cls, datadir: Path) -> ListPairsWithTimeframes:
"""
Returns a list of all pairs with ohlcv data available in this datadir
:param datadir: Directory to search for ohlcv files
:return: List of Tuples of (pair, timeframe)
"""
@abstractclassmethod
def ohlcv_get_pairs(cls, datadir: Path, timeframe: str) -> List[str]:
"""
@@ -89,23 +102,25 @@ class IDataHandler(ABC):
"""
@abstractmethod
def trades_store(self, pair: str, data: List[Dict]) -> None:
def trades_store(self, pair: str, data: TradeList) -> None:
"""
Store trades data (list of Dicts) to file
:param pair: Pair - used for filename
:param data: List of Dicts containing trade data
:param data: List of Lists containing trade data,
column sequence as in DEFAULT_TRADES_COLUMNS
"""
@abstractmethod
def trades_append(self, pair: str, data: List[Dict]):
def trades_append(self, pair: str, data: TradeList):
"""
Append data to existing files
:param pair: Pair - used for filename
:param data: List of Dicts containing trade data
:param data: List of Lists containing trade data,
column sequence as in DEFAULT_TRADES_COLUMNS
"""
@abstractmethod
def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> List[Dict]:
def _trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList:
"""
Load a pair from file, either .json.gz or .json
:param pair: Load trades for this pair
@@ -121,6 +136,16 @@ class IDataHandler(ABC):
:return: True when deleted, false if file did not exist.
"""
def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList:
"""
Load a pair from file, either .json.gz or .json
Removes duplicates in the process.
:param pair: Load trades for this pair
:param timerange: Timerange to load trades for - currently not implemented
:return: List of trades
"""
return trades_remove_duplicates(self._trades_load(pair, timerange=timerange))
def ohlcv_load(self, pair, timeframe: str,
timerange: Optional[TimeRange] = None,
fill_missing: bool = True,

View File

@@ -1,15 +1,20 @@
import logging
import re
from pathlib import Path
from typing import Dict, List, Optional
from typing import List, Optional
import numpy as np
from pandas import DataFrame, read_json, to_datetime
from freqtrade import misc
from freqtrade.configuration import TimeRange
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
from freqtrade.constants import (DEFAULT_DATAFRAME_COLUMNS,
ListPairsWithTimeframes)
from freqtrade.data.converter import trades_dict_to_list
from .idatahandler import IDataHandler
from .idatahandler import IDataHandler, TradeList
logger = logging.getLogger(__name__)
class JsonDataHandler(IDataHandler):
@@ -17,6 +22,18 @@ class JsonDataHandler(IDataHandler):
_use_zip = False
_columns = DEFAULT_DATAFRAME_COLUMNS
@classmethod
def ohlcv_get_available_data(cls, datadir: Path) -> ListPairsWithTimeframes:
"""
Returns a list of all pairs with ohlcv data available in this datadir
:param datadir: Directory to search for ohlcv files
:return: List of Tuples of (pair, timeframe)
"""
_tmp = [re.search(r'^([a-zA-Z_]+)\-(\d+\S+)(?=.json)', p.name)
for p in datadir.glob(f"*.{cls._get_file_extension()}")]
return [(match[1].replace('_', '/'), match[2]) for match in _tmp
if match and len(match.groups()) > 1]
@classmethod
def ohlcv_get_pairs(cls, datadir: Path, timeframe: str) -> List[str]:
"""
@@ -113,24 +130,26 @@ class JsonDataHandler(IDataHandler):
# Check if regex found something and only return these results to avoid exceptions.
return [match[0].replace('_', '/') for match in _tmp if match]
def trades_store(self, pair: str, data: List[Dict]) -> None:
def trades_store(self, pair: str, data: TradeList) -> None:
"""
Store trades data (list of Dicts) to file
:param pair: Pair - used for filename
:param data: List of Dicts containing trade data
:param data: List of Lists containing trade data,
column sequence as in DEFAULT_TRADES_COLUMNS
"""
filename = self._pair_trades_filename(self._datadir, pair)
misc.file_dump_json(filename, data, is_zip=self._use_zip)
def trades_append(self, pair: str, data: List[Dict]):
def trades_append(self, pair: str, data: TradeList):
"""
Append data to existing files
:param pair: Pair - used for filename
:param data: List of Dicts containing trade data
:param data: List of Lists containing trade data,
column sequence as in DEFAULT_TRADES_COLUMNS
"""
raise NotImplementedError()
def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> List[Dict]:
def _trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList:
"""
Load a pair from file, either .json.gz or .json
# TODO: respect timerange ...
@@ -140,9 +159,15 @@ class JsonDataHandler(IDataHandler):
"""
filename = self._pair_trades_filename(self._datadir, pair)
tradesdata = misc.file_load_json(filename)
if not tradesdata:
return []
if isinstance(tradesdata[0], dict):
# Convert trades dict to list
logger.info("Old trades format detected - converting")
tradesdata = trades_dict_to_list(tradesdata)
pass
return tradesdata
def trades_purge(self, pair: str) -> bool:

View File

@@ -57,9 +57,7 @@ class Edge:
if self.config['stake_amount'] != UNLIMITED_STAKE_AMOUNT:
raise OperationalException('Edge works only with unlimited stake amount')
# Deprecated capital_available_percentage. Will use tradable_balance_ratio in the future.
self._capital_percentage: float = self.edge_config.get(
'capital_available_percentage', self.config['tradable_balance_ratio'])
self._capital_ratio: float = self.config['tradable_balance_ratio']
self._allowed_risk: float = self.edge_config.get('allowed_risk')
self._since_number_of_days: int = self.edge_config.get('calculate_since_number_of_days', 14)
self._last_updated: int = 0 # Timestamp of pairs last updated time
@@ -100,14 +98,14 @@ class Edge:
datadir=self.config['datadir'],
pairs=pairs,
exchange=self.exchange,
timeframe=self.strategy.ticker_interval,
timeframe=self.strategy.timeframe,
timerange=self._timerange,
)
data = load_data(
datadir=self.config['datadir'],
pairs=pairs,
timeframe=self.strategy.ticker_interval,
timeframe=self.strategy.timeframe,
timerange=self._timerange,
startup_candles=self.strategy.startup_candle_count,
data_format=self.config.get('dataformat_ohlcv', 'json'),
@@ -157,7 +155,7 @@ class Edge:
def stake_amount(self, pair: str, free_capital: float,
total_capital: float, capital_in_trade: float) -> float:
stoploss = self.stoploss(pair)
available_capital = (total_capital + capital_in_trade) * self._capital_percentage
available_capital = (total_capital + capital_in_trade) * self._capital_ratio
allowed_capital_at_risk = available_capital * self._allowed_risk
max_position_size = abs(allowed_capital_at_risk / stoploss)
position_size = min(max_position_size, free_capital)
@@ -238,20 +236,9 @@ class Edge:
:param result Dataframe
:return: result Dataframe
"""
# stake and fees
# stake = 0.015
# 0.05% is 0.0005
# fee = 0.001
# we set stake amount to an arbitrary amount.
# as it doesn't change the calculation.
# all returned values are relative.
# they are defined as ratios.
# We set stake amount to an arbitrary amount, as it doesn't change the calculation.
# All returned values are relative, they are defined as ratios.
stake = 0.015
fee = self.fee
open_fee = fee / 2
close_fee = fee / 2
result['trade_duration'] = result['close_time'] - result['open_time']
@@ -262,12 +249,12 @@ class Edge:
# Buy Price
result['buy_vol'] = stake / result['open_rate'] # How many target are we buying
result['buy_fee'] = stake * open_fee
result['buy_fee'] = stake * self.fee
result['buy_spend'] = stake + result['buy_fee'] # How much we're spending
# Sell price
result['sell_sum'] = result['buy_vol'] * result['close_rate']
result['sell_fee'] = result['sell_sum'] * close_fee
result['sell_fee'] = result['sell_sum'] * self.fee
result['sell_take'] = result['sell_sum'] - result['sell_fee']
# profit_ratio
@@ -294,8 +281,8 @@ class Edge:
#
# Removing Pumps
if self.edge_config.get('remove_pumps', False):
results = results.groupby(['pair', 'stoploss']).apply(
lambda x: x[x['profit_abs'] < 2 * x['profit_abs'].std() + x['profit_abs'].mean()])
results = results[results['profit_abs'] < 2 * results['profit_abs'].std()
+ results['profit_abs'].mean()]
##########################################################################
# Removing trades having a duration more than X minutes (set in config)

View File

@@ -21,6 +21,14 @@ class DependencyException(FreqtradeException):
"""
class PricingError(DependencyException):
"""
Subclass of DependencyException.
Indicates that the price could not be determined.
Implicitly a buy / sell operation.
"""
class InvalidOrderException(FreqtradeException):
"""
This is returned when the order is not valid. Example:
@@ -29,9 +37,37 @@ class InvalidOrderException(FreqtradeException):
"""
class TemporaryError(FreqtradeException):
class RetryableOrderError(InvalidOrderException):
"""
This is returned when the order is not found.
This Error will be repeated with increasing backof (in line with DDosError).
"""
class ExchangeError(DependencyException):
"""
Error raised out of the exchange.
Has multiple Errors to determine the appropriate error.
"""
class TemporaryError(ExchangeError):
"""
Temporary network or exchange related error.
This could happen when an exchange is congested, unavailable, or the user
has networking problems. Usually resolves itself after a time.
"""
class DDosProtection(TemporaryError):
"""
Temporary error caused by DDOS protection.
Bot will wait for a second and then retry.
"""
class StrategyError(FreqtradeException):
"""
Errors with custom user-code deteced.
Usually caused by errors in the strategy.
"""

View File

@@ -4,9 +4,11 @@ from typing import Dict
import ccxt
from freqtrade.exceptions import (DependencyException, InvalidOrderException,
OperationalException, TemporaryError)
from freqtrade.exceptions import (DDosProtection, ExchangeError,
InvalidOrderException, OperationalException,
TemporaryError)
from freqtrade.exchange import Exchange
from freqtrade.exchange.common import retrier
logger = logging.getLogger(__name__)
@@ -20,7 +22,7 @@ class Binance(Exchange):
"trades_pagination_arg": "fromId",
}
def get_order_book(self, pair: str, limit: int = 100) -> dict:
def fetch_l2_order_book(self, pair: str, limit: int = 100) -> dict:
"""
get order book level 2 from exchange
@@ -30,7 +32,7 @@ class Binance(Exchange):
# get next-higher step in the limit_range list
limit = min(list(filter(lambda x: limit <= x, limit_range)))
return super().get_order_book(pair, limit)
return super().fetch_l2_order_book(pair, limit)
def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool:
"""
@@ -39,6 +41,7 @@ class Binance(Exchange):
"""
return order['type'] == 'stop_loss_limit' and stop_loss > float(order['info']['stopPrice'])
@retrier(retries=0)
def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict:
"""
creates a stoploss limit order.
@@ -72,13 +75,13 @@ class Binance(Exchange):
rate = self.price_to_precision(pair, rate)
order = self._api.create_order(symbol=pair, type=ordertype, side='sell',
amount=amount, price=stop_price, params=params)
amount=amount, price=rate, params=params)
logger.info('stoploss limit order added for %s. '
'stop price: %s. limit: %s', pair, stop_price, rate)
return order
except ccxt.InsufficientFunds as e:
raise DependencyException(
f'Insufficient funds to create {ordertype} sell order on market {pair}.'
raise ExchangeError(
f'Insufficient funds to create {ordertype} sell order on market {pair}. '
f'Tried to sell amount {amount} at rate {rate}. '
f'Message: {e}') from e
except ccxt.InvalidOrder as e:
@@ -88,6 +91,8 @@ class Binance(Exchange):
f'Could not create {ordertype} sell order on market {pair}. '
f'Tried to sell amount {amount} at rate {rate}. '
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not place sell order due to {e.__class__.__name__}. Message: {e}') from e

View File

@@ -1,6 +1,10 @@
import asyncio
import logging
import time
from functools import wraps
from freqtrade.exceptions import DependencyException, TemporaryError
from freqtrade.exceptions import (DDosProtection, RetryableOrderError,
TemporaryError)
logger = logging.getLogger(__name__)
@@ -88,17 +92,28 @@ MAP_EXCHANGE_CHILDCLASS = {
}
def calculate_backoff(retrycount, max_retries):
"""
Calculate backoff
"""
return (max_retries - retrycount) ** 2 + 1
def retrier_async(f):
async def wrapper(*args, **kwargs):
count = kwargs.pop('count', API_RETRY_COUNT)
try:
return await f(*args, **kwargs)
except (TemporaryError, DependencyException) as ex:
except TemporaryError as ex:
logger.warning('%s() returned exception: "%s"', f.__name__, ex)
if count > 0:
logger.warning('retrying %s() still for %s times', f.__name__, count)
count -= 1
kwargs.update({'count': count})
logger.warning('retrying %s() still for %s times', f.__name__, count)
if isinstance(ex, DDosProtection):
backoff_delay = calculate_backoff(count + 1, API_RETRY_COUNT)
logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}")
await asyncio.sleep(backoff_delay)
return await wrapper(*args, **kwargs)
else:
logger.warning('Giving up retrying: %s()', f.__name__)
@@ -106,19 +121,31 @@ def retrier_async(f):
return wrapper
def retrier(f):
def wrapper(*args, **kwargs):
count = kwargs.pop('count', API_RETRY_COUNT)
try:
return f(*args, **kwargs)
except (TemporaryError, DependencyException) as ex:
logger.warning('%s() returned exception: "%s"', f.__name__, ex)
if count > 0:
count -= 1
kwargs.update({'count': count})
logger.warning('retrying %s() still for %s times', f.__name__, count)
return wrapper(*args, **kwargs)
else:
logger.warning('Giving up retrying: %s()', f.__name__)
raise ex
return wrapper
def retrier(_func=None, retries=API_RETRY_COUNT):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
count = kwargs.pop('count', retries)
try:
return f(*args, **kwargs)
except (TemporaryError, RetryableOrderError) as ex:
logger.warning('%s() returned exception: "%s"', f.__name__, ex)
if count > 0:
logger.warning('retrying %s() still for %s times', f.__name__, count)
count -= 1
kwargs.update({'count': count})
if isinstance(ex, DDosProtection) or isinstance(ex, RetryableOrderError):
# increasing backoff
backoff_delay = calculate_backoff(count + 1, retries)
logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}")
time.sleep(backoff_delay)
return wrapper(*args, **kwargs)
else:
logger.warning('Giving up retrying: %s()', f.__name__)
raise ex
return wrapper
# Support both @retrier and @retrier(retries=2) syntax
if _func is None:
return decorator
else:
return decorator(_func)

View File

@@ -18,12 +18,13 @@ from ccxt.base.decimal_to_precision import (ROUND_DOWN, ROUND_UP, TICK_SIZE,
TRUNCATE, decimal_to_precision)
from pandas import DataFrame
from freqtrade.data.converter import ohlcv_to_dataframe
from freqtrade.exceptions import (DependencyException, InvalidOrderException,
OperationalException, TemporaryError)
from freqtrade.constants import ListPairsWithTimeframes
from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list
from freqtrade.exceptions import (DDosProtection, ExchangeError,
InvalidOrderException, OperationalException,
RetryableOrderError, TemporaryError)
from freqtrade.exchange.common import BAD_EXCHANGES, retrier, retrier_async
from freqtrade.misc import deep_merge_dicts
from freqtrade.misc import deep_merge_dicts, safe_value_fallback2
CcxtModuleType = Any
@@ -79,7 +80,7 @@ class Exchange:
if config['dry_run']:
logger.info('Instance is running with dry_run enabled')
logger.info(f"Using CCXT {ccxt.__version__}")
exchange_config = config['exchange']
# Deep merge ft_has with default ft_has options
@@ -98,12 +99,14 @@ class Exchange:
# Initialize ccxt objects
ccxt_config = self._ccxt_config.copy()
ccxt_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}),
ccxt_config)
self._api = self._init_ccxt(
exchange_config, ccxt_kwargs=ccxt_config)
ccxt_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}), ccxt_config)
ccxt_config = deep_merge_dicts(exchange_config.get('ccxt_sync_config', {}), ccxt_config)
self._api = self._init_ccxt(exchange_config, ccxt_kwargs=ccxt_config)
ccxt_async_config = self._ccxt_config.copy()
ccxt_async_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}),
ccxt_async_config)
ccxt_async_config = deep_merge_dicts(exchange_config.get('ccxt_async_config', {}),
ccxt_async_config)
self._api_async = self._init_ccxt(
@@ -113,7 +116,7 @@ class Exchange:
if validate:
# Check if timeframe is available
self.validate_timeframes(config.get('ticker_interval'))
self.validate_timeframes(config.get('timeframe'))
# Initial markets load
self._load_markets()
@@ -184,11 +187,16 @@ class Exchange:
def timeframes(self) -> List[str]:
return list((self._api.timeframes or {}).keys())
@property
def ohlcv_candle_limit(self) -> int:
"""exchange ohlcv candle limit"""
return int(self._ohlcv_candle_limit)
@property
def markets(self) -> Dict:
"""exchange ccxt markets"""
if not self._api.markets:
logger.warning("Markets were not loaded. Loading them now..")
logger.info("Markets were not loaded. Loading them now..")
self._load_markets()
return self._api.markets
@@ -250,8 +258,8 @@ class Exchange:
api.urls['api'] = api.urls['test']
logger.info("Enabled Sandbox API on %s", name)
else:
logger.warning(name, "No Sandbox URL in CCXT, exiting. "
"Please check your config.json")
logger.warning(
f"No Sandbox URL in CCXT for {name}, exiting. Please check your config.json")
raise OperationalException(f'Exchange {name} does not provide a sandbox api')
def _load_async_markets(self, reload: bool = False) -> None:
@@ -273,8 +281,8 @@ class Exchange:
except ccxt.BaseError as e:
logger.warning('Unable to initialize markets. Reason: %s', e)
def _reload_markets(self) -> None:
"""Reload markets both sync and async, if refresh interval has passed"""
def reload_markets(self) -> None:
"""Reload markets both sync and async if refresh interval has passed """
# Check whether markets have to be reloaded
if (self._last_markets_refresh > 0) and (
self._last_markets_refresh + self.markets_refresh_interval
@@ -283,6 +291,8 @@ class Exchange:
logger.debug("Performing scheduled market reload..")
try:
self._api.load_markets(reload=True)
# Also reload async markets to avoid issues with newly listed pairs
self._load_async_markets(reload=True)
self._last_markets_refresh = arrow.utcnow().timestamp
except ccxt.BaseError:
logger.exception("Could not reload markets.")
@@ -347,7 +357,7 @@ class Exchange:
for pair in [f"{curr_1}/{curr_2}", f"{curr_2}/{curr_1}"]:
if pair in self.markets and self.markets[pair].get('active'):
return pair
raise DependencyException(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")
raise ExchangeError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")
def validate_timeframes(self, timeframe: Optional[str]) -> None:
"""
@@ -367,8 +377,7 @@ class Exchange:
f"Invalid timeframe '{timeframe}'. This exchange supports: {self.timeframes}")
if timeframe and timeframe_to_minutes(timeframe) < 1:
raise OperationalException(
f"Timeframes < 1m are currently not supported by Freqtrade.")
raise OperationalException("Timeframes < 1m are currently not supported by Freqtrade.")
def validate_ordertypes(self, order_types: Dict) -> None:
"""
@@ -471,27 +480,33 @@ class Exchange:
"id": order_id,
'pair': pair,
'price': rate,
'average': rate,
'amount': _amount,
"cost": _amount * rate,
'cost': _amount * rate,
'type': ordertype,
'side': side,
'remaining': _amount,
'datetime': arrow.utcnow().isoformat(),
'status': "closed" if ordertype == "market" else "open",
'fee': None,
"info": {}
'info': {}
}
self._store_dry_order(dry_order)
self._store_dry_order(dry_order, pair)
# Copy order and close it - so the returned order is open unless it's a market order
return dry_order
def _store_dry_order(self, dry_order: Dict) -> None:
def _store_dry_order(self, dry_order: Dict, pair: str) -> None:
closed_order = dry_order.copy()
if closed_order["type"] in ["market", "limit"]:
if closed_order['type'] in ["market", "limit"]:
closed_order.update({
"status": "closed",
"filled": closed_order["amount"],
"remaining": 0
'status': 'closed',
'filled': closed_order['amount'],
'remaining': 0,
'fee': {
'currency': self.get_pair_quote_currency(pair),
'cost': dry_order['cost'] * self.get_fee(pair),
'rate': self.get_fee(pair)
}
})
if closed_order["type"] in ["stop_loss_limit"]:
closed_order["info"].update({"stopPrice": closed_order["price"]})
@@ -510,15 +525,17 @@ class Exchange:
amount, rate_for_order, params)
except ccxt.InsufficientFunds as e:
raise DependencyException(
f'Insufficient funds to create {ordertype} {side} order on market {pair}.'
raise ExchangeError(
f'Insufficient funds to create {ordertype} {side} order on market {pair}. '
f'Tried to {side} amount {amount} at rate {rate}.'
f'Message: {e}') from e
except ccxt.InvalidOrder as e:
raise DependencyException(
f'Could not create {ordertype} {side} order on market {pair}.'
f'Tried to {side} amount {amount} at rate {rate}.'
raise ExchangeError(
f'Could not create {ordertype} {side} order on market {pair}. '
f'Tried to {side} amount {amount} at rate {rate}. '
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not place {side} order due to {e.__class__.__name__}. Message: {e}') from e
@@ -598,6 +615,8 @@ class Exchange:
balances.pop("used", None)
return balances
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get balance due to {e.__class__.__name__}. Message: {e}') from e
@@ -612,6 +631,8 @@ class Exchange:
raise OperationalException(
f'Exchange {self._api.name} does not support fetching tickers in batch. '
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not load tickers due to {e.__class__.__name__}. Message: {e}') from e
@@ -622,9 +643,11 @@ class Exchange:
def fetch_ticker(self, pair: str) -> dict:
try:
if pair not in self._api.markets or not self._api.markets[pair].get('active'):
raise DependencyException(f"Pair {pair} not available")
raise ExchangeError(f"Pair {pair} not available")
data = self._api.fetch_ticker(pair)
return data
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not load ticker due to {e.__class__.__name__}. Message: {e}') from e
@@ -672,7 +695,7 @@ class Exchange:
logger.info("Downloaded data for %s with length %s.", pair, len(data))
return data
def refresh_latest_ohlcv(self, pair_list: List[Tuple[str, str]]) -> List[Tuple[str, List]]:
def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes) -> List[Tuple[str, List]]:
"""
Refresh in-memory OHLCV asynchronously and set `_klines` with the result
Loops asynchronously over pair_list and downloads all pairs async (semi-parallel).
@@ -758,6 +781,8 @@ class Exchange:
raise OperationalException(
f'Exchange {self._api.name} does not support fetching historical '
f'candle (OHLCV) data. Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(f'Could not fetch historical candle (OHLCV) data '
f'for pair {pair} due to {e.__class__.__name__}. '
@@ -769,7 +794,7 @@ class Exchange:
@retrier_async
async def _async_fetch_trades(self, pair: str,
since: Optional[int] = None,
params: Optional[dict] = None) -> List[Dict]:
params: Optional[dict] = None) -> List[List]:
"""
Asyncronously gets trade history using fetch_trades.
Handles exchange errors, does one call to the exchange.
@@ -789,11 +814,13 @@ class Exchange:
'(' + arrow.get(since // 1000).isoformat() + ') ' if since is not None else ''
)
trades = await self._api_async.fetch_trades(pair, since=since, limit=1000)
return trades
return trades_dict_to_list(trades)
except ccxt.NotSupported as e:
raise OperationalException(
f'Exchange {self._api.name} does not support fetching historical trade data.'
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(f'Could not load trade history due to {e.__class__.__name__}. '
f'Message: {e}') from e
@@ -803,7 +830,7 @@ class Exchange:
async def _async_get_trade_history_id(self, pair: str,
until: int,
since: Optional[int] = None,
from_id: Optional[str] = None) -> Tuple[str, List[Dict]]:
from_id: Optional[str] = None) -> Tuple[str, List[List]]:
"""
Asyncronously gets trade history using fetch_trades
use this when exchange uses id-based iteration (check `self._trades_pagination`)
@@ -814,7 +841,7 @@ class Exchange:
returns tuple: (pair, trades-list)
"""
trades: List[Dict] = []
trades: List[List] = []
if not from_id:
# Fetch first elements using timebased method to get an ID to paginate on
@@ -823,7 +850,9 @@ class Exchange:
# e.g. Binance returns the "last 1000" candles within a 1h time interval
# - so we will miss the first trades.
t = await self._async_fetch_trades(pair, since=since)
from_id = t[-1]['id']
# DEFAULT_TRADES_COLUMNS: 0 -> timestamp
# DEFAULT_TRADES_COLUMNS: 1 -> id
from_id = t[-1][1]
trades.extend(t[:-1])
while True:
t = await self._async_fetch_trades(pair,
@@ -831,21 +860,21 @@ class Exchange:
if len(t):
# Skip last id since its the key for the next call
trades.extend(t[:-1])
if from_id == t[-1]['id'] or t[-1]['timestamp'] > until:
if from_id == t[-1][1] or t[-1][0] > until:
logger.debug(f"Stopping because from_id did not change. "
f"Reached {t[-1]['timestamp']} > {until}")
f"Reached {t[-1][0]} > {until}")
# Reached the end of the defined-download period - add last trade as well.
trades.extend(t[-1:])
break
from_id = t[-1]['id']
from_id = t[-1][1]
else:
break
return (pair, trades)
async def _async_get_trade_history_time(self, pair: str, until: int,
since: Optional[int] = None) -> Tuple[str, List]:
since: Optional[int] = None) -> Tuple[str, List[List]]:
"""
Asyncronously gets trade history using fetch_trades,
when the exchange uses time-based iteration (check `self._trades_pagination`)
@@ -855,16 +884,18 @@ class Exchange:
returns tuple: (pair, trades-list)
"""
trades: List[Dict] = []
trades: List[List] = []
# DEFAULT_TRADES_COLUMNS: 0 -> timestamp
# DEFAULT_TRADES_COLUMNS: 1 -> id
while True:
t = await self._async_fetch_trades(pair, since=since)
if len(t):
since = t[-1]['timestamp']
since = t[-1][1]
trades.extend(t)
# Reached the end of the defined-download period
if until and t[-1]['timestamp'] > until:
if until and t[-1][0] > until:
logger.debug(
f"Stopping because until was reached. {t[-1]['timestamp']} > {until}")
f"Stopping because until was reached. {t[-1][0]} > {until}")
break
else:
break
@@ -874,19 +905,24 @@ class Exchange:
async def _async_get_trade_history(self, pair: str,
since: Optional[int] = None,
until: Optional[int] = None,
from_id: Optional[str] = None) -> Tuple[str, List[Dict]]:
from_id: Optional[str] = None) -> Tuple[str, List[List]]:
"""
Async wrapper handling downloading trades using either time or id based methods.
"""
logger.debug(f"_async_get_trade_history(), pair: {pair}, "
f"since: {since}, until: {until}, from_id: {from_id}")
if until is None:
until = ccxt.Exchange.milliseconds()
logger.debug(f"Exchange milliseconds: {until}")
if self._trades_pagination == 'time':
return await self._async_get_trade_history_time(
pair=pair, since=since,
until=until or ccxt.Exchange.milliseconds())
pair=pair, since=since, until=until)
elif self._trades_pagination == 'id':
return await self._async_get_trade_history_id(
pair=pair, since=since,
until=until or ccxt.Exchange.milliseconds(), from_id=from_id
pair=pair, since=since, until=until, from_id=from_id
)
else:
raise OperationalException(f"Exchange {self.name} does use neither time, "
@@ -916,7 +952,7 @@ class Exchange:
def check_order_canceled_empty(self, order: Dict) -> bool:
"""
Verify if an order has been cancelled without being partially filled
:param order: Order dict as returned from get_order()
:param order: Order dict as returned from fetch_order()
:return: True if order has been cancelled without being filled, False otherwise.
"""
return order.get('status') in ('closed', 'canceled') and order.get('filled') == 0.0
@@ -931,12 +967,17 @@ class Exchange:
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Could not cancel order. Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not cancel order due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
# Assign method to fetch_stoploss_order to allow easy overriding in other classes
cancel_stoploss_order = cancel_order
def is_cancel_order_result_suitable(self, corder) -> bool:
if not isinstance(corder, dict):
return False
@@ -948,7 +989,7 @@ class Exchange:
"""
Cancel order returning a result.
Creates a fake result if cancel order returns a non-usable result
and get_order does not work (certain exchanges don't return cancelled orders)
and fetch_order does not work (certain exchanges don't return cancelled orders)
:param order_id: Orderid to cancel
:param pair: Pair corresponding to order_id
:param amount: Amount to use for fake response
@@ -959,17 +1000,17 @@ class Exchange:
if self.is_cancel_order_result_suitable(corder):
return corder
except InvalidOrderException:
logger.warning(f"Could not cancel order {order_id}.")
logger.warning(f"Could not cancel order {order_id} for {pair}.")
try:
order = self.get_order(order_id, pair)
order = self.fetch_order(order_id, pair)
except InvalidOrderException:
logger.warning(f"Could not fetch cancelled order {order_id}.")
order = {'fee': {}, 'status': 'canceled', 'amount': amount, 'info': {}}
return order
@retrier
def get_order(self, order_id: str, pair: str) -> Dict:
@retrier(retries=5)
def fetch_order(self, order_id: str, pair: str) -> Dict:
if self._config['dry_run']:
try:
order = self._dry_run_open_orders[order_id]
@@ -980,17 +1021,25 @@ class Exchange:
f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e
try:
return self._api.fetch_order(order_id, pair)
except ccxt.OrderNotFound as e:
raise RetryableOrderError(
f'Order not found (pair: {pair} id: {order_id}). Message: {e}') from e
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Tried to get an invalid order (id: {order_id}). Message: {e}') from e
f'Tried to get an invalid order (pair: {pair} id: {order_id}). Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get order due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
# Assign method to fetch_stoploss_order to allow easy overriding in other classes
fetch_stoploss_order = fetch_order
@retrier
def get_order_book(self, pair: str, limit: int = 100) -> dict:
def fetch_l2_order_book(self, pair: str, limit: int = 100) -> dict:
"""
get order book level 2 from exchange
@@ -1004,6 +1053,8 @@ class Exchange:
raise OperationalException(
f'Exchange {self._api.name} does not support fetching order book.'
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get order book due to {e.__class__.__name__}. Message: {e}') from e
@@ -1040,10 +1091,11 @@ class Exchange:
matched_trades = [trade for trade in my_trades if trade['order'] == order_id]
return matched_trades
except ccxt.NetworkError as e:
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get trades due to networking error. Message: {e}') from e
f'Could not get trades due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
@@ -1057,12 +1109,71 @@ class Exchange:
return self._api.calculate_fee(symbol=symbol, type=type, side=side, amount=amount,
price=price, takerOrMaker=taker_or_maker)['rate']
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get fee info due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
@staticmethod
def order_has_fee(order: Dict) -> bool:
"""
Verifies if the passed in order dict has the needed keys to extract fees,
and that these keys (currency, cost) are not empty.
:param order: Order or trade (one trade) dict
:return: True if the fee substructure contains currency and cost, false otherwise
"""
if not isinstance(order, dict):
return False
return ('fee' in order and order['fee'] is not None
and (order['fee'].keys() >= {'currency', 'cost'})
and order['fee']['currency'] is not None
and order['fee']['cost'] is not None
)
def calculate_fee_rate(self, order: Dict) -> Optional[float]:
"""
Calculate fee rate if it's not given by the exchange.
:param order: Order or trade (one trade) dict
"""
if order['fee'].get('rate') is not None:
return order['fee'].get('rate')
fee_curr = order['fee']['currency']
# Calculate fee based on order details
if fee_curr in self.get_pair_base_currency(order['symbol']):
# Base currency - divide by amount
return round(
order['fee']['cost'] / safe_value_fallback2(order, order, 'filled', 'amount'), 8)
elif fee_curr in self.get_pair_quote_currency(order['symbol']):
# Quote currency - divide by cost
return round(order['fee']['cost'] / order['cost'], 8) if order['cost'] else None
else:
# If Fee currency is a different currency
if not order['cost']:
# If cost is None or 0.0 -> falsy, return None
return None
try:
comb = self.get_valid_pair_combination(fee_curr, self._config['stake_currency'])
tick = self.fetch_ticker(comb)
fee_to_quote_rate = safe_value_fallback2(tick, tick, 'last', 'ask')
return round((order['fee']['cost'] * fee_to_quote_rate) / order['cost'], 8)
except ExchangeError:
return None
def extract_cost_curr_rate(self, order: Dict) -> Tuple[float, str, Optional[float]]:
"""
Extract tuple of cost, currency, rate.
Requires order_has_fee to run first!
:param order: Order or trade (one trade) dict
:return: Tuple with cost, currency, rate of the given fee dict
"""
return (order['fee']['cost'],
order['fee']['currency'],
self.calculate_fee_rate(order))
def is_exchange_bad(exchange_name: str) -> bool:
return exchange_name in BAD_EXCHANGES

View File

@@ -2,7 +2,13 @@
import logging
from typing import Dict
import ccxt
from freqtrade.exceptions import (DDosProtection, ExchangeError,
InvalidOrderException, OperationalException,
TemporaryError)
from freqtrade.exchange import Exchange
from freqtrade.exchange.common import retrier
logger = logging.getLogger(__name__)
@@ -10,5 +16,111 @@ logger = logging.getLogger(__name__)
class Ftx(Exchange):
_ft_has: Dict = {
"stoploss_on_exchange": True,
"ohlcv_candle_limit": 1500,
}
def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool:
"""
Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary.
"""
return order['type'] == 'stop' and stop_loss > float(order['price'])
@retrier(retries=0)
def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict:
"""
Creates a stoploss order.
depending on order_types.stoploss configuration, uses 'market' or limit order.
Limit orders are defined by having orderPrice set, otherwise a market order is used.
"""
limit_price_pct = order_types.get('stoploss_on_exchange_limit_ratio', 0.99)
limit_rate = stop_price * limit_price_pct
ordertype = "stop"
stop_price = self.price_to_precision(pair, stop_price)
if self._config['dry_run']:
dry_order = self.dry_run_order(
pair, ordertype, "sell", amount, stop_price)
return dry_order
try:
params = self._params.copy()
if order_types.get('stoploss', 'market') == 'limit':
# set orderPrice to place limit order, otherwise it's a market order
params['orderPrice'] = limit_rate
amount = self.amount_to_precision(pair, amount)
order = self._api.create_order(symbol=pair, type=ordertype, side='sell',
amount=amount, price=stop_price, params=params)
logger.info('stoploss order added for %s. '
'stop price: %s.', pair, stop_price)
return order
except ccxt.InsufficientFunds as e:
raise ExchangeError(
f'Insufficient funds to create {ordertype} sell order on market {pair}. '
f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. '
f'Message: {e}') from e
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Could not create {ordertype} sell order on market {pair}. '
f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. '
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not place sell order due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
@retrier(retries=5)
def fetch_stoploss_order(self, order_id: str, pair: str) -> Dict:
if self._config['dry_run']:
try:
order = self._dry_run_open_orders[order_id]
return order
except KeyError as e:
# Gracefully handle errors with dry-run orders.
raise InvalidOrderException(
f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e
try:
orders = self._api.fetch_orders(pair, None, params={'type': 'stop'})
order = [order for order in orders if order['id'] == order_id]
if len(order) == 1:
return order[0]
else:
raise InvalidOrderException(f"Could not get stoploss order for id {order_id}")
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Tried to get an invalid order (id: {order_id}). Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get order due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
@retrier
def cancel_stoploss_order(self, order_id: str, pair: str) -> Dict:
if self._config['dry_run']:
return {}
try:
return self._api.cancel_order(order_id, pair, params={'type': 'stop'})
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Could not cancel order. Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not cancel order due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e

View File

@@ -4,10 +4,11 @@ from typing import Dict
import ccxt
from freqtrade.exceptions import (DependencyException, InvalidOrderException,
OperationalException, TemporaryError)
from freqtrade.exceptions import (DDosProtection, ExchangeError,
InvalidOrderException, OperationalException,
TemporaryError)
from freqtrade.exchange import Exchange
from freqtrade.exchange.exchange import retrier
from freqtrade.exchange.common import retrier
logger = logging.getLogger(__name__)
@@ -45,6 +46,8 @@ class Kraken(Exchange):
balances[bal]['free'] = balances[bal]['total'] - balances[bal]['used']
return balances
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get balance due to {e.__class__.__name__}. Message: {e}') from e
@@ -58,6 +61,7 @@ class Kraken(Exchange):
"""
return order['type'] == 'stop-loss' and stop_loss > float(order['price'])
@retrier(retries=0)
def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict:
"""
Creates a stoploss market order.
@@ -84,8 +88,8 @@ class Kraken(Exchange):
'stop price: %s.', pair, stop_price)
return order
except ccxt.InsufficientFunds as e:
raise DependencyException(
f'Insufficient funds to create {ordertype} sell order on market {pair}.'
raise ExchangeError(
f'Insufficient funds to create {ordertype} sell order on market {pair}. '
f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. '
f'Message: {e}') from e
except ccxt.InvalidOrder as e:
@@ -93,6 +97,8 @@ class Kraken(Exchange):
f'Could not create {ordertype} sell order on market {pair}. '
f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. '
f'Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not place sell order due to {e.__class__.__name__}. Message: {e}') from e

View File

@@ -7,26 +7,27 @@ import traceback
from datetime import datetime
from math import isclose
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional
import arrow
from cachetools import TTLCache
from requests.exceptions import RequestException
from freqtrade import __version__, constants, persistence
from freqtrade.configuration import validate_config_consistency
from freqtrade.data.converter import order_book_to_dataframe
from freqtrade.data.dataprovider import DataProvider
from freqtrade.edge import Edge
from freqtrade.exceptions import DependencyException, InvalidOrderException
from freqtrade.exceptions import (DependencyException, ExchangeError,
InvalidOrderException, PricingError)
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date
from freqtrade.misc import safe_value_fallback
from freqtrade.misc import safe_value_fallback, safe_value_fallback2
from freqtrade.pairlist.pairlistmanager import PairListManager
from freqtrade.persistence import Trade
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.rpc import RPCManager, RPCMessageType
from freqtrade.state import State
from freqtrade.strategy.interface import IStrategy, SellType
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
from freqtrade.wallets import Wallets
logger = logging.getLogger(__name__)
@@ -53,8 +54,11 @@ class FreqtradeBot:
# Init objects
self.config = config
self._sell_rate_cache = TTLCache(maxsize=100, ttl=5)
self._buy_rate_cache = TTLCache(maxsize=100, ttl=5)
# Cache values for 1800 to avoid frequent polling of the exchange for prices
# Caching only applies to RPC methods, so prices for open trades are still
# refreshed once every iteration.
self._sell_rate_cache = TTLCache(maxsize=100, ttl=1800)
self._buy_rate_cache = TTLCache(maxsize=100, ttl=1800)
self.strategy: IStrategy = StrategyResolver.load_strategy(self.config)
@@ -67,20 +71,20 @@ class FreqtradeBot:
self.wallets = Wallets(self.config, self.exchange)
self.dataprovider = DataProvider(self.config, self.exchange)
self.pairlists = PairListManager(self.exchange, self.config)
self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists)
# Attach Dataprovider to Strategy baseclass
IStrategy.dp = self.dataprovider
# Attach Wallets to Strategy baseclass
IStrategy.wallets = self.wallets
self.pairlists = PairListManager(self.exchange, self.config)
# Initializing Edge only if enabled
self.edge = Edge(self.config, self.exchange, self.strategy) if \
self.config.get('edge', {}).get('enabled', False) else None
self.active_pair_whitelist = self._refresh_whitelist()
self.active_pair_whitelist = self._refresh_active_whitelist()
# Set initial bot state from config
initial_state = self.config.get('initial_state')
@@ -112,6 +116,11 @@ class FreqtradeBot:
"""
logger.info('Cleaning up modules ...')
if self.config['cancel_open_orders_on_exit']:
self.cancel_all_open_orders()
self.check_for_open_trades()
self.rpc.cleanup()
persistence.cleanup()
@@ -132,18 +141,22 @@ class FreqtradeBot:
:return: True if one or more trades has been created or closed, False otherwise
"""
# Check whether markets have to be reloaded
self.exchange._reload_markets()
# Check whether markets have to be reloaded and reload them when it's needed
self.exchange.reload_markets()
# Query trades from persistence layer
trades = Trade.get_open_trades()
self.active_pair_whitelist = self._refresh_whitelist(trades)
self.active_pair_whitelist = self._refresh_active_whitelist(trades)
# Refreshing candles
self.dataprovider.refresh(self._create_pair_whitelist(self.active_pair_whitelist),
self.dataprovider.refresh(self.pairlists.create_pair_list(self.active_pair_whitelist),
self.strategy.informative_pairs())
strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)()
self.strategy.analyze(self.active_pair_whitelist)
with self._sell_lock:
# Check and handle any timed out open orders
self.check_handle_timedout()
@@ -161,9 +174,35 @@ class FreqtradeBot:
Trade.session.flush()
def _refresh_whitelist(self, trades: List[Trade] = []) -> List[str]:
def process_stopped(self) -> None:
"""
Refresh whitelist from pairlist or edge and extend it with trades.
Close all orders that were left open
"""
if self.config['cancel_open_orders_on_exit']:
self.cancel_all_open_orders()
def check_for_open_trades(self):
"""
Notify the user when the bot is stopped
and there are still open trades active.
"""
open_trades = Trade.get_trades([Trade.is_open == 1]).all()
if len(open_trades) != 0:
msg = {
'type': RPCMessageType.WARNING_NOTIFICATION,
'status': f"{len(open_trades)} open trades active.\n\n"
f"Handle these trades manually on {self.exchange.name}, "
f"or '/start' the bot again and use '/stopbuy' "
f"to handle open trades gracefully. \n"
f"{'Trades are simulated.' if self.config['dry_run'] else ''}",
}
self.rpc.send_msg(msg)
def _refresh_active_whitelist(self, trades: List[Trade] = []) -> List[str]:
"""
Refresh active whitelist from pairlist or edge and extend it with
pairs that have open trades.
"""
# Refresh whitelist
self.pairlists.refresh_pairlist()
@@ -180,12 +219,6 @@ class FreqtradeBot:
_whitelist.extend([trade.pair for trade in trades if trade.pair not in _whitelist])
return _whitelist
def _create_pair_whitelist(self, pairs: List[str]) -> List[Tuple[str, str]]:
"""
Create pair-whitelist tuple with (pair, ticker_interval)
"""
return [(pair, self.config['ticker_interval']) for pair in pairs]
def get_free_open_trades(self):
"""
Return the number of free open trades slots or 0 if
@@ -251,12 +284,19 @@ class FreqtradeBot:
f"Getting price from order book {bid_strategy['price_side'].capitalize()} side."
)
order_book_top = bid_strategy.get('order_book_top', 1)
order_book = self.exchange.get_order_book(pair, order_book_top)
order_book = self.exchange.fetch_l2_order_book(pair, order_book_top)
logger.debug('order_book %s', order_book)
# top 1 = index 0
order_book_rate = order_book[f"{bid_strategy['price_side']}s"][order_book_top - 1][0]
logger.info(f'...top {order_book_top} order book buy rate {order_book_rate:.8f}')
used_rate = order_book_rate
try:
rate_from_l2 = order_book[f"{bid_strategy['price_side']}s"][order_book_top - 1][0]
except (IndexError, KeyError) as e:
logger.warning(
"Buy Price from orderbook could not be determined."
f"Orderbook: {order_book}"
)
raise PricingError from e
logger.info(f'...top {order_book_top} order book buy rate {rate_from_l2:.8f}')
used_rate = rate_from_l2
else:
logger.info(f"Using Last {bid_strategy['price_side'].capitalize()} / Last Price")
ticker = self.exchange.fetch_ticker(pair)
@@ -404,9 +444,8 @@ class FreqtradeBot:
return False
# running get_signal on historical data fetched
(buy, sell) = self.strategy.get_signal(
pair, self.strategy.ticker_interval,
self.dataprovider.ohlcv(pair, self.strategy.ticker_interval))
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe)
(buy, sell) = self.strategy.get_signal(pair, self.strategy.timeframe, analyzed_df)
if buy and not sell:
stake_amount = self.get_trade_stake_amount(pair)
@@ -437,7 +476,7 @@ class FreqtradeBot:
"""
conf_bids_to_ask_delta = conf.get('bids_to_ask_delta', 0)
logger.info(f"Checking depth of market for {pair} ...")
order_book = self.exchange.get_order_book(pair, 1000)
order_book = self.exchange.fetch_l2_order_book(pair, 1000)
order_book_data_frame = order_book_to_dataframe(order_book['bids'], order_book['asks'])
order_book_bids = order_book_data_frame['b_size'].sum()
order_book_asks = order_book_data_frame['a_size'].sum()
@@ -479,6 +518,12 @@ class FreqtradeBot:
amount = stake_amount / buy_limit_requested
order_type = self.strategy.order_types['buy']
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):
logger.info(f"User requested abortion of buying {pair}")
return False
amount = self.exchange.amount_to_precision(pair, amount)
order = self.exchange.buy(pair=pair, ordertype=order_type,
amount=amount, rate=buy_limit_requested,
time_in_force=time_in_force)
@@ -487,6 +532,7 @@ class FreqtradeBot:
# we assume the order is executed at the price requested
buy_limit_filled_price = buy_limit_requested
amount_requested = amount
if order_status == 'expired' or order_status == 'rejected':
order_tif = self.strategy.order_time_in_force['buy']
@@ -507,15 +553,15 @@ class FreqtradeBot:
order['filled'], order['amount'], order['remaining']
)
stake_amount = order['cost']
amount = order['amount']
buy_limit_filled_price = order['price']
amount = safe_value_fallback(order, 'filled', 'amount')
buy_limit_filled_price = safe_value_fallback(order, 'average', 'price')
order_id = None
# in case of FOK the order may be filled immediately and fully
elif order_status == 'closed':
stake_amount = order['cost']
amount = order['amount']
buy_limit_filled_price = order['price']
amount = safe_value_fallback(order, 'filled', 'amount')
buy_limit_filled_price = safe_value_fallback(order, 'average', 'price')
# Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL
fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker')
@@ -523,6 +569,7 @@ class FreqtradeBot:
pair=pair,
stake_amount=stake_amount,
amount=amount,
amount_requested=amount_requested,
fee_open=fee,
fee_close=fee,
open_rate=buy_limit_filled_price,
@@ -531,7 +578,7 @@ class FreqtradeBot:
exchange=self.exchange.id,
open_order_id=order_id,
strategy=self.strategy.get_strategy_name(),
ticker_interval=timeframe_to_minutes(self.config['ticker_interval'])
timeframe=timeframe_to_minutes(self.config['timeframe'])
)
# Update fees if order is closed
@@ -553,6 +600,7 @@ class FreqtradeBot:
Sends rpc notification when a buy occured.
"""
msg = {
'trade_id': trade.id,
'type': RPCMessageType.BUY_NOTIFICATION,
'exchange': self.exchange.name.capitalize(),
'pair': trade.pair,
@@ -576,6 +624,7 @@ class FreqtradeBot:
current_rate = self.get_buy_rate(trade.pair, False)
msg = {
'trade_id': trade.id,
'type': RPCMessageType.BUY_CANCEL_NOTIFICATION,
'exchange': self.exchange.name.capitalize(),
'pair': trade.pair,
@@ -609,11 +658,11 @@ class FreqtradeBot:
trades_closed += 1
continue
# Check if we can sell our current pair
if trade.open_order_id is None and self.handle_trade(trade):
if trade.open_order_id is None and trade.is_open and self.handle_trade(trade):
trades_closed += 1
except DependencyException as exception:
logger.warning('Unable to sell trade: %s', exception)
logger.warning('Unable to sell trade %s: %s', trade.pair, exception)
# Updating wallets if any trade occured
if trades_closed:
@@ -626,7 +675,7 @@ class FreqtradeBot:
"""
Helper generator to query orderbook in loop (used for early sell-order placing)
"""
order_book = self.exchange.get_order_book(pair, order_book_max)
order_book = self.exchange.fetch_l2_order_book(pair, order_book_max)
for i in range(order_book_min, order_book_max + 1):
yield order_book[side][i - 1][0]
@@ -653,10 +702,15 @@ class FreqtradeBot:
logger.info(
f"Getting price from order book {ask_strategy['price_side'].capitalize()} side."
)
rate = next(self._order_book_gen(pair, f"{ask_strategy['price_side']}s"))
try:
rate = next(self._order_book_gen(pair, f"{ask_strategy['price_side']}s"))
except (IndexError, KeyError) as e:
logger.warning("Sell Price at location from orderbook could not be determined.")
raise PricingError from e
else:
rate = self.exchange.fetch_ticker(pair)[ask_strategy['price_side']]
if rate is None:
raise PricingError(f"Sell-Rate for {pair} was empty.")
self._sell_rate_cache[pair] = rate
return rate
@@ -676,23 +730,33 @@ class FreqtradeBot:
if (config_ask_strategy.get('use_sell_signal', True) or
config_ask_strategy.get('ignore_roi_if_buy_signal', False)):
(buy, sell) = self.strategy.get_signal(
trade.pair, self.strategy.ticker_interval,
self.dataprovider.ohlcv(trade.pair, self.strategy.ticker_interval))
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)
if config_ask_strategy.get('use_order_book', False):
logger.debug(f'Using order book for selling {trade.pair}...')
# logger.debug('Order book %s',orderBook)
order_book_min = config_ask_strategy.get('order_book_min', 1)
order_book_max = config_ask_strategy.get('order_book_max', 1)
logger.debug(f'Using order book between {order_book_min} and {order_book_max} '
f'for selling {trade.pair}...')
order_book = self._order_book_gen(trade.pair, f"{config_ask_strategy['price_side']}s",
order_book_min=order_book_min,
order_book_max=order_book_max)
for i in range(order_book_min, order_book_max + 1):
sell_rate = next(order_book)
try:
sell_rate = next(order_book)
except (IndexError, KeyError) as e:
logger.warning(
f"Sell Price at location {i} from orderbook could not be determined."
)
raise PricingError from e
logger.debug(f" order book {config_ask_strategy['price_side']} top {i}: "
f"{sell_rate:0.8f}")
# Assign sell-rate to cache - otherwise sell-rate is never updated in the cache,
# resulting in outdated RPC messages
self._sell_rate_cache[trade.pair] = sell_rate
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
return True
@@ -725,7 +789,7 @@ class FreqtradeBot:
logger.warning('Selling the trade forcefully')
self.execute_sell(trade, trade.stop_loss, sell_reason=SellType.EMERGENCY_SELL)
except DependencyException:
except ExchangeError:
trade.stoploss_order_id = None
logger.exception('Unable to place a stoploss order on exchange.')
return False
@@ -743,18 +807,18 @@ class FreqtradeBot:
try:
# First we check if there is already a stoploss on exchange
stoploss_order = self.exchange.get_order(trade.stoploss_order_id, trade.pair) \
if trade.stoploss_order_id else None
stoploss_order = self.exchange.fetch_stoploss_order(
trade.stoploss_order_id, trade.pair) if trade.stoploss_order_id else None
except InvalidOrderException as exception:
logger.warning('Unable to fetch stoploss order: %s', exception)
# We check if stoploss order is fulfilled
if stoploss_order and stoploss_order['status'] == 'closed':
if stoploss_order and stoploss_order['status'] in ('closed', 'triggered'):
trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value
trade.update(stoploss_order)
self.update_trade_state(trade, stoploss_order, sl_order=True)
# Lock pair for one candle to prevent immediate rebuys
self.strategy.lock_pair(trade.pair,
timeframe_to_next_date(self.config['ticker_interval']))
timeframe_to_next_date(self.config['timeframe']))
self._notify_sell(trade, "stoploss")
return True
@@ -765,10 +829,8 @@ class FreqtradeBot:
return False
# If buy order is fulfilled but there is no stoploss, we add a stoploss on exchange
if (not stoploss_order):
if not stoploss_order:
stoploss = self.edge.stoploss(pair=trade.pair) if self.edge else self.strategy.stoploss
stop_price = trade.open_rate * (1 + stoploss)
if self.create_stoploss_order(trade=trade, stop_price=stop_price, rate=stop_price):
@@ -776,7 +838,7 @@ class FreqtradeBot:
return False
# If stoploss order is canceled for some reason we add it
if stoploss_order and stoploss_order['status'] == 'canceled':
if stoploss_order and stoploss_order['status'] in ('canceled', 'cancelled'):
if self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss,
rate=trade.stop_loss):
return False
@@ -809,7 +871,7 @@ class FreqtradeBot:
logger.info('Trailing stoploss: cancelling current stoploss on exchange (id:{%s}) '
'in order to add another one ...', order['id'])
try:
self.exchange.cancel_order(order['id'], trade.pair)
self.exchange.cancel_stoploss_order(order['id'], trade.pair)
except InvalidOrderException:
logger.exception(f"Could not cancel stoploss order {order['id']} "
f"for pair {trade.pair}")
@@ -860,84 +922,122 @@ class FreqtradeBot:
try:
if not trade.open_order_id:
continue
order = self.exchange.get_order(trade.open_order_id, trade.pair)
except (RequestException, DependencyException, InvalidOrderException):
order = self.exchange.fetch_order(trade.open_order_id, trade.pair)
except (ExchangeError, InvalidOrderException):
logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc())
continue
trade_state_update = self.update_trade_state(trade, order)
fully_cancelled = self.update_trade_state(trade, order)
if (order['side'] == 'buy' and (
trade_state_update
or self._check_timed_out('buy', order))):
self.handle_timedout_limit_buy(trade, order)
self.wallets.update()
order_type = self.strategy.order_types['buy']
self._notify_buy_cancel(trade, order_type)
if (order['side'] == 'buy' and (order['status'] == 'open' or fully_cancelled) and (
fully_cancelled
or self._check_timed_out('buy', order)
or strategy_safe_wrapper(self.strategy.check_buy_timeout,
default_retval=False)(pair=trade.pair,
trade=trade,
order=order))):
self.handle_cancel_buy(trade, order, constants.CANCEL_REASON['TIMEOUT'])
elif (order['side'] == 'sell' and (
trade_state_update
or self._check_timed_out('sell', order))):
reason = self.handle_timedout_limit_sell(trade, order)
self.wallets.update()
order_type = self.strategy.order_types['sell']
self._notify_sell_cancel(trade, order_type, reason)
elif (order['side'] == 'sell' and (order['status'] == 'open' or fully_cancelled) and (
fully_cancelled
or self._check_timed_out('sell', order)
or strategy_safe_wrapper(self.strategy.check_sell_timeout,
default_retval=False)(pair=trade.pair,
trade=trade,
order=order))):
self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['TIMEOUT'])
def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool:
def cancel_all_open_orders(self) -> None:
"""
Buy timeout - cancel order
Cancel all orders that are currently open
:return: None
"""
for trade in Trade.get_open_order_trades():
try:
order = self.exchange.fetch_order(trade.open_order_id, trade.pair)
except (DependencyException, InvalidOrderException):
logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc())
continue
if order['side'] == 'buy':
self.handle_cancel_buy(trade, order, constants.CANCEL_REASON['ALL_CANCELLED'])
elif order['side'] == 'sell':
self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['ALL_CANCELLED'])
def handle_cancel_buy(self, trade: Trade, order: Dict, reason: str) -> bool:
"""
Buy cancel - cancel order
:return: True if order was fully cancelled
"""
if order['status'] != 'canceled':
reason = "cancelled due to timeout"
was_trade_fully_canceled = False
# Cancelled orders may have the status of 'canceled' or 'closed'
if order['status'] not in ('canceled', 'closed'):
reason = constants.CANCEL_REASON['TIMEOUT']
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.
# Simply bailing here is the only safe way - as this order will then be
# handled in the next iteration.
if corder.get('status') not in ('canceled', 'closed'):
logger.warning(f"Order {trade.open_order_id} for {trade.pair} not cancelled.")
return False
else:
# Order was cancelled already, so we can reuse the existing dict
corder = order
reason = "cancelled on exchange"
reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
logger.info('Buy order %s for %s.', reason, trade)
if safe_value_fallback(corder, order, 'remaining', 'remaining') == order['amount']:
# Using filled to determine the filled amount
filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled')
if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC):
logger.info('Buy order fully cancelled. Removing %s from database.', trade)
# if trade is not partially completed, just delete the trade
Trade.session.delete(trade)
Trade.session.flush()
return True
was_trade_fully_canceled = True
else:
# if trade is partially complete, edit the stake details for the trade
# and close the order
# cancel_order may not contain the full order dict, so we need to fallback
# to the order dict aquired before cancelling.
# we need to fall back to the values from order if corder does not contain these keys.
trade.amount = filled_amount
trade.stake_amount = trade.amount * trade.open_rate
self.update_trade_state(trade, corder, trade.amount)
# if trade is partially complete, edit the stake details for the trade
# and close the order
# cancel_order may not contain the full order dict, so we need to fallback
# to the order dict aquired before cancelling.
# we need to fall back to the values from order if corder does not contain these keys.
trade.amount = order['amount'] - safe_value_fallback(corder, order,
'remaining', 'remaining')
trade.stake_amount = trade.amount * trade.open_rate
self.update_trade_state(trade, corder, trade.amount)
trade.open_order_id = None
logger.info('Partial buy order timeout for %s.', trade)
self.rpc.send_msg({
'type': RPCMessageType.STATUS_NOTIFICATION,
'status': f'Remaining buy order for {trade.pair} cancelled due to timeout'
})
trade.open_order_id = None
logger.info('Partial buy order timeout for %s.', trade)
self.rpc.send_msg({
'type': RPCMessageType.STATUS_NOTIFICATION,
'status': f'Remaining buy order for {trade.pair} cancelled due to timeout'
})
return False
self.wallets.update()
self._notify_buy_cancel(trade, order_type=self.strategy.order_types['buy'])
return was_trade_fully_canceled
def handle_timedout_limit_sell(self, trade: Trade, order: Dict) -> str:
def handle_cancel_sell(self, trade: Trade, order: Dict, reason: str) -> str:
"""
Sell timeout - cancel order and update trade
Sell cancel - cancel order and update trade
:return: Reason for cancel
"""
# if trade is not partially completed, just cancel the trade
# if trade is not partially completed, just cancel the order
if order['remaining'] == order['amount'] or order.get('filled') == 0.0:
if not self.exchange.check_order_canceled_empty(order):
reason = "cancelled due to timeout"
# if trade is not partially completed, just delete the trade
self.exchange.cancel_order(trade.open_order_id, trade.pair)
try:
# if trade is not partially completed, just delete the order
self.exchange.cancel_order(trade.open_order_id, trade.pair)
except InvalidOrderException:
logger.exception(f"Could not cancel sell order {trade.open_order_id}")
return 'error cancelling order'
logger.info('Sell order %s for %s.', reason, trade)
else:
reason = "cancelled on exchange"
reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
logger.info('Sell order %s for %s.', reason, trade)
trade.close_rate = None
@@ -947,11 +1047,17 @@ class FreqtradeBot:
trade.close_date = None
trade.is_open = True
trade.open_order_id = None
else:
# TODO: figure out how to handle partially complete sell orders
reason = constants.CANCEL_REASON['PARTIALLY_FILLED']
return reason
# TODO: figure out how to handle partially complete sell orders
return 'partially filled - keeping order open'
self.wallets.update()
self._notify_sell_cancel(
trade,
order_type=self.strategy.order_types['sell'],
reason=reason
)
return reason
def _safe_sell_amount(self, pair: str, amount: float) -> float:
"""
@@ -972,7 +1078,7 @@ class FreqtradeBot:
if wallet_amount >= amount:
return amount
elif wallet_amount > amount * 0.98:
logger.info(f"{pair} - Falling back to wallet-amount.")
logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.")
return wallet_amount
else:
raise DependencyException(
@@ -999,7 +1105,7 @@ class FreqtradeBot:
# First cancelling stoploss on exchange ...
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
try:
self.exchange.cancel_order(trade.stoploss_order_id, trade.pair)
self.exchange.cancel_stoploss_order(trade.stoploss_order_id, trade.pair)
except InvalidOrderException:
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
@@ -1009,12 +1115,20 @@ class FreqtradeBot:
order_type = self.strategy.order_types.get("emergencysell", "market")
amount = self._safe_sell_amount(trade.pair, trade.amount)
time_in_force = self.strategy.order_time_in_force['sell']
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.value):
logger.info(f"User requested abortion of selling {trade.pair}")
return False
# Execute sell and update trade record
order = self.exchange.sell(pair=str(trade.pair),
ordertype=order_type,
amount=amount, rate=limit,
time_in_force=self.strategy.order_time_in_force['sell']
time_in_force=time_in_force
)
trade.open_order_id = order['id']
@@ -1022,11 +1136,11 @@ class FreqtradeBot:
trade.sell_reason = sell_reason.value
# In case of market sell orders the order can be closed immediately
if order.get('status', 'unknown') == 'closed':
trade.update(order)
self.update_trade_state(trade, order)
Trade.session.flush()
# Lock pair for one candle to prevent immediate rebuys
self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['ticker_interval']))
self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['timeframe']))
self._notify_sell(trade, order_type)
@@ -1045,6 +1159,7 @@ class FreqtradeBot:
msg = {
'type': RPCMessageType.SELL_NOTIFICATION,
'trade_id': trade.id,
'exchange': trade.exchange.capitalize(),
'pair': trade.pair,
'gain': gain,
@@ -1074,6 +1189,11 @@ class FreqtradeBot:
"""
Sends rpc notification when a sell cancel occured.
"""
if trade.sell_order_status == reason:
return
else:
trade.sell_order_status = reason
profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested
profit_trade = trade.calc_profit(rate=profit_rate)
current_rate = self.get_sell_rate(trade.pair, False)
@@ -1082,6 +1202,7 @@ class FreqtradeBot:
msg = {
'type': RPCMessageType.SELL_CANCEL_NOTIFICATION,
'trade_id': trade.id,
'exchange': trade.exchange.capitalize(),
'pair': trade.pair,
'gain': gain,
@@ -1113,7 +1234,7 @@ class FreqtradeBot:
#
def update_trade_state(self, trade: Trade, action_order: dict = None,
order_amount: float = None) -> bool:
order_amount: float = None, sl_order: bool = False) -> bool:
"""
Checks trades with open orders and updates the amount if necessary
Handles closing both buy and sell orders.
@@ -1121,84 +1242,126 @@ class FreqtradeBot:
"""
# Get order details for actual price per unit
if trade.open_order_id:
# Update trade with order values
logger.info('Found open order for %s', trade)
try:
order = action_order or self.exchange.get_order(trade.open_order_id, trade.pair)
except InvalidOrderException as exception:
logger.warning('Unable to fetch order %s: %s', trade.open_order_id, exception)
return False
# Try update amount (binance-fix)
try:
new_amount = self.get_real_amount(trade, order, order_amount)
if not isclose(order['amount'], new_amount, abs_tol=constants.MATH_CLOSE_PREC):
order['amount'] = new_amount
order.pop('filled', None)
# Fee was applied, so set to 0
trade.fee_open = 0
trade.recalc_open_trade_price()
except DependencyException as exception:
logger.warning("Could not update trade amount: %s", exception)
order_id = trade.open_order_id
elif trade.stoploss_order_id and sl_order:
order_id = trade.stoploss_order_id
else:
return False
# Update trade with order values
logger.info('Found open order for %s', trade)
try:
order = action_order or self.exchange.fetch_order(order_id, trade.pair)
except InvalidOrderException as exception:
logger.warning('Unable to fetch order %s: %s', order_id, exception)
return False
# Try update amount (binance-fix)
try:
new_amount = self.get_real_amount(trade, order, order_amount)
if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount,
abs_tol=constants.MATH_CLOSE_PREC):
order['amount'] = new_amount
order.pop('filled', None)
trade.recalc_open_trade_price()
except DependencyException as exception:
logger.warning("Could not update trade amount: %s", exception)
if self.exchange.check_order_canceled_empty(order):
# Trade has been cancelled on exchange
# Handling of this will happen in check_handle_timeout.
return True
trade.update(order)
# Updating wallets when order is closed
if not trade.is_open:
self.wallets.update()
if self.exchange.check_order_canceled_empty(order):
# Trade has been cancelled on exchange
# Handling of this will happen in check_handle_timeout.
return True
trade.update(order)
# Updating wallets when order is closed
if not trade.is_open:
self.wallets.update()
return False
def apply_fee_conditional(self, trade: Trade, trade_base_currency: str,
amount: float, fee_abs: float) -> float:
"""
Applies the fee to amount (either from Order or from Trades).
Can eat into dust if more than the required asset is available.
"""
self.wallets.update()
if fee_abs != 0 and self.wallets.get_free(trade_base_currency) >= amount:
# Eat into dust if we own more than base currency
logger.info(f"Fee amount for {trade} was in base currency - "
f"Eating Fee {fee_abs} into dust.")
elif fee_abs != 0:
real_amount = self.exchange.amount_to_precision(trade.pair, amount - fee_abs)
logger.info(f"Applying fee on amount for {trade} "
f"(from {amount} to {real_amount}).")
return real_amount
return amount
def get_real_amount(self, trade: Trade, order: Dict, order_amount: float = None) -> float:
"""
Get real amount for the trade
Detect and update trade fee.
Calls trade.update_fee() uppon 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
"""
# Init variables
if order_amount is None:
order_amount = order['amount']
order_amount = safe_value_fallback(order, 'filled', 'amount')
# Only run for closed orders
if trade.fee_open == 0 or order['status'] == 'open':
if trade.fee_updated(order.get('side', '')) or order['status'] == 'open':
return order_amount
trade_base_currency = self.exchange.get_pair_base_currency(trade.pair)
# use fee from order-dict if possible
if ('fee' in order and order['fee'] is not None and
(order['fee'].keys() >= {'currency', 'cost'})):
if (order['fee']['currency'] is not None and
order['fee']['cost'] is not None and
trade_base_currency == order['fee']['currency']):
new_amount = order_amount - order['fee']['cost']
logger.info("Applying fee on amount for %s (from %s to %s) from Order",
trade, order['amount'], new_amount)
return new_amount
if self.exchange.order_has_fee(order):
fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(order)
logger.info(f"Fee for Trade {trade} [{order.get('side')}]: "
f"{fee_cost:.8g} {fee_currency} - rate: {fee_rate}")
# Fallback to Trades
trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', ''))
if trade_base_currency == fee_currency:
# Apply fee to amount
return self.apply_fee_conditional(trade, trade_base_currency,
amount=order_amount, fee_abs=fee_cost)
return order_amount
return self.fee_detection_from_trades(trade, order, order_amount)
def fee_detection_from_trades(self, trade: Trade, order: Dict, order_amount: float) -> float:
"""
fee-detection fallback to Trades. Parses result of fetch_my_trades to get correct fee.
"""
trades = self.exchange.get_trades_for_order(trade.open_order_id, trade.pair,
trade.open_date)
if len(trades) == 0:
logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade)
return order_amount
fee_currency = None
amount = 0
fee_abs = 0
fee_abs = 0.0
fee_cost = 0.0
trade_base_currency = self.exchange.get_pair_base_currency(trade.pair)
fee_rate_array: List[float] = []
for exectrade in trades:
amount += exectrade['amount']
if ("fee" in exectrade and exectrade['fee'] is not None and
(exectrade['fee'].keys() >= {'currency', 'cost'})):
if self.exchange.order_has_fee(exectrade):
fee_cost_, fee_currency, fee_rate_ = self.exchange.extract_cost_curr_rate(exectrade)
fee_cost += fee_cost_
if fee_rate_ is not None:
fee_rate_array.append(fee_rate_)
# only applies if fee is in quote currency!
if (exectrade['fee']['currency'] is not None and
exectrade['fee']['cost'] is not None and
trade_base_currency == exectrade['fee']['currency']):
fee_abs += exectrade['fee']['cost']
if trade_base_currency == fee_currency:
fee_abs += fee_cost_
# Ensure at least one trade was found:
if fee_currency:
# fee_rate should use mean
fee_rate = sum(fee_rate_array) / float(len(fee_rate_array)) if fee_rate_array else None
trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', ''))
if not isclose(amount, order_amount, abs_tol=constants.MATH_CLOSE_PREC):
logger.warning(f"Amount {amount} does not match amount {trade.amount}")
raise DependencyException("Half bought? Amounts don't match")
real_amount = amount - fee_abs
if fee_abs != 0:
logger.info(f"Applying fee on amount for {trade} "
f"(from {order_amount} to {real_amount}) from Trades")
return real_amount
return self.apply_fee_conditional(trade, trade_base_currency,
amount=amount, fee_abs=fee_abs)
else:
return amount

View File

@@ -11,7 +11,7 @@ from freqtrade.exceptions import OperationalException
logger = logging.getLogger(__name__)
def _set_loggers(verbosity: int = 0) -> None:
def _set_loggers(verbosity: int = 0, api_verbosity: str = 'info') -> None:
"""
Set the logging level for third party libraries
:return: None
@@ -28,6 +28,10 @@ def _set_loggers(verbosity: int = 0) -> None:
)
logging.getLogger('telegram').setLevel(logging.INFO)
logging.getLogger('werkzeug').setLevel(
logging.ERROR if api_verbosity == 'error' else logging.INFO
)
def setup_logging(config: Dict[str, Any]) -> None:
"""
@@ -77,5 +81,5 @@ def setup_logging(config: Dict[str, Any]) -> None:
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=log_handlers
)
_set_loggers(verbosity)
_set_loggers(verbosity, config.get('api_server', {}).get('verbosity', 'info'))
logger.info('Verbosity set to %s', verbosity)

View File

@@ -134,7 +134,21 @@ def round_dict(d, n):
return {k: (round(v, n) if isinstance(v, float) else v) for k, v in d.items()}
def safe_value_fallback(dict1: dict, dict2: dict, key1: str, key2: str, default_value=None):
def safe_value_fallback(obj: dict, key1: str, key2: str, default_value=None):
"""
Search a value in obj, return this if it's not None.
Then search key2 in obj - return that if it's not none - then use default_value.
Else falls back to None.
"""
if key1 in obj and obj[key1] is not None:
return obj[key1]
else:
if key2 in obj and obj[key2] is not None:
return obj[key2]
return default_value
def safe_value_fallback2(dict1: dict, dict2: dict, key1: str, key2: str, default_value=None):
"""
Search a value in dict1, return this if it's not None.
Fall back to dict2 - return key2 from dict2 if it's not None.
@@ -163,3 +177,15 @@ def render_template(templatefile: str, arguments: dict = {}) -> str:
)
template = env.get_template(templatefile)
return template.render(**arguments)
def render_template_with_fallback(templatefile: str, templatefallbackfile: str,
arguments: dict = {}) -> str:
"""
Use templatefile if possible, otherwise fall back to templatefallbackfile
"""
from jinja2.exceptions import TemplateNotFound
try:
return render_template(templatefile, arguments)
except TemplateNotFound:
return render_template(templatefallbackfile, arguments)

View File

@@ -18,8 +18,10 @@ from freqtrade.data.converter import trim_dataframe
from freqtrade.data.dataprovider import DataProvider
from freqtrade.exceptions import OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
from freqtrade.optimize.optimize_reports import (show_backtest_results,
from freqtrade.optimize.optimize_reports import (generate_backtest_stats,
show_backtest_results,
store_backtest_result)
from freqtrade.pairlist.pairlistmanager import PairListManager
from freqtrade.persistence import Trade
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.state import RunMode
@@ -63,11 +65,6 @@ class Backtesting:
self.strategylist: List[IStrategy] = []
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
if config.get('fee'):
self.fee = config['fee']
else:
self.fee = self.exchange.get_fee(symbol=self.config['exchange']['pair_whitelist'][0])
if self.config.get('runmode') != RunMode.HYPEROPT:
self.dataprovider = DataProvider(self.config, self.exchange)
IStrategy.dp = self.dataprovider
@@ -84,12 +81,31 @@ class Backtesting:
self.strategylist.append(StrategyResolver.load_strategy(self.config))
validate_config_consistency(self.config)
if "ticker_interval" not in self.config:
if "timeframe" not in self.config:
raise OperationalException("Timeframe (ticker interval) needs to be set in either "
"configuration or as cli argument `--ticker-interval 5m`")
self.timeframe = str(self.config.get('ticker_interval'))
"configuration or as cli argument `--timeframe 5m`")
self.timeframe = str(self.config.get('timeframe'))
self.timeframe_min = timeframe_to_minutes(self.timeframe)
self.pairlists = PairListManager(self.exchange, self.config)
if 'VolumePairList' in self.pairlists.name_list:
raise OperationalException("VolumePairList not allowed for backtesting.")
if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list:
raise OperationalException(
"PrecisionFilter not allowed for backtesting multiple strategies."
)
self.pairlists.refresh_pairlist()
if len(self.pairlists.whitelist) == 0:
raise OperationalException("No pair in whitelist.")
if config.get('fee', None) is not None:
self.fee = config['fee']
else:
self.fee = self.exchange.get_fee(symbol=self.pairlists.whitelist[0])
# Get maximum required startup period
self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
# Load one (first) strategy
@@ -111,7 +127,7 @@ class Backtesting:
data = history.load_data(
datadir=self.config['datadir'],
pairs=self.config['exchange']['pair_whitelist'],
pairs=self.pairlists.whitelist,
timeframe=self.timeframe,
timerange=timerange,
startup_candles=self.required_startup,
@@ -401,4 +417,5 @@ class Backtesting:
if self.config.get('export', False):
store_backtest_result(self.config['exportfilename'], all_results)
# Show backtest results
show_backtest_results(self.config, data, all_results)
stats = generate_backtest_stats(self.config, data, all_results)
show_backtest_results(self.config, stats)

View File

@@ -42,8 +42,8 @@ class DefaultHyperOptLoss(IHyperOptLoss):
* 0.25: Avoiding trade loss
* 1.0 to total profit, compared to the expected value (`EXPECTED_MAX_PROFIT`) defined above
"""
total_profit = results.profit_percent.sum()
trade_duration = results.trade_duration.mean()
total_profit = results['profit_percent'].sum()
trade_duration = results['trade_duration'].mean()
trade_loss = 1 - 0.25 * exp(-(trade_count - TARGET_TRADES) ** 2 / 10 ** 5.8)
profit_loss = max(0, 1 - total_profit / EXPECTED_MAX_PROFIT)

View File

@@ -12,7 +12,7 @@ from math import ceil
from collections import OrderedDict
from operator import itemgetter
from pathlib import Path
from pprint import pprint
from pprint import pformat
from typing import Any, Dict, List, Optional
import rapidjson
@@ -49,9 +49,9 @@ logger = logging.getLogger(__name__)
INITIAL_POINTS = 30
# Keep no more than 2*SKOPT_MODELS_MAX_NUM models
# in the skopt models list
SKOPT_MODELS_MAX_NUM = 10
# Keep no more than SKOPT_MODEL_QUEUE_SIZE models
# in the skopt model queue, to optimize memory consumption
SKOPT_MODEL_QUEUE_SIZE = 10
MAX_LOSS = 100000 # just a big enough number to be bad result in loss optimization
@@ -75,8 +75,8 @@ class Hyperopt:
self.custom_hyperoptloss = HyperOptLossResolver.load_hyperoptloss(self.config)
self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function
self.trials_file = (self.config['user_data_dir'] /
'hyperopt_results' / 'hyperopt_results.pickle')
self.results_file = (self.config['user_data_dir'] /
'hyperopt_results' / 'hyperopt_results.pickle')
self.data_pickle_file = (self.config['user_data_dir'] /
'hyperopt_results' / 'hyperopt_tickerdata.pkl')
self.total_epochs = config.get('epochs', 0)
@@ -88,10 +88,10 @@ class Hyperopt:
else:
logger.info("Continuing on previous hyperopt results.")
self.num_trials_saved = 0
self.num_epochs_saved = 0
# Previous evaluations
self.trials: List = []
self.epochs: List = []
# Populate functions here (hasattr is slow so should not be run during "regular" operations)
if hasattr(self.custom_hyperopt, 'populate_indicators'):
@@ -132,7 +132,7 @@ class Hyperopt:
"""
Remove hyperopt pickle files to restart hyperopt.
"""
for f in [self.data_pickle_file, self.trials_file]:
for f in [self.data_pickle_file, self.results_file]:
p = Path(f)
if p.is_file():
logger.info(f"Removing `{p}`.")
@@ -151,27 +151,26 @@ 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_trials(self, final: bool = False) -> None:
def _save_results(self) -> None:
"""
Save hyperopt trials to file
Save hyperopt results to file
"""
num_trials = len(self.trials)
if num_trials > self.num_trials_saved:
logger.debug(f"Saving {num_trials} {plural(num_trials, 'epoch')}.")
dump(self.trials, self.trials_file)
self.num_trials_saved = num_trials
if final:
logger.info(f"{num_trials} {plural(num_trials, 'epoch')} "
f"saved to '{self.trials_file}'.")
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}'.")
@staticmethod
def _read_trials(trials_file: Path) -> List:
def _read_results(results_file: Path) -> List:
"""
Read hyperopt trials file
Read hyperopt results from file
"""
logger.info("Reading Trials from '%s'", trials_file)
trials = load(trials_file)
return trials
logger.info("Reading epochs from '%s'", results_file)
data = load(results_file)
return data
def _get_params_details(self, params: Dict) -> Dict:
"""
@@ -231,6 +230,9 @@ class Hyperopt:
if space in ['buy', 'sell']:
result_dict.setdefault('params', {}).update(space_params)
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)
# Convert keys in min_roi dict to strings because
# rapidjson cannot dump dicts with integer keys...
# OrderedDict is used to keep the numeric order of the items
@@ -245,11 +247,24 @@ class Hyperopt:
def _params_pretty_print(params, space: str, header: str) -> None:
if space in params:
space_params = Hyperopt._space_params(params, space, 5)
params_result = f"\n# {header}\n"
if space == 'stoploss':
print(header, space_params.get('stoploss'))
params_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)
minimal_roi_result = rapidjson.dumps(
OrderedDict(
(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}"
else:
print(header)
pprint(space_params, indent=4)
params_result += f"{space}_params = {pformat(space_params, indent=4)}"
params_result = params_result.replace("}", "\n}").replace("{", "{\n ")
params_result = params_result.replace("\n", "\n ")
print(params_result)
@staticmethod
def _space_params(params, space: str, r: int = None) -> Dict:
@@ -304,8 +319,9 @@ class Hyperopt:
trials.columns = ['Best', 'Epoch', 'Trades', 'Avg profit', 'Total profit',
'Profit', 'Avg duration', 'Objective', 'is_initial_point', 'is_best']
trials['is_profit'] = False
trials.loc[trials['is_initial_point'], 'Best'] = '*'
trials.loc[trials['is_initial_point'], 'Best'] = '* '
trials.loc[trials['is_best'], 'Best'] = 'Best'
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)
@@ -375,27 +391,35 @@ class Hyperopt:
# Verification for overwrite
if path.isfile(csv_file):
logger.error("CSV-File already exists!")
logger.error(f"CSV file already exists: {csv_file}")
return
try:
io.open(csv_file, 'w+').close()
except IOError:
logger.error("Filed to create CSV-File!")
logger.error(f"Failed to create CSV file: {csv_file}")
return
trials = json_normalize(results, max_level=1)
trials['Best'] = ''
trials['Stake currency'] = config['stake_currency']
trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count',
'results_metrics.avg_profit', 'results_metrics.total_profit',
'Stake currency', 'results_metrics.profit', 'results_metrics.duration',
'loss', 'is_initial_point', 'is_best']]
trials.columns = ['Best', 'Epoch', 'Trades', 'Avg profit', 'Total profit', 'Stake currency',
'Profit', 'Avg duration', 'Objective', 'is_initial_point', 'is_best']
base_metrics = ['Best', 'current_epoch', 'results_metrics.trade_count',
'results_metrics.avg_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]
base_columns = ['Best', 'Epoch', 'Trades', 'Avg profit', 'Total profit', 'Stake currency',
'Profit', 'Avg duration', 'Objective', 'is_initial_point', 'is_best']
param_columns = list(results[0]['params_dict'].keys())
trials.columns = base_columns + param_columns
trials['is_profit'] = False
trials.loc[trials['is_initial_point'], 'Best'] = '*'
trials.loc[trials['is_best'], 'Best'] = 'Best'
trials.loc[trials['is_initial_point'] & trials['is_best'], 'Best'] = '* Best'
trials.loc[trials['Total profit'] > 0, 'is_profit'] = True
trials['Epoch'] = trials['Epoch'].astype(str)
trials['Trades'] = trials['Trades'].astype(str)
@@ -418,7 +442,7 @@ class Hyperopt:
trials = trials.drop(columns=['is_initial_point', 'is_best', 'is_profit'])
trials.to_csv(csv_file, index=False, header=True, mode='w', encoding='UTF-8')
print("CSV-File created!")
logger.info(f"CSV file created: {csv_file}")
def has_space(self, space: str) -> bool:
"""
@@ -570,43 +594,28 @@ class Hyperopt:
n_initial_points=INITIAL_POINTS,
acq_optimizer_kwargs={'n_jobs': cpu_count},
random_state=self.random_state,
model_queue_size=SKOPT_MODEL_QUEUE_SIZE,
)
def fix_optimizer_models_list(self) -> None:
"""
WORKAROUND: Since skopt is not actively supported, this resolves problems with skopt
memory usage, see also: https://github.com/scikit-optimize/scikit-optimize/pull/746
This may cease working when skopt updates if implementation of this intrinsic
part changes.
"""
n = len(self.opt.models) - SKOPT_MODELS_MAX_NUM
# Keep no more than 2*SKOPT_MODELS_MAX_NUM models in the skopt models list,
# remove the old ones. These are actually of no use, the current model
# from the estimator is the only one used in the skopt optimizer.
# Freqtrade code also does not inspect details of the models.
if n >= SKOPT_MODELS_MAX_NUM:
logger.debug(f"Fixing skopt models list, removing {n} old items...")
del self.opt.models[0:n]
def run_optimizer_parallel(self, parallel, asked, i) -> List:
return parallel(delayed(
wrap_non_picklable_objects(self.generate_optimizer))(v, i) for v in asked)
@staticmethod
def load_previous_results(trials_file: Path) -> List:
def load_previous_results(results_file: Path) -> List:
"""
Load data for epochs from the file if we have one
"""
trials: List = []
if trials_file.is_file() and trials_file.stat().st_size > 0:
trials = Hyperopt._read_trials(trials_file)
if trials[0].get('is_best') is None:
epochs: List = []
if results_file.is_file() and results_file.stat().st_size > 0:
epochs = Hyperopt._read_results(results_file)
# Detection of some old format, without 'is_best' field saved
if epochs[0].get('is_best') is None:
raise OperationalException(
"The file with Hyperopt results is incompatible with this version "
"of Freqtrade and cannot be loaded.")
logger.info(f"Loaded {len(trials)} previous evaluations from disk.")
return trials
logger.info(f"Loaded {len(epochs)} previous evaluations from disk.")
return epochs
def _set_random_state(self, random_state: Optional[int]) -> int:
return random_state or random.randint(1, 2**16 - 1)
@@ -632,8 +641,9 @@ class Hyperopt:
# We don't need exchange instance anymore while running hyperopt
self.backtesting.exchange = None # type: ignore
self.backtesting.pairlists = None # type: ignore
self.trials = self.load_previous_results(self.trials_file)
self.epochs = self.load_previous_results(self.results_file)
cpus = cpu_count()
logger.info(f"Found {cpus} CPU cores. Let's make them scream!")
@@ -669,7 +679,7 @@ class Hyperopt:
' [', progressbar.ETA(), ', ', progressbar.Timer(), ']',
]
with progressbar.ProgressBar(
maxval=self.total_epochs, redirect_stdout=False, redirect_stderr=False,
max_value=self.total_epochs, redirect_stdout=False, redirect_stderr=False,
widgets=widgets
) as pbar:
EVALS = ceil(self.total_epochs / jobs)
@@ -682,7 +692,6 @@ class Hyperopt:
asked = self.opt.ask(n_points=current_jobs)
f_val = self.run_optimizer_parallel(parallel, asked, i)
self.opt.tell(asked, [v['loss'] for v in f_val])
self.fix_optimizer_models_list()
# Calculate progressbar outputs
for j, val in enumerate(f_val):
@@ -703,23 +712,25 @@ class Hyperopt:
if is_best:
self.current_best_loss = val['loss']
self.trials.append(val)
self.epochs.append(val)
# Save results after each best epoch and every 100 epochs
if is_best or current % 100 == 0:
self.save_trials()
self._save_results()
pbar.update(current)
except KeyboardInterrupt:
print('User interrupted..')
self.save_trials(final=True)
self._save_results()
logger.info(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
f"saved to '{self.results_file}'.")
if self.trials:
sorted_trials = sorted(self.trials, key=itemgetter('loss'))
results = sorted_trials[0]
self.print_epoch_details(results, self.total_epochs, self.print_json)
if self.epochs:
sorted_epochs = sorted(self.epochs, key=itemgetter('loss'))
best_epoch = sorted_epochs[0]
self.print_epoch_details(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.

View File

@@ -31,13 +31,15 @@ class IHyperOpt(ABC):
Class attributes you can use:
ticker_interval -> int: value of the ticker interval to use for the strategy
"""
ticker_interval: str
ticker_interval: str # DEPRECATED
timeframe: str
def __init__(self, config: dict) -> None:
self.config = config
# Assign ticker_interval to be used in hyperopt
IHyperOpt.ticker_interval = str(config['ticker_interval'])
IHyperOpt.ticker_interval = str(config['timeframe']) # DEPRECATED
IHyperOpt.timeframe = str(config['timeframe'])
@staticmethod
def buy_strategy_generator(params: Dict[str, Any]) -> Callable:
@@ -218,9 +220,10 @@ class IHyperOpt(ABC):
# Why do I still need such shamanic mantras in modern python?
def __getstate__(self):
state = self.__dict__.copy()
state['ticker_interval'] = self.ticker_interval
state['timeframe'] = self.timeframe
return state
def __setstate__(self, state):
self.__dict__.update(state)
IHyperOpt.ticker_interval = state['ticker_interval']
IHyperOpt.ticker_interval = state['timeframe']
IHyperOpt.timeframe = state['timeframe']

View File

@@ -14,7 +14,7 @@ class IHyperOptLoss(ABC):
Interface for freqtrade hyperopt Loss functions.
Defines the custom loss function (`hyperopt_loss_function()` which is evaluated every epoch.)
"""
ticker_interval: str
timeframe: str
@staticmethod
@abstractmethod

View File

@@ -34,5 +34,5 @@ class OnlyProfitHyperOptLoss(IHyperOptLoss):
"""
Objective function, returns smaller number for better results.
"""
total_profit = results.profit_percent.sum()
total_profit = results['profit_percent'].sum()
return 1 - total_profit / EXPECTED_MAX_PROFIT

View File

@@ -1,7 +1,7 @@
import logging
from datetime import timedelta
from pathlib import Path
from typing import Dict
from typing import Any, Dict, List
from pandas import DataFrame
from tabulate import tabulate
@@ -18,10 +18,7 @@ def store_backtest_result(recordfilename: Path, all_results: Dict[str, DataFrame
:param all_results: Dict of Dataframes, one results dataframe per strategy
"""
for strategy, results in all_results.items():
records = [(t.pair, t.profit_percent, t.open_time.timestamp(),
t.close_time.timestamp(), t.open_index - 1, t.trade_duration,
t.open_rate, t.close_rate, t.open_at_end, t.sell_reason.value)
for index, t in results.iterrows()]
records = backtest_result_to_list(results)
if records:
filename = recordfilename
@@ -34,153 +31,141 @@ def store_backtest_result(recordfilename: Path, all_results: Dict[str, DataFrame
file_dump_json(filename, records)
def generate_text_table(data: Dict[str, Dict], stake_currency: str, max_open_trades: int,
results: DataFrame, skip_nan: bool = False) -> str:
def backtest_result_to_list(results: DataFrame) -> List[List]:
"""
Generates and returns a text table for the given backtest data and the results dataframe
Converts a list of Backtest-results to list
:param results: Dataframe containing results for one strategy
:return: List of Lists containing the trades
"""
return [[t.pair, t.profit_percent, t.open_time.timestamp(),
t.close_time.timestamp(), t.open_index - 1, t.trade_duration,
t.open_rate, t.close_rate, t.open_at_end, t.sell_reason.value]
for index, t in results.iterrows()]
def _get_line_floatfmt() -> List[str]:
"""
Generate floatformat (goes in line with _generate_result_line())
"""
return ['s', 'd', '.2f', '.2f', '.8f', '.2f', 'd', 'd', 'd', 'd']
def _get_line_header(first_column: str, stake_currency: str) -> List[str]:
"""
Generate header lines (goes in line with _generate_result_line())
"""
return [first_column, 'Buys', 'Avg Profit %', 'Cum Profit %',
f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration',
'Wins', 'Draws', 'Losses']
def _generate_result_line(result: DataFrame, max_open_trades: int, first_column: str) -> Dict:
"""
Generate one result dict, with "first_column" as key.
"""
return {
'key': first_column,
'trades': len(result),
'profit_mean': result['profit_percent'].mean(),
'profit_mean_pct': result['profit_percent'].mean() * 100.0,
'profit_sum': result['profit_percent'].sum(),
'profit_sum_pct': result['profit_percent'].sum() * 100.0,
'profit_total_abs': result['profit_abs'].sum(),
'profit_total_pct': result['profit_percent'].sum() * 100.0 / max_open_trades,
'duration_avg': str(timedelta(
minutes=round(result['trade_duration'].mean()))
) if not result.empty else '0:00',
# 'duration_max': str(timedelta(
# minutes=round(result['trade_duration'].max()))
# ) if not result.empty else '0:00',
# 'duration_min': str(timedelta(
# minutes=round(result['trade_duration'].min()))
# ) if not result.empty else '0:00',
'wins': len(result[result['profit_abs'] > 0]),
'draws': len(result[result['profit_abs'] == 0]),
'losses': len(result[result['profit_abs'] < 0]),
}
def generate_pair_metrics(data: Dict[str, Dict], stake_currency: str, max_open_trades: int,
results: DataFrame, skip_nan: bool = False) -> List[Dict]:
"""
Generates and returns a list for the given backtest data and the results dataframe
:param data: Dict of <pair: dataframe> containing data that was used during backtesting.
:param stake_currency: stake-currency - used to correctly name headers
:param max_open_trades: Maximum allowed open trades
:param results: Dataframe containing the backtest results
:param skip_nan: Print "left open" open trades
:return: pretty printed table with tabulate as string
:return: List of Dicts containing the metrics per pair
"""
floatfmt = ('s', 'd', '.2f', '.2f', '.8f', '.2f', 'd', '.1f', '.1f')
tabular_data = []
headers = [
'Pair',
'Buys',
'Avg Profit %',
'Cum Profit %',
f'Tot Profit {stake_currency}',
'Tot Profit %',
'Avg Duration',
'Wins',
'Draws',
'Losses'
]
for pair in data:
result = results[results.pair == pair]
if skip_nan and result.profit_abs.isnull().all():
result = results[results['pair'] == pair]
if skip_nan and result['profit_abs'].isnull().all():
continue
tabular_data.append([
pair,
len(result.index),
result.profit_percent.mean() * 100.0,
result.profit_percent.sum() * 100.0,
result.profit_abs.sum(),
result.profit_percent.sum() * 100.0 / max_open_trades,
str(timedelta(
minutes=round(result.trade_duration.mean()))) if not result.empty else '0:00',
len(result[result.profit_abs > 0]),
len(result[result.profit_abs == 0]),
len(result[result.profit_abs < 0])
])
tabular_data.append(_generate_result_line(result, max_open_trades, pair))
# Append Total
tabular_data.append([
'TOTAL',
len(results.index),
results.profit_percent.mean() * 100.0,
results.profit_percent.sum() * 100.0,
results.profit_abs.sum(),
results.profit_percent.sum() * 100.0 / max_open_trades,
str(timedelta(
minutes=round(results.trade_duration.mean()))) if not results.empty else '0:00',
len(results[results.profit_abs > 0]),
len(results[results.profit_abs == 0]),
len(results[results.profit_abs < 0])
])
# Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(tabular_data, headers=headers,
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore
tabular_data.append(_generate_result_line(results, max_open_trades, 'TOTAL'))
return tabular_data
def generate_text_table_sell_reason(stake_currency: str, max_open_trades: int,
results: DataFrame) -> str:
def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List[Dict]:
"""
Generate small table outlining Backtest results
:param stake_currency: Stakecurrency used
:param max_open_trades: Max_open_trades parameter
:param results: Dataframe containing the backtest results
:return: pretty printed table with tabulate as string
:param results: Dataframe containing the backtest result for one strategy
:return: List of Dicts containing the metrics per Sell reason
"""
tabular_data = []
headers = [
"Sell Reason",
"Sells",
"Wins",
"Draws",
"Losses",
"Avg Profit %",
"Cum Profit %",
f"Tot Profit {stake_currency}",
"Tot Profit %",
]
for reason, count in results['sell_reason'].value_counts().iteritems():
result = results.loc[results['sell_reason'] == reason]
wins = len(result[result['profit_abs'] > 0])
draws = len(result[result['profit_abs'] == 0])
loss = len(result[result['profit_abs'] < 0])
profit_mean = round(result['profit_percent'].mean() * 100.0, 2)
profit_sum = round(result["profit_percent"].sum() * 100.0, 2)
profit_tot = result['profit_abs'].sum()
profit_mean = result['profit_percent'].mean()
profit_sum = result["profit_percent"].sum()
profit_percent_tot = round(result['profit_percent'].sum() * 100.0 / max_open_trades, 2)
tabular_data.append(
[
reason.value,
count,
wins,
draws,
loss,
profit_mean,
profit_sum,
profit_tot,
profit_percent_tot,
]
{
'sell_reason': reason.value,
'trades': count,
'wins': len(result[result['profit_abs'] > 0]),
'draws': len(result[result['profit_abs'] == 0]),
'losses': len(result[result['profit_abs'] < 0]),
'profit_mean': profit_mean,
'profit_mean_pct': round(profit_mean * 100, 2),
'profit_sum': profit_sum,
'profit_sum_pct': round(profit_sum * 100, 2),
'profit_total_abs': result['profit_abs'].sum(),
'profit_pct_total': profit_percent_tot,
}
)
return tabulate(tabular_data, headers=headers, tablefmt="orgtbl", stralign="right")
return tabular_data
def generate_text_table_strategy(stake_currency: str, max_open_trades: str,
all_results: Dict) -> str:
def generate_strategy_metrics(stake_currency: str, max_open_trades: int,
all_results: Dict) -> List[Dict]:
"""
Generate summary table per strategy
Generate summary per strategy
:param stake_currency: stake-currency - used to correctly name headers
:param max_open_trades: Maximum allowed open trades used for backtest
:param all_results: Dict of <Strategyname: BacktestResult> containing results for all strategies
:return: pretty printed table with tabulate as string
:return: List of Dicts containing the metrics per Strategy
"""
floatfmt = ('s', 'd', '.2f', '.2f', '.8f', '.2f', 'd', '.1f', '.1f')
tabular_data = []
headers = ['Strategy', 'Buys', 'Avg Profit %', 'Cum Profit %',
f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration',
'Wins', 'Draws', 'Losses']
for strategy, results in all_results.items():
tabular_data.append([
strategy,
len(results.index),
results.profit_percent.mean() * 100.0,
results.profit_percent.sum() * 100.0,
results.profit_abs.sum(),
results.profit_percent.sum() * 100.0 / max_open_trades,
str(timedelta(
minutes=round(results.trade_duration.mean()))) if not results.empty else '0:00',
len(results[results.profit_abs > 0]),
len(results[results.profit_abs == 0]),
len(results[results.profit_abs < 0])
])
# Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(tabular_data, headers=headers,
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore
tabular_data.append(_generate_result_line(results, max_open_trades, strategy))
return tabular_data
def generate_edge_table(results: dict) -> str:
floatfmt = ('s', '.10g', '.2f', '.2f', '.2f', '.2f', 'd', '.d')
floatfmt = ('s', '.10g', '.2f', '.2f', '.2f', '.2f', 'd', 'd', 'd')
tabular_data = []
headers = ['Pair', 'Stoploss', 'Win Rate', 'Risk Reward Ratio',
'Required Risk Reward', 'Expectancy', 'Total Number of Trades',
@@ -204,40 +189,145 @@ def generate_edge_table(results: dict) -> str:
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore
def show_backtest_results(config: Dict, btdata: Dict[str, DataFrame],
all_results: Dict[str, DataFrame]):
def generate_backtest_stats(config: Dict, btdata: Dict[str, DataFrame],
all_results: Dict[str, DataFrame]) -> Dict[str, Any]:
"""
:param config: Configuration object used for backtest
:param btdata: Backtest data
:param all_results: backtest result - dictionary with { Strategy: results}.
:return:
Dictionary containing results per strategy and a stratgy summary.
"""
stake_currency = config['stake_currency']
max_open_trades = config['max_open_trades']
result: Dict[str, Any] = {'strategy': {}}
for strategy, results in all_results.items():
pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
max_open_trades=max_open_trades,
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,
max_open_trades=max_open_trades,
results=results.loc[results['open_at_end']],
skip_nan=True)
strat_stats = {
'trades': backtest_result_to_list(results),
'results_per_pair': pair_results,
'sell_reason_summary': sell_reason_stats,
'left_open_trades': left_open_results,
}
result['strategy'][strategy] = strat_stats
strategy_results = generate_strategy_metrics(stake_currency=stake_currency,
max_open_trades=max_open_trades,
all_results=all_results)
result['strategy_comparison'] = strategy_results
return result
###
# Start output section
###
def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: str) -> str:
"""
Generates and returns a text table for the given backtest data and the results dataframe
:param pair_results: List of Dictionaries - one entry per pair + final TOTAL row
:param stake_currency: stake-currency - used to correctly name headers
:return: pretty printed table with tabulate as string
"""
headers = _get_line_header('Pair', stake_currency)
floatfmt = _get_line_floatfmt()
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 pair_results]
# Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(output, headers=headers,
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_currency: str) -> str:
"""
Generate small table outlining Backtest results
:param sell_reason_stats: Sell reason metrics
:param stake_currency: Stakecurrency used
:return: pretty printed table with tabulate as string
"""
headers = [
'Sell Reason',
'Sells',
'Wins',
'Draws',
'Losses',
'Avg Profit %',
'Cum Profit %',
f'Tot Profit {stake_currency}',
'Tot Profit %',
]
output = [[
t['sell_reason'], t['trades'], t['wins'], t['draws'], t['losses'],
t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'], t['profit_pct_total'],
] for t in sell_reason_stats]
return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right")
def text_table_strategy(strategy_results, stake_currency: str) -> str:
"""
Generate summary table per strategy
:param stake_currency: stake-currency - used to correctly name headers
:param max_open_trades: Maximum allowed open trades used for backtest
:param all_results: Dict of <Strategyname: BacktestResult> containing results for all strategies
:return: pretty printed table with tabulate as string
"""
floatfmt = _get_line_floatfmt()
headers = _get_line_header('Strategy', 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']
] for t in strategy_results]
# Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(output, headers=headers,
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
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 = generate_text_table(btdata, stake_currency=config['stake_currency'],
max_open_trades=config['max_open_trades'],
results=results)
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 = generate_text_table_sell_reason(stake_currency=config['stake_currency'],
max_open_trades=config['max_open_trades'],
results=results)
table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'],
stake_currency=stake_currency)
if isinstance(table, str):
print(' SELL REASON STATS '.center(len(table.splitlines()[0]), '='))
print(table)
table = generate_text_table(btdata,
stake_currency=config['stake_currency'],
max_open_trades=config['max_open_trades'],
results=results.loc[results.open_at_end], skip_nan=True)
table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency)
if isinstance(table, str):
print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '='))
print(table)
if isinstance(table, str):
print('=' * len(table.splitlines()[0]))
print()
if len(all_results) > 1:
if len(backtest_stats['strategy']) > 1:
# Print Strategy summary table
table = generate_text_table_strategy(config['stake_currency'],
config['max_open_trades'],
all_results=all_results)
table = text_table_strategy(backtest_stats['strategy_comparison'], stake_currency)
print(' STRATEGY SUMMARY '.center(len(table.splitlines()[0]), '='))
print(table)
print('=' * len(table.splitlines()[0]))

View File

@@ -0,0 +1,84 @@
"""
Minimum age (days listed) pair list filter
"""
import logging
import arrow
from typing import Any, Dict
from freqtrade.exceptions import OperationalException
from freqtrade.misc import plural
from freqtrade.pairlist.IPairList import IPairList
logger = logging.getLogger(__name__)
class AgeFilter(IPairList):
# Checked symbols cache (dictionary of ticker symbol => timestamp)
_symbolsChecked: Dict[str, int] = {}
def __init__(self, exchange, pairlistmanager,
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
pairlist_pos: int) -> None:
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
self._min_days_listed = pairlistconfig.get('min_days_listed', 10)
if self._min_days_listed < 1:
raise OperationalException("AgeFilter requires min_days_listed must be >= 1")
if self._min_days_listed > exchange.ohlcv_candle_limit:
raise OperationalException("AgeFilter requires min_days_listed must not exceed "
"exchange max request size "
f"({exchange.ohlcv_candle_limit})")
self._enabled = self._min_days_listed >= 1
@property
def needstickers(self) -> bool:
"""
Boolean property defining if tickers are necessary.
If no Pairlist requires tickers, an empty List is passed
as tickers argument to filter_pairlist
"""
return True
def short_desc(self) -> str:
"""
Short whitelist method description - used for startup-messages
"""
return (f"{self.name} - Filtering pairs with age less than "
f"{self._min_days_listed} {plural(self._min_days_listed, 'day')}.")
def _validate_pair(self, ticker: dict) -> bool:
"""
Validate age for the ticker
:param ticker: ticker dict as returned from ccxt.load_markets()
:return: True if the pair can stay, False if it should be removed
"""
# Check symbol in cache
if ticker['symbol'] in self._symbolsChecked:
return True
since_ms = int(arrow.utcnow()
.floor('day')
.shift(days=-self._min_days_listed)
.float_timestamp) * 1000
daily_candles = self._exchange.get_historic_ohlcv(pair=ticker['symbol'],
timeframe='1d',
since_ms=since_ms)
if daily_candles is not None:
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[ticker['symbol']] = int(arrow.utcnow().float_timestamp) * 1000
return True
else:
self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
f"because age {len(daily_candles)} is less than "
f"{self._min_days_listed} "
f"{plural(self._min_days_listed, 'day')}")
return False
return False

View File

@@ -1,9 +1,6 @@
"""
Static List provider
Provides lists as configured in config.json
"""
PairList Handler base class
"""
import logging
from abc import ABC, abstractmethod, abstractproperty
from copy import deepcopy
@@ -11,8 +8,10 @@ from typing import Any, Dict, List
from cachetools import TTLCache, cached
from freqtrade.exceptions import OperationalException
from freqtrade.exchange import market_is_active
logger = logging.getLogger(__name__)
@@ -23,11 +22,13 @@ class IPairList(ABC):
pairlist_pos: int) -> None:
"""
:param exchange: Exchange instance
:param pairlistmanager: Instanciating Pairlist manager
:param pairlistmanager: Instantiated Pairlist manager
:param config: Global bot configuration
:param pairlistconfig: Configuration for this pairlist - can be empty.
:param pairlist_pos: Position of the filter in the pairlist-filter-list
:param pairlistconfig: Configuration for this Pairlist Handler - can be empty.
:param pairlist_pos: Position of the Pairlist Handler in the chain
"""
self._enabled = True
self._exchange = exchange
self._pairlistmanager = pairlistmanager
self._config = config
@@ -67,7 +68,7 @@ class IPairList(ABC):
def needstickers(self) -> bool:
"""
Boolean property defining if tickers are necessary.
If no Pairlist requries tickers, an empty List is passed
If no Pairlist requires tickers, an empty List is passed
as tickers argument to filter_pairlist
"""
@@ -78,49 +79,68 @@ class IPairList(ABC):
-> Please overwrite in subclasses
"""
@abstractmethod
def _validate_pair(self, ticker) -> bool:
"""
Check one pair against Pairlist Handler's specific conditions.
Either implement it in the Pairlist Handler or override the generic
filter_pairlist() method.
:param ticker: ticker dict as returned from ccxt.load_markets()
:return: True if the pair can stay, false if it should be removed
"""
raise NotImplementedError()
def gen_pairlist(self, cached_pairlist: List[str], tickers: Dict) -> List[str]:
"""
Generate the pairlist.
This method is called once by the pairlistmanager in the refresh_pairlist()
method to supply the starting pairlist for the chain of the Pairlist Handlers.
Pairlist Filters (those Pairlist Handlers that cannot be used at the first
position in the chain) shall not override this base implementation --
it will raise the exception if a Pairlist Handler is used at the first
position in the chain.
:param cached_pairlist: Previously generated pairlist (cached)
:param tickers: Tickers (from exchange.get_tickers()).
:return: List of pairs
"""
raise OperationalException("This Pairlist Handler should not be used "
"at the first position in the list of Pairlist Handlers.")
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
"""
Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary
-> Please overwrite in subclasses
This generic implementation calls self._validate_pair() for each pair
in the pairlist.
Some Pairlist Handlers override this generic implementation and employ
own filtration.
:param pairlist: pairlist to filter or sort
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
:return: new whitelist
"""
if self._enabled:
# Copy list since we're modifying this list
for p in deepcopy(pairlist):
# Filter out assets
if not self._validate_pair(tickers[p]):
pairlist.remove(p)
@staticmethod
def verify_blacklist(pairlist: List[str], blacklist: List[str],
aswarning: bool) -> List[str]:
"""
Verify and remove items from pairlist - returning a filtered pairlist.
Logs a warning or info depending on `aswarning`.
Pairlists explicitly using this method shall use `aswarning=False`!
:param pairlist: Pairlist to validate
:param blacklist: Blacklist to validate pairlist against
:param aswarning: Log message as Warning or info
:return: pairlist - blacklisted pairs
"""
for pair in deepcopy(pairlist):
if pair in blacklist:
if aswarning:
logger.warning(f"Pair {pair} in your blacklist. Removing it from whitelist...")
else:
logger.info(f"Pair {pair} in your blacklist. Removing it from whitelist...")
pairlist.remove(pair)
return pairlist
def _verify_blacklist(self, pairlist: List[str], aswarning: bool = True) -> List[str]:
def verify_blacklist(self, pairlist: List[str], logmethod) -> List[str]:
"""
Proxy method to verify_blacklist for easy access for child classes.
Logs a warning or info depending on `aswarning`.
Pairlists explicitly using this method shall use aswarning=False!
:param pairlist: Pairlist to validate
:param aswarning: Log message as Warning or info.
:param logmethod: Function that'll be called, `logger.info` or `logger.warning`.
:return: pairlist - blacklisted pairs
"""
return IPairList.verify_blacklist(pairlist, self._pairlistmanager.blacklist,
aswarning=aswarning)
return self._pairlistmanager.verify_blacklist(pairlist, logmethod)
def _whitelist_for_active_markets(self, pairlist: List[str]) -> List[str]:
"""
@@ -130,6 +150,9 @@ class IPairList(ABC):
black_listed
"""
markets = self._exchange.markets
if not markets:
raise OperationalException(
'Markets not loaded. Make sure that exchange is initialized correctly.')
sanitized_whitelist: List[str] = []
for pair in pairlist:

View File

@@ -1,19 +1,37 @@
"""
Precision pair list filter
"""
import logging
from copy import deepcopy
from typing import Dict, List
from typing import Any, Dict
from freqtrade.pairlist.IPairList import IPairList
from freqtrade.exceptions import OperationalException
logger = logging.getLogger(__name__)
class PrecisionFilter(IPairList):
def __init__(self, exchange, pairlistmanager,
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
pairlist_pos: int) -> None:
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
if 'stoploss' not in self._config:
raise OperationalException(
'PrecisionFilter can only work with stoploss defined. Please add the '
'stoploss key to your configuration (overwrites eventual strategy settings).')
self._stoploss = self._config['stoploss']
self._enabled = self._stoploss != 0
# Precalculate sanitized stoploss value to avoid recalculation for every pair
self._stoploss = 1 - abs(self._stoploss)
@property
def needstickers(self) -> bool:
"""
Boolean property defining if tickers are necessary.
If no Pairlist requries tickers, an empty List is passed
If no Pairlist requires tickers, an empty List is passed
as tickers argument to filter_pairlist
"""
return True
@@ -24,41 +42,25 @@ class PrecisionFilter(IPairList):
"""
return f"{self.name} - Filtering untradable pairs."
def _validate_precision_filter(self, ticker: dict, stoploss: float) -> bool:
def _validate_pair(self, ticker: dict) -> bool:
"""
Check if pair has enough room to add a stoploss to avoid "unsellable" buys of very
low value pairs.
:param ticker: ticker dict as returned from ccxt.load_markets()
:param stoploss: stoploss value as set in the configuration
(already cleaned to be 1 - stoploss)
:return: True if the pair can stay, false if it should be removed
:return: True if the pair can stay, False if it should be removed
"""
stop_price = ticker['ask'] * stoploss
stop_price = ticker['ask'] * self._stoploss
# Adjust stop-prices to precision
sp = self._exchange.price_to_precision(ticker["symbol"], stop_price)
stop_gap_price = self._exchange.price_to_precision(ticker["symbol"], stop_price * 0.99)
logger.debug(f"{ticker['symbol']} - {sp} : {stop_gap_price}")
if sp <= stop_gap_price:
self.log_on_refresh(logger.info,
f"Removed {ticker['symbol']} from whitelist, "
f"because stop price {sp} would be <= stop limit {stop_gap_price}")
return False
return True
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
"""
Filters and sorts pairlists and assigns and returns them again.
"""
stoploss = self._config.get('stoploss')
if stoploss is not None:
# Precalculate sanitized stoploss value to avoid recalculation for every pair
stoploss = 1 - abs(stoploss)
# Copy list since we're modifying this list
for p in deepcopy(pairlist):
ticker = tickers.get(p)
# Filter out assets which would not allow setting a stoploss
if not ticker or (stoploss and not self._validate_precision_filter(ticker, stoploss)):
pairlist.remove(p)
continue
return pairlist

View File

@@ -1,9 +1,12 @@
"""
Price pair list filter
"""
import logging
from copy import deepcopy
from typing import Any, Dict, List
from typing import Any, Dict
from freqtrade.pairlist.IPairList import IPairList
logger = logging.getLogger(__name__)
@@ -15,12 +18,17 @@ class PriceFilter(IPairList):
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
self._low_price_ratio = pairlistconfig.get('low_price_ratio', 0)
self._min_price = pairlistconfig.get('min_price', 0)
self._max_price = pairlistconfig.get('max_price', 0)
self._enabled = ((self._low_price_ratio != 0) or
(self._min_price != 0) or
(self._max_price != 0))
@property
def needstickers(self) -> bool:
"""
Boolean property defining if tickers are necessary.
If no Pairlist requries tickers, an empty List is passed
If no Pairlist requires tickers, an empty List is passed
as tickers argument to filter_pairlist
"""
return True
@@ -29,45 +37,52 @@ class PriceFilter(IPairList):
"""
Short whitelist method description - used for startup-messages
"""
return f"{self.name} - Filtering pairs priced below {self._low_price_ratio * 100}%."
active_price_filters = []
if self._low_price_ratio != 0:
active_price_filters.append(f"below {self._low_price_ratio * 100}%")
if self._min_price != 0:
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}")
def _validate_ticker_lowprice(self, ticker) -> bool:
if len(active_price_filters):
return f"{self.name} - Filtering pairs priced {' or '.join(active_price_filters)}."
return f"{self.name} - No price filters configured."
def _validate_pair(self, ticker) -> bool:
"""
Check if if one price-step (pip) is > than a certain barrier.
:param ticker: ticker dict as returned from ccxt.load_markets()
:return: True if the pair can stay, false if it should be removed
"""
if ticker['last'] is None:
if ticker['last'] is None or ticker['last'] == 0:
self.log_on_refresh(logger.info,
f"Removed {ticker['symbol']} from whitelist, because "
"ticker['last'] is empty (Usually no trade in the last 24h).")
return False
compare = ticker['last'] + self._exchange.price_get_one_pip(ticker['symbol'],
ticker['last'])
changeperc = (compare - ticker['last']) / ticker['last']
if changeperc > self._low_price_ratio:
self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
f"because 1 unit is {changeperc * 100:.3f}%")
return False
# Perform low_price_ratio check.
if self._low_price_ratio != 0:
compare = self._exchange.price_get_one_pip(ticker['symbol'], ticker['last'])
changeperc = compare / ticker['last']
if changeperc > self._low_price_ratio:
self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
f"because 1 unit is {changeperc * 100:.3f}%")
return False
# Perform min_price check.
if self._min_price != 0:
if ticker['last'] < self._min_price:
self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
f"because last price < {self._min_price:.8f}")
return False
# Perform max_price check.
if self._max_price != 0:
if ticker['last'] > self._max_price:
self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
f"because last price > {self._max_price:.8f}")
return False
return True
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
"""
Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
:return: new whitelist
"""
# Copy list since we're modifying this list
for p in deepcopy(pairlist):
ticker = tickers.get(p)
if not ticker:
pairlist.remove(p)
# Filter out assets which would not allow setting a stoploss
if self._low_price_ratio and not self._validate_ticker_lowprice(ticker):
pairlist.remove(p)
return pairlist

View File

@@ -0,0 +1,51 @@
"""
Shuffle pair list filter
"""
import logging
import random
from typing import Any, Dict, List
from freqtrade.pairlist.IPairList import IPairList
logger = logging.getLogger(__name__)
class ShuffleFilter(IPairList):
def __init__(self, exchange, pairlistmanager,
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
pairlist_pos: int) -> None:
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
self._seed = pairlistconfig.get('seed')
self._random = random.Random(self._seed)
@property
def needstickers(self) -> bool:
"""
Boolean property defining if tickers are necessary.
If no Pairlist requires tickers, an empty List is passed
as tickers argument to filter_pairlist
"""
return False
def short_desc(self) -> str:
"""
Short whitelist method description - used for startup-messages
"""
return (f"{self.name} - Shuffling pairs" +
(f", seed = {self._seed}." if self._seed is not None else "."))
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
"""
Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
:return: new whitelist
"""
# Shuffle is done inplace
self._random.shuffle(pairlist)
return pairlist

View File

@@ -1,25 +1,30 @@
"""
Spread pair list filter
"""
import logging
from copy import deepcopy
from typing import Dict, List
from typing import Any, Dict
from freqtrade.pairlist.IPairList import IPairList
logger = logging.getLogger(__name__)
class SpreadFilter(IPairList):
def __init__(self, exchange, pairlistmanager, config, pairlistconfig: dict,
def __init__(self, exchange, pairlistmanager,
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
pairlist_pos: int) -> None:
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
self._max_spread_ratio = pairlistconfig.get('max_spread_ratio', 0.005)
self._enabled = self._max_spread_ratio != 0
@property
def needstickers(self) -> bool:
"""
Boolean property defining if tickers are necessary.
If no Pairlist requries tickers, an empty List is passed
If no Pairlist requires tickers, an empty List is passed
as tickers argument to filter_pairlist
"""
return True
@@ -31,29 +36,19 @@ class SpreadFilter(IPairList):
return (f"{self.name} - Filtering pairs with ask/bid diff above "
f"{self._max_spread_ratio * 100}%.")
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
def _validate_pair(self, ticker: dict) -> bool:
"""
Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
:return: new whitelist
Validate spread for the ticker
:param ticker: ticker dict as returned from ccxt.load_markets()
:return: True if the pair can stay, False if it should be removed
"""
# Copy list since we're modifying this list
spread = None
for p in deepcopy(pairlist):
ticker = tickers.get(p)
assert ticker is not None
if 'bid' in ticker and 'ask' in ticker:
spread = 1 - ticker['bid'] / ticker['ask']
if not ticker or spread > self._max_spread_ratio:
self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
f"because spread {spread * 100:.3f}% >"
f"{self._max_spread_ratio * 100}%")
pairlist.remove(p)
if 'bid' in ticker and 'ask' in ticker:
spread = 1 - ticker['bid'] / ticker['ask']
if spread > self._max_spread_ratio:
self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
f"because spread {spread * 100:.3f}% >"
f"{self._max_spread_ratio * 100}%")
return False
else:
pairlist.remove(p)
return pairlist
return True
return False

View File

@@ -1,24 +1,34 @@
"""
Static List provider
Static Pair List provider
Provides lists as configured in config.json
"""
Provides pair white list as it configured in config
"""
import logging
from typing import Dict, List
from typing import Any, Dict, List
from freqtrade.exceptions import OperationalException
from freqtrade.pairlist.IPairList import IPairList
logger = logging.getLogger(__name__)
class StaticPairList(IPairList):
def __init__(self, exchange, pairlistmanager,
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
pairlist_pos: int) -> None:
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
if self._pairlist_pos != 0:
raise OperationalException(f"{self.name} can only be used in the first position "
"in the list of Pairlist Handlers.")
@property
def needstickers(self) -> bool:
"""
Boolean property defining if tickers are necessary.
If no Pairlist requries tickers, an empty List is passed
If no Pairlist requires tickers, an empty List is passed
as tickers argument to filter_pairlist
"""
return False
@@ -30,6 +40,15 @@ class StaticPairList(IPairList):
"""
return f"{self.name}"
def gen_pairlist(self, cached_pairlist: List[str], tickers: Dict) -> List[str]:
"""
Generate the pairlist
:param cached_pairlist: Previously generated pairlist (cached)
:param tickers: Tickers (from exchange.get_tickers()).
:return: List of pairs
"""
return self._whitelist_for_active_markets(self._config['exchange']['pair_whitelist'])
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
"""
Filters and sorts pairlist and returns the whitelist again.
@@ -38,4 +57,4 @@ class StaticPairList(IPairList):
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
:return: new whitelist
"""
return self._whitelist_for_active_markets(self._config['exchange']['pair_whitelist'])
return pairlist

View File

@@ -1,9 +1,8 @@
"""
Volume PairList provider
Provides lists as configured in config.json
"""
Provides dynamic pair list based on trade volumes
"""
import logging
from datetime import datetime
from typing import Any, Dict, List
@@ -11,21 +10,26 @@ from typing import Any, Dict, List
from freqtrade.exceptions import OperationalException
from freqtrade.pairlist.IPairList import IPairList
logger = logging.getLogger(__name__)
SORT_VALUES = ['askVolume', 'bidVolume', 'quoteVolume']
class VolumePairList(IPairList):
def __init__(self, exchange, pairlistmanager, config: Dict[str, Any], pairlistconfig: dict,
def __init__(self, exchange, pairlistmanager,
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
pairlist_pos: int) -> None:
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
if 'number_assets' not in self._pairlistconfig:
raise OperationalException(
f'`number_assets` not specified. Please check your configuration '
'`number_assets` not specified. Please check your configuration '
'for "pairlist.config.number_assets"')
self._stake_currency = config['stake_currency']
self._number_pairs = self._pairlistconfig['number_assets']
self._sort_key = self._pairlistconfig.get('sort_key', 'quoteVolume')
self._min_value = self._pairlistconfig.get('min_value', 0)
@@ -33,18 +37,24 @@ class VolumePairList(IPairList):
if not self._exchange.exchange_has('fetchTickers'):
raise OperationalException(
'Exchange does not support dynamic whitelist.'
'Please edit your config and restart the bot'
'Exchange does not support dynamic whitelist. '
'Please edit your config and restart the bot.'
)
if not self._validate_keys(self._sort_key):
raise OperationalException(
f'key {self._sort_key} not in {SORT_VALUES}')
if self._sort_key != 'quoteVolume':
logger.warning(
"DEPRECATED: using any key other than quoteVolume for VolumePairList is deprecated."
)
@property
def needstickers(self) -> bool:
"""
Boolean property defining if tickers are necessary.
If no Pairlist requries tickers, an empty List is passed
If no Pairlist requires tickers, an empty List is passed
as tickers argument to filter_pairlist
"""
return True
@@ -58,6 +68,31 @@ class VolumePairList(IPairList):
"""
return f"{self.name} - top {self._pairlistconfig['number_assets']} volume pairs."
def gen_pairlist(self, cached_pairlist: List[str], tickers: Dict) -> List[str]:
"""
Generate the pairlist
:param cached_pairlist: Previously generated pairlist (cached)
:param tickers: Tickers (from exchange.get_tickers()).
:return: List of pairs
"""
# Generate dynamic whitelist
# Must always run if this pairlist is not the first in the list.
if self._last_refresh + self.refresh_period < datetime.now().timestamp():
self._last_refresh = int(datetime.now().timestamp())
# Use fresh pairlist
# Check if pair quote currency equals to the stake currency.
filtered_tickers = [
v for k, v in tickers.items()
if (self._exchange.get_pair_quote_currency(k) == self._stake_currency
and v[self._sort_key] is not None)]
pairlist = [s['symbol'] for s in filtered_tickers]
else:
# Use the cached pairlist if it's not time yet to refresh
pairlist = cached_pairlist
return pairlist
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
"""
Filters and sorts pairlist and returns the whitelist again.
@@ -66,48 +101,21 @@ class VolumePairList(IPairList):
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
:return: new whitelist
"""
# Generate dynamic whitelist
# Must always run if this pairlist is not the first in the list.
if (self._pairlist_pos != 0 or
(self._last_refresh + self.refresh_period < datetime.now().timestamp())):
# Use the incoming pairlist.
filtered_tickers = [v for k, v in tickers.items() if k in pairlist]
self._last_refresh = int(datetime.now().timestamp())
pairs = self._gen_pair_whitelist(pairlist, tickers,
self._config['stake_currency'],
self._sort_key, self._min_value)
else:
pairs = pairlist
self.log_on_refresh(logger.info, f"Searching {self._number_pairs} pairs: {pairs}")
return pairs
if self._min_value > 0:
filtered_tickers = [
v for v in filtered_tickers if v[self._sort_key] > self._min_value]
def _gen_pair_whitelist(self, pairlist: List[str], tickers: Dict,
base_currency: str, key: str, min_val: int) -> List[str]:
"""
Updates the whitelist with with a dynamically generated list
:param base_currency: base currency as str
:param key: sort key (defaults to 'quoteVolume')
:param tickers: Tickers (from exchange.get_tickers()).
:return: List of pairs
"""
if self._pairlist_pos == 0:
# If VolumePairList is the first in the list, use fresh pairlist
# Check if pair quote currency equals to the stake currency.
filtered_tickers = [v for k, v in tickers.items()
if (self._exchange.get_pair_quote_currency(k) == base_currency
and v[key] is not None)]
else:
# If other pairlist is in front, use the incomming pairlist.
filtered_tickers = [v for k, v in tickers.items() if k in pairlist]
if min_val > 0:
filtered_tickers = list(filter(lambda t: t[key] > min_val, filtered_tickers))
sorted_tickers = sorted(filtered_tickers, reverse=True, key=lambda t: t[key])
sorted_tickers = sorted(filtered_tickers, reverse=True, key=lambda t: t[self._sort_key])
# Validate whitelist to only have active market pairs
pairs = self._whitelist_for_active_markets([s['symbol'] for s in sorted_tickers])
pairs = self._verify_blacklist(pairs, aswarning=False)
# Limit to X number of pairs
pairs = self.verify_blacklist(pairs, logger.info)
# Limit pairlist to the requested number of pairs
pairs = pairs[:self._number_pairs]
self.log_on_refresh(logger.info, f"Searching {self._number_pairs} pairs: {pairs}")
return pairs

View File

@@ -1,10 +1,8 @@
"""
Static List provider
Provides lists as configured in config.json
"""
PairList manager class
"""
import logging
from copy import deepcopy
from typing import Dict, List
from cachetools import TTLCache, cached
@@ -12,6 +10,8 @@ from cachetools import TTLCache, cached
from freqtrade.exceptions import OperationalException
from freqtrade.pairlist.IPairList import IPairList
from freqtrade.resolvers import PairListResolver
from freqtrade.constants import ListPairsWithTimeframes
logger = logging.getLogger(__name__)
@@ -23,24 +23,25 @@ class PairListManager():
self._config = config
self._whitelist = self._config['exchange'].get('pair_whitelist')
self._blacklist = self._config['exchange'].get('pair_blacklist', [])
self._pairlists: List[IPairList] = []
self._pairlist_handlers: List[IPairList] = []
self._tickers_needed = False
for pl in self._config.get('pairlists', None):
if 'method' not in pl:
logger.warning(f"No method in {pl}")
for pairlist_handler_config in self._config.get('pairlists', None):
if 'method' not in pairlist_handler_config:
logger.warning(f"No method found in {pairlist_handler_config}, ignoring.")
continue
pairl = PairListResolver.load_pairlist(pl.get('method'),
exchange=exchange,
pairlistmanager=self,
config=config,
pairlistconfig=pl,
pairlist_pos=len(self._pairlists)
)
self._tickers_needed = pairl.needstickers or self._tickers_needed
self._pairlists.append(pairl)
pairlist_handler = PairListResolver.load_pairlist(
pairlist_handler_config['method'],
exchange=exchange,
pairlistmanager=self,
config=config,
pairlistconfig=pairlist_handler_config,
pairlist_pos=len(self._pairlist_handlers)
)
self._tickers_needed |= pairlist_handler.needstickers
self._pairlist_handlers.append(pairlist_handler)
if not self._pairlists:
raise OperationalException("No Pairlist defined!")
if not self._pairlist_handlers:
raise OperationalException("No Pairlist Handlers defined")
@property
def whitelist(self) -> List[str]:
@@ -60,15 +61,15 @@ class PairListManager():
@property
def name_list(self) -> List[str]:
"""
Get list of loaded pairlists names
Get list of loaded Pairlist Handler names
"""
return [p.name for p in self._pairlists]
return [p.name for p in self._pairlist_handlers]
def short_desc(self) -> List[Dict]:
"""
List of short_desc for each pairlist
List of short_desc for each Pairlist Handler
"""
return [{p.name: p.short_desc()} for p in self._pairlists]
return [{p.name: p.short_desc()} for p in self._pairlist_handlers]
@cached(TTLCache(maxsize=1, ttl=1800))
def _get_cached_tickers(self):
@@ -76,21 +77,60 @@ class PairListManager():
def refresh_pairlist(self) -> None:
"""
Run pairlist through all configured pairlists.
Run pairlist through all configured Pairlist Handlers.
"""
pairlist = self._whitelist.copy()
# tickers should be cached to avoid calling the exchange on each call.
# Tickers should be cached to avoid calling the exchange on each call.
tickers: Dict = {}
if self._tickers_needed:
tickers = self._get_cached_tickers()
# Process all pairlists in chain
for pl in self._pairlists:
pairlist = pl.filter_pairlist(pairlist, tickers)
# Adjust whitelist if filters are using tickers
pairlist = self._prepare_whitelist(self._whitelist.copy(), tickers)
# Validation against blacklist happens after the pairlists to ensure blacklist is respected.
pairlist = IPairList.verify_blacklist(pairlist, self.blacklist, True)
# Generate the pairlist with first Pairlist Handler in the chain
pairlist = self._pairlist_handlers[0].gen_pairlist(self._whitelist, tickers)
# Process all Pairlist Handlers in the chain
for pairlist_handler in self._pairlist_handlers:
pairlist = pairlist_handler.filter_pairlist(pairlist, tickers)
# Validation against blacklist happens after the chain of Pairlist Handlers
# to ensure blacklist is respected.
pairlist = self.verify_blacklist(pairlist, logger.warning)
self._whitelist = pairlist
def _prepare_whitelist(self, pairlist: List[str], tickers) -> List[str]:
"""
Prepare sanitized pairlist for Pairlist Handlers that use tickers data - remove
pairs that do not have ticker available
"""
if self._tickers_needed:
# Copy list since we're modifying this list
for p in deepcopy(pairlist):
if p not in tickers:
pairlist.remove(p)
return pairlist
def verify_blacklist(self, pairlist: List[str], logmethod) -> List[str]:
"""
Verify and remove items from pairlist - returning a filtered pairlist.
Logs a warning or info depending on `aswarning`.
Pairlist Handlers explicitly using this method shall use
`logmethod=logger.info` to avoid spamming with warning messages
:param pairlist: Pairlist to validate
:param logmethod: Function that'll be called, `logger.info` or `logger.warning`.
:return: pairlist - blacklisted pairs
"""
for pair in deepcopy(pairlist):
if pair in self._blacklist:
logmethod(f"Pair {pair} in your blacklist. Removing it from whitelist...")
pairlist.remove(pair)
return pairlist
def create_pair_list(self, pairs: List[str], timeframe: str = None) -> ListPairsWithTimeframes:
"""
Create list of pair tuples with (pair, timeframe)
"""
return [(pair, timeframe or self._config['timeframe']) for pair in pairs]

View File

@@ -17,6 +17,7 @@ from sqlalchemy.orm.session import sessionmaker
from sqlalchemy.pool import StaticPool
from freqtrade.exceptions import OperationalException
from freqtrade.misc import safe_value_fallback
logger = logging.getLogger(__name__)
@@ -86,11 +87,15 @@ def check_migrate(engine) -> None:
logger.debug(f'trying {table_back_name}')
# Check for latest column
if not has_column(cols, 'close_profit_abs'):
if not has_column(cols, 'amount_requested'):
logger.info(f'Running database migration - backup available as {table_back_name}')
fee_open = get_column_def(cols, 'fee_open', 'fee')
fee_open_cost = get_column_def(cols, 'fee_open_cost', 'null')
fee_open_currency = get_column_def(cols, 'fee_open_currency', 'null')
fee_close = get_column_def(cols, 'fee_close', 'fee')
fee_close_cost = get_column_def(cols, 'fee_close_cost', 'null')
fee_close_currency = get_column_def(cols, 'fee_close_currency', 'null')
open_rate_requested = get_column_def(cols, 'open_rate_requested', 'null')
close_rate_requested = get_column_def(cols, 'close_rate_requested', 'null')
stop_loss = get_column_def(cols, 'stop_loss', '0.0')
@@ -103,12 +108,19 @@ def check_migrate(engine) -> None:
min_rate = get_column_def(cols, 'min_rate', 'null')
sell_reason = get_column_def(cols, 'sell_reason', 'null')
strategy = get_column_def(cols, 'strategy', 'null')
ticker_interval = get_column_def(cols, 'ticker_interval', 'null')
# If ticker-interval existed use that, else null.
if has_column(cols, 'ticker_interval'):
timeframe = get_column_def(cols, 'timeframe', 'ticker_interval')
else:
timeframe = get_column_def(cols, 'timeframe', 'null')
open_trade_price = get_column_def(cols, 'open_trade_price',
f'amount * open_rate * (1 + {fee_open})')
close_profit_abs = get_column_def(
cols, 'close_profit_abs',
f"(amount * close_rate * (1 - {fee_close})) - {open_trade_price}")
sell_order_status = get_column_def(cols, 'sell_order_status', 'null')
amount_requested = get_column_def(cols, 'amount_requested', 'amount')
# Schema migration necessary
engine.execute(f"alter table trades rename to {table_back_name}")
@@ -120,13 +132,15 @@ def check_migrate(engine) -> None:
# Copy data back - following the correct schema
engine.execute(f"""insert into trades
(id, exchange, pair, is_open, fee_open, fee_close, open_rate,
(id, exchange, pair, is_open,
fee_open, fee_open_cost, fee_open_currency,
fee_close, fee_close_cost, fee_open_currency, open_rate,
open_rate_requested, close_rate, close_rate_requested, close_profit,
stake_amount, amount, open_date, close_date, open_order_id,
stake_amount, amount, amount_requested, open_date, close_date, open_order_id,
stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct,
stoploss_order_id, stoploss_last_update,
max_rate, min_rate, sell_reason, strategy,
ticker_interval, open_trade_price, close_profit_abs
max_rate, min_rate, sell_reason, sell_order_status, strategy,
timeframe, open_trade_price, close_profit_abs
)
select id, lower(exchange),
case
@@ -136,16 +150,19 @@ def check_migrate(engine) -> None:
else pair
end
pair,
is_open, {fee_open} fee_open, {fee_close} fee_close,
is_open, {fee_open} fee_open, {fee_open_cost} fee_open_cost,
{fee_open_currency} fee_open_currency, {fee_close} fee_close,
{fee_close_cost} fee_close_cost, {fee_close_currency} fee_close_currency,
open_rate, {open_rate_requested} open_rate_requested, close_rate,
{close_rate_requested} close_rate_requested, close_profit,
stake_amount, amount, open_date, close_date, open_order_id,
stake_amount, amount, {amount_requested}, open_date, close_date, open_order_id,
{stop_loss} stop_loss, {stop_loss_pct} stop_loss_pct,
{initial_stop_loss} initial_stop_loss,
{initial_stop_loss_pct} initial_stop_loss_pct,
{stoploss_order_id} stoploss_order_id, {stoploss_last_update} stoploss_last_update,
{max_rate} max_rate, {min_rate} min_rate, {sell_reason} sell_reason,
{strategy} strategy, {ticker_interval} ticker_interval,
{sell_order_status} sell_order_status,
{strategy} strategy, {timeframe} timeframe,
{open_trade_price} open_trade_price, {close_profit_abs} close_profit_abs
from {table_back_name}
""")
@@ -185,7 +202,11 @@ class Trade(_DECL_BASE):
pair = Column(String, 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_close = Column(Float, nullable=False, default=0.0)
fee_close_cost = Column(Float, nullable=True)
fee_close_currency = Column(String, nullable=True)
open_rate = Column(Float)
open_rate_requested = Column(Float)
# open_trade_price - calculated via _calc_open_trade_price
@@ -196,6 +217,7 @@ class Trade(_DECL_BASE):
close_profit_abs = Column(Float)
stake_amount = Column(Float, nullable=False)
amount = Column(Float)
amount_requested = Column(Float)
open_date = Column(DateTime, nullable=False, default=datetime.utcnow)
close_date = Column(DateTime)
open_order_id = Column(String)
@@ -216,8 +238,9 @@ class Trade(_DECL_BASE):
# 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)
ticker_interval = Column(Integer, nullable=True)
timeframe = Column(Integer, nullable=True)
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -234,32 +257,58 @@ class Trade(_DECL_BASE):
'trade_id': self.id,
'pair': self.pair,
'is_open': self.is_open,
'exchange': self.exchange,
'amount': round(self.amount, 8),
'amount_requested': round(self.amount_requested, 8) if self.amount_requested else None,
'stake_amount': round(self.stake_amount, 8),
'strategy': self.strategy,
'ticker_interval': self.timeframe, # DEPRECATED
'timeframe': self.timeframe,
'fee_open': self.fee_open,
'fee_open_cost': self.fee_open_cost,
'fee_open_currency': self.fee_open_currency,
'fee_close': self.fee_close,
'fee_close_cost': self.fee_close_cost,
'fee_close_currency': self.fee_close_currency,
'open_date_hum': arrow.get(self.open_date).humanize(),
'open_date': self.open_date.strftime("%Y-%m-%d %H:%M:%S"),
'open_timestamp': int(self.open_date.timestamp() * 1000),
'open_rate': self.open_rate,
'open_rate_requested': self.open_rate_requested,
'open_trade_price': round(self.open_trade_price, 8),
'close_date_hum': (arrow.get(self.close_date).humanize()
if self.close_date else None),
'close_date': (self.close_date.strftime("%Y-%m-%d %H:%M:%S")
if self.close_date else None),
'open_rate': self.open_rate,
'open_rate_requested': self.open_rate_requested,
'open_trade_price': self.open_trade_price,
'close_timestamp': int(self.close_date.timestamp() * 1000) if self.close_date else None,
'close_rate': self.close_rate,
'close_rate_requested': self.close_rate_requested,
'amount': round(self.amount, 8),
'stake_amount': round(self.stake_amount, 8),
'close_profit': self.close_profit,
'close_profit_abs': self.close_profit_abs,
'sell_reason': self.sell_reason,
'stop_loss': self.stop_loss,
'sell_order_status': self.sell_order_status,
'stop_loss': self.stop_loss, # Deprecated - should not be used
'stop_loss_abs': self.stop_loss,
'stop_loss_ratio': self.stop_loss_pct if self.stop_loss_pct else None,
'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None,
'initial_stop_loss': self.initial_stop_loss,
'stoploss_order_id': self.stoploss_order_id,
'stoploss_last_update': (self.stoploss_last_update.strftime("%Y-%m-%d %H:%M:%S")
if self.stoploss_last_update else None),
'stoploss_last_update_timestamp': (int(self.stoploss_last_update.timestamp() * 1000)
if self.stoploss_last_update else None),
'initial_stop_loss': self.initial_stop_loss, # Deprecated - should not be used
'initial_stop_loss_abs': self.initial_stop_loss,
'initial_stop_loss_ratio': (self.initial_stop_loss_pct
if self.initial_stop_loss_pct else None),
'initial_stop_loss_pct': (self.initial_stop_loss_pct * 100
if self.initial_stop_loss_pct else None),
'min_rate': self.min_rate,
'max_rate': self.max_rate,
'strategy': self.strategy,
'ticker_interval': self.ticker_interval,
'open_order_id': self.open_order_id,
}
@@ -315,27 +364,27 @@ class Trade(_DECL_BASE):
def update(self, order: Dict) -> None:
"""
Updates this entity with amount and actual open/close rates.
:param order: order retrieved by exchange.get_order()
:param order: order retrieved by exchange.fetch_order()
:return: None
"""
order_type = order['type']
# Ignore open and cancelled orders
if order['status'] == 'open' or order['price'] is None:
if order['status'] == 'open' or safe_value_fallback(order, 'average', 'price') is None:
return
logger.info('Updating trade (id=%s) ...', self.id)
if order_type in ('market', 'limit') and order['side'] == 'buy':
# Update open rate and actual amount
self.open_rate = Decimal(order['price'])
self.amount = Decimal(order.get('filled', order['amount']))
self.open_rate = Decimal(safe_value_fallback(order, 'average', 'price'))
self.amount = Decimal(safe_value_fallback(order, 'filled', 'amount'))
self.recalc_open_trade_price()
logger.info('%s_BUY has been fulfilled for %s.', order_type.upper(), self)
self.open_order_id = None
elif order_type in ('market', 'limit') and order['side'] == 'sell':
self.close(order['price'])
self.close(safe_value_fallback(order, 'average', 'price'))
logger.info('%s_SELL has been fulfilled for %s.', order_type.upper(), self)
elif order_type in ('stop_loss_limit', 'stop-loss'):
elif order_type in ('stop_loss_limit', 'stop-loss', 'stop'):
self.stoploss_order_id = None
self.close_rate_requested = self.stop_loss
logger.info('%s is hit for %s.', order_type.upper(), self)
@@ -354,12 +403,42 @@ class Trade(_DECL_BASE):
self.close_profit_abs = self.calc_profit()
self.close_date = datetime.utcnow()
self.is_open = False
self.sell_order_status = 'closed'
self.open_order_id = None
logger.info(
'Marking %s as closed as the trade is fulfilled and found no open orders for it.',
self
)
def update_fee(self, fee_cost: float, fee_currency: Optional[str], fee_rate: Optional[float],
side: str) -> None:
"""
Update Fee parameters. Only acts once per side
"""
if side == 'buy' and self.fee_open_currency is None:
self.fee_open_cost = fee_cost
self.fee_open_currency = fee_currency
if fee_rate is not None:
self.fee_open = fee_rate
# Assume close-fee will fall into the same fee category and take an educated guess
self.fee_close = fee_rate
elif side == 'sell' and self.fee_close_currency is None:
self.fee_close_cost = fee_cost
self.fee_close_currency = fee_currency
if fee_rate is not None:
self.fee_close = fee_rate
def fee_updated(self, side: str) -> bool:
"""
Verify if this side (buy / sell) has already been updated
"""
if side == 'buy':
return self.fee_open_currency is not None
elif side == 'sell':
return self.fee_close_currency is not None
else:
return False
def _calc_open_trade_price(self) -> float:
"""
Calculate the open_rate including open_fee.
@@ -494,6 +573,7 @@ class Trade(_DECL_BASE):
def get_best_pair():
"""
Get best pair with closed trade.
:returns: Tuple containing (pair, profit_sum)
"""
best_pair = Trade.session.query(
Trade.pair, func.sum(Trade.close_profit).label('profit_sum')

View File

@@ -10,10 +10,13 @@ from freqtrade.data.btanalysis import (calculate_max_drawdown,
create_cum_profit,
extract_trades_of_period, load_trades)
from freqtrade.data.converter import trim_dataframe
from freqtrade.exchange import timeframe_to_prev_date
from freqtrade.data.dataprovider import DataProvider
from freqtrade.data.history import load_data
from freqtrade.exceptions import OperationalException
from freqtrade.exchange import timeframe_to_prev_date
from freqtrade.misc import pair_to_filename
from freqtrade.resolvers import StrategyResolver
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.strategy import IStrategy
logger = logging.getLogger(__name__)
@@ -44,7 +47,7 @@ def init_plotscript(config):
data = load_data(
datadir=config.get("datadir"),
pairs=pairs,
timeframe=config.get('ticker_interval', '5m'),
timeframe=config.get('timeframe', '5m'),
timerange=timerange,
data_format=config.get('dataformat_ohlcv', 'json'),
)
@@ -161,7 +164,7 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
# Trades can be empty
if trades is not None and len(trades) > 0:
# Create description for sell summarizing the trade
trades['desc'] = trades.apply(lambda row: f"{round(row['profitperc'] * 100, 1)}%, "
trades['desc'] = trades.apply(lambda row: f"{round(row['profit_percent'] * 100, 1)}%, "
f"{row['sell_reason']}, {row['duration']} min",
axis=1)
trade_buys = go.Scatter(
@@ -180,9 +183,9 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
)
trade_sells = go.Scatter(
x=trades.loc[trades['profitperc'] > 0, "close_time"],
y=trades.loc[trades['profitperc'] > 0, "close_rate"],
text=trades.loc[trades['profitperc'] > 0, "desc"],
x=trades.loc[trades['profit_percent'] > 0, "close_time"],
y=trades.loc[trades['profit_percent'] > 0, "close_rate"],
text=trades.loc[trades['profit_percent'] > 0, "desc"],
mode='markers',
name='Sell - Profit',
marker=dict(
@@ -193,9 +196,9 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
)
)
trade_sells_loss = go.Scatter(
x=trades.loc[trades['profitperc'] <= 0, "close_time"],
y=trades.loc[trades['profitperc'] <= 0, "close_rate"],
text=trades.loc[trades['profitperc'] <= 0, "desc"],
x=trades.loc[trades['profit_percent'] <= 0, "close_time"],
y=trades.loc[trades['profit_percent'] <= 0, "close_rate"],
text=trades.loc[trades['profit_percent'] <= 0, "desc"],
mode='markers',
name='Sell - Loss',
marker=dict(
@@ -414,9 +417,12 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
for pair in pairs:
profit_col = f'cum_profit_{pair}'
df_comb = create_cum_profit(df_comb, trades[trades['pair'] == pair], profit_col, timeframe)
fig = add_profit(fig, 3, df_comb, profit_col, f"Profit {pair}")
try:
df_comb = create_cum_profit(df_comb, trades[trades['pair'] == pair], profit_col,
timeframe)
fig = add_profit(fig, 3, df_comb, profit_col, f"Profit {pair}")
except ValueError:
pass
return fig
@@ -463,6 +469,8 @@ def load_and_plot_trades(config: Dict[str, Any]):
"""
strategy = StrategyResolver.load_strategy(config)
exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config)
IStrategy.dp = DataProvider(config, exchange)
plot_elements = init_plotscript(config)
trades = plot_elements['trades']
pair_counter = 0
@@ -483,7 +491,7 @@ def load_and_plot_trades(config: Dict[str, Any]):
plot_config=strategy.plot_config if hasattr(strategy, 'plot_config') else {}
)
store_plot_file(fig, filename=generate_plot_filename(pair, config['ticker_interval']),
store_plot_file(fig, filename=generate_plot_filename(pair, config['timeframe']),
directory=config['user_data_dir'] / "plot")
logger.info('End of plotting process. %s plots generated', pair_counter)
@@ -504,10 +512,13 @@ def plot_profit(config: Dict[str, Any]) -> None:
trades = trades[(trades['pair'].isin(plot_elements["pairs"]))
& (~trades['close_time'].isnull())
]
if len(trades) == 0:
raise OperationalException("No trades found, cannot generate Profit-plot without "
"trades from either Backtest result or database.")
# Create an average close price of all the pairs that were involved.
# this could be useful to gauge the overall market trend
fig = generate_profit_graph(plot_elements["pairs"], plot_elements["ohlcv"],
trades, config.get('ticker_interval', '5m'))
trades, config.get('timeframe', '5m'))
store_plot_file(fig, filename='freqtrade-profit-plot.html',
directory=config['user_data_dir'] / "plot", auto_open=True)

View File

@@ -42,14 +42,14 @@ class HyperOptResolver(IResolver):
extra_dir=config.get('hyperopt_path'))
if not hasattr(hyperopt, 'populate_indicators'):
logger.warning("Hyperopt class does not provide populate_indicators() method. "
"Using populate_indicators from the strategy.")
logger.info("Hyperopt class does not provide populate_indicators() method. "
"Using populate_indicators from the strategy.")
if not hasattr(hyperopt, 'populate_buy_trend'):
logger.warning("Hyperopt class does not provide populate_buy_trend() method. "
"Using populate_buy_trend from the strategy.")
logger.info("Hyperopt class does not provide populate_buy_trend() method. "
"Using populate_buy_trend from the strategy.")
if not hasattr(hyperopt, 'populate_sell_trend'):
logger.warning("Hyperopt class does not provide populate_sell_trend() method. "
"Using populate_sell_trend from the strategy.")
logger.info("Hyperopt class does not provide populate_sell_trend() method. "
"Using populate_sell_trend from the strategy.")
return hyperopt
@@ -77,8 +77,9 @@ class HyperOptLossResolver(IResolver):
config, kwargs={},
extra_dir=config.get('hyperopt_path'))
# Assign ticker_interval to be used in hyperopt
hyperoptloss.__class__.ticker_interval = str(config['ticker_interval'])
# Assign timeframe to be used in hyperopt
hyperoptloss.__class__.ticker_interval = str(config['timeframe'])
hyperoptloss.__class__.timeframe = str(config['timeframe'])
if not hasattr(hyperoptloss, 'hyperopt_loss_function'):
raise OperationalException(

View File

@@ -50,39 +50,51 @@ class StrategyResolver(IResolver):
if 'ask_strategy' not in config:
config['ask_strategy'] = {}
if hasattr(strategy, 'ticker_interval') and not hasattr(strategy, 'timeframe'):
# Assign ticker_interval to timeframe to keep compatibility
if 'timeframe' not in config:
logger.warning(
"DEPRECATED: Please migrate to using 'timeframe' instead of 'ticker_interval'."
)
strategy.timeframe = strategy.ticker_interval
# Set attributes
# Check if we need to override configuration
# (Attribute name, default, ask_strategy)
attributes = [("minimal_roi", {"0": 10.0}, False),
("ticker_interval", None, False),
("stoploss", None, False),
("trailing_stop", None, False),
("trailing_stop_positive", None, False),
("trailing_stop_positive_offset", 0.0, False),
("trailing_only_offset_is_reached", None, False),
("process_only_new_candles", None, False),
("order_types", None, False),
("order_time_in_force", None, False),
("stake_currency", None, False),
("stake_amount", None, False),
("startup_candle_count", None, False),
("unfilledtimeout", None, False),
("use_sell_signal", True, True),
("sell_profit_only", False, True),
("ignore_roi_if_buy_signal", False, True),
# (Attribute name, default, subkey)
attributes = [("minimal_roi", {"0": 10.0}, None),
("timeframe", None, None),
("stoploss", None, None),
("trailing_stop", None, None),
("trailing_stop_positive", None, None),
("trailing_stop_positive_offset", 0.0, None),
("trailing_only_offset_is_reached", None, None),
("process_only_new_candles", None, None),
("order_types", None, None),
("order_time_in_force", None, None),
("stake_currency", None, None),
("stake_amount", None, None),
("startup_candle_count", None, None),
("unfilledtimeout", None, None),
("use_sell_signal", True, 'ask_strategy'),
("sell_profit_only", False, 'ask_strategy'),
("ignore_roi_if_buy_signal", False, 'ask_strategy'),
("disable_dataframe_checks", False, None),
]
for attribute, default, ask_strategy in attributes:
if ask_strategy:
StrategyResolver._override_attribute_helper(strategy, config['ask_strategy'],
for attribute, default, subkey in attributes:
if subkey:
StrategyResolver._override_attribute_helper(strategy, config.get(subkey, {}),
attribute, default)
else:
StrategyResolver._override_attribute_helper(strategy, config,
attribute, default)
# Assign deprecated variable - to not break users code relying on this.
strategy.ticker_interval = strategy.timeframe
# Loop this list again to have output combined
for attribute, _, exp in attributes:
if exp and attribute in config['ask_strategy']:
logger.info("Strategy using %s: %s", attribute, config['ask_strategy'][attribute])
for attribute, _, subkey in attributes:
if subkey and attribute in config[subkey]:
logger.info("Strategy using %s: %s", attribute, config[subkey][attribute])
elif attribute in config:
logger.info("Strategy using %s: %s", attribute, config[attribute])

View File

@@ -2,15 +2,22 @@ import logging
import threading
from datetime import date, datetime
from ipaddress import IPv4Address
from typing import Dict, Callable, Any
from typing import Any, Callable, Dict
from arrow import Arrow
from flask import Flask, jsonify, request
from flask.json import JSONEncoder
from flask_cors import CORS
from flask_jwt_extended import (JWTManager, create_access_token,
create_refresh_token, get_jwt_identity,
jwt_refresh_token_required,
verify_jwt_in_request_optional)
from werkzeug.security import safe_str_cmp
from werkzeug.serving import make_server
from freqtrade.__init__ import __version__
from freqtrade.rpc.rpc import RPC, RPCException
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
logger = logging.getLogger(__name__)
@@ -38,9 +45,9 @@ class ArrowJSONEncoder(JSONEncoder):
def require_login(func: Callable[[Any, Any], Any]):
def func_wrapper(obj, *args, **kwargs):
verify_jwt_in_request_optional()
auth = request.authorization
if auth and obj.check_auth(auth.username, auth.password):
if get_jwt_identity() or auth and obj.check_auth(auth.username, auth.password):
return func(obj, *args, **kwargs)
else:
return jsonify({"error": "Unauthorized"}), 401
@@ -49,7 +56,7 @@ def require_login(func: Callable[[Any, Any], Any]):
# Type should really be Callable[[ApiServer], Any], but that will create a circular dependency
def rpc_catch_errors(func: Callable[[Any], Any]):
def rpc_catch_errors(func: Callable[..., Any]):
def func_wrapper(obj, *args, **kwargs):
@@ -70,8 +77,8 @@ class ApiServer(RPC):
"""
def check_auth(self, username, password):
return (username == self._config['api_server'].get('username') and
password == self._config['api_server'].get('password'))
return (safe_str_cmp(username, self._config['api_server'].get('username')) and
safe_str_cmp(password, self._config['api_server'].get('password')))
def __init__(self, freqtrade) -> None:
"""
@@ -83,11 +90,25 @@ class ApiServer(RPC):
self._config = freqtrade.config
self.app = Flask(__name__)
self._cors = CORS(self.app,
resources={r"/api/*": {
"supports_credentials": True,
"origins": self._config['api_server'].get('CORS_origins', [])}}
)
# Setup the Flask-JWT-Extended extension
self.app.config['JWT_SECRET_KEY'] = self._config['api_server'].get(
'jwt_secret_key', 'super-secret')
self.jwt = JWTManager(self.app)
self.app.json_encoder = ArrowJSONEncoder
# Register application handling
self.register_rest_rpc_urls()
if self._config.get('fiat_display_currency', None):
self._fiat_converter = CryptoToFiatConverter()
thread = threading.Thread(target=self.run, daemon=True)
thread.start()
@@ -148,13 +169,17 @@ class ApiServer(RPC):
self.app.register_error_handler(404, self.page_not_found)
# Actions to control the bot
self.app.add_url_rule(f'{BASE_URI}/token/login', 'login',
view_func=self._token_login, methods=['POST'])
self.app.add_url_rule(f'{BASE_URI}/token/refresh', 'token_refresh',
view_func=self._token_refresh, methods=['POST'])
self.app.add_url_rule(f'{BASE_URI}/start', 'start',
view_func=self._start, methods=['POST'])
self.app.add_url_rule(f'{BASE_URI}/stop', 'stop', view_func=self._stop, methods=['POST'])
self.app.add_url_rule(f'{BASE_URI}/stopbuy', 'stopbuy',
view_func=self._stopbuy, methods=['POST'])
self.app.add_url_rule(f'{BASE_URI}/reload_conf', 'reload_conf',
view_func=self._reload_conf, methods=['POST'])
self.app.add_url_rule(f'{BASE_URI}/reload_config', 'reload_config',
view_func=self._reload_config, methods=['POST'])
# Info commands
self.app.add_url_rule(f'{BASE_URI}/balance', 'balance',
view_func=self._balance, methods=['GET'])
@@ -175,6 +200,8 @@ class ApiServer(RPC):
view_func=self._ping, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/trades', 'trades',
view_func=self._trades, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/trades/<int:tradeid>', 'trades_delete',
view_func=self._trades_delete, methods=['DELETE'])
# Combined actions and infos
self.app.add_url_rule(f'{BASE_URI}/blacklist', 'blacklist', view_func=self._blacklist,
methods=['GET', 'POST'])
@@ -199,6 +226,37 @@ class ApiServer(RPC):
'code': 404
}), 404
@require_login
@rpc_catch_errors
def _token_login(self):
"""
Handler for /token/login
Returns a JWT token
"""
auth = request.authorization
if auth and self.check_auth(auth.username, auth.password):
keystuff = {'u': auth.username}
ret = {
'access_token': create_access_token(identity=keystuff),
'refresh_token': create_refresh_token(identity=keystuff),
}
return self.rest_dump(ret)
return jsonify({"error": "Unauthorized"}), 401
@jwt_refresh_token_required
@rpc_catch_errors
def _token_refresh(self):
"""
Handler for /token/refresh
Returns a JWT token based on a JWT refresh token
"""
current_user = get_jwt_identity()
new_token = create_access_token(identity=current_user, fresh=False)
ret = {'access_token': new_token}
return self.rest_dump(ret)
@require_login
@rpc_catch_errors
def _start(self):
@@ -254,12 +312,12 @@ class ApiServer(RPC):
@require_login
@rpc_catch_errors
def _reload_conf(self):
def _reload_config(self):
"""
Handler for /reload_conf.
Handler for /reload_config.
Triggers a config file reload
"""
msg = self._rpc_reload_conf()
msg = self._rpc_reload_config()
return self.rest_dump(msg)
@require_login
@@ -310,7 +368,6 @@ class ApiServer(RPC):
Returns a cumulative profit statistics
:return: stats
"""
logger.info("LocalRPC - Profit Command Called")
stats = self._rpc_trade_statistics(self._config['stake_currency'],
self._config.get('fiat_display_currency')
@@ -327,8 +384,6 @@ class ApiServer(RPC):
Returns a cumulative performance statistics
:return: stats
"""
logger.info("LocalRPC - performance Command Called")
stats = self._rpc_performance()
return self.rest_dump(stats)
@@ -371,6 +426,19 @@ class ApiServer(RPC):
results = self._rpc_trade_history(limit)
return self.rest_dump(results)
@require_login
@rpc_catch_errors
def _trades_delete(self, tradeid):
"""
Handler for DELETE /trades/<tradeid> endpoint.
Removes the trade from the database (tries to cancel open orders first!)
get:
param:
tradeid: Numeric trade-id assigned to the trade.
"""
result = self._rpc_delete(tradeid)
return self.rest_dump(result)
@require_login
@rpc_catch_errors
def _whitelist(self):

View File

@@ -6,12 +6,14 @@ from abc import abstractmethod
from datetime import date, datetime, timedelta
from enum import Enum
from math import isnan
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, Union
import arrow
from numpy import NAN, mean
from freqtrade.exceptions import DependencyException, TemporaryError
from freqtrade.exceptions import (ExchangeError, InvalidOrderException,
PricingError)
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs
from freqtrade.misc import shorten_date
from freqtrade.persistence import Trade
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
@@ -94,15 +96,23 @@ class RPC:
'dry_run': config['dry_run'],
'stake_currency': config['stake_currency'],
'stake_amount': config['stake_amount'],
'max_open_trades': config['max_open_trades'],
'minimal_roi': config['minimal_roi'].copy(),
'stoploss': config['stoploss'],
'trailing_stop': config['trailing_stop'],
'trailing_stop_positive': config.get('trailing_stop_positive'),
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset'),
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached'),
'ticker_interval': config['ticker_interval'],
'ticker_interval': config['timeframe'], # DEPRECATED
'timeframe': config['timeframe'],
'timeframe_ms': timeframe_to_msecs(config['timeframe']),
'timeframe_min': timeframe_to_minutes(config['timeframe']),
'exchange': config['exchange']['name'],
'strategy': config['strategy'],
'forcebuy_enabled': config.get('forcebuy_enable', False),
'ask_strategy': config.get('ask_strategy', {}),
'bid_strategy': config.get('bid_strategy', {}),
'state': str(self._freqtrade.state)
}
return val
@@ -120,21 +130,36 @@ class RPC:
for trade in trades:
order = None
if trade.open_order_id:
order = self._freqtrade.exchange.get_order(trade.open_order_id, trade.pair)
order = self._freqtrade.exchange.fetch_order(trade.open_order_id, trade.pair)
# calculate profit and send message to user
try:
current_rate = self._freqtrade.get_sell_rate(trade.pair, False)
except DependencyException:
except (ExchangeError, PricingError):
current_rate = NAN
current_profit = trade.calc_profit_ratio(current_rate)
current_profit_abs = trade.calc_profit(current_rate)
# Calculate guaranteed profit (in case of trailing stop)
stoploss_entry_dist = trade.calc_profit(trade.stop_loss)
stoploss_entry_dist_ratio = trade.calc_profit_ratio(trade.stop_loss)
# calculate distance to stoploss
stoploss_current_dist = trade.stop_loss - current_rate
stoploss_current_dist_ratio = stoploss_current_dist / current_rate
fmt_close_profit = (f'{round(trade.close_profit * 100, 2):.2f}%'
if trade.close_profit else None)
if trade.close_profit is not None else None)
trade_dict = trade.to_json()
trade_dict.update(dict(
base_currency=self._freqtrade.config['stake_currency'],
close_profit=fmt_close_profit,
close_profit=trade.close_profit if trade.close_profit is not None else None,
close_profit_pct=fmt_close_profit,
current_rate=current_rate,
current_profit=round(current_profit * 100, 2),
current_profit=current_profit,
current_profit_pct=round(current_profit * 100, 2),
current_profit_abs=current_profit_abs,
stoploss_current_dist=stoploss_current_dist,
stoploss_current_dist_ratio=round(stoploss_current_dist_ratio, 8),
stoploss_entry_dist=stoploss_entry_dist,
stoploss_entry_dist_ratio=round(stoploss_entry_dist_ratio, 8),
open_order='({} {} rem={:.8f})'.format(
order['type'], order['side'], order['remaining']
) if order else None,
@@ -153,7 +178,7 @@ class RPC:
# calculate profit and send message to user
try:
current_rate = self._freqtrade.get_sell_rate(trade.pair, False)
except DependencyException:
except (PricingError, ExchangeError):
current_rate = NAN
trade_percent = (100 * trade.calc_profit_ratio(current_rate))
trade_profit = trade.calc_profit(current_rate)
@@ -183,7 +208,7 @@ class RPC:
def _rpc_daily_profit(
self, timescale: int,
stake_currency: str, fiat_display_currency: str) -> List[List[Any]]:
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
today = datetime.utcnow().date()
profit_days: Dict[date, Dict] = {}
@@ -203,35 +228,34 @@ class RPC:
'trades': len(trades)
}
return [
[
key,
'{value:.8f} {symbol}'.format(
value=float(value['amount']),
symbol=stake_currency
),
'{value:.3f} {symbol}'.format(
data = [
{
'date': key,
'abs_profit': f'{float(value["amount"]):.8f}',
'fiat_value': '{value:.3f}'.format(
value=self._fiat_converter.convert_amount(
value['amount'],
stake_currency,
fiat_display_currency
) if self._fiat_converter else 0,
symbol=fiat_display_currency
),
'{value} trade{s}'.format(
value=value['trades'],
s='' if value['trades'] < 2 else 's'
),
]
'trade_count': f'{value["trades"]}',
}
for key, value in profit_days.items()
]
return {
'stake_currency': stake_currency,
'fiat_display_currency': fiat_display_currency,
'data': data
}
def _rpc_trade_history(self, limit: int) -> Dict:
""" Returns the X last trades """
if limit > 0:
trades = Trade.get_trades().order_by(Trade.id.desc()).limit(limit)
trades = Trade.get_trades([Trade.is_open.is_(False)]).order_by(
Trade.id.desc()).limit(limit)
else:
trades = Trade.get_trades().order_by(Trade.id.desc()).all()
trades = Trade.get_trades([Trade.is_open.is_(False)]).order_by(Trade.id.desc()).all()
output = [trade.to_json() for trade in trades]
@@ -250,6 +274,8 @@ class RPC:
profit_closed_coin = []
profit_closed_ratio = []
durations = []
winning_trades = 0
losing_trades = 0
for trade in trades:
current_rate: float = 0.0
@@ -263,11 +289,15 @@ class RPC:
profit_ratio = trade.close_profit
profit_closed_coin.append(trade.close_profit_abs)
profit_closed_ratio.append(profit_ratio)
if trade.close_profit >= 0:
winning_trades += 1
else:
losing_trades += 1
else:
# Get current rate
try:
current_rate = self._freqtrade.get_sell_rate(trade.pair, False)
except DependencyException:
except (PricingError, ExchangeError):
current_rate = NAN
profit_ratio = trade.calc_profit_ratio(rate=current_rate)
@@ -278,15 +308,11 @@ class RPC:
best_pair = Trade.get_best_pair()
if not best_pair:
raise RPCException('no closed trade')
bp_pair, bp_rate = best_pair
# Prepare data to display
profit_closed_coin_sum = round(sum(profit_closed_coin), 8)
profit_closed_percent = (round(mean(profit_closed_ratio) * 100, 2) if profit_closed_ratio
else 0.0)
profit_closed_ratio_mean = mean(profit_closed_ratio) if profit_closed_ratio else 0.0
profit_closed_ratio_sum = sum(profit_closed_ratio) if profit_closed_ratio else 0.0
profit_closed_fiat = self._fiat_converter.convert_amount(
profit_closed_coin_sum,
stake_currency,
@@ -294,27 +320,43 @@ class RPC:
) if self._fiat_converter else 0
profit_all_coin_sum = round(sum(profit_all_coin), 8)
profit_all_percent = round(mean(profit_all_ratio) * 100, 2) if profit_all_ratio else 0.0
profit_all_ratio_mean = mean(profit_all_ratio) if profit_all_ratio else 0.0
profit_all_ratio_sum = sum(profit_all_ratio) if profit_all_ratio else 0.0
profit_all_fiat = self._fiat_converter.convert_amount(
profit_all_coin_sum,
stake_currency,
fiat_display_currency
) if self._fiat_converter else 0
first_date = trades[0].open_date if trades else None
last_date = trades[-1].open_date if trades else None
num = float(len(durations) or 1)
return {
'profit_closed_coin': profit_closed_coin_sum,
'profit_closed_percent': profit_closed_percent,
'profit_closed_percent': round(profit_closed_ratio_mean * 100, 2), # DEPRECATED
'profit_closed_percent_mean': round(profit_closed_ratio_mean * 100, 2),
'profit_closed_ratio_mean': profit_closed_ratio_mean,
'profit_closed_percent_sum': round(profit_closed_ratio_sum * 100, 2),
'profit_closed_ratio_sum': profit_closed_ratio_sum,
'profit_closed_fiat': profit_closed_fiat,
'profit_all_coin': profit_all_coin_sum,
'profit_all_percent': profit_all_percent,
'profit_all_percent': round(profit_all_ratio_mean * 100, 2), # DEPRECATED
'profit_all_percent_mean': round(profit_all_ratio_mean * 100, 2),
'profit_all_ratio_mean': profit_all_ratio_mean,
'profit_all_percent_sum': round(profit_all_ratio_sum * 100, 2),
'profit_all_ratio_sum': profit_all_ratio_sum,
'profit_all_fiat': profit_all_fiat,
'trade_count': len(trades),
'first_trade_date': arrow.get(trades[0].open_date).humanize(),
'latest_trade_date': arrow.get(trades[-1].open_date).humanize(),
'closed_trade_count': len([t for t in trades if not t.is_open]),
'first_trade_date': arrow.get(first_date).humanize() if first_date else '',
'first_trade_timestamp': int(first_date.timestamp() * 1000) if first_date else 0,
'latest_trade_date': arrow.get(last_date).humanize() if last_date else '',
'latest_trade_timestamp': int(last_date.timestamp() * 1000) if last_date else 0,
'avg_duration': str(timedelta(seconds=sum(durations) / num)).split('.')[0],
'best_pair': bp_pair,
'best_rate': round(bp_rate * 100, 2),
'best_pair': best_pair[0] if best_pair else '',
'best_rate': round(best_pair[1] * 100, 2) if best_pair else 0,
'winning_trades': winning_trades,
'losing_trades': losing_trades,
}
def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> Dict:
@@ -323,7 +365,7 @@ class RPC:
total = 0.0
try:
tickers = self._freqtrade.exchange.get_tickers()
except (TemporaryError, DependencyException):
except (ExchangeError):
raise RPCException('Error getting current tickers.')
self._freqtrade.wallets.update(require_update=False)
@@ -344,7 +386,7 @@ class RPC:
if pair.startswith(stake_currency):
rate = 1.0 / rate
est_stake = rate * balance.total
except (TemporaryError, DependencyException):
except (ExchangeError):
logger.warning(f" Could not get rate for pair {coin}.")
continue
total = total + (est_stake or 0)
@@ -390,9 +432,9 @@ class RPC:
return {'status': 'already stopped'}
def _rpc_reload_conf(self) -> Dict[str, str]:
""" Handler for reload_conf. """
self._freqtrade.state = State.RELOAD_CONF
def _rpc_reload_config(self) -> Dict[str, str]:
""" Handler for reload_config. """
self._freqtrade.state = State.RELOAD_CONFIG
return {'status': 'reloading config ...'}
def _rpc_stopbuy(self) -> Dict[str, str]:
@@ -403,7 +445,7 @@ class RPC:
# Set 'max_open_trades' to 0
self._freqtrade.config['max_open_trades'] = 0
return {'status': 'No more buy will occur from now. Run /reload_conf to reset.'}
return {'status': 'No more buy will occur from now. Run /reload_config to reset.'}
def _rpc_forcesell(self, trade_id: str) -> Dict[str, str]:
"""
@@ -413,7 +455,7 @@ class RPC:
def _exec_forcesell(trade: Trade) -> None:
# Check if there is there is an open order
if trade.open_order_id:
order = self._freqtrade.exchange.get_order(trade.open_order_id, trade.pair)
order = self._freqtrade.exchange.fetch_order(trade.open_order_id, trade.pair)
# Cancel open LIMIT_BUY orders and close trade
if order and order['status'] == 'open' \
@@ -482,7 +524,7 @@ class RPC:
# check if valid pair
# check if pair already has an open pair
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair.is_(pair)]).first()
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
if trade:
raise RPCException(f'position for {pair} already open - id: {trade.id}')
@@ -491,11 +533,51 @@ class RPC:
# execute buy
if self._freqtrade.execute_buy(pair, stakeamount, price):
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair.is_(pair)]).first()
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
return trade
else:
return None
def _rpc_delete(self, trade_id: str) -> Dict[str, Union[str, int]]:
"""
Handler for delete <id>.
Delete the given trade and close eventually existing open orders.
"""
with self._freqtrade._sell_lock:
c_count = 0
trade = Trade.get_trades(trade_filter=[Trade.id == trade_id]).first()
if not trade:
logger.warning('delete trade: Invalid argument received')
raise RPCException('invalid argument')
# Try cancelling regular order if that exists
if trade.open_order_id:
try:
self._freqtrade.exchange.cancel_order(trade.open_order_id, trade.pair)
c_count += 1
except (ExchangeError, InvalidOrderException):
pass
# cancel stoploss on exchange ...
if (self._freqtrade.strategy.order_types.get('stoploss_on_exchange')
and trade.stoploss_order_id):
try:
self._freqtrade.exchange.cancel_stoploss_order(trade.stoploss_order_id,
trade.pair)
c_count += 1
except (ExchangeError, InvalidOrderException):
pass
Trade.session.delete(trade)
Trade.session.flush()
self._freqtrade.wallets.update()
return {
'result': 'success',
'trade_id': trade_id,
'result_msg': f'Deleted trade {trade_id}. Closed {c_count} open orders.',
'cancel_order_count': c_count,
}
def _rpc_performance(self) -> List[Dict[str, Any]]:
"""
Handler for performance.
@@ -528,21 +610,31 @@ class RPC:
def _rpc_blacklist(self, add: List[str] = None) -> Dict:
""" Returns the currently active blacklist"""
errors = {}
if add:
stake_currency = self._freqtrade.config.get('stake_currency')
for pair in add:
if (self._freqtrade.exchange.get_pair_quote_currency(pair) == stake_currency
and pair not in self._freqtrade.pairlists.blacklist):
self._freqtrade.pairlists.blacklist.append(pair)
if self._freqtrade.exchange.get_pair_quote_currency(pair) == stake_currency:
if pair not in self._freqtrade.pairlists.blacklist:
self._freqtrade.pairlists.blacklist.append(pair)
else:
errors[pair] = {
'error_msg': f'Pair {pair} already in pairlist.'}
else:
errors[pair] = {
'error_msg': f"Pair {pair} does not match stake currency."
}
res = {'method': self._freqtrade.pairlists.name_list,
'length': len(self._freqtrade.pairlists.blacklist),
'blacklist': self._freqtrade.pairlists.blacklist,
'errors': errors,
}
return res
def _rpc_edge(self) -> List[Dict[str, Any]]:
""" Returns information related to Edge """
if not self._freqtrade.edge:
raise RPCException(f'Edge is not enabled.')
raise RPCException('Edge is not enabled.')
return self._freqtrade.edge.accepted_pairs()

View File

@@ -72,7 +72,7 @@ class RPCManager:
minimal_roi = config['minimal_roi']
stoploss = config['stoploss']
trailing_stop = config['trailing_stop']
ticker_interval = config['ticker_interval']
timeframe = config['timeframe']
exchange_name = config['exchange']['name']
strategy_name = config.get('strategy', '')
self.send_msg({
@@ -81,7 +81,7 @@ class RPCManager:
f'*Stake per trade:* `{stake_amount} {stake_currency}`\n'
f'*Minimum ROI:* `{minimal_roi}`\n'
f'*{"Trailing " if trailing_stop else ""}Stoploss:* `{stoploss}`\n'
f'*Ticker Interval:* `{ticker_interval}`\n'
f'*Timeframe:* `{timeframe}`\n'
f'*Strategy:* `{strategy_name}`'
})
self.send_msg({

View File

@@ -3,7 +3,9 @@
"""
This module manage Telegram communication
"""
import json
import logging
import arrow
from typing import Any, Callable, Dict
from tabulate import tabulate
@@ -19,7 +21,6 @@ logger = logging.getLogger(__name__)
logger.debug('Included module rpc.telegram ...')
MAX_TELEGRAM_MESSAGE_LENGTH = 4096
@@ -29,6 +30,7 @@ def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]:
:param command_handler: Telegram CommandHandler
:return: decorated function
"""
def wrapper(self, *args, **kwargs):
""" Decorator logic """
update = kwargs.get('update') or args[0]
@@ -91,11 +93,13 @@ class Telegram(RPC):
CommandHandler('stop', self._stop),
CommandHandler('forcesell', self._forcesell),
CommandHandler('forcebuy', self._forcebuy),
CommandHandler('trades', self._trades),
CommandHandler('delete', self._delete_trade),
CommandHandler('performance', self._performance),
CommandHandler('daily', self._daily),
CommandHandler('count', self._count),
CommandHandler('reload_conf', self._reload_conf),
CommandHandler('show_config', self._show_config),
CommandHandler(['reload_config', 'reload_conf'], self._reload_config),
CommandHandler(['show_config', 'show_conf'], self._show_config),
CommandHandler('stopbuy', self._stopbuy),
CommandHandler('whitelist', self._whitelist),
CommandHandler('blacklist', self._blacklist),
@@ -133,7 +137,7 @@ class Telegram(RPC):
else:
msg['stake_amount_fiat'] = 0
message = ("*{exchange}:* Buying {pair}\n"
message = ("\N{LARGE BLUE CIRCLE} *{exchange}:* Buying {pair}\n"
"*Amount:* `{amount:.8f}`\n"
"*Open Rate:* `{limit:.8f}`\n"
"*Current Rate:* `{current_rate:.8f}`\n"
@@ -144,7 +148,8 @@ class Telegram(RPC):
message += ")`"
elif msg['type'] == RPCMessageType.BUY_CANCEL_NOTIFICATION:
message = "*{exchange}:* Cancelling Open Buy Order for {pair}".format(**msg)
message = ("\N{WARNING SIGN} *{exchange}:* "
"Cancelling Open Buy Order for {pair}".format(**msg))
elif msg['type'] == RPCMessageType.SELL_NOTIFICATION:
msg['amount'] = round(msg['amount'], 8)
@@ -153,7 +158,9 @@ class Telegram(RPC):
microsecond=0) - msg['open_date'].replace(microsecond=0)
msg['duration_min'] = msg['duration'].total_seconds() / 60
message = ("*{exchange}:* Selling {pair}\n"
msg['emoji'] = self._get_sell_emoji(msg)
message = ("{emoji} *{exchange}:* Selling {pair}\n"
"*Amount:* `{amount:.8f}`\n"
"*Open Rate:* `{open_rate:.8f}`\n"
"*Current Rate:* `{current_rate:.8f}`\n"
@@ -165,21 +172,21 @@ class Telegram(RPC):
# Check if all sell properties are available.
# This might not be the case if the message origin is triggered by /forcesell
if (all(prop in msg for prop in ['gain', 'fiat_currency', 'stake_currency'])
and self._fiat_converter):
and self._fiat_converter):
msg['profit_fiat'] = self._fiat_converter.convert_amount(
msg['profit_amount'], msg['stake_currency'], msg['fiat_currency'])
message += (' `({gain}: {profit_amount:.8f} {stake_currency}'
' / {profit_fiat:.3f} {fiat_currency})`').format(**msg)
elif msg['type'] == RPCMessageType.SELL_CANCEL_NOTIFICATION:
message = ("*{exchange}:* Cancelling Open Sell Order "
message = ("\N{WARNING SIGN} *{exchange}:* Cancelling Open Sell Order "
"for {pair}. Reason: {reason}").format(**msg)
elif msg['type'] == RPCMessageType.STATUS_NOTIFICATION:
message = '*Status:* `{status}`'.format(**msg)
elif msg['type'] == RPCMessageType.WARNING_NOTIFICATION:
message = '*Warning:* `{status}`'.format(**msg)
message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg)
elif msg['type'] == RPCMessageType.CUSTOM_NOTIFICATION:
message = '{status}'.format(**msg)
@@ -189,6 +196,20 @@ class Telegram(RPC):
self._send_msg(message)
def _get_sell_emoji(self, msg):
"""
Get emoji for sell-side
"""
if float(msg['profit_percent']) >= 5.0:
return "\N{ROCKET}"
elif float(msg['profit_percent']) >= 0.0:
return "\N{EIGHT SPOKED ASTERISK}"
elif msg['sell_reason'] == "stop_loss":
return"\N{WARNING SIGN}"
else:
return "\N{CROSS MARK}"
@authorized_only
def _status(self, update: Update, context: CallbackContext) -> None:
"""
@@ -215,22 +236,28 @@ class Telegram(RPC):
"*Open Rate:* `{open_rate:.8f}`",
"*Close Rate:* `{close_rate}`" if r['close_rate'] else "",
"*Current Rate:* `{current_rate:.8f}`",
"*Close Profit:* `{close_profit}`" if r['close_profit'] else "",
"*Current Profit:* `{current_profit:.2f}%`",
("*Close Profit:* `{close_profit_pct}`"
if r['close_profit_pct'] is not None else ""),
"*Current Profit:* `{current_profit_pct:.2f}%`",
# Adding initial stoploss only if it is different from stoploss
"*Initial Stoploss:* `{initial_stop_loss:.8f}` " +
("`({initial_stop_loss_pct:.2f}%)`" if r['initial_stop_loss_pct'] else "")
if r['stop_loss'] != r['initial_stop_loss'] else "",
("`({initial_stop_loss_pct:.2f}%)`") if (
r['stop_loss'] != r['initial_stop_loss']
and r['initial_stop_loss_pct'] is not None) else "",
# Adding stoploss and stoploss percentage only if it is not None
"*Stoploss:* `{stop_loss:.8f}` " +
("`({stop_loss_pct:.2f}%)`" if r['stop_loss_pct'] else ""),
"*Open Order:* `{open_order}`" if r['open_order'] else ""
]
if r['open_order']:
if r['sell_order_status']:
lines.append("*Open Order:* `{open_order}` - `{sell_order_status}`")
else:
lines.append("*Open Order:* `{open_order}`")
# Filter empty lines using list-comprehension
messages.append("\n".join([l for l in lines if l]).format(**r))
messages.append("\n".join([line for line in lines if line]).format(**r))
for msg in messages:
self._send_msg(msg)
@@ -276,14 +303,18 @@ class Telegram(RPC):
stake_cur,
fiat_disp_cur
)
stats_tab = tabulate(stats,
headers=[
'Day',
f'Profit {stake_cur}',
f'Profit {fiat_disp_cur}',
f'Trades'
],
tablefmt='simple')
stats_tab = tabulate(
[[day['date'],
f"{day['abs_profit']} {stats['stake_currency']}",
f"{day['fiat_value']} {stats['fiat_display_currency']}",
f"{day['trade_count']} trades"] for day in stats['data']],
headers=[
'Day',
f'Profit {stake_cur}',
f'Profit {fiat_disp_cur}',
'Trades',
],
tablefmt='simple')
message = f'<b>Daily Profit over the last {timescale} days</b>:\n<pre>{stats_tab}</pre>'
self._send_msg(message, parse_mode=ParseMode.HTML)
except RPCException as e:
@@ -301,38 +332,50 @@ class Telegram(RPC):
stake_cur = self._config['stake_currency']
fiat_disp_cur = self._config.get('fiat_display_currency', '')
try:
stats = self._rpc_trade_statistics(
stake_cur,
fiat_disp_cur)
profit_closed_coin = stats['profit_closed_coin']
profit_closed_percent = stats['profit_closed_percent']
profit_closed_fiat = stats['profit_closed_fiat']
profit_all_coin = stats['profit_all_coin']
profit_all_percent = stats['profit_all_percent']
profit_all_fiat = stats['profit_all_fiat']
trade_count = stats['trade_count']
first_trade_date = stats['first_trade_date']
latest_trade_date = stats['latest_trade_date']
avg_duration = stats['avg_duration']
best_pair = stats['best_pair']
best_rate = stats['best_rate']
stats = self._rpc_trade_statistics(
stake_cur,
fiat_disp_cur)
profit_closed_coin = stats['profit_closed_coin']
profit_closed_percent_mean = stats['profit_closed_percent_mean']
profit_closed_percent_sum = stats['profit_closed_percent_sum']
profit_closed_fiat = stats['profit_closed_fiat']
profit_all_coin = stats['profit_all_coin']
profit_all_percent_mean = stats['profit_all_percent_mean']
profit_all_percent_sum = stats['profit_all_percent_sum']
profit_all_fiat = stats['profit_all_fiat']
trade_count = stats['trade_count']
first_trade_date = stats['first_trade_date']
latest_trade_date = stats['latest_trade_date']
avg_duration = stats['avg_duration']
best_pair = stats['best_pair']
best_rate = stats['best_rate']
if stats['trade_count'] == 0:
markdown_msg = 'No trades yet.'
else:
# Message to display
markdown_msg = "*ROI:* Close trades\n" \
f"∙ `{profit_closed_coin:.8f} {stake_cur} "\
f"({profit_closed_percent:.2f}%)`\n" \
f"∙ `{profit_closed_fiat:.3f} {fiat_disp_cur}`\n" \
f"*ROI:* All trades\n" \
f"∙ `{profit_all_coin:.8f} {stake_cur} ({profit_all_percent:.2f}%)`\n" \
f"∙ `{profit_all_fiat:.3f} {fiat_disp_cur}`\n" \
f"*Total Trade Count:* `{trade_count}`\n" \
f"*First Trade opened:* `{first_trade_date}`\n" \
f"*Latest Trade opened:* `{latest_trade_date}`\n" \
f"*Avg. Duration:* `{avg_duration}`\n" \
f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`"
self._send_msg(markdown_msg)
except RPCException as e:
self._send_msg(str(e))
if stats['closed_trade_count'] > 0:
markdown_msg = ("*ROI:* Closed trades\n"
f"∙ `{profit_closed_coin:.8f} {stake_cur} "
f"({profit_closed_percent_mean:.2f}%) "
f"({profit_closed_percent_sum} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"∙ `{profit_closed_fiat:.3f} {fiat_disp_cur}`\n")
else:
markdown_msg = "`No closed trade` \n"
markdown_msg += (f"*ROI:* All trades\n"
f"∙ `{profit_all_coin:.8f} {stake_cur} "
f"({profit_all_percent_mean:.2f}%) "
f"({profit_all_percent_sum} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"∙ `{profit_all_fiat:.3f} {fiat_disp_cur}`\n"
f"*Total Trade Count:* `{trade_count}`\n"
f"*First Trade opened:* `{first_trade_date}`\n"
f"*Latest Trade opened:* `{latest_trade_date}\n`"
f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`"
)
if stats['closed_trade_count'] > 0:
markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`")
self._send_msg(markdown_msg)
@authorized_only
def _balance(self, update: Update, context: CallbackContext) -> None:
@@ -348,14 +391,14 @@ class Telegram(RPC):
"This mode is still experimental!\n"
"Starting capital: "
f"`{self._config['dry_run_wallet']}` {self._config['stake_currency']}.\n"
)
)
for currency in result['currencies']:
if currency['est_stake'] > 0.0001:
curr_output = "*{currency}:*\n" \
"\t`Available: {free: .8f}`\n" \
"\t`Balance: {balance: .8f}`\n" \
"\t`Pending: {used: .8f}`\n" \
"\t`Est. {stake}: {est_stake: .8f}`\n".format(**currency)
curr_output = ("*{currency}:*\n"
"\t`Available: {free: .8f}`\n"
"\t`Balance: {balance: .8f}`\n"
"\t`Pending: {used: .8f}`\n"
"\t`Est. {stake}: {est_stake: .8f}`\n").format(**currency)
else:
curr_output = "*{currency}:* not showing <1$ amount \n".format(**currency)
@@ -366,9 +409,9 @@ class Telegram(RPC):
else:
output += curr_output
output += "\n*Estimated Value*:\n" \
"\t`{stake}: {total: .8f}`\n" \
"\t`{symbol}: {value: .2f}`\n".format(**result)
output += ("\n*Estimated Value*:\n"
"\t`{stake}: {total: .8f}`\n"
"\t`{symbol}: {value: .2f}`\n").format(**result)
self._send_msg(output)
except RPCException as e:
self._send_msg(str(e))
@@ -398,15 +441,15 @@ class Telegram(RPC):
self._send_msg('Status: `{status}`'.format(**msg))
@authorized_only
def _reload_conf(self, update: Update, context: CallbackContext) -> None:
def _reload_config(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /reload_conf.
Handler for /reload_config.
Triggers a config file reload
:param bot: telegram bot
:param update: message update
:return: None
"""
msg = self._rpc_reload_conf()
msg = self._rpc_reload_config()
self._send_msg('Status: `{status}`'.format(**msg))
@authorized_only
@@ -456,6 +499,62 @@ class Telegram(RPC):
except RPCException as e:
self._send_msg(str(e))
@authorized_only
def _trades(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /trades <n>
Returns last n recent trades.
:param bot: telegram bot
:param update: message update
:return: None
"""
stake_cur = self._config['stake_currency']
try:
nrecent = int(context.args[0])
except (TypeError, ValueError, IndexError):
nrecent = 10
try:
trades = self._rpc_trade_history(
nrecent
)
trades_tab = tabulate(
[[arrow.get(trade['open_date']).humanize(),
trade['pair'],
f"{(100 * trade['close_profit']):.2f}% ({trade['close_profit_abs']})"]
for trade in trades['trades']],
headers=[
'Open Date',
'Pair',
f'Profit ({stake_cur})',
],
tablefmt='simple')
message = (f"<b>{min(trades['trades_count'], nrecent)} recent trades</b>:\n"
+ (f"<pre>{trades_tab}</pre>" if trades['trades_count'] > 0 else ''))
self._send_msg(message, parse_mode=ParseMode.HTML)
except RPCException as e:
self._send_msg(str(e))
@authorized_only
def _delete_trade(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /delete <id>.
Delete the given trade
:param bot: telegram bot
:param update: message update
:return: None
"""
trade_id = context.args[0] if len(context.args) > 0 else None
try:
msg = self._rpc_delete(trade_id)
self._send_msg((
'`{result_msg}`\n'
'Please make sure to take care of this asset on the exchange manually.'
).format(**msg))
except RPCException as e:
self._send_msg(str(e))
@authorized_only
def _performance(self, update: Update, context: CallbackContext) -> None:
"""
@@ -524,6 +623,11 @@ class Telegram(RPC):
try:
blacklist = self._rpc_blacklist(context.args)
errmsgs = []
for pair, error in blacklist['errors'].items():
errmsgs.append(f"Error adding `{pair}` to blacklist: `{error['error_msg']}`")
if errmsgs:
self._send_msg('\n'.join(errmsgs))
message = f"Blacklist contains {blacklist['length']} pairs\n"
message += f"`{', '.join(blacklist['blacklist'])}`"
@@ -556,32 +660,34 @@ class Telegram(RPC):
:param update: message update
:return: None
"""
forcebuy_text = "*/forcebuy <pair> [<rate>]:* `Instantly buys the given pair. " \
"Optionally takes a rate at which to buy.` \n"
message = "*/start:* `Starts the trader`\n" \
"*/stop:* `Stops the trader`\n" \
"*/status [table]:* `Lists all open trades`\n" \
" *table :* `will display trades in a table`\n" \
" `pending buy orders are marked with an asterisk (*)`\n" \
" `pending sell orders are marked with a double asterisk (**)`\n" \
"*/profit:* `Lists cumulative profit from all finished trades`\n" \
"*/forcesell <trade_id>|all:* `Instantly sells the given trade or all trades, " \
"regardless of profit`\n" \
f"{forcebuy_text if self._config.get('forcebuy_enable', False) else '' }" \
"*/performance:* `Show performance of each finished trade grouped by pair`\n" \
"*/daily <n>:* `Shows profit or loss per day, over the last n days`\n" \
"*/count:* `Show number of trades running compared to allowed number of trades`" \
"\n" \
"*/balance:* `Show account balance per currency`\n" \
"*/stopbuy:* `Stops buying, but handles open trades gracefully` \n" \
"*/reload_conf:* `Reload configuration file` \n" \
"*/show_config:* `Show running configuration` \n" \
"*/whitelist:* `Show current whitelist` \n" \
"*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs " \
"to the blacklist.` \n" \
"*/edge:* `Shows validated pairs by Edge if it is enabeld` \n" \
"*/help:* `This help message`\n" \
"*/version:* `Show version`"
forcebuy_text = ("*/forcebuy <pair> [<rate>]:* `Instantly buys the given pair. "
"Optionally takes a rate at which to buy.` \n")
message = ("*/start:* `Starts the trader`\n"
"*/stop:* `Stops the trader`\n"
"*/status [table]:* `Lists all open trades`\n"
" *table :* `will display trades in a table`\n"
" `pending buy orders are marked with an asterisk (*)`\n"
" `pending sell orders are marked with a double asterisk (**)`\n"
"*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n"
"*/profit:* `Lists cumulative profit from all finished trades`\n"
"*/forcesell <trade_id>|all:* `Instantly sells the given trade or all trades, "
"regardless of profit`\n"
f"{forcebuy_text if self._config.get('forcebuy_enable', False) else ''}"
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
"*/performance:* `Show performance of each finished trade grouped by pair`\n"
"*/daily <n>:* `Shows profit or loss per day, over the last n days`\n"
"*/count:* `Show number of trades running compared to allowed number of trades`"
"\n"
"*/balance:* `Show account balance per currency`\n"
"*/stopbuy:* `Stops buying, but handles open trades gracefully` \n"
"*/reload_config:* `Reload configuration file` \n"
"*/show_config:* `Show running configuration` \n"
"*/whitelist:* `Show current whitelist` \n"
"*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs "
"to the blacklist.` \n"
"*/edge:* `Shows validated pairs by Edge if it is enabled` \n"
"*/help:* `This help message`\n"
"*/version:* `Show version`")
self._send_msg(message)
@@ -621,10 +727,14 @@ class Telegram(RPC):
f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n"
f"*Exchange:* `{val['exchange']}`\n"
f"*Stake per trade:* `{val['stake_amount']} {val['stake_currency']}`\n"
f"*Max open Trades:* `{val['max_open_trades']}`\n"
f"*Minimum ROI:* `{val['minimal_roi']}`\n"
f"*Ask strategy:* ```\n{json.dumps(val['ask_strategy'])}```\n"
f"*Bid strategy:* ```\n{json.dumps(val['bid_strategy'])}```\n"
f"{sl_info}"
f"*Ticker Interval:* `{val['ticker_interval']}`\n"
f"*Strategy:* `{val['strategy']}`"
f"*Timeframe:* `{val['timeframe']}`\n"
f"*Strategy:* `{val['strategy']}`\n"
f"*Current state:* `{val['state']}`"
)
def _send_msg(self, msg: str, parse_mode: ParseMode = ParseMode.MARKDOWN) -> None:

View File

@@ -47,9 +47,9 @@ class Webhook(RPC):
valuedict = self._config['webhook'].get('webhooksell', None)
elif msg['type'] == RPCMessageType.SELL_CANCEL_NOTIFICATION:
valuedict = self._config['webhook'].get('webhooksellcancel', None)
elif msg['type'] in(RPCMessageType.STATUS_NOTIFICATION,
RPCMessageType.CUSTOM_NOTIFICATION,
RPCMessageType.WARNING_NOTIFICATION):
elif msg['type'] in (RPCMessageType.STATUS_NOTIFICATION,
RPCMessageType.CUSTOM_NOTIFICATION,
RPCMessageType.WARNING_NOTIFICATION):
valuedict = self._config['webhook'].get('webhookstatus', None)
else:
raise NotImplementedError('Unknown message type: {}'.format(msg['type']))

View File

@@ -12,7 +12,10 @@ class State(Enum):
"""
RUNNING = 1
STOPPED = 2
RELOAD_CONF = 3
RELOAD_CONFIG = 3
def __str__(self):
return f"{self.name.lower()}"
class RunMode(Enum):

View File

@@ -3,21 +3,22 @@ IStrategy interface
This module defines the interface to apply for strategies
"""
import logging
import warnings
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, NamedTuple, Optional, Tuple
import warnings
import arrow
from pandas import DataFrame
from freqtrade.constants import ListPairsWithTimeframes
from freqtrade.data.dataprovider import DataProvider
from freqtrade.exceptions import StrategyError, OperationalException
from freqtrade.exchange import timeframe_to_minutes
from freqtrade.persistence import Trade
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
from freqtrade.wallets import Wallets
from freqtrade.exceptions import DependencyException
logger = logging.getLogger(__name__)
@@ -60,7 +61,7 @@ class IStrategy(ABC):
Attributes you can use:
minimal_roi -> Dict: Minimal ROI designed for the strategy
stoploss -> float: optimal stoploss designed for the strategy
ticker_interval -> str: value of the timeframe (ticker interval) to use with the strategy
timeframe -> str: value of the timeframe (ticker interval) to use with the strategy
"""
# Strategy interface version
# Default to version 2
@@ -83,8 +84,9 @@ class IStrategy(ABC):
trailing_stop_positive_offset: float = 0.0
trailing_only_offset_is_reached = False
# associated ticker interval
ticker_interval: str
# associated timeframe
ticker_interval: str # DEPRECATED
timeframe: str
# Optional order types
order_types: Dict = {
@@ -104,6 +106,9 @@ class IStrategy(ABC):
# run "populate_indicators" only for new candle
process_only_new_candles: bool = False
# Disable checking the dataframe (converts the error into a warning message)
disable_dataframe_checks: bool = False
# Count of candles the strategy requires before producing valid signals
startup_candle_count: int = 0
@@ -149,7 +154,100 @@ class IStrategy(ABC):
:return: DataFrame with sell column
"""
def informative_pairs(self) -> List[Tuple[str, str]]:
def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
"""
Check buy timeout function callback.
This method can be used to override the buy-timeout.
It is called whenever a limit buy order has been created,
and is not yet fully filled.
Configuration options in `unfilledtimeout` will be verified before this,
so ensure to set these timeouts high enough.
When not implemented by a strategy, this simply returns False.
:param pair: Pair the trade is for
:param trade: trade object.
:param order: Order dictionary as returned from CCXT.
: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 cancelled.
"""
return False
def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
"""
Check sell timeout function callback.
This method can be used to override the sell-timeout.
It is called whenever a limit sell order has been created,
and is not yet fully filled.
Configuration options in `unfilledtimeout` will be verified before this,
so ensure to set these timeouts high enough.
When not implemented by a strategy, this simply returns False.
:param pair: Pair the trade is for
:param trade: trade object.
:param order: Order dictionary as returned from CCXT.
: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 cancelled.
"""
return False
def bot_loop_start(self, **kwargs) -> None:
"""
Called at the start of the bot iteration (one loop).
Might be used to perform pair-independent tasks
(e.g. gather some remote resource for comparison)
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
"""
pass
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
time_in_force: str, **kwargs) -> bool:
"""
Called right before placing a buy order.
Timing for this function is critical, so avoid doing heavy computations or
network requests in this method.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, returns True (always confirming).
:param pair: Pair that's about to be bought.
:param order_type: Order type (as configured in order_types). usually limit or market.
: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 **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
"""
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:
"""
Called right before placing a regular sell order.
Timing for this function is critical, so avoid doing heavy computations or
network requests in this method.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, returns True (always confirming).
:param pair: Pair that's about to be sold.
:param trade: trade object.
:param order_type: Order type (as configured in order_types). usually limit or market.
:param amount: Amount in quote currency.
: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 sell_reason: Sell reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
'sell_signal', 'force_sell', 'emergency_sell']
: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
"""
return True
def informative_pairs(self) -> ListPairsWithTimeframes:
"""
Define additional, informative pair/interval combinations to be cached from the exchange.
These pair/interval combinations are non-tradeable, unless they are part
@@ -162,6 +260,10 @@ class IStrategy(ABC):
"""
return []
###
# END - Intended to be overridden by strategy
###
def get_strategy_name(self) -> str:
"""
Returns strategy class name
@@ -231,6 +333,8 @@ class IStrategy(ABC):
# Defs that only make change on new candle data.
dataframe = self.analyze_ticker(dataframe, metadata)
self._last_candle_seen_per_pair[pair] = dataframe.iloc[-1]['date']
if self.dp:
self.dp._set_cached_df(pair, self.timeframe, dataframe)
else:
logger.debug("Skipping TA Analysis for already analyzed candle")
dataframe['buy'] = 0
@@ -242,14 +346,53 @@ class IStrategy(ABC):
return dataframe
def analyze_pair(self, pair: str) -> None:
"""
Fetch data for this pair from dataprovider and analyze.
Stores the dataframe into the dataprovider.
The analyzed dataframe is then accessible via `dp.get_analyzed_dataframe()`.
:param pair: Pair to analyze.
"""
if not self.dp:
raise OperationalException("DataProvider not found.")
dataframe = self.dp.ohlcv(pair, self.timeframe)
if not isinstance(dataframe, DataFrame) or dataframe.empty:
logger.warning('Empty candle (OHLCV) data for pair %s', pair)
return
try:
df_len, df_close, df_date = self.preserve_df(dataframe)
dataframe = strategy_safe_wrapper(
self._analyze_ticker_internal, message=""
)(dataframe, {'pair': pair})
self.assert_df(dataframe, df_len, df_close, df_date)
except StrategyError as error:
logger.warning(f"Unable to analyze candle (OHLCV) data for pair {pair}: {error}")
return
if dataframe.empty:
logger.warning('Empty dataframe for pair %s', pair)
return
def analyze(self, pairs: List[str]) -> None:
"""
Analyze all pairs using analyze_pair().
:param pairs: List of pairs to analyze
"""
for pair in pairs:
self.analyze_pair(pair)
@staticmethod
def preserve_df(dataframe: DataFrame) -> Tuple[int, float, datetime]:
""" keep some data for dataframes """
return len(dataframe), dataframe["close"].iloc[-1], dataframe["date"].iloc[-1]
@staticmethod
def assert_df(dataframe: DataFrame, df_len: int, df_close: float, df_date: datetime):
""" make sure data is unmodified """
def assert_df(self, dataframe: DataFrame, df_len: int, df_close: float, df_date: datetime):
"""
Ensure dataframe (length, last candle) was not modified, and has all elements we need.
"""
message = ""
if df_len != len(dataframe):
message = "length"
@@ -258,68 +401,42 @@ class IStrategy(ABC):
elif df_date != dataframe["date"].iloc[-1]:
message = "last date"
if message:
raise DependencyException("Dataframe returned from strategy has mismatching "
f"{message}.")
if self.disable_dataframe_checks:
logger.warning(f"Dataframe returned from strategy has mismatching {message}.")
else:
raise StrategyError(f"Dataframe returned from strategy has mismatching {message}.")
def get_signal(self, pair: str, interval: str, dataframe: DataFrame) -> Tuple[bool, bool]:
def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[bool, bool]:
"""
Calculates current signal based several technical analysis indicators
Calculates current signal based based on the buy / sell columns of the dataframe.
Used by Bot to get the signal to buy or sell
:param pair: pair in format ANT/BTC
:param interval: Interval to use (in min)
:param dataframe: Dataframe to analyze
:param timeframe: timeframe to use
:param dataframe: Analyzed dataframe to get signal from.
:return: (Buy, Sell) A bool-tuple indicating buy/sell signal
"""
if not isinstance(dataframe, DataFrame) or dataframe.empty:
logger.warning('Empty candle (OHLCV) data for pair %s', pair)
logger.warning(f'Empty candle (OHLCV) data for pair {pair}')
return False, False
latest_date = dataframe['date'].max()
try:
df_len, df_close, df_date = self.preserve_df(dataframe)
dataframe = self._analyze_ticker_internal(dataframe, {'pair': pair})
self.assert_df(dataframe, df_len, df_close, df_date)
except ValueError as error:
logger.warning('Unable to analyze candle (OHLCV) data for pair %s: %s',
pair, str(error))
return False, False
except DependencyException as error:
logger.warning("Unable to analyze candle (OHLCV) data for pair %s: %s",
pair, str(error))
return False, False
except Exception as error:
logger.exception(
'Unexpected error when analyzing candle (OHLCV) data for pair %s: %s',
pair,
str(error)
)
return False, False
if dataframe.empty:
logger.warning('Empty dataframe for pair %s', pair)
return False, False
latest = dataframe.loc[dataframe['date'] == latest_date].iloc[-1]
# Explicitly convert to arrow object to ensure the below comparison does not fail
latest_date = arrow.get(latest_date)
# Check if dataframe is out of date
signal_date = arrow.get(latest['date'])
interval_minutes = timeframe_to_minutes(interval)
timeframe_minutes = timeframe_to_minutes(timeframe)
offset = self.config.get('exchange', {}).get('outdated_offset', 5)
if signal_date < (arrow.utcnow().shift(minutes=-(interval_minutes * 2 + offset))):
if latest_date < (arrow.utcnow().shift(minutes=-(timeframe_minutes * 2 + offset))):
logger.warning(
'Outdated history for pair %s. Last tick is %s minutes old',
pair,
(arrow.utcnow() - signal_date).seconds // 60
pair, (arrow.utcnow() - latest_date).seconds // 60
)
return False, False
(buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1
logger.debug(
'trigger: %s (pair=%s) buy=%s sell=%s',
latest['date'],
pair,
str(buy),
str(sell)
)
logger.debug('trigger: %s (pair=%s) buy=%s sell=%s',
latest['date'], pair, str(buy), str(sell))
return buy, sell
def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool,
@@ -465,7 +582,8 @@ class IStrategy(ABC):
def ohlcvdata_to_dataframe(self, data: Dict[str, DataFrame]) -> Dict[str, DataFrame]:
"""
Creates a dataframe and populates indicators for given candle (OHLCV) data
Populates indicators for given candle (OHLCV) data (for multiple pairs)
Does not run advice_buy or advise_sell!
Used by optimize operations only, not during dry / live runs.
Using .copy() to get a fresh copy of the dataframe for every strategy run.
Has positive effects on memory usage for whatever reason - also when

View File

@@ -0,0 +1,35 @@
import logging
from freqtrade.exceptions import StrategyError
logger = logging.getLogger(__name__)
def strategy_safe_wrapper(f, message: str = "", default_retval=None, supress_error=False):
"""
Wrapper around user-provided methods and functions.
Caches all exceptions and returns either the default_retval (if it's not None) or raises
a StrategyError exception, which then needs to be handled by the calling method.
"""
def wrapper(*args, **kwargs):
try:
return f(*args, **kwargs)
except ValueError as error:
logger.warning(
f"{message}"
f"Strategy caused the following exception: {error}"
f"{f}"
)
if default_retval is None and not supress_error:
raise StrategyError(str(error)) from error
return default_retval
except Exception as error:
logger.exception(
f"{message}"
f"Unexpected error {error} calling {f}"
)
if default_retval is None and not supress_error:
raise StrategyError(str(error)) from error
return default_retval
return wrapper

View File

@@ -4,8 +4,9 @@
"stake_amount": {{ stake_amount }},
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "{{ fiat_display_currency }}",
"ticker_interval": "{{ ticker_interval }}",
"timeframe": "{{ timeframe }}",
"dry_run": {{ dry_run | lower }},
"cancel_open_orders_on_exit": false,
"unfilledtimeout": {
"buy": 10,
"sell": 30
@@ -52,6 +53,16 @@
"token": "{{ telegram_token }}",
"chat_id": "{{ telegram_chat_id }}"
},
"api_server": {
"enabled": false,
"listen_ip_address": "127.0.0.1",
"listen_port": 8080,
"verbosity": "info",
"jwt_secret_key": "somethingrandom",
"CORS_origins": [],
"username": "",
"password": ""
},
"initial_state": "running",
"forcebuy_enable": false,
"internals": {

View File

@@ -51,8 +51,8 @@ class {{ strategy }}(IStrategy):
# trailing_stop_positive = 0.01
# trailing_stop_positive_offset = 0.0 # Disabled / not configured
# Optimal ticker interval for the strategy.
ticker_interval = '5m'
# Optimal timeframe for the strategy.
timeframe = '5m'
# Run "populate_indicators()" only for new candle.
process_only_new_candles = False
@@ -137,3 +137,4 @@ class {{ strategy }}(IStrategy):
),
'sell'] = 1
return dataframe
{{ additional_methods | indent(4) }}

View File

@@ -53,7 +53,7 @@ class SampleStrategy(IStrategy):
# trailing_stop_positive_offset = 0.0 # Disabled / not configured
# Optimal ticker interval for the strategy.
ticker_interval = '5m'
timeframe = '5m'
# Run "populate_indicators()" only for new candle.
process_only_new_candles = False

View File

@@ -0,0 +1,101 @@
def bot_loop_start(self, **kwargs) -> None:
"""
Called at the start of the bot iteration (one loop).
Might be used to perform pair-independent tasks
(e.g. gather some remote ressource for comparison)
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, this simply does nothing.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
"""
pass
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
time_in_force: str, **kwargs) -> bool:
"""
Called right before placing a buy order.
Timing for this function is critical, so avoid doing heavy computations or
network requests in this method.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, returns True (always confirming).
:param pair: Pair that's about to be bought.
:param order_type: Order type (as configured in order_types). usually limit or market.
: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 **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
"""
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:
"""
Called right before placing a regular sell order.
Timing for this function is critical, so avoid doing heavy computations or
network requests in this method.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, returns True (always confirming).
:param pair: Pair that's about to be sold.
:param trade: trade object.
:param order_type: Order type (as configured in order_types). usually limit or market.
:param amount: Amount in quote currency.
: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 sell_reason: Sell reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
'sell_signal', 'force_sell', 'emergency_sell']
: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
"""
return True
def check_buy_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool:
"""
Check buy timeout function callback.
This method can be used to override the buy-timeout.
It is called whenever a limit buy order has been created,
and is not yet fully filled.
Configuration options in `unfilledtimeout` will be verified before this,
so ensure to set these timeouts high enough.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, this simply returns False.
:param pair: Pair the trade is for
:param trade: trade object.
:param order: Order dictionary as returned from CCXT.
: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 cancelled.
"""
return False
def check_sell_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool:
"""
Check sell timeout function callback.
This method can be used to override the sell-timeout.
It is called whenever a limit sell order has been created,
and is not yet fully filled.
Configuration options in `unfilledtimeout` will be verified before this,
so ensure to set these timeouts high enough.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, this simply returns False.
:param pair: Pair the trade is for
:param trade: trade object.
:param order: Order dictionary as returned from CCXT.
: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 cancelled.
"""
return False

View File

@@ -37,9 +37,7 @@ class Worker:
self._heartbeat_msg: float = 0
# Tell systemd that we completed initialization phase
if self._sd_notify:
logger.debug("sd_notify: READY=1")
self._sd_notify.notify("READY=1")
self._notify("READY=1")
def _init(self, reconfig: bool) -> None:
"""
@@ -60,11 +58,20 @@ class Worker:
self._sd_notify = sdnotify.SystemdNotifier() if \
self._config.get('internals', {}).get('sd_notify', False) else None
def _notify(self, message: str) -> None:
"""
Removes the need to verify in all occurances if sd_notify is enabled
:param message: Message to send to systemd if it's enabled.
"""
if self._sd_notify:
logger.debug(f"sd_notify: {message}")
self._sd_notify.notify(message)
def run(self) -> None:
state = None
while True:
state = self._worker(old_state=state)
if state == State.RELOAD_CONF:
if state == State.RELOAD_CONFIG:
self._reconfigure()
def _worker(self, old_state: Optional[State]) -> State:
@@ -83,23 +90,22 @@ class Worker:
if state == State.RUNNING:
self.freqtrade.startup()
if state == State.STOPPED:
self.freqtrade.check_for_open_trades()
# Reset heartbeat timestamp to log the heartbeat message at
# first throttling iteration when the state changes
self._heartbeat_msg = 0
if state == State.STOPPED:
# Ping systemd watchdog before sleeping in the stopped state
if self._sd_notify:
logger.debug("sd_notify: WATCHDOG=1\\nSTATUS=State: STOPPED.")
self._sd_notify.notify("WATCHDOG=1\nSTATUS=State: STOPPED.")
self._notify("WATCHDOG=1\nSTATUS=State: STOPPED.")
self._throttle(func=self._process_stopped, throttle_secs=self._throttle_secs)
elif state == State.RUNNING:
# Ping systemd watchdog before throttling
if self._sd_notify:
logger.debug("sd_notify: WATCHDOG=1\\nSTATUS=State: RUNNING.")
self._sd_notify.notify("WATCHDOG=1\nSTATUS=State: RUNNING.")
self._notify("WATCHDOG=1\nSTATUS=State: RUNNING.")
self._throttle(func=self._process_running, throttle_secs=self._throttle_secs)
@@ -131,8 +137,7 @@ class Worker:
return result
def _process_stopped(self) -> None:
# Maybe do here something in the future...
pass
self.freqtrade.process_stopped()
def _process_running(self) -> None:
try:
@@ -155,9 +160,7 @@ class Worker:
replaces it with the new instance
"""
# Tell systemd that we initiated reconfiguration
if self._sd_notify:
logger.debug("sd_notify: RELOADING=1")
self._sd_notify.notify("RELOADING=1")
self._notify("RELOADING=1")
# Clean up current freqtrade modules
self.freqtrade.cleanup()
@@ -168,15 +171,11 @@ class Worker:
self.freqtrade.notify_status('config reloaded')
# Tell systemd that we completed reconfiguration
if self._sd_notify:
logger.debug("sd_notify: READY=1")
self._sd_notify.notify("READY=1")
self._notify("READY=1")
def exit(self) -> None:
# Tell systemd that we are exiting now
if self._sd_notify:
logger.debug("sd_notify: STOPPING=1")
self._sd_notify.notify("STOPPING=1")
self._notify("STOPPING=1")
if self.freqtrade:
self.freqtrade.notify_status('process died')