Merge branch 'develop' into pr/eatrisno/4308
This commit is contained in:
@@ -14,18 +14,18 @@ ARGS_COMMON = ["verbosity", "logfile", "version", "config", "datadir", "user_dat
|
||||
|
||||
ARGS_STRATEGY = ["strategy", "strategy_path"]
|
||||
|
||||
ARGS_TRADE = ["db_url", "sd_notify", "dry_run"]
|
||||
ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "dry_run_wallet", "fee"]
|
||||
|
||||
ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
|
||||
"max_open_trades", "stake_amount", "fee"]
|
||||
"max_open_trades", "stake_amount", "fee", "pairs"]
|
||||
|
||||
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
|
||||
"enable_protections",
|
||||
"enable_protections", "dry_run_wallet",
|
||||
"strategy_list", "export", "exportfilename"]
|
||||
|
||||
ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
|
||||
"position_stacking", "use_max_market_positions",
|
||||
"enable_protections",
|
||||
"enable_protections", "dry_run_wallet",
|
||||
"epochs", "spaces", "print_all",
|
||||
"print_colorized", "print_json", "hyperopt_jobs",
|
||||
"hyperopt_random_state", "hyperopt_min_trades",
|
||||
@@ -60,15 +60,16 @@ ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes"]
|
||||
|
||||
ARGS_LIST_DATA = ["exchange", "dataformat_ohlcv", "pairs"]
|
||||
|
||||
ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "timerange", "download_trades", "exchange",
|
||||
"timeframes", "erase", "dataformat_ohlcv", "dataformat_trades"]
|
||||
ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "new_pairs_days", "timerange",
|
||||
"download_trades", "exchange", "timeframes", "erase", "dataformat_ohlcv",
|
||||
"dataformat_trades"]
|
||||
|
||||
ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit",
|
||||
"db_url", "trade_source", "export", "exportfilename",
|
||||
"timerange", "timeframe", "no_trades"]
|
||||
|
||||
ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url",
|
||||
"trade_source", "timeframe"]
|
||||
"trade_source", "timeframe", "plot_auto_open"]
|
||||
|
||||
ARGS_INSTALL_UI = ["erase_ui_only"]
|
||||
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from questionary import Separator, prompt
|
||||
|
||||
from freqtrade.configuration.directory_operations import chown_user_directory
|
||||
from freqtrade.constants import UNLIMITED_STAKE_AMOUNT
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import MAP_EXCHANGE_CHILDCLASS, available_exchanges
|
||||
@@ -93,10 +95,10 @@ def ask_user_config() -> Dict[str, Any]:
|
||||
"message": "Select exchange",
|
||||
"choices": [
|
||||
"binance",
|
||||
"binanceje",
|
||||
"binanceus",
|
||||
"bittrex",
|
||||
"kraken",
|
||||
"ftx",
|
||||
Separator(),
|
||||
"other",
|
||||
],
|
||||
@@ -138,6 +140,32 @@ def ask_user_config() -> Dict[str, Any]:
|
||||
"message": "Insert Telegram chat id",
|
||||
"when": lambda x: x['telegram']
|
||||
},
|
||||
{
|
||||
"type": "confirm",
|
||||
"name": "api_server",
|
||||
"message": "Do you want to enable the Rest API (includes FreqUI)?",
|
||||
"default": False,
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"name": "api_server_listen_addr",
|
||||
"message": "Insert Api server Listen Address (best left untouched default!)",
|
||||
"default": "127.0.0.1",
|
||||
"when": lambda x: x['api_server']
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"name": "api_server_username",
|
||||
"message": "Insert api-server username",
|
||||
"default": "freqtrader",
|
||||
"when": lambda x: x['api_server']
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"name": "api_server_password",
|
||||
"message": "Insert api-server password",
|
||||
"when": lambda x: x['api_server']
|
||||
},
|
||||
]
|
||||
answers = prompt(questions)
|
||||
|
||||
@@ -145,6 +173,9 @@ def ask_user_config() -> Dict[str, Any]:
|
||||
# Interrupted questionary sessions return an empty dict.
|
||||
raise OperationalException("User interrupted interactive questions.")
|
||||
|
||||
# Force JWT token to be a random string
|
||||
answers['api_server_jwt_key'] = secrets.token_hex()
|
||||
|
||||
return answers
|
||||
|
||||
|
||||
@@ -173,6 +204,9 @@ def deploy_new_config(config_path: Path, selections: Dict[str, Any]) -> None:
|
||||
arguments=selections)
|
||||
|
||||
logger.info(f"Writing config to `{config_path}`.")
|
||||
logger.info(
|
||||
"Please make sure to check the configuration contents and adjust settings to your needs.")
|
||||
|
||||
config_path.write_text(config_text)
|
||||
|
||||
|
||||
@@ -183,6 +217,7 @@ def start_new_config(args: Dict[str, Any]) -> None:
|
||||
"""
|
||||
|
||||
config_path = Path(args['config'][0])
|
||||
chown_user_directory(config_path.parent)
|
||||
if config_path.exists():
|
||||
overwrite = ask_user_overwrite(config_path)
|
||||
if overwrite:
|
||||
|
@@ -110,10 +110,15 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
help='Enforce dry-run for trading (removes Exchange secrets and simulates trades).',
|
||||
action='store_true',
|
||||
),
|
||||
"dry_run_wallet": Arg(
|
||||
'--dry-run-wallet', '--starting-balance',
|
||||
help='Starting balance, used for backtesting / hyperopt and dry-runs.',
|
||||
type=float,
|
||||
),
|
||||
# Optimize common
|
||||
"timeframe": Arg(
|
||||
'-i', '--timeframe', '--ticker-interval',
|
||||
help='Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`).',
|
||||
help='Specify timeframe (`1m`, `5m`, `30m`, `1h`, `1d`).',
|
||||
),
|
||||
"timerange": Arg(
|
||||
'--timerange',
|
||||
@@ -128,7 +133,6 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
"stake_amount": Arg(
|
||||
'--stake-amount',
|
||||
help='Override the value of the `stake_amount` configuration setting.',
|
||||
type=float,
|
||||
),
|
||||
# Backtesting
|
||||
"position_stacking": Arg(
|
||||
@@ -191,6 +195,7 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
'--hyperopt',
|
||||
help='Specify hyperopt class name which will be used by the bot.',
|
||||
metavar='NAME',
|
||||
required=False,
|
||||
),
|
||||
"hyperopt_path": Arg(
|
||||
'--hyperopt-path',
|
||||
@@ -262,7 +267,7 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
default=1,
|
||||
),
|
||||
"hyperopt_loss": Arg(
|
||||
'--hyperopt-loss',
|
||||
'--hyperopt-loss', '--hyperoptloss',
|
||||
help='Specify the class name of the hyperopt loss function class (IHyperOptLoss). '
|
||||
'Different functions can generate completely different results, '
|
||||
'since the target for optimization is different. Built-in Hyperopt-loss-functions are: '
|
||||
@@ -325,7 +330,7 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
# Script options
|
||||
"pairs": Arg(
|
||||
'-p', '--pairs',
|
||||
help='Show profits for only these pairs. Pairs are space-separated.',
|
||||
help='Limit command to these pairs. Pairs are space-separated.',
|
||||
nargs='+',
|
||||
),
|
||||
# Download data
|
||||
@@ -340,6 +345,12 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
type=check_int_positive,
|
||||
metavar='INT',
|
||||
),
|
||||
"new_pairs_days": Arg(
|
||||
'--new-pairs-days',
|
||||
help='Download data of new pairs for given number of days. Default: `%(default)s`.',
|
||||
type=check_int_positive,
|
||||
metavar='INT',
|
||||
),
|
||||
"download_trades": Arg(
|
||||
'--dl-trades',
|
||||
help='Download trades instead of OHLCV data. The bot will resample trades to the '
|
||||
@@ -422,6 +433,11 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
metavar='INT',
|
||||
default=750,
|
||||
),
|
||||
"plot_auto_open": Arg(
|
||||
'--auto-open',
|
||||
help='Automatically open generated plot.',
|
||||
action='store_true',
|
||||
),
|
||||
"no_trades": Arg(
|
||||
'--no-trades',
|
||||
help='Skip using trades from backtesting file and DB.',
|
||||
|
@@ -8,11 +8,11 @@ from freqtrade.configuration import TimeRange, setup_utils_configuration
|
||||
from freqtrade.data.converter import convert_ohlcv_format, convert_trades_format
|
||||
from freqtrade.data.history import (convert_trades_to_ohlcv, refresh_backtest_ohlcv_data,
|
||||
refresh_backtest_trades_data)
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import timeframe_to_minutes
|
||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||
from freqtrade.resolvers import ExchangeResolver
|
||||
from freqtrade.state import RunMode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,8 +62,8 @@ def start_download_data(args: Dict[str, Any]) -> None:
|
||||
if config.get('download_trades'):
|
||||
pairs_not_available = refresh_backtest_trades_data(
|
||||
exchange, pairs=expanded_pairs, datadir=config['datadir'],
|
||||
timerange=timerange, erase=bool(config.get('erase')),
|
||||
data_format=config['dataformat_trades'])
|
||||
timerange=timerange, new_pairs_days=config['new_pairs_days'],
|
||||
erase=bool(config.get('erase')), data_format=config['dataformat_trades'])
|
||||
|
||||
# Convert downloaded trade data to different timeframes
|
||||
convert_trades_to_ohlcv(
|
||||
@@ -75,8 +75,9 @@ def start_download_data(args: Dict[str, Any]) -> None:
|
||||
else:
|
||||
pairs_not_available = refresh_backtest_ohlcv_data(
|
||||
exchange, pairs=expanded_pairs, timeframes=config['timeframes'],
|
||||
datadir=config['datadir'], timerange=timerange, erase=bool(config.get('erase')),
|
||||
data_format=config['dataformat_ohlcv'])
|
||||
datadir=config['datadir'], timerange=timerange,
|
||||
new_pairs_days=config['new_pairs_days'],
|
||||
erase=bool(config.get('erase')), data_format=config['dataformat_ohlcv'])
|
||||
|
||||
except KeyboardInterrupt:
|
||||
sys.exit("SIGINT received, aborting ...")
|
||||
|
@@ -8,9 +8,9 @@ import requests
|
||||
from freqtrade.configuration import setup_utils_configuration
|
||||
from freqtrade.configuration.directory_operations import copy_sample_files, create_userdata_dir
|
||||
from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGIES
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import render_template, render_template_with_fallback
|
||||
from freqtrade.state import RunMode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@@ -6,8 +6,9 @@ from colorama import init as colorama_init
|
||||
|
||||
from freqtrade.configuration import setup_utils_configuration
|
||||
from freqtrade.data.btanalysis import get_latest_hyperopt_file
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.state import RunMode
|
||||
from freqtrade.optimize.optimize_reports import show_backtest_result
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,7 +18,7 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None:
|
||||
"""
|
||||
List hyperopt epochs previously evaluated
|
||||
"""
|
||||
from freqtrade.optimize.hyperopt import Hyperopt
|
||||
from freqtrade.optimize.hyperopt_tools import HyperoptTools
|
||||
|
||||
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
|
||||
|
||||
@@ -47,7 +48,7 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None:
|
||||
config.get('hyperoptexportfilename'))
|
||||
|
||||
# Previous evaluations
|
||||
epochs = Hyperopt.load_previous_results(results_file)
|
||||
epochs = HyperoptTools.load_previous_results(results_file)
|
||||
total_epochs = len(epochs)
|
||||
|
||||
epochs = hyperopt_filter_epochs(epochs, filteroptions)
|
||||
@@ -57,18 +58,19 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None:
|
||||
|
||||
if not export_csv:
|
||||
try:
|
||||
print(Hyperopt.get_result_table(config, epochs, total_epochs,
|
||||
not filteroptions['only_best'], print_colorized, 0))
|
||||
print(HyperoptTools.get_result_table(config, epochs, total_epochs,
|
||||
not filteroptions['only_best'],
|
||||
print_colorized, 0))
|
||||
except KeyboardInterrupt:
|
||||
print('User interrupted..')
|
||||
|
||||
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)
|
||||
HyperoptTools.show_epoch_details(results, total_epochs, print_json, no_header)
|
||||
|
||||
if epochs and export_csv:
|
||||
Hyperopt.export_csv_file(
|
||||
HyperoptTools.export_csv_file(
|
||||
config, epochs, total_epochs, not filteroptions['only_best'], export_csv
|
||||
)
|
||||
|
||||
@@ -77,7 +79,7 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Show details of a hyperopt epoch previously evaluated
|
||||
"""
|
||||
from freqtrade.optimize.hyperopt import Hyperopt
|
||||
from freqtrade.optimize.hyperopt_tools import HyperoptTools
|
||||
|
||||
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
|
||||
|
||||
@@ -105,7 +107,7 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None:
|
||||
}
|
||||
|
||||
# Previous evaluations
|
||||
epochs = Hyperopt.load_previous_results(results_file)
|
||||
epochs = HyperoptTools.load_previous_results(results_file)
|
||||
total_epochs = len(epochs)
|
||||
|
||||
epochs = hyperopt_filter_epochs(epochs, filteroptions)
|
||||
@@ -124,18 +126,26 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None:
|
||||
|
||||
if epochs:
|
||||
val = epochs[n]
|
||||
Hyperopt.print_epoch_details(val, total_epochs, print_json, no_header,
|
||||
header_str="Epoch details")
|
||||
|
||||
metrics = val['results_metrics']
|
||||
if 'strategy_name' in metrics:
|
||||
show_backtest_result(metrics['strategy_name'], metrics,
|
||||
metrics['stake_currency'])
|
||||
|
||||
HyperoptTools.show_epoch_details(val, total_epochs, print_json, no_header,
|
||||
header_str="Epoch details")
|
||||
|
||||
|
||||
def hyperopt_filter_epochs(epochs: List, filteroptions: dict) -> List:
|
||||
"""
|
||||
Filter our items from the list of hyperopt results
|
||||
TODO: after 2021.5 remove all "legacy" mode queries.
|
||||
"""
|
||||
if filteroptions['only_best']:
|
||||
epochs = [x for x in epochs if x['is_best']]
|
||||
if filteroptions['only_profitable']:
|
||||
epochs = [x for x in epochs if x['results_metrics']['profit'] > 0]
|
||||
epochs = [x for x in epochs if x['results_metrics'].get(
|
||||
'profit', x['results_metrics'].get('profit_total', 0)) > 0]
|
||||
|
||||
epochs = _hyperopt_filter_epochs_trade_count(epochs, filteroptions)
|
||||
|
||||
@@ -152,34 +162,59 @@ def hyperopt_filter_epochs(epochs: List, filteroptions: dict) -> List:
|
||||
return epochs
|
||||
|
||||
|
||||
def _hyperopt_filter_epochs_trade(epochs: List, trade_count: int):
|
||||
"""
|
||||
Filter epochs with trade-counts > trades
|
||||
"""
|
||||
return [
|
||||
x for x in epochs
|
||||
if x['results_metrics'].get(
|
||||
'trade_count', x['results_metrics'].get('total_trades', 0)
|
||||
) > trade_count
|
||||
]
|
||||
|
||||
|
||||
def _hyperopt_filter_epochs_trade_count(epochs: List, filteroptions: dict) -> List:
|
||||
|
||||
if filteroptions['filter_min_trades'] > 0:
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['trade_count'] > filteroptions['filter_min_trades']
|
||||
]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, filteroptions['filter_min_trades'])
|
||||
|
||||
if filteroptions['filter_max_trades'] > 0:
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['trade_count'] < filteroptions['filter_max_trades']
|
||||
if x['results_metrics'].get(
|
||||
'trade_count', x['results_metrics'].get('total_trades')
|
||||
) < filteroptions['filter_max_trades']
|
||||
]
|
||||
return epochs
|
||||
|
||||
|
||||
def _hyperopt_filter_epochs_duration(epochs: List, filteroptions: dict) -> List:
|
||||
|
||||
def get_duration_value(x):
|
||||
# Duration in minutes ...
|
||||
if 'duration' in x['results_metrics']:
|
||||
return x['results_metrics']['duration']
|
||||
else:
|
||||
# New mode
|
||||
if 'holding_avg_s' in x['results_metrics']:
|
||||
avg = x['results_metrics']['holding_avg_s']
|
||||
return avg // 60
|
||||
raise OperationalException(
|
||||
"Holding-average not available. Please omit the filter on average time, "
|
||||
"or rerun hyperopt with this version")
|
||||
|
||||
if filteroptions['filter_min_avg_time'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['duration'] > filteroptions['filter_min_avg_time']
|
||||
if get_duration_value(x) > filteroptions['filter_min_avg_time']
|
||||
]
|
||||
if filteroptions['filter_max_avg_time'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['duration'] < filteroptions['filter_max_avg_time']
|
||||
if get_duration_value(x) < filteroptions['filter_max_avg_time']
|
||||
]
|
||||
|
||||
return epochs
|
||||
@@ -188,28 +223,36 @@ def _hyperopt_filter_epochs_duration(epochs: List, filteroptions: dict) -> List:
|
||||
def _hyperopt_filter_epochs_profit(epochs: List, filteroptions: dict) -> List:
|
||||
|
||||
if filteroptions['filter_min_avg_profit'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['avg_profit'] > filteroptions['filter_min_avg_profit']
|
||||
if x['results_metrics'].get(
|
||||
'avg_profit', x['results_metrics'].get('profit_mean', 0) * 100
|
||||
) > filteroptions['filter_min_avg_profit']
|
||||
]
|
||||
if filteroptions['filter_max_avg_profit'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['avg_profit'] < filteroptions['filter_max_avg_profit']
|
||||
if x['results_metrics'].get(
|
||||
'avg_profit', x['results_metrics'].get('profit_mean', 0) * 100
|
||||
) < filteroptions['filter_max_avg_profit']
|
||||
]
|
||||
if filteroptions['filter_min_total_profit'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['profit'] > filteroptions['filter_min_total_profit']
|
||||
if x['results_metrics'].get(
|
||||
'profit', x['results_metrics'].get('profit_total_abs', 0)
|
||||
) > filteroptions['filter_min_total_profit']
|
||||
]
|
||||
if filteroptions['filter_max_total_profit'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
epochs = [
|
||||
x for x in epochs
|
||||
if x['results_metrics']['profit'] < filteroptions['filter_max_total_profit']
|
||||
if x['results_metrics'].get(
|
||||
'profit', x['results_metrics'].get('profit_total_abs', 0)
|
||||
) < filteroptions['filter_max_total_profit']
|
||||
]
|
||||
return epochs
|
||||
|
||||
@@ -217,11 +260,11 @@ def _hyperopt_filter_epochs_profit(epochs: List, filteroptions: dict) -> List:
|
||||
def _hyperopt_filter_epochs_objective(epochs: List, filteroptions: dict) -> List:
|
||||
|
||||
if filteroptions['filter_min_objective'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
|
||||
epochs = [x for x in epochs if x['loss'] < filteroptions['filter_min_objective']]
|
||||
if filteroptions['filter_max_objective'] is not None:
|
||||
epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0]
|
||||
epochs = _hyperopt_filter_epochs_trade(epochs, 0)
|
||||
|
||||
epochs = [x for x in epochs if x['loss'] > filteroptions['filter_max_objective']]
|
||||
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import csv
|
||||
import logging
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -12,11 +11,11 @@ from tabulate import tabulate
|
||||
|
||||
from freqtrade.configuration import setup_utils_configuration
|
||||
from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGIES
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import available_exchanges, ccxt_exchanges, market_is_active
|
||||
from freqtrade.exchange import market_is_active, validate_exchanges
|
||||
from freqtrade.misc import plural
|
||||
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
|
||||
from freqtrade.state import RunMode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,14 +27,18 @@ def start_list_exchanges(args: Dict[str, Any]) -> None:
|
||||
:param args: Cli args from Arguments()
|
||||
:return: None
|
||||
"""
|
||||
exchanges = ccxt_exchanges() if args['list_exchanges_all'] else available_exchanges()
|
||||
exchanges = validate_exchanges(args['list_exchanges_all'])
|
||||
|
||||
if args['print_one_column']:
|
||||
print('\n'.join(exchanges))
|
||||
print('\n'.join([e[0] for e in exchanges]))
|
||||
else:
|
||||
if args['list_exchanges_all']:
|
||||
print(f"All exchanges supported by the ccxt library: {', '.join(exchanges)}")
|
||||
print("All exchanges supported by the ccxt library:")
|
||||
else:
|
||||
print(f"Exchanges available for Freqtrade: {', '.join(exchanges)}")
|
||||
print("Exchanges available for Freqtrade:")
|
||||
exchanges = [e for e in exchanges if e[1] is not False]
|
||||
|
||||
print(tabulate(exchanges, headers=['Exchange name', 'Valid', 'reason']))
|
||||
|
||||
|
||||
def _print_objs_tabular(objs: List, print_colorized: bool) -> None:
|
||||
@@ -50,15 +53,21 @@ def _print_objs_tabular(objs: List, print_colorized: bool) -> None:
|
||||
reset = ''
|
||||
|
||||
names = [s['name'] for s in objs]
|
||||
objss_to_print = [{
|
||||
objs_to_print = [{
|
||||
'name': s['name'] if s['name'] else "--",
|
||||
'location': s['location'].name,
|
||||
'status': (red + "LOAD FAILED" + reset if s['class'] is None
|
||||
else "OK" if names.count(s['name']) == 1
|
||||
else yellow + "DUPLICATE NAME" + reset)
|
||||
} for s in objs]
|
||||
|
||||
print(tabulate(objss_to_print, headers='keys', tablefmt='psql', stralign='right'))
|
||||
for idx, s in enumerate(objs):
|
||||
if 'hyperoptable' in s:
|
||||
objs_to_print[idx].update({
|
||||
'hyperoptable': "Yes" if s['hyperoptable']['count'] > 0 else "No",
|
||||
'buy-Params': len(s['hyperoptable'].get('buy', [])),
|
||||
'sell-Params': len(s['hyperoptable'].get('sell', [])),
|
||||
})
|
||||
print(tabulate(objs_to_print, headers='keys', tablefmt='psql', stralign='right'))
|
||||
|
||||
|
||||
def start_list_strategies(args: Dict[str, Any]) -> None:
|
||||
@@ -71,6 +80,11 @@ def start_list_strategies(args: Dict[str, Any]) -> None:
|
||||
strategy_objs = StrategyResolver.search_all_objects(directory, not args['print_one_column'])
|
||||
# Sort alphabetically
|
||||
strategy_objs = sorted(strategy_objs, key=lambda x: x['name'])
|
||||
for obj in strategy_objs:
|
||||
if obj['class']:
|
||||
obj['hyperoptable'] = obj['class'].detect_all_parameters()
|
||||
else:
|
||||
obj['hyperoptable'] = {'count': 0}
|
||||
|
||||
if args['print_one_column']:
|
||||
print('\n'.join([s['name'] for s in strategy_objs]))
|
||||
@@ -99,7 +113,7 @@ def start_list_hyperopts(args: Dict[str, Any]) -> None:
|
||||
|
||||
def start_list_timeframes(args: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Print ticker intervals (timeframes) available on Exchange
|
||||
Print timeframes available on Exchange
|
||||
"""
|
||||
config = setup_utils_configuration(args, RunMode.UTIL_EXCHANGE)
|
||||
# Do not use timeframe set in the config
|
||||
@@ -139,7 +153,7 @@ def start_list_markets(args: Dict[str, Any], pairs_only: bool = False) -> None:
|
||||
pairs_only=pairs_only,
|
||||
active_only=active_only)
|
||||
# Sort the pairs/markets by symbol
|
||||
pairs = OrderedDict(sorted(pairs.items()))
|
||||
pairs = dict(sorted(pairs.items()))
|
||||
except Exception as e:
|
||||
raise OperationalException(f"Cannot get markets. Reason: {e}") from e
|
||||
|
||||
@@ -177,7 +191,7 @@ def start_list_markets(args: Dict[str, Any], pairs_only: bool = False) -> None:
|
||||
# human-readable formats.
|
||||
print()
|
||||
|
||||
if len(pairs):
|
||||
if pairs:
|
||||
if args.get('print_list', False):
|
||||
# print data as a list, with human-readable summary
|
||||
print(f"{summary_str}: {', '.join(pairs.keys())}.")
|
||||
|
@@ -3,8 +3,9 @@ from typing import Any, Dict
|
||||
|
||||
from freqtrade import constants
|
||||
from freqtrade.configuration import setup_utils_configuration
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
from freqtrade.state import RunMode
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import round_coin_value
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,11 +23,13 @@ def setup_optimize_configuration(args: Dict[str, Any], method: RunMode) -> Dict[
|
||||
RunMode.BACKTEST: 'backtesting',
|
||||
RunMode.HYPEROPT: 'hyperoptimization',
|
||||
}
|
||||
if (method in no_unlimited_runmodes.keys() and
|
||||
config['stake_amount'] == constants.UNLIMITED_STAKE_AMOUNT):
|
||||
raise DependencyException(
|
||||
f'The value of `stake_amount` cannot be set as "{constants.UNLIMITED_STAKE_AMOUNT}" '
|
||||
f'for {no_unlimited_runmodes[method]}')
|
||||
if method in no_unlimited_runmodes.keys():
|
||||
if (config['stake_amount'] != constants.UNLIMITED_STAKE_AMOUNT
|
||||
and config['stake_amount'] > config['dry_run_wallet']):
|
||||
wallet = round_coin_value(config['dry_run_wallet'], config['stake_currency'])
|
||||
stake = round_coin_value(config['stake_amount'], config['stake_currency'])
|
||||
raise OperationalException(f"Starting balance ({wallet}) "
|
||||
f"is smaller than stake_amount {stake}.")
|
||||
|
||||
return config
|
||||
|
||||
|
@@ -4,8 +4,8 @@ from typing import Any, Dict
|
||||
import rapidjson
|
||||
|
||||
from freqtrade.configuration import setup_utils_configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.resolvers import ExchangeResolver
|
||||
from freqtrade.state import RunMode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,7 +31,7 @@ def start_test_pairlist(args: Dict[str, Any]) -> None:
|
||||
results[curr] = pairlists.whitelist
|
||||
|
||||
for curr, pairlist in results.items():
|
||||
if not args.get('print_one_column', False):
|
||||
if not args.get('print_one_column', False) and not args.get('list_pairs_print_json', False):
|
||||
print(f"Pairs for {curr}: ")
|
||||
|
||||
if args.get('print_one_column', False):
|
||||
|
@@ -1,8 +1,8 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from freqtrade.configuration import setup_utils_configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.state import RunMode
|
||||
|
||||
|
||||
def validate_plot_args(args: Dict[str, Any]) -> None:
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import (available_exchanges, get_exchange_bad_reason, is_exchange_bad,
|
||||
is_exchange_known_ccxt, is_exchange_officially_supported)
|
||||
from freqtrade.state import RunMode
|
||||
from freqtrade.exchange import (available_exchanges, is_exchange_known_ccxt,
|
||||
is_exchange_officially_supported, validate_exchange)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -57,9 +57,13 @@ def check_exchange(config: Dict[str, Any], check_for_bad: bool = True) -> bool:
|
||||
f'{", ".join(available_exchanges())}'
|
||||
)
|
||||
|
||||
if check_for_bad and is_exchange_bad(exchange):
|
||||
raise OperationalException(f'Exchange "{exchange}" is known to not work with the bot yet. '
|
||||
f'Reason: {get_exchange_bad_reason(exchange)}')
|
||||
valid, reason = validate_exchange(exchange)
|
||||
if not valid:
|
||||
if check_for_bad:
|
||||
raise OperationalException(f'Exchange "{exchange}" will not work with Freqtrade. '
|
||||
f'Reason: {reason}')
|
||||
else:
|
||||
logger.warning(f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}')
|
||||
|
||||
if is_exchange_officially_supported(exchange):
|
||||
logger.info(f'Exchange "{exchange}" is officially supported '
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from freqtrade.state import RunMode
|
||||
from freqtrade.enums import RunMode
|
||||
|
||||
from .check_exchange import remove_credentials
|
||||
from .config_validation import validate_config_consistency
|
||||
|
@@ -6,8 +6,8 @@ from jsonschema import Draft4Validator, validators
|
||||
from jsonschema.exceptions import ValidationError, best_match
|
||||
|
||||
from freqtrade import constants
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.state import RunMode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,6 +47,8 @@ def validate_config_schema(conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||
conf_schema = deepcopy(constants.CONF_SCHEMA)
|
||||
if conf.get('runmode', RunMode.OTHER) in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
conf_schema['required'] = constants.SCHEMA_TRADE_REQUIRED
|
||||
elif conf.get('runmode', RunMode.OTHER) in (RunMode.BACKTEST, RunMode.HYPEROPT):
|
||||
conf_schema['required'] = constants.SCHEMA_BACKTEST_REQUIRED
|
||||
else:
|
||||
conf_schema['required'] = constants.SCHEMA_MINIMAL_REQUIRED
|
||||
try:
|
||||
@@ -72,6 +74,7 @@ def validate_config_consistency(conf: Dict[str, Any]) -> None:
|
||||
|
||||
# validating trailing stoploss
|
||||
_validate_trailing_stoploss(conf)
|
||||
_validate_price_config(conf)
|
||||
_validate_edge(conf)
|
||||
_validate_whitelist(conf)
|
||||
_validate_protections(conf)
|
||||
@@ -93,6 +96,19 @@ def _validate_unlimited_amount(conf: Dict[str, Any]) -> None:
|
||||
raise OperationalException("`max_open_trades` and `stake_amount` cannot both be unlimited.")
|
||||
|
||||
|
||||
def _validate_price_config(conf: Dict[str, Any]) -> None:
|
||||
"""
|
||||
When using market orders, price sides must be using the "other" side of the price
|
||||
"""
|
||||
if (conf.get('order_types', {}).get('buy') == 'market'
|
||||
and conf.get('bid_strategy', {}).get('price_side') != 'ask'):
|
||||
raise OperationalException('Market buy orders require bid_strategy.price_side = "ask".')
|
||||
|
||||
if (conf.get('order_types', {}).get('sell') == 'market'
|
||||
and conf.get('ask_strategy', {}).get('price_side') != 'bid'):
|
||||
raise OperationalException('Market sell orders require ask_strategy.price_side = "bid".')
|
||||
|
||||
|
||||
def _validate_trailing_stoploss(conf: Dict[str, Any]) -> None:
|
||||
|
||||
if conf.get('stoploss') == 0.0:
|
||||
@@ -133,11 +149,6 @@ def _validate_edge(conf: Dict[str, Any]) -> None:
|
||||
if not conf.get('edge', {}).get('enabled'):
|
||||
return
|
||||
|
||||
if conf.get('pairlist', {}).get('method') == 'VolumePairList':
|
||||
raise OperationalException(
|
||||
"Edge and VolumePairList are incompatible, "
|
||||
"Edge will override whatever pairs VolumePairlist selects."
|
||||
)
|
||||
if not conf.get('ask_strategy', {}).get('use_sell_signal', True):
|
||||
raise OperationalException(
|
||||
"Edge requires `use_sell_signal` to be True, otherwise no sells will happen."
|
||||
|
@@ -11,11 +11,11 @@ from freqtrade import constants
|
||||
from freqtrade.configuration.check_exchange import check_exchange
|
||||
from freqtrade.configuration.deprecated_settings import process_temporary_deprecated_settings
|
||||
from freqtrade.configuration.directory_operations import create_datadir, create_userdata_dir
|
||||
from freqtrade.configuration.load_config import load_config_file
|
||||
from freqtrade.configuration.load_config import load_config_file, load_file
|
||||
from freqtrade.enums import NON_UTIL_MODES, TRADING_MODES, RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.loggers import setup_logging
|
||||
from freqtrade.misc import deep_merge_dicts, json_load
|
||||
from freqtrade.state import NON_UTIL_MODES, TRADING_MODES, RunMode
|
||||
from freqtrade.misc import deep_merge_dicts
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -75,8 +75,6 @@ class Configuration:
|
||||
# Normalize config
|
||||
if 'internals' not in config:
|
||||
config['internals'] = {}
|
||||
# TODO: This can be deleted along with removal of deprecated
|
||||
# experimental settings
|
||||
if 'ask_strategy' not in config:
|
||||
config['ask_strategy'] = {}
|
||||
|
||||
@@ -108,6 +106,8 @@ class Configuration:
|
||||
|
||||
self._process_plot_options(config)
|
||||
|
||||
self._process_data_options(config)
|
||||
|
||||
# Check if the exchange set by the user is supported
|
||||
check_exchange(config, config.get('experimental', {}).get('block_bad_exchanges', True))
|
||||
|
||||
@@ -214,9 +214,6 @@ class Configuration:
|
||||
self._args_to_config(
|
||||
config, argname='enable_protections',
|
||||
logstring='Parameter --enable-protections detected, enabling Protections. ...')
|
||||
# Setting max_open_trades to infinite if -1
|
||||
if config.get('max_open_trades') == -1:
|
||||
config['max_open_trades'] = float('inf')
|
||||
|
||||
if 'use_max_market_positions' in self.args and not self.args["use_max_market_positions"]:
|
||||
config.update({'use_max_market_positions': False})
|
||||
@@ -228,11 +225,23 @@ class Configuration:
|
||||
'overriding max_open_trades to: %s ...', config.get('max_open_trades'))
|
||||
elif config['runmode'] in NON_UTIL_MODES:
|
||||
logger.info('Using max_open_trades: %s ...', config.get('max_open_trades'))
|
||||
# Setting max_open_trades to infinite if -1
|
||||
if config.get('max_open_trades') == -1:
|
||||
config['max_open_trades'] = float('inf')
|
||||
|
||||
if self.args.get('stake_amount', None):
|
||||
# Convert explicitly to float to support CLI argument for both unlimited and value
|
||||
try:
|
||||
self.args['stake_amount'] = float(self.args['stake_amount'])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self._args_to_config(config, argname='stake_amount',
|
||||
logstring='Parameter --stake-amount detected, '
|
||||
'overriding stake_amount to: {} ...')
|
||||
|
||||
self._args_to_config(config, argname='dry_run_wallet',
|
||||
logstring='Parameter --dry-run-wallet detected, '
|
||||
'overriding dry_run_wallet to: {} ...')
|
||||
self._args_to_config(config, argname='fee',
|
||||
logstring='Parameter --fee detected, '
|
||||
'setting fee to: {} ...')
|
||||
@@ -366,6 +375,9 @@ class Configuration:
|
||||
self._args_to_config(config, argname='plot_limit',
|
||||
logstring='Limiting plot to: {}')
|
||||
|
||||
self._args_to_config(config, argname='plot_auto_open',
|
||||
logstring='Parameter --auto-open detected.')
|
||||
|
||||
self._args_to_config(config, argname='trade_source',
|
||||
logstring='Using trades from: {}')
|
||||
|
||||
@@ -390,6 +402,11 @@ class Configuration:
|
||||
self._args_to_config(config, argname='dataformat_trades',
|
||||
logstring='Using "{}" to store trades data.')
|
||||
|
||||
def _process_data_options(self, config: Dict[str, Any]) -> None:
|
||||
|
||||
self._args_to_config(config, argname='new_pairs_days',
|
||||
logstring='Detected --new-pairs-days: {}')
|
||||
|
||||
def _process_runmode(self, config: Dict[str, Any]) -> None:
|
||||
|
||||
self._args_to_config(config, argname='dry_run',
|
||||
@@ -436,6 +453,7 @@ class Configuration:
|
||||
"""
|
||||
|
||||
if "pairs" in config:
|
||||
config['exchange']['pair_whitelist'] = config['pairs']
|
||||
return
|
||||
|
||||
if "pairs_file" in self.args and self.args["pairs_file"]:
|
||||
@@ -445,9 +463,8 @@ class Configuration:
|
||||
# or if pairs file is specified explicitely
|
||||
if not pairs_file.exists():
|
||||
raise OperationalException(f'No pairs file found with path "{pairs_file}".')
|
||||
with pairs_file.open('r') as f:
|
||||
config['pairs'] = json_load(f)
|
||||
config['pairs'].sort()
|
||||
config['pairs'] = load_file(pairs_file)
|
||||
config['pairs'].sort()
|
||||
return
|
||||
|
||||
if 'config' in self.args and self.args['config']:
|
||||
@@ -457,7 +474,6 @@ class Configuration:
|
||||
# Fall back to /dl_path/pairs.json
|
||||
pairs_file = config['datadir'] / 'pairs.json'
|
||||
if pairs_file.exists():
|
||||
with pairs_file.open('r') as f:
|
||||
config['pairs'] = json_load(f)
|
||||
config['pairs'] = load_file(pairs_file)
|
||||
if 'pairs' in config:
|
||||
config['pairs'].sort()
|
||||
|
@@ -24,6 +24,21 @@ def create_datadir(config: Dict[str, Any], datadir: Optional[str] = None) -> Pat
|
||||
return folder
|
||||
|
||||
|
||||
def chown_user_directory(directory: Path) -> None:
|
||||
"""
|
||||
Use Sudo to change permissions of the home-directory if necessary
|
||||
Only applies when running in docker!
|
||||
"""
|
||||
import os
|
||||
if os.environ.get('FT_APP_ENV') == 'docker':
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.check_output(
|
||||
['sudo', 'chown', '-R', 'ftuser:', str(directory.resolve())])
|
||||
except Exception:
|
||||
logger.warning(f"Could not chown {directory}")
|
||||
|
||||
|
||||
def create_userdata_dir(directory: str, create_dir: bool = False) -> Path:
|
||||
"""
|
||||
Create userdata directory structure.
|
||||
@@ -37,6 +52,7 @@ def create_userdata_dir(directory: str, create_dir: bool = False) -> Path:
|
||||
sub_dirs = ["backtest_results", "data", "hyperopts", "hyperopt_results", "logs",
|
||||
"notebooks", "plot", "strategies", ]
|
||||
folder = Path(directory)
|
||||
chown_user_directory(folder)
|
||||
if not folder.is_dir():
|
||||
if create_dir:
|
||||
folder.mkdir(parents=True)
|
||||
@@ -72,6 +88,5 @@ def copy_sample_files(directory: Path, overwrite: bool = False) -> None:
|
||||
if not overwrite:
|
||||
logger.warning(f"File `{targetfile}` exists already, not deploying sample file.")
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"File `{targetfile}` exists already, overwriting.")
|
||||
logger.warning(f"File `{targetfile}` exists already, overwriting.")
|
||||
shutil.copy(str(sourcedir / source), str(targetfile))
|
||||
|
@@ -38,6 +38,15 @@ def log_config_error_range(path: str, errmsg: str) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
def load_file(path: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
with path.open('r') as file:
|
||||
config = rapidjson.load(file, parse_mode=CONFIG_PARSE_MODE)
|
||||
except FileNotFoundError:
|
||||
raise OperationalException(f'File "{path}" not found!')
|
||||
return config
|
||||
|
||||
|
||||
def load_config_file(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Loads a config file from the given path
|
||||
|
@@ -3,10 +3,13 @@ This module contains the argument manager class
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import arrow
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,7 +44,7 @@ class TimeRange:
|
||||
self.startts = self.startts - seconds
|
||||
|
||||
def adjust_start_if_necessary(self, timeframe_secs: int, startup_candles: int,
|
||||
min_date: arrow.Arrow) -> None:
|
||||
min_date: datetime) -> None:
|
||||
"""
|
||||
Adjust startts by <startup_candles> candles.
|
||||
Applies only if no startup-candles have been available.
|
||||
@@ -52,11 +55,11 @@ class TimeRange:
|
||||
:return: None (Modifies the object in place)
|
||||
"""
|
||||
if (not self.starttype or (startup_candles
|
||||
and min_date.int_timestamp >= self.startts)):
|
||||
and min_date.timestamp() >= self.startts)):
|
||||
# If no startts was defined, or backtest-data starts at the defined backtest-date
|
||||
logger.warning("Moving start-date by %s candles to account for startup time.",
|
||||
startup_candles)
|
||||
self.startts = (min_date.int_timestamp + timeframe_secs * startup_candles)
|
||||
self.startts = int(min_date.timestamp() + timeframe_secs * startup_candles)
|
||||
self.starttype = 'date'
|
||||
|
||||
@staticmethod
|
||||
@@ -103,5 +106,8 @@ class TimeRange:
|
||||
stop = int(stops) // 1000
|
||||
else:
|
||||
stop = int(stops)
|
||||
if start > stop > 0:
|
||||
raise OperationalException(
|
||||
f'Start date is after stop date for timerange "{text}"')
|
||||
return TimeRange(stype[0], stype[1], start, stop)
|
||||
raise Exception('Incorrect syntax for timerange "%s"' % text)
|
||||
raise OperationalException(f'Incorrect syntax for timerange "{text}"')
|
||||
|
@@ -11,6 +11,7 @@ DEFAULT_EXCHANGE = 'bittrex'
|
||||
PROCESS_THROTTLE_SECS = 5 # sec
|
||||
HYPEROPT_EPOCH = 100 # epochs
|
||||
RETRY_TIMEOUT = 30 # sec
|
||||
TIMEOUT_UNITS = ['minutes', 'seconds']
|
||||
DEFAULT_DB_PROD_URL = 'sqlite:///tradesv3.sqlite'
|
||||
DEFAULT_DB_DRYRUN_URL = 'sqlite:///tradesv3.dryrun.sqlite'
|
||||
UNLIMITED_STAKE_AMOUNT = 'unlimited'
|
||||
@@ -26,7 +27,7 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss',
|
||||
AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
|
||||
'AgeFilter', 'PerformanceFilter', 'PrecisionFilter',
|
||||
'PriceFilter', 'RangeStabilityFilter', 'ShuffleFilter',
|
||||
'SpreadFilter']
|
||||
'SpreadFilter', 'VolatilityFilter']
|
||||
AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard']
|
||||
AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5']
|
||||
DRY_RUN_WALLET = 1000
|
||||
@@ -54,6 +55,11 @@ DECIMALS_PER_COIN = {
|
||||
'ETH': 5,
|
||||
}
|
||||
|
||||
DUST_PER_COIN = {
|
||||
'BTC': 0.0001,
|
||||
'ETH': 0.01
|
||||
}
|
||||
|
||||
|
||||
# Soure files with destination directories within user-directory
|
||||
USER_DATA_FILES = {
|
||||
@@ -91,6 +97,7 @@ CONF_SCHEMA = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'max_open_trades': {'type': ['integer', 'number'], 'minimum': -1},
|
||||
'new_pairs_days': {'type': 'integer', 'default': 30},
|
||||
'timeframe': {'type': 'string'},
|
||||
'stake_currency': {'type': 'string'},
|
||||
'stake_amount': {
|
||||
@@ -131,7 +138,8 @@ CONF_SCHEMA = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'buy': {'type': 'number', 'minimum': 1},
|
||||
'sell': {'type': 'number', 'minimum': 1}
|
||||
'sell': {'type': 'number', 'minimum': 1},
|
||||
'unit': {'type': 'string', 'enum': TIMEOUT_UNITS, 'default': 'minutes'}
|
||||
}
|
||||
},
|
||||
'bid_strategy': {
|
||||
@@ -160,12 +168,18 @@ CONF_SCHEMA = {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'price_side': {'type': 'string', 'enum': ORDERBOOK_SIDES, 'default': 'ask'},
|
||||
'bid_last_balance': {
|
||||
'type': 'number',
|
||||
'minimum': 0,
|
||||
'maximum': 1,
|
||||
'exclusiveMaximum': False,
|
||||
},
|
||||
'use_order_book': {'type': 'boolean'},
|
||||
'order_book_min': {'type': 'integer', 'minimum': 1},
|
||||
'order_book_max': {'type': 'integer', 'minimum': 1, 'maximum': 50},
|
||||
'use_sell_signal': {'type': 'boolean'},
|
||||
'sell_profit_only': {'type': 'boolean'},
|
||||
'sell_profit_offset': {'type': 'number', 'minimum': 0.0},
|
||||
'sell_profit_offset': {'type': 'number'},
|
||||
'ignore_roi_if_buy_signal': {'type': 'boolean'}
|
||||
}
|
||||
},
|
||||
@@ -174,6 +188,8 @@ CONF_SCHEMA = {
|
||||
'properties': {
|
||||
'buy': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
|
||||
'sell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
|
||||
'forcesell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
|
||||
'forcebuy': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
|
||||
'emergencysell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
|
||||
'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES},
|
||||
'stoploss_on_exchange': {'type': 'boolean'},
|
||||
@@ -230,20 +246,37 @@ CONF_SCHEMA = {
|
||||
'enabled': {'type': 'boolean'},
|
||||
'token': {'type': 'string'},
|
||||
'chat_id': {'type': 'string'},
|
||||
'balance_dust_level': {'type': 'number', 'minimum': 0.0},
|
||||
'notification_settings': {
|
||||
'type': 'object',
|
||||
'default': {},
|
||||
'properties': {
|
||||
'status': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'warning': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'startup': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'buy': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'sell': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'buy_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'sell_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}
|
||||
'buy_fill': {'type': 'string',
|
||||
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||
'default': 'off'
|
||||
},
|
||||
'sell': {
|
||||
'type': ['string', 'object'],
|
||||
'additionalProperties': {
|
||||
'type': 'string',
|
||||
'enum': TELEGRAM_SETTING_OPTIONS
|
||||
}
|
||||
},
|
||||
'sell_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS},
|
||||
'sell_fill': {
|
||||
'type': 'string',
|
||||
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||
'default': 'off'
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
'required': ['enabled', 'token', 'chat_id']
|
||||
'required': ['enabled', 'token', 'chat_id'],
|
||||
},
|
||||
'webhook': {
|
||||
'type': 'object',
|
||||
@@ -370,6 +403,16 @@ SCHEMA_TRADE_REQUIRED = [
|
||||
'dataformat_trades',
|
||||
]
|
||||
|
||||
SCHEMA_BACKTEST_REQUIRED = [
|
||||
'exchange',
|
||||
'max_open_trades',
|
||||
'stake_currency',
|
||||
'stake_amount',
|
||||
'dry_run_wallet',
|
||||
'dataformat_ohlcv',
|
||||
'dataformat_trades',
|
||||
]
|
||||
|
||||
SCHEMA_MINIMAL_REQUIRED = [
|
||||
'exchange',
|
||||
'dry_run',
|
||||
|
@@ -10,7 +10,7 @@ import pandas as pd
|
||||
|
||||
from freqtrade.constants import LAST_BT_RESULT_FN
|
||||
from freqtrade.misc import json_load
|
||||
from freqtrade.persistence import Trade, init_db
|
||||
from freqtrade.persistence import LocalTrade, Trade, init_db
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -156,33 +156,35 @@ def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = Non
|
||||
|
||||
data = data['strategy'][strategy]['trades']
|
||||
df = pd.DataFrame(data)
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
if not df.empty:
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
else:
|
||||
# old format - only with lists.
|
||||
df = pd.DataFrame(data, columns=BT_DATA_COLUMNS_OLD)
|
||||
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
# Create compatibility with new format
|
||||
df['profit_abs'] = df['close_rate'] - df['open_rate']
|
||||
if 'profit_ratio' not in df.columns:
|
||||
df['profit_ratio'] = df['profit_percent']
|
||||
df = df.sort_values("open_date").reset_index(drop=True)
|
||||
if not df.empty:
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
# Create compatibility with new format
|
||||
df['profit_abs'] = df['close_rate'] - df['open_rate']
|
||||
if not df.empty:
|
||||
if 'profit_ratio' not in df.columns:
|
||||
df['profit_ratio'] = df['profit_percent']
|
||||
df = df.sort_values("open_date").reset_index(drop=True)
|
||||
return df
|
||||
|
||||
|
||||
@@ -224,7 +226,7 @@ def evaluate_result_multi(results: pd.DataFrame, timeframe: str,
|
||||
return df_final[df_final['open_trades'] > max_open_trades]
|
||||
|
||||
|
||||
def trade_list_to_dataframe(trades: List[Trade]) -> pd.DataFrame:
|
||||
def trade_list_to_dataframe(trades: List[LocalTrade]) -> pd.DataFrame:
|
||||
"""
|
||||
Convert list of Trade objects to pandas Dataframe
|
||||
:param trades: List of trade objects
|
||||
@@ -337,7 +339,7 @@ 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_date and profit_ratio)
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_abs)
|
||||
: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.
|
||||
@@ -349,8 +351,8 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str,
|
||||
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_date'
|
||||
)[['profit_ratio']].sum()
|
||||
df.loc[:, col_name] = _trades_sum['profit_ratio'].cumsum()
|
||||
)[['profit_abs']].sum()
|
||||
df.loc[:, col_name] = _trades_sum['profit_abs'].cumsum()
|
||||
# Set first value to 0
|
||||
df.loc[df.iloc[0].name, col_name] = 0
|
||||
# FFill to get continuous
|
||||
@@ -360,13 +362,14 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str,
|
||||
|
||||
def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date',
|
||||
value_col: str = 'profit_ratio'
|
||||
) -> Tuple[float, pd.Timestamp, pd.Timestamp]:
|
||||
) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float]:
|
||||
"""
|
||||
Calculate max drawdown and the corresponding close dates
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_ratio)
|
||||
:param date_col: Column in DataFrame to use for dates (defaults to 'close_date')
|
||||
:param value_col: Column in DataFrame to use for values (defaults to 'profit_ratio')
|
||||
:return: Tuple (float, highdate, lowdate) with absolute max drawdown, high and low time
|
||||
:return: Tuple (float, highdate, lowdate, highvalue, lowvalue) with absolute max drawdown,
|
||||
high and low time and high and low value.
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
@@ -382,4 +385,26 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date'
|
||||
raise ValueError("No losing trade, therefore no drawdown.")
|
||||
high_date = profit_results.loc[max_drawdown_df.iloc[:idxmin]['high_value'].idxmax(), date_col]
|
||||
low_date = profit_results.loc[idxmin, date_col]
|
||||
return abs(min(max_drawdown_df['drawdown'])), high_date, low_date
|
||||
high_val = max_drawdown_df.loc[max_drawdown_df.iloc[:idxmin]
|
||||
['high_value'].idxmax(), 'cumulative']
|
||||
low_val = max_drawdown_df.loc[idxmin, 'cumulative']
|
||||
return abs(min(max_drawdown_df['drawdown'])), high_date, low_date, high_val, low_val
|
||||
|
||||
|
||||
def calculate_csum(trades: pd.DataFrame, starting_balance: float = 0) -> Tuple[float, float]:
|
||||
"""
|
||||
Calculate min/max cumsum of trades, to show if the wallet/stake amount ratio is sane
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_percent)
|
||||
:param starting_balance: Add starting balance to results, to show the wallets high / low points
|
||||
:return: Tuple (float, float) with cumsum of profit_abs
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
|
||||
csum_df = pd.DataFrame()
|
||||
csum_df['sum'] = trades['profit_abs'].cumsum()
|
||||
csum_min = csum_df['sum'].min() + starting_balance
|
||||
csum_max = csum_df['sum'].max() + starting_balance
|
||||
|
||||
return csum_min, csum_max
|
||||
|
@@ -110,28 +110,62 @@ def ohlcv_fill_up_missing_data(dataframe: DataFrame, timeframe: str, pair: str)
|
||||
df.reset_index(inplace=True)
|
||||
len_before = len(dataframe)
|
||||
len_after = len(df)
|
||||
pct_missing = (len_after - len_before) / len_before if len_before > 0 else 0
|
||||
if len_before != len_after:
|
||||
logger.info(f"Missing data fillup for {pair}: before: {len_before} - after: {len_after}")
|
||||
message = (f"Missing data fillup for {pair}: before: {len_before} - after: {len_after}"
|
||||
f" - {round(pct_missing * 100, 2)}%")
|
||||
if pct_missing > 0.01:
|
||||
logger.info(message)
|
||||
else:
|
||||
# Don't be verbose if only a small amount is missing
|
||||
logger.debug(message)
|
||||
return df
|
||||
|
||||
|
||||
def trim_dataframe(df: DataFrame, timerange, df_date_col: str = 'date') -> DataFrame:
|
||||
def trim_dataframe(df: DataFrame, timerange, df_date_col: str = 'date',
|
||||
startup_candles: int = 0) -> DataFrame:
|
||||
"""
|
||||
Trim dataframe based on given timerange
|
||||
:param df: Dataframe to trim
|
||||
:param timerange: timerange (use start and end date if available)
|
||||
:param: df_date_col: Column in the dataframe to use as Date column
|
||||
:param df_date_col: Column in the dataframe to use as Date column
|
||||
:param startup_candles: When not 0, is used instead the timerange start date
|
||||
:return: trimmed dataframe
|
||||
"""
|
||||
if timerange.starttype == 'date':
|
||||
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
df = df.loc[df[df_date_col] >= start, :]
|
||||
if startup_candles:
|
||||
# Trim candles instead of timeframe in case of given startup_candle count
|
||||
df = df.iloc[startup_candles:, :]
|
||||
else:
|
||||
if timerange.starttype == 'date':
|
||||
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
df = df.loc[df[df_date_col] >= start, :]
|
||||
if timerange.stoptype == 'date':
|
||||
stop = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
|
||||
df = df.loc[df[df_date_col] <= stop, :]
|
||||
return df
|
||||
|
||||
|
||||
def trim_dataframes(preprocessed: Dict[str, DataFrame], timerange,
|
||||
startup_candles: int) -> Dict[str, DataFrame]:
|
||||
"""
|
||||
Trim startup period from analyzed dataframes
|
||||
:param preprocessed: Dict of pair: dataframe
|
||||
:param timerange: timerange (use start and end date if available)
|
||||
:param startup_candles: Startup-candles that should be removed
|
||||
:return: Dict of trimmed dataframes
|
||||
"""
|
||||
processed: Dict[str, DataFrame] = {}
|
||||
|
||||
for pair, df in preprocessed.items():
|
||||
trimed_df = trim_dataframe(df, timerange, startup_candles=startup_candles)
|
||||
if not trimed_df.empty:
|
||||
processed[pair] = trimed_df
|
||||
else:
|
||||
logger.warning(f'{pair} has no data left after adjusting for startup candles, '
|
||||
f'skipping.')
|
||||
return processed
|
||||
|
||||
|
||||
def order_book_to_dataframe(bids: list, asks: list) -> DataFrame:
|
||||
"""
|
||||
TODO: This should get a dedicated test
|
||||
|
@@ -12,21 +12,32 @@ from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe
|
||||
from freqtrade.data.history import load_pair_history
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import ExchangeError, OperationalException
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.state import RunMode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NO_EXCHANGE_EXCEPTION = 'Exchange is not available to DataProvider.'
|
||||
MAX_DATAFRAME_CANDLES = 1000
|
||||
|
||||
|
||||
class DataProvider:
|
||||
|
||||
def __init__(self, config: dict, exchange: Exchange, pairlists=None) -> None:
|
||||
def __init__(self, config: dict, exchange: Optional[Exchange], pairlists=None) -> None:
|
||||
self._config = config
|
||||
self._exchange = exchange
|
||||
self._pairlists = pairlists
|
||||
self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {}
|
||||
self.__slice_index: Optional[int] = None
|
||||
|
||||
def _set_dataframe_max_index(self, limit_index: int):
|
||||
"""
|
||||
Limit analyzed dataframe to max specified index.
|
||||
:param limit_index: dataframe index.
|
||||
"""
|
||||
self.__slice_index = limit_index
|
||||
|
||||
def _set_cached_df(self, pair: str, timeframe: str, dataframe: DataFrame) -> None:
|
||||
"""
|
||||
@@ -45,40 +56,6 @@ class DataProvider:
|
||||
"""
|
||||
self._pairlists = pairlists
|
||||
|
||||
def refresh(self,
|
||||
pairlist: ListPairsWithTimeframes,
|
||||
helping_pairs: ListPairsWithTimeframes = None) -> None:
|
||||
"""
|
||||
Refresh data, called with each cycle
|
||||
"""
|
||||
if helping_pairs:
|
||||
self._exchange.refresh_latest_ohlcv(pairlist + helping_pairs)
|
||||
else:
|
||||
self._exchange.refresh_latest_ohlcv(pairlist)
|
||||
|
||||
@property
|
||||
def available_pairs(self) -> ListPairsWithTimeframes:
|
||||
"""
|
||||
Return a list of tuples containing (pair, timeframe) for which data is currently cached.
|
||||
Should be whitelist + open trades.
|
||||
"""
|
||||
return list(self._exchange._klines.keys())
|
||||
|
||||
def ohlcv(self, pair: str, timeframe: str = None, copy: bool = True) -> DataFrame:
|
||||
"""
|
||||
Get candle (OHLCV) data for the given pair as DataFrame
|
||||
Please use the `available_pairs` method to verify which pairs are currently cached.
|
||||
:param pair: pair to get the data for
|
||||
:param timeframe: Timeframe to get data for
|
||||
:param copy: copy dataframe before returning if True.
|
||||
Use False only for read-only operations (where the dataframe is not modified)
|
||||
"""
|
||||
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
return self._exchange.klines((pair, timeframe or self._config['timeframe']),
|
||||
copy=copy)
|
||||
else:
|
||||
return DataFrame()
|
||||
|
||||
def historic_ohlcv(self, pair: str, timeframe: str = None) -> DataFrame:
|
||||
"""
|
||||
Get stored historical candle (OHLCV) data
|
||||
@@ -111,47 +88,27 @@ class DataProvider:
|
||||
|
||||
def get_analyzed_dataframe(self, pair: str, timeframe: str) -> Tuple[DataFrame, datetime]:
|
||||
"""
|
||||
Retrieve the analyzed dataframe. Returns the full dataframe in trade mode (live / dry),
|
||||
and the last 1000 candles (up to the time evaluated at this moment) in all other modes.
|
||||
:param pair: pair to get the data for
|
||||
:param timeframe: timeframe to get data for
|
||||
:return: Tuple of (Analyzed Dataframe, lastrefreshed) for the requested pair / timeframe
|
||||
combination.
|
||||
Returns empty dataframe and Epoch 0 (1970-01-01) if no dataframe was cached.
|
||||
"""
|
||||
if (pair, timeframe) in self.__cached_pairs:
|
||||
return self.__cached_pairs[(pair, timeframe)]
|
||||
pair_key = (pair, timeframe)
|
||||
if pair_key in self.__cached_pairs:
|
||||
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
df, date = self.__cached_pairs[pair_key]
|
||||
else:
|
||||
df, date = self.__cached_pairs[pair_key]
|
||||
if self.__slice_index is not None:
|
||||
max_index = self.__slice_index
|
||||
df = df.iloc[max(0, max_index - MAX_DATAFRAME_CANDLES):max_index]
|
||||
return df, date
|
||||
else:
|
||||
|
||||
return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc))
|
||||
|
||||
def market(self, pair: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return market data for the pair
|
||||
:param pair: Pair to get the data for
|
||||
:return: Market data dict from ccxt or None if market info is not available for the pair
|
||||
"""
|
||||
return self._exchange.markets.get(pair)
|
||||
|
||||
def ticker(self, pair: str):
|
||||
"""
|
||||
Return last ticker data from exchange
|
||||
:param pair: Pair to get the data for
|
||||
:return: Ticker dict from exchange or empty dict if ticker is not available for the pair
|
||||
"""
|
||||
try:
|
||||
return self._exchange.fetch_ticker(pair)
|
||||
except ExchangeError:
|
||||
return {}
|
||||
|
||||
def orderbook(self, pair: str, maximum: int) -> Dict[str, List]:
|
||||
"""
|
||||
Fetch latest l2 orderbook data
|
||||
Warning: Does a network request - so use with common sense.
|
||||
:param pair: pair to get the data for
|
||||
:param maximum: Maximum number of orderbook entries to query
|
||||
:return: dict including bids/asks with a total of `maximum` entries.
|
||||
"""
|
||||
return self._exchange.fetch_l2_order_book(pair, maximum)
|
||||
|
||||
@property
|
||||
def runmode(self) -> RunMode:
|
||||
"""
|
||||
@@ -170,6 +127,89 @@ class DataProvider:
|
||||
"""
|
||||
|
||||
if self._pairlists:
|
||||
return self._pairlists.whitelist
|
||||
return self._pairlists.whitelist.copy()
|
||||
else:
|
||||
raise OperationalException("Dataprovider was not initialized with a pairlist provider.")
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
Clear pair dataframe cache.
|
||||
"""
|
||||
self.__cached_pairs = {}
|
||||
|
||||
# Exchange functions
|
||||
|
||||
def refresh(self,
|
||||
pairlist: ListPairsWithTimeframes,
|
||||
helping_pairs: ListPairsWithTimeframes = None) -> None:
|
||||
"""
|
||||
Refresh data, called with each cycle
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
if helping_pairs:
|
||||
self._exchange.refresh_latest_ohlcv(pairlist + helping_pairs)
|
||||
else:
|
||||
self._exchange.refresh_latest_ohlcv(pairlist)
|
||||
|
||||
@property
|
||||
def available_pairs(self) -> ListPairsWithTimeframes:
|
||||
"""
|
||||
Return a list of tuples containing (pair, timeframe) for which data is currently cached.
|
||||
Should be whitelist + open trades.
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
return list(self._exchange._klines.keys())
|
||||
|
||||
def ohlcv(self, pair: str, timeframe: str = None, copy: bool = True) -> DataFrame:
|
||||
"""
|
||||
Get candle (OHLCV) data for the given pair as DataFrame
|
||||
Please use the `available_pairs` method to verify which pairs are currently cached.
|
||||
:param pair: pair to get the data for
|
||||
:param timeframe: Timeframe to get data for
|
||||
:param copy: copy dataframe before returning if True.
|
||||
Use False only for read-only operations (where the dataframe is not modified)
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
return self._exchange.klines((pair, timeframe or self._config['timeframe']),
|
||||
copy=copy)
|
||||
else:
|
||||
return DataFrame()
|
||||
|
||||
def market(self, pair: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Return market data for the pair
|
||||
:param pair: Pair to get the data for
|
||||
:return: Market data dict from ccxt or None if market info is not available for the pair
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
return self._exchange.markets.get(pair)
|
||||
|
||||
def ticker(self, pair: str):
|
||||
"""
|
||||
Return last ticker data from exchange
|
||||
:param pair: Pair to get the data for
|
||||
:return: Ticker dict from exchange or empty dict if ticker is not available for the pair
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
try:
|
||||
return self._exchange.fetch_ticker(pair)
|
||||
except ExchangeError:
|
||||
return {}
|
||||
|
||||
def orderbook(self, pair: str, maximum: int) -> Dict[str, List]:
|
||||
"""
|
||||
Fetch latest l2 orderbook data
|
||||
Warning: Does a network request - so use with common sense.
|
||||
:param pair: pair to get the data for
|
||||
:param maximum: Maximum number of orderbook entries to query
|
||||
:return: dict including bids/asks with a total of `maximum` entries.
|
||||
"""
|
||||
if self._exchange is None:
|
||||
raise OperationalException(NO_EXCHANGE_EXCEPTION)
|
||||
return self._exchange.fetch_l2_order_book(pair, maximum)
|
||||
|
@@ -89,7 +89,7 @@ class HDF5DataHandler(IDataHandler):
|
||||
if timerange.starttype == 'date':
|
||||
where.append(f"date >= Timestamp({timerange.startts * 1e9})")
|
||||
if timerange.stoptype == 'date':
|
||||
where.append(f"date < Timestamp({timerange.stopts * 1e9})")
|
||||
where.append(f"date <= Timestamp({timerange.stopts * 1e9})")
|
||||
|
||||
pairdata = pd.read_hdf(filename, key=key, mode="r", where=where)
|
||||
|
||||
|
@@ -155,6 +155,7 @@ def _load_cached_data_for_updating(pair: str, timeframe: str, timerange: Optiona
|
||||
def _download_pair_history(datadir: Path,
|
||||
exchange: Exchange,
|
||||
pair: str, *,
|
||||
new_pairs_days: int = 30,
|
||||
timeframe: str = '5m',
|
||||
timerange: Optional[TimeRange] = None,
|
||||
data_handler: IDataHandler = None) -> bool:
|
||||
@@ -193,7 +194,7 @@ def _download_pair_history(datadir: Path,
|
||||
timeframe=timeframe,
|
||||
since_ms=since_ms if since_ms else
|
||||
int(arrow.utcnow().shift(
|
||||
days=-30).float_timestamp) * 1000
|
||||
days=-new_pairs_days).float_timestamp) * 1000
|
||||
)
|
||||
# TODO: Maybe move parsing to exchange class (?)
|
||||
new_dataframe = ohlcv_to_dataframe(new_data, timeframe, pair,
|
||||
@@ -223,7 +224,8 @@ def _download_pair_history(datadir: Path,
|
||||
|
||||
def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes: List[str],
|
||||
datadir: Path, timerange: Optional[TimeRange] = None,
|
||||
erase: bool = False, data_format: str = None) -> List[str]:
|
||||
new_pairs_days: int = 30, erase: bool = False,
|
||||
data_format: str = None) -> List[str]:
|
||||
"""
|
||||
Refresh stored ohlcv data for backtesting and hyperopt operations.
|
||||
Used by freqtrade download-data subcommand.
|
||||
@@ -246,12 +248,14 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
|
||||
logger.info(f'Downloading pair {pair}, interval {timeframe}.')
|
||||
_download_pair_history(datadir=datadir, exchange=exchange,
|
||||
pair=pair, timeframe=str(timeframe),
|
||||
new_pairs_days=new_pairs_days,
|
||||
timerange=timerange, data_handler=data_handler)
|
||||
return pairs_not_available
|
||||
|
||||
|
||||
def _download_trades_history(exchange: Exchange,
|
||||
pair: str, *,
|
||||
new_pairs_days: int = 30,
|
||||
timerange: Optional[TimeRange] = None,
|
||||
data_handler: IDataHandler
|
||||
) -> bool:
|
||||
@@ -261,9 +265,13 @@ def _download_trades_history(exchange: Exchange,
|
||||
"""
|
||||
try:
|
||||
|
||||
since = timerange.startts * 1000 if \
|
||||
(timerange and timerange.starttype == 'date') else int(arrow.utcnow().shift(
|
||||
days=-30).float_timestamp) * 1000
|
||||
until = None
|
||||
if (timerange and timerange.starttype == 'date'):
|
||||
since = timerange.startts * 1000
|
||||
if timerange.stoptype == 'date':
|
||||
until = timerange.stopts * 1000
|
||||
else:
|
||||
since = int(arrow.utcnow().shift(days=-new_pairs_days).float_timestamp) * 1000
|
||||
|
||||
trades = data_handler.trades_load(pair)
|
||||
|
||||
@@ -291,6 +299,7 @@ def _download_trades_history(exchange: Exchange,
|
||||
# Default since_ms to 30 days if nothing is given
|
||||
new_trades = exchange.get_historic_trades(pair=pair,
|
||||
since=since,
|
||||
until=until,
|
||||
from_id=from_id,
|
||||
)
|
||||
trades.extend(new_trades[1])
|
||||
@@ -311,8 +320,8 @@ def _download_trades_history(exchange: Exchange,
|
||||
|
||||
|
||||
def refresh_backtest_trades_data(exchange: Exchange, pairs: List[str], datadir: Path,
|
||||
timerange: TimeRange, erase: bool = False,
|
||||
data_format: str = 'jsongz') -> List[str]:
|
||||
timerange: TimeRange, new_pairs_days: int = 30,
|
||||
erase: bool = False, data_format: str = 'jsongz') -> List[str]:
|
||||
"""
|
||||
Refresh stored trades data for backtesting and hyperopt operations.
|
||||
Used by freqtrade download-data subcommand.
|
||||
@@ -333,6 +342,7 @@ def refresh_backtest_trades_data(exchange: Exchange, pairs: List[str], datadir:
|
||||
logger.info(f'Downloading trades for pair {pair}.')
|
||||
_download_trades_history(exchange=exchange,
|
||||
pair=pair,
|
||||
new_pairs_days=new_pairs_days,
|
||||
timerange=timerange,
|
||||
data_handler=data_handler)
|
||||
return pairs_not_available
|
||||
@@ -362,7 +372,7 @@ def convert_trades_to_ohlcv(pairs: List[str], timeframes: List[str],
|
||||
logger.exception(f'Could not convert {pair} to OHLCV.')
|
||||
|
||||
|
||||
def get_timerange(data: Dict[str, DataFrame]) -> Tuple[arrow.Arrow, arrow.Arrow]:
|
||||
def get_timerange(data: Dict[str, DataFrame]) -> Tuple[datetime, datetime]:
|
||||
"""
|
||||
Get the maximum common timerange for the given backtest data.
|
||||
|
||||
@@ -370,7 +380,7 @@ def get_timerange(data: Dict[str, DataFrame]) -> Tuple[arrow.Arrow, arrow.Arrow]
|
||||
:return: tuple containing min_date, max_date
|
||||
"""
|
||||
timeranges = [
|
||||
(arrow.get(frame['date'].min()), arrow.get(frame['date'].max()))
|
||||
(frame['date'].min().to_pydatetime(), frame['date'].max().to_pydatetime())
|
||||
for frame in data.values()
|
||||
]
|
||||
return (min(timeranges, key=operator.itemgetter(0))[0],
|
||||
|
@@ -1,6 +1,8 @@
|
||||
# pragma pylint: disable=W0603
|
||||
""" Edge positioning package """
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
|
||||
import arrow
|
||||
@@ -11,9 +13,11 @@ from pandas import DataFrame
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, UNLIMITED_STAKE_AMOUNT
|
||||
from freqtrade.data.history import get_timerange, load_data, refresh_data
|
||||
from freqtrade.enums import RunMode, SellType
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange.exchange import timeframe_to_seconds
|
||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||
from freqtrade.strategy.interface import SellType
|
||||
from freqtrade.strategy.interface import IStrategy
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,7 +49,7 @@ class Edge:
|
||||
|
||||
self.config = config
|
||||
self.exchange = exchange
|
||||
self.strategy = strategy
|
||||
self.strategy: IStrategy = strategy
|
||||
|
||||
self.edge_config = self.config.get('edge', {})
|
||||
self._cached_pairs: Dict[str, Any] = {} # Keeps a list of pairs
|
||||
@@ -81,12 +85,16 @@ class Edge:
|
||||
if config.get('fee'):
|
||||
self.fee = config['fee']
|
||||
else:
|
||||
self.fee = self.exchange.get_fee(symbol=expand_pairlist(
|
||||
self.config['exchange']['pair_whitelist'], list(self.exchange.markets))[0])
|
||||
try:
|
||||
self.fee = self.exchange.get_fee(symbol=expand_pairlist(
|
||||
self.config['exchange']['pair_whitelist'], list(self.exchange.markets))[0])
|
||||
except IndexError:
|
||||
self.fee = None
|
||||
|
||||
def calculate(self, pairs: List[str]) -> bool:
|
||||
if self.fee is None and pairs:
|
||||
self.fee = self.exchange.get_fee(pairs[0])
|
||||
|
||||
def calculate(self) -> bool:
|
||||
pairs = expand_pairlist(self.config['exchange']['pair_whitelist'],
|
||||
list(self.exchange.markets))
|
||||
heartbeat = self.edge_config.get('process_throttle_secs')
|
||||
|
||||
if (self._last_updated > 0) and (
|
||||
@@ -98,13 +106,33 @@ class Edge:
|
||||
logger.info('Using local backtesting data (using whitelist in given config) ...')
|
||||
|
||||
if self._refresh_pairs:
|
||||
timerange_startup = deepcopy(self._timerange)
|
||||
timerange_startup.subtract_start(timeframe_to_seconds(
|
||||
self.strategy.timeframe) * self.strategy.startup_candle_count)
|
||||
refresh_data(
|
||||
datadir=self.config['datadir'],
|
||||
pairs=pairs,
|
||||
exchange=self.exchange,
|
||||
timeframe=self.strategy.timeframe,
|
||||
timerange=self._timerange,
|
||||
timerange=timerange_startup,
|
||||
data_format=self.config.get('dataformat_ohlcv', 'json'),
|
||||
)
|
||||
# Download informative pairs too
|
||||
res = defaultdict(list)
|
||||
for p, t in self.strategy.informative_pairs():
|
||||
res[t].append(p)
|
||||
for timeframe, inf_pairs in res.items():
|
||||
timerange_startup = deepcopy(self._timerange)
|
||||
timerange_startup.subtract_start(timeframe_to_seconds(
|
||||
timeframe) * self.strategy.startup_candle_count)
|
||||
refresh_data(
|
||||
datadir=self.config['datadir'],
|
||||
pairs=inf_pairs,
|
||||
exchange=self.exchange,
|
||||
timeframe=timeframe,
|
||||
timerange=timerange_startup,
|
||||
data_format=self.config.get('dataformat_ohlcv', 'json'),
|
||||
)
|
||||
|
||||
data = load_data(
|
||||
datadir=self.config['datadir'],
|
||||
@@ -120,8 +148,11 @@ class Edge:
|
||||
self._cached_pairs = {}
|
||||
logger.critical("No data found. Edge is stopped ...")
|
||||
return False
|
||||
|
||||
# Fake run-mode to Edge
|
||||
prior_rm = self.config['runmode']
|
||||
self.config['runmode'] = RunMode.EDGE
|
||||
preprocessed = self.strategy.ohlcvdata_to_dataframe(data)
|
||||
self.config['runmode'] = prior_rm
|
||||
|
||||
# Print timeframe
|
||||
min_date, max_date = get_timerange(preprocessed)
|
||||
@@ -178,7 +209,7 @@ class Edge:
|
||||
if pair in self._cached_pairs:
|
||||
return self._cached_pairs[pair].stoploss
|
||||
else:
|
||||
logger.warning('tried to access stoploss of a non-existing pair, '
|
||||
logger.warning(f'Tried to access stoploss of non-existing pair {pair}, '
|
||||
'strategy stoploss is returned instead.')
|
||||
return self.strategy.stoploss
|
||||
|
||||
@@ -209,7 +240,7 @@ class Edge:
|
||||
|
||||
return self._final_pairs
|
||||
|
||||
def accepted_pairs(self) -> list:
|
||||
def accepted_pairs(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
return a list of accepted pairs along with their winrate, expectancy and stoploss
|
||||
"""
|
||||
|
6
freqtrade/enums/__init__.py
Normal file
6
freqtrade/enums/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# flake8: noqa: F401
|
||||
from freqtrade.enums.rpcmessagetype import RPCMessageType
|
||||
from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode
|
||||
from freqtrade.enums.selltype import SellType
|
||||
from freqtrade.enums.signaltype import SignalType
|
||||
from freqtrade.enums.state import State
|
19
freqtrade/enums/rpcmessagetype.py
Normal file
19
freqtrade/enums/rpcmessagetype.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RPCMessageType(Enum):
|
||||
STATUS = 'status'
|
||||
WARNING = 'warning'
|
||||
STARTUP = 'startup'
|
||||
BUY = 'buy'
|
||||
BUY_FILL = 'buy_fill'
|
||||
BUY_CANCEL = 'buy_cancel'
|
||||
SELL = 'sell'
|
||||
SELL_FILL = 'sell_fill'
|
||||
SELL_CANCEL = 'sell_cancel'
|
||||
|
||||
def __repr__(self):
|
||||
return self.value
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
@@ -1,23 +1,6 @@
|
||||
# pragma pylint: disable=too-few-public-methods
|
||||
|
||||
"""
|
||||
Bot state constant
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class State(Enum):
|
||||
"""
|
||||
Bot application states
|
||||
"""
|
||||
RUNNING = 1
|
||||
STOPPED = 2
|
||||
RELOAD_CONFIG = 3
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name.lower()}"
|
||||
|
||||
|
||||
class RunMode(Enum):
|
||||
"""
|
||||
Bot running mode (backtest, hyperopt, ...)
|
20
freqtrade/enums/selltype.py
Normal file
20
freqtrade/enums/selltype.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SellType(Enum):
|
||||
"""
|
||||
Enum to distinguish between sell reasons
|
||||
"""
|
||||
ROI = "roi"
|
||||
STOP_LOSS = "stop_loss"
|
||||
STOPLOSS_ON_EXCHANGE = "stoploss_on_exchange"
|
||||
TRAILING_STOP_LOSS = "trailing_stop_loss"
|
||||
SELL_SIGNAL = "sell_signal"
|
||||
FORCE_SELL = "force_sell"
|
||||
EMERGENCY_SELL = "emergency_sell"
|
||||
CUSTOM_SELL = "custom_sell"
|
||||
NONE = ""
|
||||
|
||||
def __str__(self):
|
||||
# explicitly convert to String to help with exporting data.
|
||||
return self.value
|
9
freqtrade/enums/signaltype.py
Normal file
9
freqtrade/enums/signaltype.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SignalType(Enum):
|
||||
"""
|
||||
Enum to distinguish between buy and sell signals
|
||||
"""
|
||||
BUY = "buy"
|
||||
SELL = "sell"
|
13
freqtrade/enums/state.py
Normal file
13
freqtrade/enums/state.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class State(Enum):
|
||||
"""
|
||||
Bot application states
|
||||
"""
|
||||
RUNNING = 1
|
||||
STOPPED = 2
|
||||
RELOAD_CONFIG = 3
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name.lower()}"
|
@@ -7,11 +7,14 @@ from freqtrade.exchange.bibox import Bibox
|
||||
from freqtrade.exchange.binance import Binance
|
||||
from freqtrade.exchange.bittrex import Bittrex
|
||||
from freqtrade.exchange.bybit import Bybit
|
||||
from freqtrade.exchange.coinbasepro import Coinbasepro
|
||||
from freqtrade.exchange.exchange import (available_exchanges, ccxt_exchanges,
|
||||
get_exchange_bad_reason, is_exchange_bad,
|
||||
is_exchange_known_ccxt, is_exchange_officially_supported,
|
||||
market_is_active, timeframe_to_minutes, timeframe_to_msecs,
|
||||
timeframe_to_next_date, timeframe_to_prev_date,
|
||||
timeframe_to_seconds)
|
||||
timeframe_to_seconds, validate_exchange,
|
||||
validate_exchanges)
|
||||
from freqtrade.exchange.ftx import Ftx
|
||||
from freqtrade.exchange.hitbtc import Hitbtc
|
||||
from freqtrade.exchange.kraken import Kraken
|
||||
from freqtrade.exchange.kucoin import Kucoin
|
||||
|
@@ -52,7 +52,7 @@ class Binance(Exchange):
|
||||
'In stoploss limit order, stop price should be more than limit price')
|
||||
|
||||
if self._config['dry_run']:
|
||||
dry_order = self.dry_run_order(
|
||||
dry_order = self.create_dry_run_order(
|
||||
pair, ordertype, "sell", amount, stop_price)
|
||||
return dry_order
|
||||
|
||||
|
@@ -12,12 +12,14 @@ class Bittrex(Exchange):
|
||||
"""
|
||||
Bittrex exchange class. Contains adjustments needed for Freqtrade to work
|
||||
with this exchange.
|
||||
|
||||
Please note that this exchange is not included in the list of exchanges
|
||||
officially supported by the Freqtrade development team. So some features
|
||||
may still not work as expected.
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit_per_timeframe": {
|
||||
'1m': 1440,
|
||||
'5m': 288,
|
||||
'1h': 744,
|
||||
'1d': 365,
|
||||
},
|
||||
"l2_limit_range": [1, 25, 500],
|
||||
}
|
||||
|
@@ -18,7 +18,6 @@ class Bybit(Exchange):
|
||||
may still not work as expected.
|
||||
"""
|
||||
|
||||
# fetchCurrencies API point requires authentication for Bybit,
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 200,
|
||||
}
|
||||
|
23
freqtrade/exchange/coinbasepro.py
Normal file
23
freqtrade/exchange/coinbasepro.py
Normal file
@@ -0,0 +1,23 @@
|
||||
""" CoinbasePro exchange subclass """
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from freqtrade.exchange import Exchange
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Coinbasepro(Exchange):
|
||||
"""
|
||||
CoinbasePro exchange class. Contains adjustments needed for Freqtrade to work
|
||||
with this exchange.
|
||||
|
||||
Please note that this exchange is not included in the list of exchanges
|
||||
officially supported by the Freqtrade development team. So some features
|
||||
may still not work as expected.
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 300,
|
||||
}
|
@@ -18,78 +18,8 @@ BAD_EXCHANGES = {
|
||||
"bitmex": "Various reasons.",
|
||||
"bitstamp": "Does not provide history. "
|
||||
"Details in https://github.com/freqtrade/freqtrade/issues/1983",
|
||||
"hitbtc": "This API cannot be used with Freqtrade. "
|
||||
"Use `hitbtc2` exchange id to access this exchange.",
|
||||
"phemex": "Does not provide history. ",
|
||||
"poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders.",
|
||||
**dict.fromkeys([
|
||||
'adara',
|
||||
'anxpro',
|
||||
'bigone',
|
||||
'coinbase',
|
||||
'coinexchange',
|
||||
'coinmarketcap',
|
||||
'lykke',
|
||||
'xbtce',
|
||||
], "Does not provide timeframes. ccxt fetchOHLCV: False"),
|
||||
**dict.fromkeys([
|
||||
'bcex',
|
||||
'bit2c',
|
||||
'bitbay',
|
||||
'bitflyer',
|
||||
'bitforex',
|
||||
'bithumb',
|
||||
'bitso',
|
||||
'bitstamp1',
|
||||
'bl3p',
|
||||
'braziliex',
|
||||
'btcbox',
|
||||
'btcchina',
|
||||
'btctradeim',
|
||||
'btctradeua',
|
||||
'bxinth',
|
||||
'chilebit',
|
||||
'coincheck',
|
||||
'coinegg',
|
||||
'coinfalcon',
|
||||
'coinfloor',
|
||||
'coingi',
|
||||
'coinmate',
|
||||
'coinone',
|
||||
'coinspot',
|
||||
'coolcoin',
|
||||
'crypton',
|
||||
'deribit',
|
||||
'exmo',
|
||||
'exx',
|
||||
'flowbtc',
|
||||
'foxbit',
|
||||
'fybse',
|
||||
# 'hitbtc',
|
||||
'ice3x',
|
||||
'independentreserve',
|
||||
'indodax',
|
||||
'itbit',
|
||||
'lakebtc',
|
||||
'latoken',
|
||||
'liquid',
|
||||
'livecoin',
|
||||
'luno',
|
||||
'mixcoins',
|
||||
'negociecoins',
|
||||
'nova',
|
||||
'paymium',
|
||||
'southxchange',
|
||||
'stronghold',
|
||||
'surbitcoin',
|
||||
'therock',
|
||||
'tidex',
|
||||
'vaultoro',
|
||||
'vbtc',
|
||||
'virwox',
|
||||
'yobit',
|
||||
'zaif',
|
||||
], "Does not provide timeframes. ccxt fetchOHLCV: emulated"),
|
||||
}
|
||||
|
||||
MAP_EXCHANGE_CHILDCLASS = {
|
||||
@@ -98,6 +28,29 @@ MAP_EXCHANGE_CHILDCLASS = {
|
||||
}
|
||||
|
||||
|
||||
EXCHANGE_HAS_REQUIRED = [
|
||||
# Required / private
|
||||
'fetchOrder',
|
||||
'cancelOrder',
|
||||
'createOrder',
|
||||
# 'createLimitOrder', 'createMarketOrder',
|
||||
'fetchBalance',
|
||||
|
||||
# Public endpoints
|
||||
'loadMarkets',
|
||||
'fetchOHLCV',
|
||||
]
|
||||
|
||||
EXCHANGE_HAS_OPTIONAL = [
|
||||
# Private
|
||||
'fetchMyTrades', # Trades for order - fee detection
|
||||
# Public
|
||||
'fetchOrderBook', 'fetchL2OrderBook', 'fetchTicker', # OR for pricing
|
||||
'fetchTickers', # For volumepairlist?
|
||||
'fetchTrades', # Downloading trades data
|
||||
]
|
||||
|
||||
|
||||
def calculate_backoff(retrycount, max_retries):
|
||||
"""
|
||||
Calculate backoff
|
||||
@@ -140,7 +93,7 @@ def retrier(_func=None, retries=API_RETRY_COUNT):
|
||||
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):
|
||||
if isinstance(ex, (DDosProtection, RetryableOrderError)):
|
||||
# increasing backoff
|
||||
backoff_delay = calculate_backoff(count + 1, retries)
|
||||
logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}")
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, Invali
|
||||
OperationalException, TemporaryError)
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.exchange.common import API_FETCH_ORDER_RETRY_COUNT, retrier
|
||||
from freqtrade.misc import safe_value_fallback2
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,7 +54,7 @@ class Ftx(Exchange):
|
||||
stop_price = self.price_to_precision(pair, stop_price)
|
||||
|
||||
if self._config['dry_run']:
|
||||
dry_order = self.dry_run_order(
|
||||
dry_order = self.create_dry_run_order(
|
||||
pair, ordertype, "sell", amount, stop_price)
|
||||
return dry_order
|
||||
|
||||
@@ -63,10 +64,11 @@ class Ftx(Exchange):
|
||||
# set orderPrice to place limit order, otherwise it's a market order
|
||||
params['orderPrice'] = limit_rate
|
||||
|
||||
params['stopPrice'] = stop_price
|
||||
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)
|
||||
amount=amount, params=params)
|
||||
logger.info('stoploss order added for %s. '
|
||||
'stop price: %s.', pair, stop_price)
|
||||
return order
|
||||
@@ -91,18 +93,24 @@ class Ftx(Exchange):
|
||||
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
|
||||
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
|
||||
return self.fetch_dry_run_order(order_id)
|
||||
|
||||
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:
|
||||
if order[0].get('status') == 'closed':
|
||||
# Trigger order was triggered ...
|
||||
real_order_id = order[0].get('info', {}).get('orderId')
|
||||
|
||||
order1 = self._api.fetch_order(real_order_id, pair)
|
||||
# Fake type to stop - as this was really a stop order.
|
||||
order1['id_stop'] = order1['id']
|
||||
order1['id'] = order_id
|
||||
order1['type'] = 'stop'
|
||||
order1['status_stop'] = 'triggered'
|
||||
return order1
|
||||
return order[0]
|
||||
else:
|
||||
raise InvalidOrderException(f"Could not get stoploss order for id {order_id}")
|
||||
@@ -134,3 +142,8 @@ class Ftx(Exchange):
|
||||
f'Could not cancel order due to {e.__class__.__name__}. Message: {e}') from e
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def get_order_id_conditional(self, order: Dict[str, Any]) -> str:
|
||||
if order['type'] == 'stop':
|
||||
return safe_value_fallback2(order, order, 'id_stop', 'id')
|
||||
return order['id']
|
||||
|
23
freqtrade/exchange/hitbtc.py
Normal file
23
freqtrade/exchange/hitbtc.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from freqtrade.exchange import Exchange
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Hitbtc(Exchange):
|
||||
"""
|
||||
Hitbtc exchange class. Contains adjustments needed for Freqtrade to work
|
||||
with this exchange.
|
||||
|
||||
Please note that this exchange is not included in the list of exchanges
|
||||
officially supported by the Freqtrade development team. So some features
|
||||
may still not work as expected.
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 1000,
|
||||
"ohlcv_params": {"sort": "DESC"}
|
||||
}
|
@@ -53,6 +53,8 @@ class Kraken(Exchange):
|
||||
# x["side"], x["amount"],
|
||||
) for x in orders]
|
||||
for bal in balances:
|
||||
if not isinstance(balances[bal], dict):
|
||||
continue
|
||||
balances[bal]['used'] = sum(order[1] for order in order_list if order[0] == bal)
|
||||
balances[bal]['free'] = balances[bal]['total'] - balances[bal]['used']
|
||||
|
||||
@@ -92,7 +94,7 @@ class Kraken(Exchange):
|
||||
stop_price = self.price_to_precision(pair, stop_price)
|
||||
|
||||
if self._config['dry_run']:
|
||||
dry_order = self.dry_run_order(
|
||||
dry_order = self.create_dry_run_order(
|
||||
pair, ordertype, "sell", amount, stop_price)
|
||||
return dry_order
|
||||
|
||||
|
24
freqtrade/exchange/kucoin.py
Normal file
24
freqtrade/exchange/kucoin.py
Normal file
@@ -0,0 +1,24 @@
|
||||
""" Kucoin exchange subclass """
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from freqtrade.exchange import Exchange
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Kucoin(Exchange):
|
||||
"""
|
||||
Kucoin exchange class. Contains adjustments needed for Freqtrade to work
|
||||
with this exchange.
|
||||
|
||||
Please note that this exchange is not included in the list of exchanges
|
||||
officially supported by the Freqtrade development team. So some features
|
||||
may still not work as expected.
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"l2_limit_range": [20, 100],
|
||||
"l2_limit_range_required": False,
|
||||
}
|
@@ -10,13 +10,13 @@ from threading import Lock
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import arrow
|
||||
from cachetools import TTLCache
|
||||
|
||||
from freqtrade import __version__, constants
|
||||
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.enums import RPCMessageType, SellType, State
|
||||
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
|
||||
InvalidOrderException, PricingError)
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
|
||||
@@ -26,9 +26,8 @@ from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db
|
||||
from freqtrade.plugins.pairlistmanager import PairListManager
|
||||
from freqtrade.plugins.protectionmanager import ProtectionManager
|
||||
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.rpc import RPCManager
|
||||
from freqtrade.strategy.interface import IStrategy, SellCheckTuple
|
||||
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||
from freqtrade.wallets import Wallets
|
||||
|
||||
@@ -48,6 +47,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
:param config: configuration dict, you can use Configuration.get_config()
|
||||
to get the config dict.
|
||||
"""
|
||||
self.active_pair_whitelist: List[str] = []
|
||||
|
||||
logger.info('Starting freqtrade %s', __version__)
|
||||
|
||||
@@ -57,12 +57,6 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Init objects
|
||||
self.config = config
|
||||
|
||||
# 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 = TTLCache(maxsize=100, ttl=1800)
|
||||
self._buy_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=1800)
|
||||
|
||||
self.strategy: IStrategy = StrategyResolver.load_strategy(self.config)
|
||||
|
||||
# Check config consistency here since strategies can set certain options
|
||||
@@ -76,12 +70,19 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
PairLocks.timeframe = self.config['timeframe']
|
||||
|
||||
self.protections = ProtectionManager(self.config)
|
||||
|
||||
# RPC runs in separate threads, can start handling external commands just after
|
||||
# initialization, even before Freqtradebot has a chance to start its throttling,
|
||||
# so anything in the Freqtradebot instance should be ready (initialized), including
|
||||
# the initial state of the bot.
|
||||
# Keep this at the end of this initialization method.
|
||||
self.rpc: RPCManager = RPCManager(self)
|
||||
|
||||
self.pairlists = PairListManager(self.exchange, self.config)
|
||||
|
||||
self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists)
|
||||
|
||||
self.protections = ProtectionManager(self.config)
|
||||
|
||||
# Attach Dataprovider to Strategy baseclass
|
||||
IStrategy.dp = self.dataprovider
|
||||
# Attach Wallets to Strategy baseclass
|
||||
@@ -97,12 +98,6 @@ class FreqtradeBot(LoggingMixin):
|
||||
initial_state = self.config.get('initial_state')
|
||||
self.state = State[initial_state.upper()] if initial_state else State.STOPPED
|
||||
|
||||
# RPC runs in separate threads, can start handling external commands just after
|
||||
# initialization, even before Freqtradebot has a chance to start its throttling,
|
||||
# so anything in the Freqtradebot instance should be ready (initialized), including
|
||||
# the initial state of the bot.
|
||||
# Keep this at the end of this initialization method.
|
||||
self.rpc: RPCManager = RPCManager(self)
|
||||
# Protect sell-logic from forcesell and viceversa
|
||||
self._sell_lock = Lock()
|
||||
LoggingMixin.__init__(self, logger, timeframe_to_seconds(self.strategy.timeframe))
|
||||
@@ -113,7 +108,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
via RPC about changes in the bot status.
|
||||
"""
|
||||
self.rpc.send_msg({
|
||||
'type': RPCMessageType.STATUS_NOTIFICATION,
|
||||
'type': RPCMessageType.STATUS,
|
||||
'status': msg
|
||||
})
|
||||
|
||||
@@ -179,6 +174,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Without this, freqtrade my try to recreate stoploss_on_exchange orders
|
||||
# while selling is in process, since telegram messages arrive in an different thread.
|
||||
with self._sell_lock:
|
||||
trades = Trade.get_open_trades()
|
||||
# First process current opened trades (positions)
|
||||
self.exit_positions(trades)
|
||||
|
||||
@@ -186,7 +182,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
if self.get_free_open_trades():
|
||||
self.enter_positions()
|
||||
|
||||
Trade.session.flush()
|
||||
Trade.commit()
|
||||
|
||||
def process_stopped(self) -> None:
|
||||
"""
|
||||
@@ -204,7 +200,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
if len(open_trades) != 0:
|
||||
msg = {
|
||||
'type': RPCMessageType.WARNING_NOTIFICATION,
|
||||
'type': RPCMessageType.WARNING,
|
||||
'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' "
|
||||
@@ -224,7 +220,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
# Calculating Edge positioning
|
||||
if self.edge:
|
||||
self.edge.calculate()
|
||||
self.edge.calculate(_whitelist)
|
||||
_whitelist = self.edge.adjust(_whitelist)
|
||||
|
||||
if trades:
|
||||
@@ -266,7 +262,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
def update_closed_trades_without_assigned_fees(self):
|
||||
"""
|
||||
Update closed trades without close fees assigned.
|
||||
Only acts when Orders are in the database, otherwise the last orderid is unknown.
|
||||
Only acts when Orders are in the database, otherwise the last order-id is unknown.
|
||||
"""
|
||||
if self.config['dry_run']:
|
||||
# Updating open orders in dry-run does not make sense and will fail.
|
||||
@@ -341,7 +337,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Assume this as the open order
|
||||
trade.open_order_id = order.order_id
|
||||
if fo:
|
||||
logger.info(f"Found {order} for trade {trade}.jj")
|
||||
logger.info(f"Found {order} for trade {trade}.")
|
||||
self.update_trade_state(trade, order.order_id, fo,
|
||||
stoploss_order=order.ft_order_side == 'stoploss')
|
||||
|
||||
@@ -377,7 +373,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
if lock:
|
||||
self.log_once(f"Global pairlock active until "
|
||||
f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)}. "
|
||||
"Not creating new trades.", logger.info)
|
||||
f"Not creating new trades, reason: {lock.reason}.", logger.info)
|
||||
else:
|
||||
self.log_once("Global pairlock active. Not creating new trades.", logger.info)
|
||||
return trades_created
|
||||
@@ -393,52 +389,6 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
return trades_created
|
||||
|
||||
def get_buy_rate(self, pair: str, refresh: bool) -> float:
|
||||
"""
|
||||
Calculates bid target between current ask price and last price
|
||||
:param pair: Pair to get rate for
|
||||
:param refresh: allow cached data
|
||||
:return: float: Price
|
||||
"""
|
||||
if not refresh:
|
||||
rate = self._buy_rate_cache.get(pair)
|
||||
# Check if cache has been invalidated
|
||||
if rate:
|
||||
logger.debug(f"Using cached buy rate for {pair}.")
|
||||
return rate
|
||||
|
||||
bid_strategy = self.config.get('bid_strategy', {})
|
||||
if 'use_order_book' in bid_strategy and bid_strategy.get('use_order_book', False):
|
||||
logger.info(
|
||||
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.fetch_l2_order_book(pair, order_book_top)
|
||||
logger.debug('order_book %s', order_book)
|
||||
# top 1 = index 0
|
||||
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)
|
||||
ticker_rate = ticker[bid_strategy['price_side']]
|
||||
if ticker['last'] and ticker_rate > ticker['last']:
|
||||
balance = self.config['bid_strategy']['ask_last_balance']
|
||||
ticker_rate = ticker_rate + balance * (ticker['last'] - ticker_rate)
|
||||
used_rate = ticker_rate
|
||||
|
||||
self._buy_rate_cache[pair] = used_rate
|
||||
|
||||
return used_rate
|
||||
|
||||
def create_trade(self, pair: str) -> bool:
|
||||
"""
|
||||
Check the implemented trading strategy for buy signals.
|
||||
@@ -456,7 +406,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
lock = PairLocks.get_pair_longest_lock(pair, nowtime)
|
||||
if lock:
|
||||
self.log_once(f"Pair {pair} is still locked until "
|
||||
f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)}.",
|
||||
f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)} "
|
||||
f"due to {lock.reason}.",
|
||||
logger.info)
|
||||
else:
|
||||
self.log_once(f"Pair {pair} is still locked.", logger.info)
|
||||
@@ -472,25 +423,22 @@ class FreqtradeBot(LoggingMixin):
|
||||
(buy, sell) = self.strategy.get_signal(pair, self.strategy.timeframe, analyzed_df)
|
||||
|
||||
if buy and not sell:
|
||||
stake_amount = self.wallets.get_trade_stake_amount(pair, self.get_free_open_trades(),
|
||||
self.edge)
|
||||
stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge)
|
||||
if not stake_amount:
|
||||
logger.debug(f"Stake amount is 0, ignoring possible trade for {pair}.")
|
||||
return False
|
||||
|
||||
logger.info(f"Buy signal found: about create a new trade with stake_amount: "
|
||||
logger.info(f"Buy signal found: about create a new trade for {pair} with stake_amount: "
|
||||
f"{stake_amount} ...")
|
||||
|
||||
bid_check_dom = self.config.get('bid_strategy', {}).get('check_depth_of_market', {})
|
||||
if ((bid_check_dom.get('enabled', False)) and
|
||||
(bid_check_dom.get('bids_to_ask_delta', 0) > 0)):
|
||||
if self._check_depth_of_market_buy(pair, bid_check_dom):
|
||||
logger.info(f'Executing Buy for {pair}.')
|
||||
return self.execute_buy(pair, stake_amount)
|
||||
else:
|
||||
return False
|
||||
|
||||
logger.info(f'Executing Buy for {pair}')
|
||||
return self.execute_buy(pair, stake_amount)
|
||||
else:
|
||||
return False
|
||||
@@ -519,7 +467,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
logger.info(f"Bids to asks delta for {pair} does not satisfy condition.")
|
||||
return False
|
||||
|
||||
def execute_buy(self, pair: str, stake_amount: float, price: Optional[float] = None) -> bool:
|
||||
def execute_buy(self, pair: str, stake_amount: float, price: Optional[float] = None,
|
||||
forcebuy: bool = False) -> bool:
|
||||
"""
|
||||
Executes a limit buy for the given pair
|
||||
:param pair: pair for which we want to create a LIMIT_BUY
|
||||
@@ -531,7 +480,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
buy_limit_requested = price
|
||||
else:
|
||||
# Calculate price
|
||||
buy_limit_requested = self.get_buy_rate(pair, True)
|
||||
buy_limit_requested = self.exchange.get_buy_rate(pair, True)
|
||||
|
||||
if not buy_limit_requested:
|
||||
raise PricingError('Could not determine buy price.')
|
||||
@@ -547,9 +496,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
amount = stake_amount / buy_limit_requested
|
||||
order_type = self.strategy.order_types['buy']
|
||||
if forcebuy:
|
||||
# Forcebuy can define a different ordertype
|
||||
order_type = self.strategy.order_types.get('forcebuy', order_type)
|
||||
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
pair=pair, order_type=order_type, amount=amount, rate=buy_limit_requested,
|
||||
time_in_force=time_in_force):
|
||||
time_in_force=time_in_force, current_time=datetime.now(timezone.utc)):
|
||||
logger.info(f"User requested abortion of buying {pair}")
|
||||
return False
|
||||
amount = self.exchange.amount_to_precision(pair, amount)
|
||||
@@ -598,6 +551,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
pair=pair,
|
||||
stake_amount=stake_amount,
|
||||
amount=amount,
|
||||
is_open=True,
|
||||
amount_requested=amount_requested,
|
||||
fee_open=fee,
|
||||
fee_close=fee,
|
||||
@@ -615,8 +569,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
if order_status == 'closed':
|
||||
self.update_trade_state(trade, order_id, order)
|
||||
|
||||
Trade.session.add(trade)
|
||||
Trade.session.flush()
|
||||
Trade.query.session.add(trade)
|
||||
Trade.commit()
|
||||
|
||||
# Updating wallets
|
||||
self.wallets.update()
|
||||
@@ -627,11 +581,11 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def _notify_buy(self, trade: Trade, order_type: str) -> None:
|
||||
"""
|
||||
Sends rpc notification when a buy occured.
|
||||
Sends rpc notification when a buy occurred.
|
||||
"""
|
||||
msg = {
|
||||
'trade_id': trade.id,
|
||||
'type': RPCMessageType.BUY_NOTIFICATION,
|
||||
'type': RPCMessageType.BUY,
|
||||
'exchange': self.exchange.name.capitalize(),
|
||||
'pair': trade.pair,
|
||||
'limit': trade.open_rate,
|
||||
@@ -649,13 +603,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def _notify_buy_cancel(self, trade: Trade, order_type: str, reason: str) -> None:
|
||||
"""
|
||||
Sends rpc notification when a buy cancel occured.
|
||||
Sends rpc notification when a buy cancel occurred.
|
||||
"""
|
||||
current_rate = self.get_buy_rate(trade.pair, False)
|
||||
current_rate = self.exchange.get_buy_rate(trade.pair, False)
|
||||
|
||||
msg = {
|
||||
'trade_id': trade.id,
|
||||
'type': RPCMessageType.BUY_CANCEL_NOTIFICATION,
|
||||
'type': RPCMessageType.BUY_CANCEL,
|
||||
'exchange': self.exchange.name.capitalize(),
|
||||
'pair': trade.pair,
|
||||
'limit': trade.open_rate,
|
||||
@@ -672,6 +626,21 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Send the message
|
||||
self.rpc.send_msg(msg)
|
||||
|
||||
def _notify_buy_fill(self, trade: Trade) -> None:
|
||||
msg = {
|
||||
'trade_id': trade.id,
|
||||
'type': RPCMessageType.BUY_FILL,
|
||||
'exchange': self.exchange.name.capitalize(),
|
||||
'pair': trade.pair,
|
||||
'open_rate': trade.open_rate,
|
||||
'stake_amount': trade.stake_amount,
|
||||
'stake_currency': self.config['stake_currency'],
|
||||
'fiat_currency': self.config.get('fiat_display_currency', None),
|
||||
'amount': trade.amount,
|
||||
'open_date': trade.open_date,
|
||||
}
|
||||
self.rpc.send_msg(msg)
|
||||
|
||||
#
|
||||
# SELL / exit positions / close trades logic and methods
|
||||
#
|
||||
@@ -687,6 +656,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
if (self.strategy.order_types.get('stoploss_on_exchange') and
|
||||
self.handle_stoploss_on_exchange(trade)):
|
||||
trades_closed += 1
|
||||
Trade.commit()
|
||||
continue
|
||||
# Check if we can sell our current pair
|
||||
if trade.open_order_id is None and trade.is_open and self.handle_trade(trade):
|
||||
@@ -695,56 +665,12 @@ class FreqtradeBot(LoggingMixin):
|
||||
except DependencyException as exception:
|
||||
logger.warning('Unable to sell trade %s: %s', trade.pair, exception)
|
||||
|
||||
# Updating wallets if any trade occured
|
||||
# Updating wallets if any trade occurred
|
||||
if trades_closed:
|
||||
self.wallets.update()
|
||||
|
||||
return trades_closed
|
||||
|
||||
def _order_book_gen(self, pair: str, side: str, order_book_max: int = 1,
|
||||
order_book_min: int = 1):
|
||||
"""
|
||||
Helper generator to query orderbook in loop (used for early sell-order placing)
|
||||
"""
|
||||
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]
|
||||
|
||||
def get_sell_rate(self, pair: str, refresh: bool) -> float:
|
||||
"""
|
||||
Get sell rate - either using ticker bid or first bid based on orderbook
|
||||
The orderbook portion is only used for rpc messaging, which would otherwise fail
|
||||
for BitMex (has no bid/ask in fetch_ticker)
|
||||
or remain static in any other case since it's not updating.
|
||||
:param pair: Pair to get rate for
|
||||
:param refresh: allow cached data
|
||||
:return: Bid rate
|
||||
"""
|
||||
if not refresh:
|
||||
rate = self._sell_rate_cache.get(pair)
|
||||
# Check if cache has been invalidated
|
||||
if rate:
|
||||
logger.debug(f"Using cached sell rate for {pair}.")
|
||||
return rate
|
||||
|
||||
ask_strategy = self.config.get('ask_strategy', {})
|
||||
if ask_strategy.get('use_order_book', False):
|
||||
# This code is only used for notifications, selling uses the generator directly
|
||||
logger.info(
|
||||
f"Getting price from order book {ask_strategy['price_side'].capitalize()} side."
|
||||
)
|
||||
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
|
||||
|
||||
def handle_trade(self, trade: Trade) -> bool:
|
||||
"""
|
||||
Sells the current pair if the threshold is reached and updates the trade record.
|
||||
@@ -772,9 +698,9 @@ class FreqtradeBot(LoggingMixin):
|
||||
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)
|
||||
order_book = self.exchange._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):
|
||||
try:
|
||||
sell_rate = next(order_book)
|
||||
@@ -787,14 +713,14 @@ class FreqtradeBot(LoggingMixin):
|
||||
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
|
||||
self.exchange._sell_rate_cache[trade.pair] = sell_rate
|
||||
|
||||
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
|
||||
return True
|
||||
|
||||
else:
|
||||
logger.debug('checking sell')
|
||||
sell_rate = self.get_sell_rate(trade.pair, True)
|
||||
sell_rate = self.exchange.get_sell_rate(trade.pair, True)
|
||||
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
|
||||
return True
|
||||
|
||||
@@ -826,7 +752,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.stoploss_order_id = None
|
||||
logger.error(f'Unable to place a stoploss order on exchange. {e}')
|
||||
logger.warning('Selling the trade forcefully')
|
||||
self.execute_sell(trade, trade.stop_loss, sell_reason=SellType.EMERGENCY_SELL)
|
||||
self.execute_sell(trade, trade.stop_loss, sell_reason=SellCheckTuple(
|
||||
sell_type=SellType.EMERGENCY_SELL))
|
||||
|
||||
except ExchangeError:
|
||||
trade.stoploss_order_id = None
|
||||
@@ -889,8 +816,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
logger.warning('Stoploss order was cancelled, but unable to recreate one.')
|
||||
|
||||
# Finally we check if stoploss on exchange should be moved up because of trailing.
|
||||
if stoploss_order and (self.config.get('trailing_stop', False)
|
||||
or self.config.get('use_custom_stoploss', False)):
|
||||
# Triggered Orders are now real orders - so don't replace stoploss anymore
|
||||
if (
|
||||
stoploss_order
|
||||
and stoploss_order.get('status_stop') != 'triggered'
|
||||
and (self.config.get('trailing_stop', False)
|
||||
or self.config.get('use_custom_stoploss', False))
|
||||
):
|
||||
# if trailing stoploss is enabled we check if stoploss value has changed
|
||||
# in which case we cancel stoploss order and put another one with new
|
||||
# value immediately
|
||||
@@ -907,14 +839,15 @@ class FreqtradeBot(LoggingMixin):
|
||||
:return: None
|
||||
"""
|
||||
if self.exchange.stoploss_adjust(trade.stop_loss, order):
|
||||
# we check if the update is neccesary
|
||||
# we check if the update is necessary
|
||||
update_beat = self.strategy.order_types.get('stoploss_on_exchange_interval', 60)
|
||||
if (datetime.utcnow() - trade.stoploss_last_update).total_seconds() >= update_beat:
|
||||
# cancelling the current stoploss on exchange first
|
||||
logger.info(f"Cancelling current stoploss on exchange for pair {trade.pair} "
|
||||
f"(orderid:{order['id']}) in order to add another one ...")
|
||||
try:
|
||||
co = self.exchange.cancel_stoploss_order(order['id'], trade.pair)
|
||||
co = self.exchange.cancel_stoploss_order_with_result(order['id'], trade.pair,
|
||||
trade.amount)
|
||||
trade.update_order(co)
|
||||
except InvalidOrderException:
|
||||
logger.exception(f"Could not cancel stoploss order {order['id']} "
|
||||
@@ -931,13 +864,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
Check and execute sell
|
||||
"""
|
||||
should_sell = self.strategy.should_sell(
|
||||
trade, sell_rate, datetime.utcnow(), buy, sell,
|
||||
trade, sell_rate, datetime.now(timezone.utc), buy, sell,
|
||||
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
|
||||
)
|
||||
|
||||
if should_sell.sell_flag:
|
||||
logger.info(f'Executing Sell for {trade.pair}. Reason: {should_sell.sell_type}')
|
||||
self.execute_sell(trade, sell_rate, should_sell.sell_type)
|
||||
self.execute_sell(trade, sell_rate, should_sell)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -948,15 +881,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
timeout = self.config.get('unfilledtimeout', {}).get(side)
|
||||
ordertime = arrow.get(order['datetime']).datetime
|
||||
if timeout is not None:
|
||||
timeout_threshold = arrow.utcnow().shift(minutes=-timeout).datetime
|
||||
|
||||
timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes')
|
||||
timeout_kwargs = {timeout_unit: -timeout}
|
||||
timeout_threshold = arrow.utcnow().shift(**timeout_kwargs).datetime
|
||||
return (order['status'] == 'open' and order['side'] == side
|
||||
and ordertime < timeout_threshold)
|
||||
return False
|
||||
|
||||
def check_handle_timedout(self) -> None:
|
||||
"""
|
||||
Check if any orders are timed out and cancel if neccessary
|
||||
Check if any orders are timed out and cancel if necessary
|
||||
:param timeoutvalue: Number of minutes until order is considered timed out
|
||||
:return: None
|
||||
"""
|
||||
@@ -1008,6 +942,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
elif order['side'] == 'sell':
|
||||
self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['ALL_CANCELLED'])
|
||||
Trade.commit()
|
||||
|
||||
def handle_cancel_buy(self, trade: Trade, order: Dict, reason: str) -> bool:
|
||||
"""
|
||||
@@ -1017,13 +952,23 @@ class FreqtradeBot(LoggingMixin):
|
||||
was_trade_fully_canceled = False
|
||||
|
||||
# Cancelled orders may have the status of 'canceled' or 'closed'
|
||||
if order['status'] not in ('canceled', 'closed'):
|
||||
if order['status'] not in ('cancelled', 'canceled', 'closed'):
|
||||
filled_val = order.get('filled', 0.0) or 0.0
|
||||
filled_stake = filled_val * trade.open_rate
|
||||
minstake = self.exchange.get_min_pair_stake_amount(
|
||||
trade.pair, trade.open_rate, self.strategy.stoploss)
|
||||
|
||||
if filled_val > 0 and filled_stake < minstake:
|
||||
logger.warning(
|
||||
f"Order {trade.open_order_id} for {trade.pair} not cancelled, "
|
||||
f"as the filled amount of {filled_val} would result in an unsellable trade.")
|
||||
return False
|
||||
corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair,
|
||||
trade.amount)
|
||||
# Avoid race condition where the order could not be cancelled coz its already filled.
|
||||
# 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'):
|
||||
if corder.get('status') not in ('cancelled', 'canceled', 'closed'):
|
||||
logger.warning(f"Order {trade.open_order_id} for {trade.pair} not cancelled.")
|
||||
return False
|
||||
else:
|
||||
@@ -1126,16 +1071,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
raise DependencyException(
|
||||
f"Not enough amount to sell. Trade-amount: {amount}, Wallet: {wallet_amount}")
|
||||
|
||||
def execute_sell(self, trade: Trade, limit: float, sell_reason: SellType) -> bool:
|
||||
def execute_sell(self, trade: Trade, limit: float, sell_reason: SellCheckTuple) -> bool:
|
||||
"""
|
||||
Executes a limit sell for the given trade and limit
|
||||
:param trade: Trade instance
|
||||
:param limit: limit rate for the sell order
|
||||
:param sellreason: Reason the sell was triggered
|
||||
:param sell_reason: Reason the sell was triggered
|
||||
:return: True if it succeeds (supported) False (not supported)
|
||||
"""
|
||||
sell_type = 'sell'
|
||||
if sell_reason in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
|
||||
if sell_reason.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
|
||||
sell_type = 'stoploss'
|
||||
|
||||
# if stoploss is on exchange and we are on dry_run mode,
|
||||
@@ -1147,22 +1092,28 @@ class FreqtradeBot(LoggingMixin):
|
||||
# First cancelling stoploss on exchange ...
|
||||
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
|
||||
try:
|
||||
self.exchange.cancel_stoploss_order(trade.stoploss_order_id, trade.pair)
|
||||
co = self.exchange.cancel_stoploss_order_with_result(trade.stoploss_order_id,
|
||||
trade.pair, trade.amount)
|
||||
trade.update_order(co)
|
||||
except InvalidOrderException:
|
||||
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
|
||||
|
||||
order_type = self.strategy.order_types[sell_type]
|
||||
if sell_reason == SellType.EMERGENCY_SELL:
|
||||
if sell_reason.sell_type == SellType.EMERGENCY_SELL:
|
||||
# Emergency sells (default to market!)
|
||||
order_type = self.strategy.order_types.get("emergencysell", "market")
|
||||
if sell_reason.sell_type == SellType.FORCE_SELL:
|
||||
# Force sells (default to the sell_type defined in the strategy,
|
||||
# but we allow this value to be changed)
|
||||
order_type = self.strategy.order_types.get("forcesell", order_type)
|
||||
|
||||
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):
|
||||
time_in_force=time_in_force, sell_reason=sell_reason.sell_reason,
|
||||
current_time=datetime.now(timezone.utc)):
|
||||
logger.info(f"User requested abortion of selling {trade.pair}")
|
||||
return False
|
||||
|
||||
@@ -1183,14 +1134,15 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.orders.append(order_obj)
|
||||
|
||||
trade.open_order_id = order['id']
|
||||
trade.sell_order_status = ''
|
||||
trade.close_rate_requested = limit
|
||||
trade.sell_reason = sell_reason.value
|
||||
trade.sell_reason = sell_reason.sell_reason
|
||||
# In case of market sell orders the order can be closed immediately
|
||||
if order.get('status', 'unknown') == 'closed':
|
||||
self.update_trade_state(trade, trade.open_order_id, order)
|
||||
Trade.session.flush()
|
||||
Trade.commit()
|
||||
|
||||
# Lock pair for one candle to prevent immediate rebuys
|
||||
# Lock pair for one candle to prevent immediate re-buys
|
||||
self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc),
|
||||
reason='Auto lock')
|
||||
|
||||
@@ -1198,19 +1150,20 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
return True
|
||||
|
||||
def _notify_sell(self, trade: Trade, order_type: str) -> None:
|
||||
def _notify_sell(self, trade: Trade, order_type: str, fill: bool = False) -> None:
|
||||
"""
|
||||
Sends rpc notification when a sell occured.
|
||||
Sends rpc notification when a sell occurred.
|
||||
"""
|
||||
profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested
|
||||
profit_trade = trade.calc_profit(rate=profit_rate)
|
||||
# Use cached rates here - it was updated seconds ago.
|
||||
current_rate = self.get_sell_rate(trade.pair, False)
|
||||
current_rate = self.exchange.get_sell_rate(trade.pair, False) if not fill else None
|
||||
profit_ratio = trade.calc_profit_ratio(profit_rate)
|
||||
gain = "profit" if profit_ratio > 0 else "loss"
|
||||
|
||||
msg = {
|
||||
'type': RPCMessageType.SELL_NOTIFICATION,
|
||||
'type': (RPCMessageType.SELL_FILL if fill
|
||||
else RPCMessageType.SELL),
|
||||
'trade_id': trade.id,
|
||||
'exchange': trade.exchange.capitalize(),
|
||||
'pair': trade.pair,
|
||||
@@ -1219,6 +1172,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
'order_type': order_type,
|
||||
'amount': trade.amount,
|
||||
'open_rate': trade.open_rate,
|
||||
'close_rate': trade.close_rate,
|
||||
'current_rate': current_rate,
|
||||
'profit_amount': profit_trade,
|
||||
'profit_ratio': profit_ratio,
|
||||
@@ -1239,7 +1193,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
def _notify_sell_cancel(self, trade: Trade, order_type: str, reason: str) -> None:
|
||||
"""
|
||||
Sends rpc notification when a sell cancel occured.
|
||||
Sends rpc notification when a sell cancel occurred.
|
||||
"""
|
||||
if trade.sell_order_status == reason:
|
||||
return
|
||||
@@ -1248,12 +1202,12 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
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)
|
||||
current_rate = self.exchange.get_sell_rate(trade.pair, False)
|
||||
profit_ratio = trade.calc_profit_ratio(profit_rate)
|
||||
gain = "profit" if profit_ratio > 0 else "loss"
|
||||
|
||||
msg = {
|
||||
'type': RPCMessageType.SELL_CANCEL_NOTIFICATION,
|
||||
'type': RPCMessageType.SELL_CANCEL,
|
||||
'trade_id': trade.id,
|
||||
'exchange': trade.exchange.capitalize(),
|
||||
'pair': trade.pair,
|
||||
@@ -1292,7 +1246,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
Handles closing both buy and sell orders.
|
||||
:param trade: Trade object of the trade we're analyzing
|
||||
:param order_id: Order-id of the order we're analyzing
|
||||
:param action_order: Already aquired order object
|
||||
:param action_order: Already acquired order object
|
||||
:return: True if order has been cancelled without being filled partially, False otherwise
|
||||
"""
|
||||
if not order_id:
|
||||
@@ -1327,12 +1281,19 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Handling of this will happen in check_handle_timeout.
|
||||
return True
|
||||
trade.update(order)
|
||||
Trade.commit()
|
||||
|
||||
# Updating wallets when order is closed
|
||||
if not trade.is_open:
|
||||
if not stoploss_order and not trade.open_order_id:
|
||||
self._notify_sell(trade, '', True)
|
||||
self.protections.stop_per_pair(trade.pair)
|
||||
self.protections.global_stop()
|
||||
self.wallets.update()
|
||||
elif not trade.open_order_id:
|
||||
# Buy fill
|
||||
self._notify_buy_fill(trade)
|
||||
|
||||
return False
|
||||
|
||||
def apply_fee_conditional(self, trade: Trade, trade_base_currency: str,
|
||||
@@ -1356,7 +1317,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
def get_real_amount(self, trade: Trade, order: Dict) -> float:
|
||||
"""
|
||||
Detect and update trade fee.
|
||||
Calls trade.update_fee() uppon correct detection.
|
||||
Calls trade.update_fee() upon correct detection.
|
||||
Returns modified amount if the fee was taken from the destination currency.
|
||||
Necessary for exchanges which charge fees in base currency (e.g. binance)
|
||||
:return: identical (or new) amount for the trade
|
||||
@@ -1389,8 +1350,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
"""
|
||||
fee-detection fallback to Trades. Parses result of fetch_my_trades to get correct fee.
|
||||
"""
|
||||
trades = self.exchange.get_trades_for_order(order['id'], trade.pair,
|
||||
trade.open_date)
|
||||
trades = self.exchange.get_trades_for_order(self.exchange.get_order_id_conditional(order),
|
||||
trade.pair, trade.open_date)
|
||||
|
||||
if len(trades) == 0:
|
||||
logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade)
|
||||
|
@@ -6,7 +6,7 @@ import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Iterator, List
|
||||
from typing.io import IO
|
||||
|
||||
import rapidjson
|
||||
@@ -81,7 +81,7 @@ def json_load(datafile: IO) -> Any:
|
||||
"""
|
||||
load data with rapidjson
|
||||
Use this to have a consistent experience,
|
||||
sete number_mode to "NM_NATIVE" for greatest speed
|
||||
set number_mode to "NM_NATIVE" for greatest speed
|
||||
"""
|
||||
return rapidjson.load(datafile, number_mode=rapidjson.NM_NATIVE)
|
||||
|
||||
@@ -202,3 +202,14 @@ def render_template_with_fallback(templatefile: str, templatefallbackfile: str,
|
||||
return render_template(templatefile, arguments)
|
||||
except TemplateNotFound:
|
||||
return render_template(templatefallbackfile, arguments)
|
||||
|
||||
|
||||
def chunks(lst: List[Any], n: int) -> Iterator[List[Any]]:
|
||||
"""
|
||||
Split lst into chunks of the size n.
|
||||
:param lst: list to split into chunks
|
||||
:param n: number of max elements per chunk
|
||||
:return: None
|
||||
"""
|
||||
for chunk in range(0, len(lst), n):
|
||||
yield (lst[chunk:chunk + n])
|
||||
|
@@ -15,19 +15,21 @@ from freqtrade.configuration import TimeRange, remove_credentials, validate_conf
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT
|
||||
from freqtrade.data import history
|
||||
from freqtrade.data.btanalysis import trade_list_to_dataframe
|
||||
from freqtrade.data.converter import trim_dataframe
|
||||
from freqtrade.data.converter import trim_dataframes
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.enums import SellType
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
|
||||
store_backtest_stats)
|
||||
from freqtrade.persistence import PairLocks, Trade
|
||||
from freqtrade.persistence import LocalTrade, PairLocks, Trade
|
||||
from freqtrade.plugins.pairlistmanager import PairListManager
|
||||
from freqtrade.plugins.protectionmanager import ProtectionManager
|
||||
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
|
||||
from freqtrade.strategy.interface import IStrategy, SellCheckTuple, SellType
|
||||
from freqtrade.strategy.interface import IStrategy, SellCheckTuple
|
||||
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||
from freqtrade.wallets import Wallets
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,9 +64,7 @@ class Backtesting:
|
||||
self.all_results: Dict[str, Dict] = {}
|
||||
|
||||
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
|
||||
|
||||
dataprovider = DataProvider(self.config, self.exchange)
|
||||
IStrategy.dp = dataprovider
|
||||
self.dataprovider = DataProvider(self.config, None)
|
||||
|
||||
if self.config.get('strategy_list', None):
|
||||
for strat in list(self.config['strategy_list']):
|
||||
@@ -95,7 +95,7 @@ class Backtesting:
|
||||
"PrecisionFilter not allowed for backtesting multiple strategies."
|
||||
)
|
||||
|
||||
dataprovider.add_pairlisthandler(self.pairlists)
|
||||
self.dataprovider.add_pairlisthandler(self.pairlists)
|
||||
self.pairlists.refresh_pairlist()
|
||||
|
||||
if len(self.pairlists.whitelist) == 0:
|
||||
@@ -111,28 +111,33 @@ class Backtesting:
|
||||
PairLocks.timeframe = self.config['timeframe']
|
||||
PairLocks.use_db = False
|
||||
PairLocks.reset_locks()
|
||||
if self.config.get('enable_protections', False):
|
||||
self.protections = ProtectionManager(self.config)
|
||||
|
||||
self.wallets = Wallets(self.config, self.exchange, log=False)
|
||||
|
||||
# Get maximum required startup period
|
||||
self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
|
||||
# Load one (first) strategy
|
||||
self._set_strategy(self.strategylist[0])
|
||||
|
||||
def __del__(self):
|
||||
LoggingMixin.show_output = True
|
||||
PairLocks.use_db = True
|
||||
Trade.use_db = True
|
||||
|
||||
def _set_strategy(self, strategy):
|
||||
def _set_strategy(self, strategy: IStrategy):
|
||||
"""
|
||||
Load strategy into backtesting
|
||||
"""
|
||||
self.strategy: IStrategy = strategy
|
||||
strategy.dp = self.dataprovider
|
||||
# Set stoploss_on_exchange to false for backtesting,
|
||||
# since a "perfect" stoploss-sell is assumed anyway
|
||||
# And the regular "stoploss" function would not apply to that case
|
||||
self.strategy.order_types['stoploss_on_exchange'] = False
|
||||
if self.config.get('enable_protections', False):
|
||||
conf = self.config
|
||||
if hasattr(strategy, 'protections'):
|
||||
conf = deepcopy(conf)
|
||||
conf['protections'] = strategy.protections
|
||||
self.protections = ProtectionManager(conf)
|
||||
|
||||
def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
|
||||
"""
|
||||
@@ -156,7 +161,7 @@ class Backtesting:
|
||||
|
||||
logger.info(f'Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'({(max_date - min_date).days} days)..')
|
||||
f'({(max_date - min_date).days} days).')
|
||||
|
||||
# Adjust startts forward if not enough data is available
|
||||
timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
|
||||
@@ -171,10 +176,10 @@ class Backtesting:
|
||||
PairLocks.use_db = False
|
||||
PairLocks.timeframe = self.config['timeframe']
|
||||
Trade.use_db = False
|
||||
if enable_protections:
|
||||
# Reset persisted data - used for protections only
|
||||
PairLocks.reset_locks()
|
||||
Trade.reset_trades()
|
||||
PairLocks.reset_locks()
|
||||
Trade.reset_trades()
|
||||
self.rejected_trades = 0
|
||||
self.dataprovider.clear_cache()
|
||||
|
||||
def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
|
||||
"""
|
||||
@@ -188,8 +193,9 @@ class Backtesting:
|
||||
data: Dict = {}
|
||||
# Create dict with data
|
||||
for pair, pair_data in processed.items():
|
||||
pair_data.loc[:, 'buy'] = 0 # cleanup from previous run
|
||||
pair_data.loc[:, 'sell'] = 0 # cleanup from previous run
|
||||
if not pair_data.empty:
|
||||
pair_data.loc[:, 'buy'] = 0 # cleanup if buy_signal is exist
|
||||
pair_data.loc[:, 'sell'] = 0 # cleanup if sell_signal is exist
|
||||
|
||||
df_analyzed = self.strategy.advise_sell(
|
||||
self.strategy.advise_buy(pair_data, {'pair': pair}), {'pair': pair})[headers].copy()
|
||||
@@ -203,16 +209,22 @@ class Backtesting:
|
||||
|
||||
# Convert from Pandas to list for performance reasons
|
||||
# (Looping Pandas is slow.)
|
||||
data[pair] = [x for x in df_analyzed.itertuples(index=False, name=None)]
|
||||
data[pair] = df_analyzed.values.tolist()
|
||||
return data
|
||||
|
||||
def _get_close_rate(self, sell_row: Tuple, trade: Trade, sell: SellCheckTuple,
|
||||
def _get_close_rate(self, sell_row: Tuple, trade: LocalTrade, sell: SellCheckTuple,
|
||||
trade_dur: int) -> float:
|
||||
"""
|
||||
Get close rate for backtesting result
|
||||
"""
|
||||
# Special handling if high or low hit STOP_LOSS or ROI
|
||||
if sell.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
|
||||
if trade.stop_loss > sell_row[HIGH_IDX]:
|
||||
# our stoploss was already higher than candle high,
|
||||
# possibly due to a cancelled trade exit.
|
||||
# sell at open price.
|
||||
return sell_row[OPEN_IDX]
|
||||
|
||||
# Set close_rate to stoploss
|
||||
return trade.stop_loss
|
||||
elif sell.sell_type == (SellType.ROI):
|
||||
@@ -238,7 +250,7 @@ class Backtesting:
|
||||
# Use the maximum between close_rate and low as we
|
||||
# cannot sell outside of a candle.
|
||||
# Applies when a new ROI setting comes in place and the whole candle is above that.
|
||||
return max(close_rate, sell_row[LOW_IDX])
|
||||
return min(max(close_rate, sell_row[LOW_IDX]), sell_row[HIGH_IDX])
|
||||
|
||||
else:
|
||||
# This should not be reached...
|
||||
@@ -246,24 +258,67 @@ class Backtesting:
|
||||
else:
|
||||
return sell_row[OPEN_IDX]
|
||||
|
||||
def _get_sell_trade_entry(self, trade: Trade, sell_row: Tuple) -> Optional[Trade]:
|
||||
def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]:
|
||||
|
||||
sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], sell_row[DATE_IDX],
|
||||
sell_row[BUY_IDX], sell_row[SELL_IDX],
|
||||
sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], # type: ignore
|
||||
sell_row[DATE_IDX].to_pydatetime(), sell_row[BUY_IDX],
|
||||
sell_row[SELL_IDX],
|
||||
low=sell_row[LOW_IDX], high=sell_row[HIGH_IDX])
|
||||
|
||||
if sell.sell_flag:
|
||||
trade_dur = int((sell_row[DATE_IDX] - trade.open_date).total_seconds() // 60)
|
||||
trade.close_date = sell_row[DATE_IDX].to_pydatetime()
|
||||
trade.sell_reason = sell.sell_reason
|
||||
trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
|
||||
closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
|
||||
|
||||
trade.close_date = sell_row[DATE_IDX]
|
||||
trade.sell_reason = sell.sell_type
|
||||
# Confirm trade exit:
|
||||
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='limit', amount=trade.amount,
|
||||
rate=closerate,
|
||||
time_in_force=time_in_force,
|
||||
sell_reason=sell.sell_reason,
|
||||
current_time=sell_row[DATE_IDX].to_pydatetime()):
|
||||
return None
|
||||
|
||||
trade.close(closerate, show_msg=False)
|
||||
return trade
|
||||
|
||||
return None
|
||||
|
||||
def handle_left_open(self, open_trades: Dict[str, List[Trade]],
|
||||
data: Dict[str, List[Tuple]]) -> List[Trade]:
|
||||
def _enter_trade(self, pair: str, row: List) -> Optional[LocalTrade]:
|
||||
try:
|
||||
stake_amount = self.wallets.get_trade_stake_amount(pair, None)
|
||||
except DependencyException:
|
||||
return None
|
||||
min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, row[OPEN_IDX], -0.05)
|
||||
|
||||
order_type = self.strategy.order_types['buy']
|
||||
time_in_force = self.strategy.order_time_in_force['sell']
|
||||
# Confirm trade entry:
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
pair=pair, order_type=order_type, amount=stake_amount, rate=row[OPEN_IDX],
|
||||
time_in_force=time_in_force, current_time=row[DATE_IDX].to_pydatetime()):
|
||||
return None
|
||||
|
||||
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
|
||||
# Enter trade
|
||||
trade = LocalTrade(
|
||||
pair=pair,
|
||||
open_rate=row[OPEN_IDX],
|
||||
open_date=row[DATE_IDX].to_pydatetime(),
|
||||
stake_amount=stake_amount,
|
||||
amount=round(stake_amount / row[OPEN_IDX], 8),
|
||||
fee_open=self.fee,
|
||||
fee_close=self.fee,
|
||||
is_open=True,
|
||||
exchange='backtesting',
|
||||
)
|
||||
return trade
|
||||
return None
|
||||
|
||||
def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
|
||||
data: Dict[str, List[Tuple]]) -> List[LocalTrade]:
|
||||
"""
|
||||
Handling of left open trades at the end of backtesting
|
||||
"""
|
||||
@@ -273,17 +328,28 @@ class Backtesting:
|
||||
for trade in open_trades[pair]:
|
||||
sell_row = data[pair][-1]
|
||||
|
||||
trade.close_date = sell_row[DATE_IDX]
|
||||
trade.sell_reason = SellType.FORCE_SELL
|
||||
trade.close_date = sell_row[DATE_IDX].to_pydatetime()
|
||||
trade.sell_reason = SellType.FORCE_SELL.value
|
||||
trade.close(sell_row[OPEN_IDX], show_msg=False)
|
||||
trade.is_open = True
|
||||
trades.append(trade)
|
||||
LocalTrade.close_bt_trade(trade)
|
||||
# Deepcopy object to have wallets update correctly
|
||||
trade1 = deepcopy(trade)
|
||||
trade1.is_open = True
|
||||
trades.append(trade1)
|
||||
return trades
|
||||
|
||||
def backtest(self, processed: Dict, stake_amount: float,
|
||||
def trade_slot_available(self, max_open_trades: int, open_trade_count: int) -> bool:
|
||||
# Always allow trades when max_open_trades is enabled.
|
||||
if max_open_trades <= 0 or open_trade_count < max_open_trades:
|
||||
return True
|
||||
# Rejected trade
|
||||
self.rejected_trades += 1
|
||||
return False
|
||||
|
||||
def backtest(self, processed: Dict,
|
||||
start_date: datetime, end_date: datetime,
|
||||
max_open_trades: int = 0, position_stacking: bool = False,
|
||||
enable_protections: bool = False) -> DataFrame:
|
||||
enable_protections: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Implement backtesting functionality
|
||||
|
||||
@@ -292,7 +358,6 @@ class Backtesting:
|
||||
Avoid extensive logging in this method and functions it calls.
|
||||
|
||||
:param processed: a processed dictionary with format {pair, data}
|
||||
:param stake_amount: amount to use for each trade
|
||||
:param start_date: backtesting timerange start datetime
|
||||
:param end_date: backtesting timerange end datetime
|
||||
:param max_open_trades: maximum number of concurrent trades, <= 0 means unlimited
|
||||
@@ -300,22 +365,22 @@ class Backtesting:
|
||||
:param enable_protections: Should protections be enabled?
|
||||
:return: DataFrame with trades (results of backtesting)
|
||||
"""
|
||||
logger.debug(f"Run backtest, stake_amount: {stake_amount}, "
|
||||
f"start_date: {start_date}, end_date: {end_date}, "
|
||||
f"max_open_trades: {max_open_trades}, position_stacking: {position_stacking}"
|
||||
)
|
||||
trades: List[Trade] = []
|
||||
trades: List[LocalTrade] = []
|
||||
self.prepare_backtest(enable_protections)
|
||||
|
||||
# Update dataprovider cache
|
||||
for pair, dataframe in processed.items():
|
||||
self.dataprovider._set_cached_df(pair, self.timeframe, dataframe)
|
||||
|
||||
# Use dict of lists with data for performance
|
||||
# (looping lists is a lot faster than pandas DataFrames)
|
||||
data: Dict = self._get_ohlcv_as_lists(processed)
|
||||
|
||||
# Indexes per pair, so some pairs are allowed to have a missing start.
|
||||
indexes: Dict = {}
|
||||
indexes: Dict = defaultdict(int)
|
||||
tmp = start_date + timedelta(minutes=self.timeframe_min)
|
||||
|
||||
open_trades: Dict[str, List] = defaultdict(list)
|
||||
open_trades: Dict[str, List[LocalTrade]] = defaultdict(list)
|
||||
open_trade_count = 0
|
||||
|
||||
# Loop timerange and get candle for each pair at that point in time
|
||||
@@ -323,11 +388,9 @@ class Backtesting:
|
||||
open_trade_count_start = open_trade_count
|
||||
|
||||
for i, pair in enumerate(data):
|
||||
if pair not in indexes:
|
||||
indexes[pair] = 0
|
||||
|
||||
row_index = indexes[pair]
|
||||
try:
|
||||
row = data[pair][indexes[pair]]
|
||||
row = data[pair][row_index]
|
||||
except IndexError:
|
||||
# missing Data for one pair at the end.
|
||||
# Warnings for this are shown during data loading
|
||||
@@ -336,38 +399,34 @@ class Backtesting:
|
||||
# Waits until the time-counter reaches the start of the data for this pair.
|
||||
if row[DATE_IDX] > tmp:
|
||||
continue
|
||||
indexes[pair] += 1
|
||||
|
||||
row_index += 1
|
||||
self.dataprovider._set_dataframe_max_index(row_index)
|
||||
indexes[pair] = row_index
|
||||
|
||||
# without positionstacking, we can only have one open trade per pair.
|
||||
# max_open_trades must be respected
|
||||
# don't open on the last row
|
||||
if ((position_stacking or len(open_trades[pair]) == 0)
|
||||
and (max_open_trades <= 0 or open_trade_count_start < max_open_trades)
|
||||
and tmp != end_date
|
||||
and row[BUY_IDX] == 1 and row[SELL_IDX] != 1
|
||||
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])):
|
||||
# Enter trade
|
||||
trade = Trade(
|
||||
pair=pair,
|
||||
open_rate=row[OPEN_IDX],
|
||||
open_date=row[DATE_IDX],
|
||||
stake_amount=stake_amount,
|
||||
amount=round(stake_amount / row[OPEN_IDX], 8),
|
||||
fee_open=self.fee,
|
||||
fee_close=self.fee,
|
||||
is_open=True,
|
||||
)
|
||||
# TODO: hacky workaround to avoid opening > max_open_trades
|
||||
# This emulates previous behaviour - not sure if this is correct
|
||||
# Prevents buying if the trade-slot was freed in this candle
|
||||
open_trade_count_start += 1
|
||||
open_trade_count += 1
|
||||
# logger.debug(f"{pair} - Backtesting emulates creation of new trade: {trade}.")
|
||||
open_trades[pair].append(trade)
|
||||
Trade.trades.append(trade)
|
||||
if (
|
||||
(position_stacking or len(open_trades[pair]) == 0)
|
||||
and self.trade_slot_available(max_open_trades, open_trade_count_start)
|
||||
and tmp != end_date
|
||||
and row[BUY_IDX] == 1
|
||||
and row[SELL_IDX] != 1
|
||||
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])
|
||||
):
|
||||
trade = self._enter_trade(pair, row)
|
||||
if trade:
|
||||
# TODO: hacky workaround to avoid opening > max_open_trades
|
||||
# This emulates previous behaviour - not sure if this is correct
|
||||
# Prevents buying if the trade-slot was freed in this candle
|
||||
open_trade_count_start += 1
|
||||
open_trade_count += 1
|
||||
# logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
|
||||
open_trades[pair].append(trade)
|
||||
LocalTrade.add_bt_trade(trade)
|
||||
|
||||
for trade in open_trades[pair]:
|
||||
# since indexes has been incremented before, we need to go one step back to
|
||||
# also check the buying candle for sell conditions.
|
||||
trade_entry = self._get_sell_trade_entry(trade, row)
|
||||
# Sell occured
|
||||
@@ -375,6 +434,8 @@ class Backtesting:
|
||||
# logger.debug(f"{pair} - Backtesting sell {trade}")
|
||||
open_trade_count -= 1
|
||||
open_trades[pair].remove(trade)
|
||||
|
||||
LocalTrade.close_bt_trade(trade)
|
||||
trades.append(trade_entry)
|
||||
if enable_protections:
|
||||
self.protections.stop_per_pair(pair, row[DATE_IDX])
|
||||
@@ -384,8 +445,16 @@ class Backtesting:
|
||||
tmp += timedelta(minutes=self.timeframe_min)
|
||||
|
||||
trades += self.handle_left_open(open_trades, data=data)
|
||||
self.wallets.update()
|
||||
|
||||
return trade_list_to_dataframe(trades)
|
||||
results = trade_list_to_dataframe(trades)
|
||||
return {
|
||||
'results': results,
|
||||
'config': self.strategy.config,
|
||||
'locks': PairLocks.get_all_locks(),
|
||||
'rejected_signals': self.rejected_trades,
|
||||
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
|
||||
}
|
||||
|
||||
def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, Any], timerange: TimeRange):
|
||||
logger.info("Running backtesting for Strategy %s", strat.get_strategy_name())
|
||||
@@ -407,31 +476,32 @@ class Backtesting:
|
||||
preprocessed = self.strategy.ohlcvdata_to_dataframe(data)
|
||||
|
||||
# Trim startup period from analyzed dataframe
|
||||
for pair, df in preprocessed.items():
|
||||
preprocessed[pair] = trim_dataframe(df, timerange)
|
||||
min_date, max_date = history.get_timerange(preprocessed)
|
||||
preprocessed = trim_dataframes(preprocessed, timerange, self.required_startup)
|
||||
|
||||
if not preprocessed:
|
||||
raise OperationalException(
|
||||
"No data left after adjusting for startup candles.")
|
||||
|
||||
min_date, max_date = history.get_timerange(preprocessed)
|
||||
logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'({(max_date - min_date).days} days)..')
|
||||
f'({(max_date - min_date).days} days).')
|
||||
# Execute backtest and store results
|
||||
results = self.backtest(
|
||||
processed=preprocessed,
|
||||
stake_amount=self.config['stake_amount'],
|
||||
start_date=min_date.datetime,
|
||||
end_date=max_date.datetime,
|
||||
start_date=min_date,
|
||||
end_date=max_date,
|
||||
max_open_trades=max_open_trades,
|
||||
position_stacking=self.config.get('position_stacking', False),
|
||||
enable_protections=self.config.get('enable_protections', False),
|
||||
)
|
||||
backtest_end_time = datetime.now(timezone.utc)
|
||||
self.all_results[self.strategy.get_strategy_name()] = {
|
||||
'results': results,
|
||||
'config': self.strategy.config,
|
||||
'locks': PairLocks.locks,
|
||||
results.update({
|
||||
'backtest_start_time': int(backtest_start_time.timestamp()),
|
||||
'backtest_end_time': int(backtest_end_time.timestamp()),
|
||||
}
|
||||
})
|
||||
self.all_results[self.strategy.get_strategy_name()] = results
|
||||
|
||||
return min_date, max_date
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -442,17 +512,16 @@ class Backtesting:
|
||||
data: Dict[str, Any] = {}
|
||||
|
||||
data, timerange = self.load_bt_data()
|
||||
logger.info("Dataload complete. Calculating indicators")
|
||||
|
||||
min_date = None
|
||||
max_date = None
|
||||
for strat in self.strategylist:
|
||||
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
|
||||
if len(self.strategylist) > 0:
|
||||
stats = generate_backtest_stats(data, self.all_results,
|
||||
min_date=min_date, max_date=max_date)
|
||||
|
||||
stats = generate_backtest_stats(data, self.all_results,
|
||||
min_date=min_date, max_date=max_date)
|
||||
if self.config.get('export', False):
|
||||
store_backtest_stats(self.config['exportfilename'], stats)
|
||||
|
||||
if self.config.get('export', False):
|
||||
store_backtest_stats(self.config['exportfilename'], stats)
|
||||
|
||||
# Show backtest results
|
||||
show_backtest_results(self.config, stats)
|
||||
# Show backtest results
|
||||
show_backtest_results(self.config, stats)
|
||||
|
@@ -44,7 +44,7 @@ class EdgeCli:
|
||||
'timerange') is None else str(self.config.get('timerange')))
|
||||
|
||||
def start(self) -> None:
|
||||
result = self.edge.calculate()
|
||||
result = self.edge.calculate(self.config['exchange']['pair_whitelist'])
|
||||
if result:
|
||||
print('') # blank line for readability
|
||||
print(generate_edge_table(self.edge._cached_pairs))
|
||||
|
@@ -4,38 +4,34 @@
|
||||
This module contains the hyperopt logic
|
||||
"""
|
||||
|
||||
import io
|
||||
import locale
|
||||
import logging
|
||||
import random
|
||||
import warnings
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from math import ceil
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
from pprint import pformat
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import progressbar
|
||||
import rapidjson
|
||||
import tabulate
|
||||
from colorama import Fore, Style
|
||||
from colorama import init as colorama_init
|
||||
from joblib import Parallel, cpu_count, delayed, dump, load, wrap_non_picklable_objects
|
||||
from pandas import DataFrame, isna, json_normalize
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN
|
||||
from freqtrade.data.converter import trim_dataframe
|
||||
from freqtrade.data.converter import trim_dataframes
|
||||
from freqtrade.data.history import get_timerange
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import file_dump_json, plural, round_dict
|
||||
from freqtrade.misc import file_dump_json, plural
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
# Import IHyperOpt and IHyperOptLoss to allow unpickling classes from these modules
|
||||
from freqtrade.optimize.hyperopt_auto import HyperOptAuto
|
||||
from freqtrade.optimize.hyperopt_interface import IHyperOpt # noqa: F401
|
||||
from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss # noqa: F401
|
||||
from freqtrade.optimize.hyperopt_tools import HyperoptTools
|
||||
from freqtrade.optimize.optimize_reports import generate_strategy_stats
|
||||
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver, HyperOptResolver
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
|
||||
# Suppress scikit-learn FutureWarnings from skopt
|
||||
@@ -66,19 +62,33 @@ class Hyperopt:
|
||||
hyperopt = Hyperopt(config)
|
||||
hyperopt.start()
|
||||
"""
|
||||
custom_hyperopt: IHyperOpt
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
self.buy_space: List[Dimension] = []
|
||||
self.sell_space: List[Dimension] = []
|
||||
self.roi_space: List[Dimension] = []
|
||||
self.stoploss_space: List[Dimension] = []
|
||||
self.trailing_space: List[Dimension] = []
|
||||
self.dimensions: List[Dimension] = []
|
||||
|
||||
self.config = config
|
||||
|
||||
self.backtesting = Backtesting(self.config)
|
||||
|
||||
self.custom_hyperopt = HyperOptResolver.load_hyperopt(self.config)
|
||||
if not self.config.get('hyperopt'):
|
||||
self.custom_hyperopt = HyperOptAuto(self.config)
|
||||
else:
|
||||
self.custom_hyperopt = HyperOptResolver.load_hyperopt(self.config)
|
||||
self.backtesting._set_strategy(self.backtesting.strategylist[0])
|
||||
self.custom_hyperopt.strategy = self.backtesting.strategy
|
||||
|
||||
self.custom_hyperoptloss = HyperOptLossResolver.load_hyperoptloss(self.config)
|
||||
self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function
|
||||
time_now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
self.results_file = (self.config['user_data_dir'] /
|
||||
'hyperopt_results' / f'hyperopt_results_{time_now}.pickle')
|
||||
strategy = str(self.config['strategy'])
|
||||
self.results_file: Path = (self.config['user_data_dir'] / 'hyperopt_results' /
|
||||
f'strategy_{strategy}_{time_now}.fthypt')
|
||||
self.data_pickle_file = (self.config['user_data_dir'] /
|
||||
'hyperopt_results' / 'hyperopt_tickerdata.pkl')
|
||||
self.total_epochs = config.get('epochs', 0)
|
||||
@@ -88,9 +98,7 @@ class Hyperopt:
|
||||
self.clean_hyperopt()
|
||||
|
||||
self.num_epochs_saved = 0
|
||||
|
||||
# Previous evaluations
|
||||
self.epochs: List = []
|
||||
self.current_best_epoch: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Populate functions here (hasattr is slow so should not be run during "regular" operations)
|
||||
if hasattr(self.custom_hyperopt, 'populate_indicators'):
|
||||
@@ -111,7 +119,7 @@ class Hyperopt:
|
||||
self.max_open_trades = 0
|
||||
self.position_stacking = self.config.get('position_stacking', False)
|
||||
|
||||
if self.has_space('sell'):
|
||||
if HyperoptTools.has_space(self.config, 'sell'):
|
||||
# Make sure use_sell_signal is enabled
|
||||
if 'ask_strategy' not in self.config:
|
||||
self.config['ask_strategy'] = {}
|
||||
@@ -137,9 +145,7 @@ class Hyperopt:
|
||||
logger.info(f"Removing `{p}`.")
|
||||
p.unlink()
|
||||
|
||||
def _get_params_dict(self, raw_params: List[Any]) -> Dict:
|
||||
|
||||
dimensions: List[Dimension] = self.dimensions
|
||||
def _get_params_dict(self, dimensions: List[Dimension], raw_params: List[Any]) -> Dict:
|
||||
|
||||
# Ensure the number of dimensions match
|
||||
# the number of parameters in the list.
|
||||
@@ -150,30 +156,30 @@ class Hyperopt:
|
||||
# and the values are taken from the list of parameters.
|
||||
return {d.name: v for d, v in zip(dimensions, raw_params)}
|
||||
|
||||
def _save_results(self) -> None:
|
||||
def _save_result(self, epoch: Dict) -> None:
|
||||
"""
|
||||
Save hyperopt results to file
|
||||
Store one line per epoch.
|
||||
While not a valid json object - this allows appending easily.
|
||||
:param epoch: result dictionary for this epoch.
|
||||
"""
|
||||
num_epochs = len(self.epochs)
|
||||
if num_epochs > self.num_epochs_saved:
|
||||
logger.debug(f"Saving {num_epochs} {plural(num_epochs, 'epoch')}.")
|
||||
dump(self.epochs, self.results_file)
|
||||
self.num_epochs_saved = num_epochs
|
||||
logger.debug(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
|
||||
f"saved to '{self.results_file}'.")
|
||||
# Store hyperopt filename
|
||||
latest_filename = Path.joinpath(self.results_file.parent, LAST_BT_RESULT_FN)
|
||||
file_dump_json(latest_filename, {'latest_hyperopt': str(self.results_file.name)},
|
||||
log=False)
|
||||
def default_parser(x):
|
||||
if isinstance(x, np.integer):
|
||||
return int(x)
|
||||
return str(x)
|
||||
|
||||
@staticmethod
|
||||
def _read_results(results_file: Path) -> List:
|
||||
"""
|
||||
Read hyperopt results from file
|
||||
"""
|
||||
logger.info("Reading epochs from '%s'", results_file)
|
||||
data = load(results_file)
|
||||
return data
|
||||
with self.results_file.open('a') as f:
|
||||
rapidjson.dump(epoch, f, default=default_parser,
|
||||
number_mode=rapidjson.NM_NATIVE | rapidjson.NM_NAN)
|
||||
f.write("\n")
|
||||
|
||||
self.num_epochs_saved += 1
|
||||
logger.debug(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
|
||||
f"saved to '{self.results_file}'.")
|
||||
# Store hyperopt filename
|
||||
latest_filename = Path.joinpath(self.results_file.parent, LAST_BT_RESULT_FN)
|
||||
file_dump_json(latest_filename, {'latest_hyperopt': str(self.results_file.name)},
|
||||
log=False)
|
||||
|
||||
def _get_params_details(self, params: Dict) -> Dict:
|
||||
"""
|
||||
@@ -181,118 +187,30 @@ class Hyperopt:
|
||||
"""
|
||||
result: Dict = {}
|
||||
|
||||
if self.has_space('buy'):
|
||||
result['buy'] = {p.name: params.get(p.name)
|
||||
for p in self.hyperopt_space('buy')}
|
||||
if self.has_space('sell'):
|
||||
result['sell'] = {p.name: params.get(p.name)
|
||||
for p in self.hyperopt_space('sell')}
|
||||
if self.has_space('roi'):
|
||||
result['roi'] = self.custom_hyperopt.generate_roi_table(params)
|
||||
if self.has_space('stoploss'):
|
||||
result['stoploss'] = {p.name: params.get(p.name)
|
||||
for p in self.hyperopt_space('stoploss')}
|
||||
if self.has_space('trailing'):
|
||||
if HyperoptTools.has_space(self.config, 'buy'):
|
||||
result['buy'] = {p.name: params.get(p.name) for p in self.buy_space}
|
||||
if HyperoptTools.has_space(self.config, 'sell'):
|
||||
result['sell'] = {p.name: params.get(p.name) for p in self.sell_space}
|
||||
if HyperoptTools.has_space(self.config, 'roi'):
|
||||
result['roi'] = {str(k): v for k, v in
|
||||
self.custom_hyperopt.generate_roi_table(params).items()}
|
||||
if HyperoptTools.has_space(self.config, 'stoploss'):
|
||||
result['stoploss'] = {p.name: params.get(p.name) for p in self.stoploss_space}
|
||||
if HyperoptTools.has_space(self.config, 'trailing'):
|
||||
result['trailing'] = self.custom_hyperopt.generate_trailing_params(params)
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def print_epoch_details(results, total_epochs: int, print_json: bool,
|
||||
no_header: bool = False, header_str: str = None) -> None:
|
||||
"""
|
||||
Display details of the hyperopt result
|
||||
"""
|
||||
params = results.get('params_details', {})
|
||||
|
||||
# Default header string
|
||||
if header_str is None:
|
||||
header_str = "Best result"
|
||||
|
||||
if not no_header:
|
||||
explanation_str = Hyperopt._format_explanation_string(results, total_epochs)
|
||||
print(f"\n{header_str}:\n\n{explanation_str}\n")
|
||||
|
||||
if print_json:
|
||||
result_dict: Dict = {}
|
||||
for s in ['buy', 'sell', 'roi', 'stoploss', 'trailing']:
|
||||
Hyperopt._params_update_for_json(result_dict, params, s)
|
||||
print(rapidjson.dumps(result_dict, default=str, number_mode=rapidjson.NM_NATIVE))
|
||||
|
||||
else:
|
||||
Hyperopt._params_pretty_print(params, 'buy', "Buy hyperspace params:")
|
||||
Hyperopt._params_pretty_print(params, 'sell', "Sell hyperspace params:")
|
||||
Hyperopt._params_pretty_print(params, 'roi', "ROI table:")
|
||||
Hyperopt._params_pretty_print(params, 'stoploss', "Stoploss:")
|
||||
Hyperopt._params_pretty_print(params, 'trailing', "Trailing stop:")
|
||||
|
||||
@staticmethod
|
||||
def _params_update_for_json(result_dict, params, space: str) -> None:
|
||||
if space in params:
|
||||
space_params = Hyperopt._space_params(params, space)
|
||||
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
|
||||
# in the dict.
|
||||
result_dict['minimal_roi'] = OrderedDict(
|
||||
(str(k), v) for k, v in space_params.items()
|
||||
)
|
||||
else: # 'stoploss', 'trailing'
|
||||
result_dict.update(space_params)
|
||||
|
||||
@staticmethod
|
||||
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':
|
||||
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}"
|
||||
elif space == 'trailing':
|
||||
|
||||
for k, v in space_params.items():
|
||||
params_result += f'{k} = {v}\n'
|
||||
|
||||
else:
|
||||
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:
|
||||
d = params[space]
|
||||
# Round floats to `r` digits after the decimal point if requested
|
||||
return round_dict(d, r) if r else d
|
||||
|
||||
@staticmethod
|
||||
def is_best_loss(results, current_best_loss: float) -> bool:
|
||||
return results['loss'] < current_best_loss
|
||||
|
||||
def print_results(self, results) -> None:
|
||||
"""
|
||||
Log results if it is better than any previous evaluation
|
||||
TODO: this should be moved to HyperoptTools too
|
||||
"""
|
||||
is_best = results['is_best']
|
||||
|
||||
if self.print_all or is_best:
|
||||
print(
|
||||
self.get_result_table(
|
||||
HyperoptTools.get_result_table(
|
||||
self.config, results, self.total_epochs,
|
||||
self.print_all, self.print_colorized,
|
||||
self.hyperopt_table_header
|
||||
@@ -300,229 +218,58 @@ class Hyperopt:
|
||||
)
|
||||
self.hyperopt_table_header = 2
|
||||
|
||||
@staticmethod
|
||||
def _format_explanation_string(results, total_epochs) -> str:
|
||||
return (("*" if results['is_initial_point'] else " ") +
|
||||
f"{results['current_epoch']:5d}/{total_epochs}: " +
|
||||
f"{results['results_explanation']} " +
|
||||
f"Objective: {results['loss']:.5f}")
|
||||
|
||||
@staticmethod
|
||||
def get_result_table(config: dict, results: list, total_epochs: int, highlight_best: bool,
|
||||
print_colorized: bool, remove_header: int) -> str:
|
||||
def init_spaces(self):
|
||||
"""
|
||||
Log result table
|
||||
Assign the dimensions in the hyperoptimization space.
|
||||
"""
|
||||
if not results:
|
||||
return ''
|
||||
|
||||
tabulate.PRESERVE_WHITESPACE = True
|
||||
|
||||
trials = json_normalize(results, max_level=1)
|
||||
trials['Best'] = ''
|
||||
if 'results_metrics.winsdrawslosses' not in trials.columns:
|
||||
# Ensure compatibility with older versions of hyperopt results
|
||||
trials['results_metrics.winsdrawslosses'] = 'N/A'
|
||||
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count',
|
||||
'results_metrics.winsdrawslosses',
|
||||
'results_metrics.avg_profit', 'results_metrics.total_profit',
|
||||
'results_metrics.profit', 'results_metrics.duration',
|
||||
'loss', 'is_initial_point', 'is_best']]
|
||||
trials.columns = ['Best', 'Epoch', 'Trades', ' Win Draw Loss', 'Avg profit',
|
||||
'Total profit', 'Profit', 'Avg duration', 'Objective',
|
||||
'is_initial_point', 'is_best']
|
||||
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['Trades'] = trials['Trades'].astype(str)
|
||||
|
||||
trials['Epoch'] = trials['Epoch'].apply(
|
||||
lambda x: '{}/{}'.format(str(x).rjust(len(str(total_epochs)), ' '), total_epochs)
|
||||
)
|
||||
trials['Avg profit'] = trials['Avg profit'].apply(
|
||||
lambda x: '{:,.2f}%'.format(x).rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
|
||||
)
|
||||
trials['Avg duration'] = trials['Avg duration'].apply(
|
||||
lambda x: '{:,.1f} m'.format(x).rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
|
||||
)
|
||||
trials['Objective'] = trials['Objective'].apply(
|
||||
lambda x: '{:,.5f}'.format(x).rjust(8, ' ') if x != 100000 else "N/A".rjust(8, ' ')
|
||||
)
|
||||
|
||||
trials['Profit'] = trials.apply(
|
||||
lambda x: '{:,.8f} {} {}'.format(
|
||||
x['Total profit'], config['stake_currency'],
|
||||
'({:,.2f}%)'.format(x['Profit']).rjust(10, ' ')
|
||||
).rjust(25+len(config['stake_currency']))
|
||||
if x['Total profit'] != 0.0 else '--'.rjust(25+len(config['stake_currency'])),
|
||||
axis=1
|
||||
)
|
||||
trials = trials.drop(columns=['Total profit'])
|
||||
|
||||
if print_colorized:
|
||||
for i in range(len(trials)):
|
||||
if trials.loc[i]['is_profit']:
|
||||
for j in range(len(trials.loc[i])-3):
|
||||
trials.iat[i, j] = "{}{}{}".format(Fore.GREEN,
|
||||
str(trials.loc[i][j]), Fore.RESET)
|
||||
if trials.loc[i]['is_best'] and highlight_best:
|
||||
for j in range(len(trials.loc[i])-3):
|
||||
trials.iat[i, j] = "{}{}{}".format(Style.BRIGHT,
|
||||
str(trials.loc[i][j]), Style.RESET_ALL)
|
||||
|
||||
trials = trials.drop(columns=['is_initial_point', 'is_best', 'is_profit'])
|
||||
if remove_header > 0:
|
||||
table = tabulate.tabulate(
|
||||
trials.to_dict(orient='list'), tablefmt='orgtbl',
|
||||
headers='keys', stralign="right"
|
||||
)
|
||||
|
||||
table = table.split("\n", remove_header)[remove_header]
|
||||
elif remove_header < 0:
|
||||
table = tabulate.tabulate(
|
||||
trials.to_dict(orient='list'), tablefmt='psql',
|
||||
headers='keys', stralign="right"
|
||||
)
|
||||
table = "\n".join(table.split("\n")[0:remove_header])
|
||||
else:
|
||||
table = tabulate.tabulate(
|
||||
trials.to_dict(orient='list'), tablefmt='psql',
|
||||
headers='keys', stralign="right"
|
||||
)
|
||||
return table
|
||||
|
||||
@staticmethod
|
||||
def export_csv_file(config: dict, results: list, total_epochs: int, highlight_best: bool,
|
||||
csv_file: str) -> None:
|
||||
"""
|
||||
Log result to csv-file
|
||||
"""
|
||||
if not results:
|
||||
return
|
||||
|
||||
# Verification for overwrite
|
||||
if Path(csv_file).is_file():
|
||||
logger.error(f"CSV file already exists: {csv_file}")
|
||||
return
|
||||
|
||||
try:
|
||||
io.open(csv_file, 'w+').close()
|
||||
except IOError:
|
||||
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']
|
||||
|
||||
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)
|
||||
|
||||
trials['Total profit'] = trials['Total profit'].apply(
|
||||
lambda x: '{:,.8f}'.format(x) if x != 0.0 else ""
|
||||
)
|
||||
trials['Profit'] = trials['Profit'].apply(
|
||||
lambda x: '{:,.2f}'.format(x) if not isna(x) else ""
|
||||
)
|
||||
trials['Avg profit'] = trials['Avg profit'].apply(
|
||||
lambda x: '{:,.2f}%'.format(x) if not isna(x) else ""
|
||||
)
|
||||
trials['Avg duration'] = trials['Avg duration'].apply(
|
||||
lambda x: '{:,.1f} m'.format(x) if not isna(x) else ""
|
||||
)
|
||||
trials['Objective'] = trials['Objective'].apply(
|
||||
lambda x: '{:,.5f}'.format(x) if x != 100000 else ""
|
||||
)
|
||||
|
||||
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')
|
||||
logger.info(f"CSV file created: {csv_file}")
|
||||
|
||||
def has_space(self, space: str) -> bool:
|
||||
"""
|
||||
Tell if the space value is contained in the configuration
|
||||
"""
|
||||
# The 'trailing' space is not included in the 'default' set of spaces
|
||||
if space == 'trailing':
|
||||
return any(s in self.config['spaces'] for s in [space, 'all'])
|
||||
else:
|
||||
return any(s in self.config['spaces'] for s in [space, 'all', 'default'])
|
||||
|
||||
def hyperopt_space(self, space: Optional[str] = None) -> List[Dimension]:
|
||||
"""
|
||||
Return the dimensions in the hyperoptimization space.
|
||||
:param space: Defines hyperspace to return dimensions for.
|
||||
If None, then the self.has_space() will be used to return dimensions
|
||||
for all hyperspaces used.
|
||||
"""
|
||||
spaces: List[Dimension] = []
|
||||
|
||||
if space == 'buy' or (space is None and self.has_space('buy')):
|
||||
if HyperoptTools.has_space(self.config, 'buy'):
|
||||
logger.debug("Hyperopt has 'buy' space")
|
||||
spaces += self.custom_hyperopt.indicator_space()
|
||||
self.buy_space = self.custom_hyperopt.indicator_space()
|
||||
|
||||
if space == 'sell' or (space is None and self.has_space('sell')):
|
||||
if HyperoptTools.has_space(self.config, 'sell'):
|
||||
logger.debug("Hyperopt has 'sell' space")
|
||||
spaces += self.custom_hyperopt.sell_indicator_space()
|
||||
self.sell_space = self.custom_hyperopt.sell_indicator_space()
|
||||
|
||||
if space == 'roi' or (space is None and self.has_space('roi')):
|
||||
if HyperoptTools.has_space(self.config, 'roi'):
|
||||
logger.debug("Hyperopt has 'roi' space")
|
||||
spaces += self.custom_hyperopt.roi_space()
|
||||
self.roi_space = self.custom_hyperopt.roi_space()
|
||||
|
||||
if space == 'stoploss' or (space is None and self.has_space('stoploss')):
|
||||
if HyperoptTools.has_space(self.config, 'stoploss'):
|
||||
logger.debug("Hyperopt has 'stoploss' space")
|
||||
spaces += self.custom_hyperopt.stoploss_space()
|
||||
self.stoploss_space = self.custom_hyperopt.stoploss_space()
|
||||
|
||||
if space == 'trailing' or (space is None and self.has_space('trailing')):
|
||||
if HyperoptTools.has_space(self.config, 'trailing'):
|
||||
logger.debug("Hyperopt has 'trailing' space")
|
||||
spaces += self.custom_hyperopt.trailing_space()
|
||||
|
||||
return spaces
|
||||
self.trailing_space = self.custom_hyperopt.trailing_space()
|
||||
self.dimensions = (self.buy_space + self.sell_space + self.roi_space +
|
||||
self.stoploss_space + self.trailing_space)
|
||||
|
||||
def generate_optimizer(self, raw_params: List[Any], iteration=None) -> Dict:
|
||||
"""
|
||||
Used Optimize function. Called once per epoch to optimize whatever is configured.
|
||||
Keep this function as optimized as possible!
|
||||
"""
|
||||
params_dict = self._get_params_dict(raw_params)
|
||||
params_details = self._get_params_details(params_dict)
|
||||
backtest_start_time = datetime.now(timezone.utc)
|
||||
params_dict = self._get_params_dict(self.dimensions, raw_params)
|
||||
|
||||
if self.has_space('roi'):
|
||||
# Apply parameters
|
||||
if HyperoptTools.has_space(self.config, 'roi'):
|
||||
self.backtesting.strategy.minimal_roi = ( # type: ignore
|
||||
self.custom_hyperopt.generate_roi_table(params_dict))
|
||||
|
||||
if self.has_space('buy'):
|
||||
if HyperoptTools.has_space(self.config, 'buy'):
|
||||
self.backtesting.strategy.advise_buy = ( # type: ignore
|
||||
self.custom_hyperopt.buy_strategy_generator(params_dict))
|
||||
|
||||
if self.has_space('sell'):
|
||||
if HyperoptTools.has_space(self.config, 'sell'):
|
||||
self.backtesting.strategy.advise_sell = ( # type: ignore
|
||||
self.custom_hyperopt.sell_strategy_generator(params_dict))
|
||||
|
||||
if self.has_space('stoploss'):
|
||||
if HyperoptTools.has_space(self.config, 'stoploss'):
|
||||
self.backtesting.strategy.stoploss = params_dict['stoploss']
|
||||
|
||||
if self.has_space('trailing'):
|
||||
if HyperoptTools.has_space(self.config, 'trailing'):
|
||||
d = self.custom_hyperopt.generate_trailing_params(params_dict)
|
||||
self.backtesting.strategy.trailing_stop = d['trailing_stop']
|
||||
self.backtesting.strategy.trailing_stop_positive = d['trailing_stop_positive']
|
||||
@@ -531,30 +278,42 @@ class Hyperopt:
|
||||
self.backtesting.strategy.trailing_only_offset_is_reached = \
|
||||
d['trailing_only_offset_is_reached']
|
||||
|
||||
processed = load(self.data_pickle_file)
|
||||
|
||||
min_date, max_date = get_timerange(processed)
|
||||
|
||||
backtesting_results = self.backtesting.backtest(
|
||||
with self.data_pickle_file.open('rb') as f:
|
||||
processed = load(f, mmap_mode='r')
|
||||
bt_results = self.backtesting.backtest(
|
||||
processed=processed,
|
||||
stake_amount=self.config['stake_amount'],
|
||||
start_date=min_date.datetime,
|
||||
end_date=max_date.datetime,
|
||||
start_date=self.min_date,
|
||||
end_date=self.max_date,
|
||||
max_open_trades=self.max_open_trades,
|
||||
position_stacking=self.position_stacking,
|
||||
enable_protections=self.config.get('enable_protections', False),
|
||||
|
||||
)
|
||||
return self._get_results_dict(backtesting_results, min_date, max_date,
|
||||
params_dict, params_details)
|
||||
backtest_end_time = datetime.now(timezone.utc)
|
||||
bt_results.update({
|
||||
'backtest_start_time': int(backtest_start_time.timestamp()),
|
||||
'backtest_end_time': int(backtest_end_time.timestamp()),
|
||||
})
|
||||
|
||||
return self._get_results_dict(bt_results, self.min_date, self.max_date,
|
||||
params_dict,
|
||||
processed=processed)
|
||||
|
||||
def _get_results_dict(self, backtesting_results, min_date, max_date,
|
||||
params_dict, params_details):
|
||||
results_metrics = self._calculate_results_metrics(backtesting_results)
|
||||
results_explanation = self._format_results_explanation_string(results_metrics)
|
||||
params_dict, processed: Dict[str, DataFrame]
|
||||
) -> Dict[str, Any]:
|
||||
params_details = self._get_params_details(params_dict)
|
||||
|
||||
trade_count = results_metrics['trade_count']
|
||||
total_profit = results_metrics['total_profit']
|
||||
strat_stats = generate_strategy_stats(
|
||||
processed, self.backtesting.strategy.get_strategy_name(),
|
||||
backtesting_results, min_date, max_date, market_change=0
|
||||
)
|
||||
results_explanation = HyperoptTools.format_results_explanation_string(
|
||||
strat_stats, self.config['stake_currency'])
|
||||
|
||||
not_optimized = self.backtesting.strategy.get_params_dict()
|
||||
|
||||
trade_count = strat_stats['total_trades']
|
||||
total_profit = strat_stats['profit_total']
|
||||
|
||||
# If this evaluation contains too short amount of trades to be
|
||||
# interesting -- consider it as 'bad' (assigned max. loss value)
|
||||
@@ -562,49 +321,20 @@ class Hyperopt:
|
||||
# path. We do not want to optimize 'hodl' strategies.
|
||||
loss: float = MAX_LOSS
|
||||
if trade_count >= self.config['hyperopt_min_trades']:
|
||||
loss = self.calculate_loss(results=backtesting_results, trade_count=trade_count,
|
||||
min_date=min_date.datetime, max_date=max_date.datetime)
|
||||
loss = self.calculate_loss(results=backtesting_results['results'],
|
||||
trade_count=trade_count,
|
||||
min_date=min_date, max_date=max_date,
|
||||
config=self.config, processed=processed)
|
||||
return {
|
||||
'loss': loss,
|
||||
'params_dict': params_dict,
|
||||
'params_details': params_details,
|
||||
'results_metrics': results_metrics,
|
||||
'params_not_optimized': not_optimized,
|
||||
'results_metrics': strat_stats,
|
||||
'results_explanation': results_explanation,
|
||||
'total_profit': total_profit,
|
||||
}
|
||||
|
||||
def _calculate_results_metrics(self, backtesting_results: DataFrame) -> Dict:
|
||||
wins = len(backtesting_results[backtesting_results['profit_ratio'] > 0])
|
||||
draws = len(backtesting_results[backtesting_results['profit_ratio'] == 0])
|
||||
losses = len(backtesting_results[backtesting_results['profit_ratio'] < 0])
|
||||
return {
|
||||
'trade_count': len(backtesting_results.index),
|
||||
'wins': wins,
|
||||
'draws': draws,
|
||||
'losses': losses,
|
||||
'winsdrawslosses': f"{wins:>4} {draws:>4} {losses:>4}",
|
||||
'avg_profit': backtesting_results['profit_ratio'].mean() * 100.0,
|
||||
'median_profit': backtesting_results['profit_ratio'].median() * 100.0,
|
||||
'total_profit': backtesting_results['profit_abs'].sum(),
|
||||
'profit': backtesting_results['profit_ratio'].sum() * 100.0,
|
||||
'duration': backtesting_results['trade_duration'].mean(),
|
||||
}
|
||||
|
||||
def _format_results_explanation_string(self, results_metrics: Dict) -> str:
|
||||
"""
|
||||
Return the formatted results explanation in a string
|
||||
"""
|
||||
stake_cur = self.config['stake_currency']
|
||||
return (f"{results_metrics['trade_count']:6d} trades. "
|
||||
f"{results_metrics['wins']}/{results_metrics['draws']}"
|
||||
f"/{results_metrics['losses']} Wins/Draws/Losses. "
|
||||
f"Avg profit {results_metrics['avg_profit']: 6.2f}%. "
|
||||
f"Median profit {results_metrics['median_profit']: 6.2f}%. "
|
||||
f"Total profit {results_metrics['total_profit']: 11.8f} {stake_cur} "
|
||||
f"({results_metrics['profit']: 7.2f}\N{GREEK CAPITAL LETTER SIGMA}%). "
|
||||
f"Avg duration {results_metrics['duration']:5.1f} min."
|
||||
).encode(locale.getpreferredencoding(), 'replace').decode('utf-8')
|
||||
|
||||
def get_optimizer(self, dimensions: List[Dimension], cpu_count) -> Optimizer:
|
||||
return Optimizer(
|
||||
dimensions,
|
||||
@@ -620,56 +350,47 @@ class Hyperopt:
|
||||
return parallel(delayed(
|
||||
wrap_non_picklable_objects(self.generate_optimizer))(v, i) for v in asked)
|
||||
|
||||
@staticmethod
|
||||
def load_previous_results(results_file: Path) -> List:
|
||||
"""
|
||||
Load data for epochs from the file if we have one
|
||||
"""
|
||||
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(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)
|
||||
|
||||
def prepare_hyperopt_data(self) -> None:
|
||||
data, timerange = self.backtesting.load_bt_data()
|
||||
logger.info("Dataload complete. Calculating indicators")
|
||||
|
||||
preprocessed = self.backtesting.strategy.ohlcvdata_to_dataframe(data)
|
||||
|
||||
# Trim startup period from analyzed dataframe
|
||||
processed = trim_dataframes(preprocessed, timerange, self.backtesting.required_startup)
|
||||
|
||||
self.min_date, self.max_date = get_timerange(processed)
|
||||
|
||||
logger.info(f'Hyperopting with data from {self.min_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'up to {self.max_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'({(self.max_date - self.min_date).days} days)..')
|
||||
|
||||
dump(processed, self.data_pickle_file)
|
||||
|
||||
def start(self) -> None:
|
||||
self.random_state = self._set_random_state(self.config.get('hyperopt_random_state', None))
|
||||
logger.info(f"Using optimizer random state: {self.random_state}")
|
||||
self.hyperopt_table_header = -1
|
||||
data, timerange = self.backtesting.load_bt_data()
|
||||
# Initialize spaces ...
|
||||
self.init_spaces()
|
||||
|
||||
preprocessed = self.backtesting.strategy.ohlcvdata_to_dataframe(data)
|
||||
|
||||
# Trim startup period from analyzed dataframe
|
||||
for pair, df in preprocessed.items():
|
||||
preprocessed[pair] = trim_dataframe(df, timerange)
|
||||
min_date, max_date = get_timerange(preprocessed)
|
||||
|
||||
logger.info(f'Hyperopting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
|
||||
f'({(max_date - min_date).days} days)..')
|
||||
|
||||
dump(preprocessed, self.data_pickle_file)
|
||||
self.prepare_hyperopt_data()
|
||||
|
||||
# We don't need exchange instance anymore while running hyperopt
|
||||
self.backtesting.exchange = None # type: ignore
|
||||
self.backtesting.exchange.close()
|
||||
self.backtesting.exchange._api = None # type: ignore
|
||||
self.backtesting.exchange._api_async = None # type: ignore
|
||||
# self.backtesting.exchange = None # type: ignore
|
||||
self.backtesting.pairlists = None # type: ignore
|
||||
self.backtesting.strategy.dp = None # type: ignore
|
||||
IStrategy.dp = None # type: ignore
|
||||
|
||||
cpus = cpu_count()
|
||||
logger.info(f"Found {cpus} CPU cores. Let's make them scream!")
|
||||
config_jobs = self.config.get('hyperopt_jobs', -1)
|
||||
logger.info(f'Number of parallel jobs set as: {config_jobs}')
|
||||
|
||||
self.dimensions: List[Dimension] = self.hyperopt_space()
|
||||
self.opt = self.get_optimizer(self.dimensions, config_jobs)
|
||||
|
||||
if self.print_colorized:
|
||||
@@ -725,7 +446,7 @@ class Hyperopt:
|
||||
|
||||
logger.debug(f"Optimizer epoch evaluated: {val}")
|
||||
|
||||
is_best = self.is_best_loss(val, self.current_best_loss)
|
||||
is_best = HyperoptTools.is_best_loss(val, self.current_best_loss)
|
||||
# This value is assigned here and not in the optimization method
|
||||
# to keep proper order in the list of results. That's because
|
||||
# evaluations can take different time. Here they are aligned in the
|
||||
@@ -735,25 +456,21 @@ class Hyperopt:
|
||||
|
||||
if is_best:
|
||||
self.current_best_loss = val['loss']
|
||||
self.epochs.append(val)
|
||||
self.current_best_epoch = val
|
||||
|
||||
# Save results after each best epoch and every 100 epochs
|
||||
if is_best or current % 100 == 0:
|
||||
self._save_results()
|
||||
self._save_result(val)
|
||||
|
||||
pbar.update(current)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print('User interrupted..')
|
||||
|
||||
self._save_results()
|
||||
logger.info(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
|
||||
f"saved to '{self.results_file}'.")
|
||||
|
||||
if self.epochs:
|
||||
sorted_epochs = sorted(self.epochs, key=itemgetter('loss'))
|
||||
best_epoch = sorted_epochs[0]
|
||||
self.print_epoch_details(best_epoch, self.total_epochs, self.print_json)
|
||||
if self.current_best_epoch:
|
||||
HyperoptTools.show_epoch_details(self.current_best_epoch, self.total_epochs,
|
||||
self.print_json)
|
||||
else:
|
||||
# This is printed when Ctrl+C is pressed quickly, before first epochs have
|
||||
# a chance to be evaluated.
|
||||
|
89
freqtrade/optimize/hyperopt_auto.py
Normal file
89
freqtrade/optimize/hyperopt_auto.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
HyperOptAuto class.
|
||||
This module implements a convenience auto-hyperopt class, which can be used together with strategies
|
||||
that implement IHyperStrategy interface.
|
||||
"""
|
||||
from contextlib import suppress
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from pandas import DataFrame
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
from skopt.space import Dimension
|
||||
|
||||
from freqtrade.optimize.hyperopt_interface import IHyperOpt
|
||||
|
||||
|
||||
class HyperOptAuto(IHyperOpt):
|
||||
"""
|
||||
This class delegates functionality to Strategy(IHyperStrategy) and Strategy.HyperOpt classes.
|
||||
Most of the time Strategy.HyperOpt class would only implement indicator_space and
|
||||
sell_indicator_space methods, but other hyperopt methods can be overridden as well.
|
||||
"""
|
||||
|
||||
def buy_strategy_generator(self, params: Dict[str, Any]) -> Callable:
|
||||
def populate_buy_trend(dataframe: DataFrame, metadata: dict):
|
||||
for attr_name, attr in self.strategy.enumerate_parameters('buy'):
|
||||
if attr.optimize:
|
||||
# noinspection PyProtectedMember
|
||||
attr.value = params[attr_name]
|
||||
return self.strategy.populate_buy_trend(dataframe, metadata)
|
||||
|
||||
return populate_buy_trend
|
||||
|
||||
def sell_strategy_generator(self, params: Dict[str, Any]) -> Callable:
|
||||
def populate_sell_trend(dataframe: DataFrame, metadata: dict):
|
||||
for attr_name, attr in self.strategy.enumerate_parameters('sell'):
|
||||
if attr.optimize:
|
||||
# noinspection PyProtectedMember
|
||||
attr.value = params[attr_name]
|
||||
return self.strategy.populate_sell_trend(dataframe, metadata)
|
||||
|
||||
return populate_sell_trend
|
||||
|
||||
def _get_func(self, name) -> Callable:
|
||||
"""
|
||||
Return a function defined in Strategy.HyperOpt class, or one defined in super() class.
|
||||
:param name: function name.
|
||||
:return: a requested function.
|
||||
"""
|
||||
hyperopt_cls = getattr(self.strategy, 'HyperOpt', None)
|
||||
default_func = getattr(super(), name)
|
||||
if hyperopt_cls:
|
||||
return getattr(hyperopt_cls, name, default_func)
|
||||
else:
|
||||
return default_func
|
||||
|
||||
def _generate_indicator_space(self, category):
|
||||
for attr_name, attr in self.strategy.enumerate_parameters(category):
|
||||
if attr.optimize:
|
||||
yield attr.get_space(attr_name)
|
||||
|
||||
def _get_indicator_space(self, category, fallback_method_name):
|
||||
indicator_space = list(self._generate_indicator_space(category))
|
||||
if len(indicator_space) > 0:
|
||||
return indicator_space
|
||||
else:
|
||||
return self._get_func(fallback_method_name)()
|
||||
|
||||
def indicator_space(self) -> List['Dimension']:
|
||||
return self._get_indicator_space('buy', 'indicator_space')
|
||||
|
||||
def sell_indicator_space(self) -> List['Dimension']:
|
||||
return self._get_indicator_space('sell', 'sell_indicator_space')
|
||||
|
||||
def generate_roi_table(self, params: Dict) -> Dict[int, float]:
|
||||
return self._get_func('generate_roi_table')(params)
|
||||
|
||||
def roi_space(self) -> List['Dimension']:
|
||||
return self._get_func('roi_space')()
|
||||
|
||||
def stoploss_space(self) -> List['Dimension']:
|
||||
return self._get_func('stoploss_space')()
|
||||
|
||||
def generate_trailing_params(self, params: Dict) -> Dict:
|
||||
return self._get_func('generate_trailing_params')(params)
|
||||
|
||||
def trailing_space(self) -> List['Dimension']:
|
||||
return self._get_func('trailing_space')()
|
@@ -7,11 +7,13 @@ import math
|
||||
from abc import ABC
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from skopt.space import Categorical, Dimension, Integer, Real
|
||||
from skopt.space import Categorical, Dimension, Integer
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import timeframe_to_minutes
|
||||
from freqtrade.misc import round_dict
|
||||
from freqtrade.optimize.space import SKDecimal
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,10 +32,11 @@ class IHyperOpt(ABC):
|
||||
Defines the mandatory structure must follow any custom hyperopt
|
||||
|
||||
Class attributes you can use:
|
||||
ticker_interval -> int: value of the ticker interval to use for the strategy
|
||||
timeframe -> int: value of the timeframe to use for the strategy
|
||||
"""
|
||||
ticker_interval: str # DEPRECATED
|
||||
timeframe: str
|
||||
strategy: IStrategy
|
||||
|
||||
def __init__(self, config: dict) -> None:
|
||||
self.config = config
|
||||
@@ -42,36 +45,31 @@ class IHyperOpt(ABC):
|
||||
IHyperOpt.ticker_interval = str(config['timeframe']) # DEPRECATED
|
||||
IHyperOpt.timeframe = str(config['timeframe'])
|
||||
|
||||
@staticmethod
|
||||
def buy_strategy_generator(params: Dict[str, Any]) -> Callable:
|
||||
def buy_strategy_generator(self, params: Dict[str, Any]) -> Callable:
|
||||
"""
|
||||
Create a buy strategy generator.
|
||||
"""
|
||||
raise OperationalException(_format_exception_message('buy_strategy_generator', 'buy'))
|
||||
|
||||
@staticmethod
|
||||
def sell_strategy_generator(params: Dict[str, Any]) -> Callable:
|
||||
def sell_strategy_generator(self, params: Dict[str, Any]) -> Callable:
|
||||
"""
|
||||
Create a sell strategy generator.
|
||||
"""
|
||||
raise OperationalException(_format_exception_message('sell_strategy_generator', 'sell'))
|
||||
|
||||
@staticmethod
|
||||
def indicator_space() -> List[Dimension]:
|
||||
def indicator_space(self) -> List[Dimension]:
|
||||
"""
|
||||
Create an indicator space.
|
||||
"""
|
||||
raise OperationalException(_format_exception_message('indicator_space', 'buy'))
|
||||
|
||||
@staticmethod
|
||||
def sell_indicator_space() -> List[Dimension]:
|
||||
def sell_indicator_space(self) -> List[Dimension]:
|
||||
"""
|
||||
Create a sell indicator space.
|
||||
"""
|
||||
raise OperationalException(_format_exception_message('sell_indicator_space', 'sell'))
|
||||
|
||||
@staticmethod
|
||||
def generate_roi_table(params: Dict) -> Dict[int, float]:
|
||||
def generate_roi_table(self, params: Dict) -> Dict[int, float]:
|
||||
"""
|
||||
Create a ROI table.
|
||||
|
||||
@@ -86,8 +84,7 @@ class IHyperOpt(ABC):
|
||||
|
||||
return roi_table
|
||||
|
||||
@staticmethod
|
||||
def roi_space() -> List[Dimension]:
|
||||
def roi_space(self) -> List[Dimension]:
|
||||
"""
|
||||
Create a ROI space.
|
||||
|
||||
@@ -95,7 +92,7 @@ class IHyperOpt(ABC):
|
||||
|
||||
This method implements adaptive roi hyperspace with varied
|
||||
ranges for parameters which automatically adapts to the
|
||||
ticker interval used.
|
||||
timeframe used.
|
||||
|
||||
It's used by Freqtrade by default, if no custom roi_space method is defined.
|
||||
"""
|
||||
@@ -107,7 +104,7 @@ class IHyperOpt(ABC):
|
||||
roi_t_alpha = 1.0
|
||||
roi_p_alpha = 1.0
|
||||
|
||||
timeframe_min = timeframe_to_minutes(IHyperOpt.ticker_interval)
|
||||
timeframe_min = timeframe_to_minutes(self.timeframe)
|
||||
|
||||
# We define here limits for the ROI space parameters automagically adapted to the
|
||||
# timeframe used by the bot:
|
||||
@@ -117,7 +114,7 @@ class IHyperOpt(ABC):
|
||||
# * 'roi_p' (limits for the ROI value steps) components are scaled logarithmically.
|
||||
#
|
||||
# The scaling is designed so that it maps exactly to the legacy Freqtrade roi_space()
|
||||
# method for the 5m ticker interval.
|
||||
# method for the 5m timeframe.
|
||||
roi_t_scale = timeframe_min / 5
|
||||
roi_p_scale = math.log1p(timeframe_min) / math.log1p(5)
|
||||
roi_limits = {
|
||||
@@ -143,7 +140,7 @@ class IHyperOpt(ABC):
|
||||
'roi_p2': roi_limits['roi_p2_min'],
|
||||
'roi_p3': roi_limits['roi_p3_min'],
|
||||
}
|
||||
logger.info(f"Min roi table: {round_dict(IHyperOpt.generate_roi_table(p), 5)}")
|
||||
logger.info(f"Min roi table: {round_dict(self.generate_roi_table(p), 3)}")
|
||||
p = {
|
||||
'roi_t1': roi_limits['roi_t1_max'],
|
||||
'roi_t2': roi_limits['roi_t2_max'],
|
||||
@@ -152,19 +149,21 @@ class IHyperOpt(ABC):
|
||||
'roi_p2': roi_limits['roi_p2_max'],
|
||||
'roi_p3': roi_limits['roi_p3_max'],
|
||||
}
|
||||
logger.info(f"Max roi table: {round_dict(IHyperOpt.generate_roi_table(p), 5)}")
|
||||
logger.info(f"Max roi table: {round_dict(self.generate_roi_table(p), 3)}")
|
||||
|
||||
return [
|
||||
Integer(roi_limits['roi_t1_min'], roi_limits['roi_t1_max'], name='roi_t1'),
|
||||
Integer(roi_limits['roi_t2_min'], roi_limits['roi_t2_max'], name='roi_t2'),
|
||||
Integer(roi_limits['roi_t3_min'], roi_limits['roi_t3_max'], name='roi_t3'),
|
||||
Real(roi_limits['roi_p1_min'], roi_limits['roi_p1_max'], name='roi_p1'),
|
||||
Real(roi_limits['roi_p2_min'], roi_limits['roi_p2_max'], name='roi_p2'),
|
||||
Real(roi_limits['roi_p3_min'], roi_limits['roi_p3_max'], name='roi_p3'),
|
||||
SKDecimal(roi_limits['roi_p1_min'], roi_limits['roi_p1_max'], decimals=3,
|
||||
name='roi_p1'),
|
||||
SKDecimal(roi_limits['roi_p2_min'], roi_limits['roi_p2_max'], decimals=3,
|
||||
name='roi_p2'),
|
||||
SKDecimal(roi_limits['roi_p3_min'], roi_limits['roi_p3_max'], decimals=3,
|
||||
name='roi_p3'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def stoploss_space() -> List[Dimension]:
|
||||
def stoploss_space(self) -> List[Dimension]:
|
||||
"""
|
||||
Create a stoploss space.
|
||||
|
||||
@@ -172,11 +171,10 @@ class IHyperOpt(ABC):
|
||||
You may override it in your custom Hyperopt class.
|
||||
"""
|
||||
return [
|
||||
Real(-0.35, -0.02, name='stoploss'),
|
||||
SKDecimal(-0.35, -0.02, decimals=3, name='stoploss'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def generate_trailing_params(params: Dict) -> Dict:
|
||||
def generate_trailing_params(self, params: Dict) -> Dict:
|
||||
"""
|
||||
Create dict with trailing stop parameters.
|
||||
"""
|
||||
@@ -188,8 +186,7 @@ class IHyperOpt(ABC):
|
||||
'trailing_only_offset_is_reached': params['trailing_only_offset_is_reached'],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def trailing_space() -> List[Dimension]:
|
||||
def trailing_space(self) -> List[Dimension]:
|
||||
"""
|
||||
Create a trailing stoploss space.
|
||||
|
||||
@@ -204,14 +201,14 @@ class IHyperOpt(ABC):
|
||||
# other 'trailing' hyperspace parameters.
|
||||
Categorical([True], name='trailing_stop'),
|
||||
|
||||
Real(0.01, 0.35, name='trailing_stop_positive'),
|
||||
SKDecimal(0.01, 0.35, decimals=3, name='trailing_stop_positive'),
|
||||
|
||||
# 'trailing_stop_positive_offset' should be greater than 'trailing_stop_positive',
|
||||
# so this intermediate parameter is used as the value of the difference between
|
||||
# them. The value of the 'trailing_stop_positive_offset' is constructed in the
|
||||
# generate_trailing_params() method.
|
||||
# This is similar to the hyperspace dimensions used for constructing the ROI tables.
|
||||
Real(0.001, 0.1, name='trailing_stop_positive_offset_p1'),
|
||||
SKDecimal(0.001, 0.1, decimals=3, name='trailing_stop_positive_offset_p1'),
|
||||
|
||||
Categorical([True, False], name='trailing_only_offset_is_reached'),
|
||||
]
|
||||
|
@@ -5,6 +5,7 @@ This module defines the interface for the loss-function for hyperopt
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -19,7 +20,9 @@ class IHyperOptLoss(ABC):
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def hyperopt_loss_function(results: DataFrame, trade_count: int,
|
||||
min_date: datetime, max_date: datetime, *args, **kwargs) -> float:
|
||||
min_date: datetime, max_date: datetime,
|
||||
config: Dict, processed: Dict[str, DataFrame],
|
||||
*args, **kwargs) -> float:
|
||||
"""
|
||||
Objective function, returns smaller number for better results
|
||||
"""
|
||||
|
@@ -9,23 +9,11 @@ from pandas import DataFrame
|
||||
from freqtrade.optimize.hyperopt import IHyperOptLoss
|
||||
|
||||
|
||||
# This is assumed to be expected avg profit * expected trade count.
|
||||
# For example, for 0.35% avg per trade (or 0.0035 as ratio) and 1100 trades,
|
||||
# expected max profit = 3.85
|
||||
#
|
||||
# Note, this is ratio. 3.85 stated above means 385Σ%, 3.0 means 300Σ%.
|
||||
#
|
||||
# In this implementation it's only used in calculation of the resulting value
|
||||
# of the objective function as a normalization coefficient and does not
|
||||
# represent any limit for profits as in the Freqtrade legacy default loss function.
|
||||
EXPECTED_MAX_PROFIT = 3.0
|
||||
|
||||
|
||||
class OnlyProfitHyperOptLoss(IHyperOptLoss):
|
||||
"""
|
||||
Defines the loss function for hyperopt.
|
||||
|
||||
This implementation takes only profit into account.
|
||||
This implementation takes only absolute profit into account, not looking at any other indicator.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -34,5 +22,5 @@ class OnlyProfitHyperOptLoss(IHyperOptLoss):
|
||||
"""
|
||||
Objective function, returns smaller number for better results.
|
||||
"""
|
||||
total_profit = results['profit_ratio'].sum()
|
||||
return 1 - total_profit / EXPECTED_MAX_PROFIT
|
||||
total_profit = results['profit_abs'].sum()
|
||||
return -1 * total_profit
|
||||
|
410
freqtrade/optimize/hyperopt_tools.py
Normal file
410
freqtrade/optimize/hyperopt_tools.py
Normal file
@@ -0,0 +1,410 @@
|
||||
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import rapidjson
|
||||
import tabulate
|
||||
from colorama import Fore, Style
|
||||
from pandas import isna, json_normalize
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import round_coin_value, round_dict
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HyperoptTools():
|
||||
|
||||
@staticmethod
|
||||
def has_space(config: Dict[str, Any], space: str) -> bool:
|
||||
"""
|
||||
Tell if the space value is contained in the configuration
|
||||
"""
|
||||
# The 'trailing' space is not included in the 'default' set of spaces
|
||||
if space == 'trailing':
|
||||
return any(s in config['spaces'] for s in [space, 'all'])
|
||||
else:
|
||||
return any(s in config['spaces'] for s in [space, 'all', 'default'])
|
||||
|
||||
@staticmethod
|
||||
def _read_results_pickle(results_file: Path) -> List:
|
||||
"""
|
||||
Read hyperopt results from pickle file
|
||||
LEGACY method - new files are written as json and cannot be read with this method.
|
||||
"""
|
||||
from joblib import load
|
||||
|
||||
logger.info(f"Reading pickled epochs from '{results_file}'")
|
||||
data = load(results_file)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _read_results(results_file: Path) -> List:
|
||||
"""
|
||||
Read hyperopt results from file
|
||||
"""
|
||||
import rapidjson
|
||||
logger.info(f"Reading epochs from '{results_file}'")
|
||||
with results_file.open('r') as f:
|
||||
data = [rapidjson.loads(line) for line in f]
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def load_previous_results(results_file: Path) -> List:
|
||||
"""
|
||||
Load data for epochs from the file if we have one
|
||||
"""
|
||||
epochs: List = []
|
||||
if results_file.is_file() and results_file.stat().st_size > 0:
|
||||
if results_file.suffix == '.pickle':
|
||||
epochs = HyperoptTools._read_results_pickle(results_file)
|
||||
else:
|
||||
epochs = HyperoptTools._read_results(results_file)
|
||||
# Detection of some old format, without 'is_best' field saved
|
||||
if epochs[0].get('is_best') is None:
|
||||
raise OperationalException(
|
||||
"The file with HyperoptTools results is incompatible with this version "
|
||||
"of Freqtrade and cannot be loaded.")
|
||||
logger.info(f"Loaded {len(epochs)} previous evaluations from disk.")
|
||||
return epochs
|
||||
|
||||
@staticmethod
|
||||
def show_epoch_details(results, total_epochs: int, print_json: bool,
|
||||
no_header: bool = False, header_str: str = None) -> None:
|
||||
"""
|
||||
Display details of the hyperopt result
|
||||
"""
|
||||
params = results.get('params_details', {})
|
||||
non_optimized = results.get('params_not_optimized', {})
|
||||
|
||||
# Default header string
|
||||
if header_str is None:
|
||||
header_str = "Best result"
|
||||
|
||||
if not no_header:
|
||||
explanation_str = HyperoptTools._format_explanation_string(results, total_epochs)
|
||||
print(f"\n{header_str}:\n\n{explanation_str}\n")
|
||||
|
||||
if print_json:
|
||||
result_dict: Dict = {}
|
||||
for s in ['buy', 'sell', 'roi', 'stoploss', 'trailing']:
|
||||
HyperoptTools._params_update_for_json(result_dict, params, s)
|
||||
print(rapidjson.dumps(result_dict, default=str, number_mode=rapidjson.NM_NATIVE))
|
||||
|
||||
else:
|
||||
HyperoptTools._params_pretty_print(params, 'buy', "Buy hyperspace params:",
|
||||
non_optimized)
|
||||
HyperoptTools._params_pretty_print(params, 'sell', "Sell hyperspace params:",
|
||||
non_optimized)
|
||||
HyperoptTools._params_pretty_print(params, 'roi', "ROI table:")
|
||||
HyperoptTools._params_pretty_print(params, 'stoploss', "Stoploss:")
|
||||
HyperoptTools._params_pretty_print(params, 'trailing', "Trailing stop:")
|
||||
|
||||
@staticmethod
|
||||
def _params_update_for_json(result_dict, params, space: str) -> None:
|
||||
if space in params:
|
||||
space_params = HyperoptTools._space_params(params, space)
|
||||
if space in ['buy', 'sell']:
|
||||
result_dict.setdefault('params', {}).update(space_params)
|
||||
elif space == 'roi':
|
||||
# Convert keys in min_roi dict to strings because
|
||||
# rapidjson cannot dump dicts with integer keys...
|
||||
result_dict['minimal_roi'] = {str(k): v for k, v in space_params.items()}
|
||||
else: # 'stoploss', 'trailing'
|
||||
result_dict.update(space_params)
|
||||
|
||||
@staticmethod
|
||||
def _params_pretty_print(params, space: str, header: str, non_optimized={}) -> None:
|
||||
if space in params or space in non_optimized:
|
||||
space_params = HyperoptTools._space_params(params, space, 5)
|
||||
result = f"\n# {header}\n"
|
||||
if space == 'stoploss':
|
||||
result += f"stoploss = {space_params.get('stoploss')}"
|
||||
elif space == 'roi':
|
||||
minimal_roi_result = rapidjson.dumps({
|
||||
str(k): v for k, v in space_params.items()
|
||||
}, default=str, indent=4, number_mode=rapidjson.NM_NATIVE)
|
||||
result += f"minimal_roi = {minimal_roi_result}"
|
||||
elif space == 'trailing':
|
||||
|
||||
for k, v in space_params.items():
|
||||
result += f'{k} = {v}\n'
|
||||
|
||||
else:
|
||||
no_params = HyperoptTools._space_params(non_optimized, space, 5)
|
||||
|
||||
result += f"{space}_params = {HyperoptTools._pprint(space_params, no_params)}"
|
||||
|
||||
result = result.replace("\n", "\n ")
|
||||
print(result)
|
||||
|
||||
@staticmethod
|
||||
def _space_params(params, space: str, r: int = None) -> Dict:
|
||||
d = params.get(space)
|
||||
if d:
|
||||
# Round floats to `r` digits after the decimal point if requested
|
||||
return round_dict(d, r) if r else d
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _pprint(params, non_optimized, indent: int = 4):
|
||||
"""
|
||||
Pretty-print hyperopt results (based on 2 dicts - with add. comment)
|
||||
"""
|
||||
p = params.copy()
|
||||
p.update(non_optimized)
|
||||
result = '{\n'
|
||||
|
||||
for k, param in p.items():
|
||||
result += " " * indent + f'"{k}": '
|
||||
result += f'"{param}",' if isinstance(param, str) else f'{param},'
|
||||
if k in non_optimized:
|
||||
result += " # value loaded from strategy"
|
||||
result += "\n"
|
||||
result += '}'
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def is_best_loss(results, current_best_loss: float) -> bool:
|
||||
return bool(results['loss'] < current_best_loss)
|
||||
|
||||
@staticmethod
|
||||
def format_results_explanation_string(results_metrics: Dict, stake_currency: str) -> str:
|
||||
"""
|
||||
Return the formatted results explanation in a string
|
||||
"""
|
||||
return (f"{results_metrics['total_trades']:6d} trades. "
|
||||
f"{results_metrics['wins']}/{results_metrics['draws']}"
|
||||
f"/{results_metrics['losses']} Wins/Draws/Losses. "
|
||||
f"Avg profit {results_metrics['profit_mean'] * 100: 6.2f}%. "
|
||||
f"Median profit {results_metrics['profit_median'] * 100: 6.2f}%. "
|
||||
f"Total profit {results_metrics['profit_total_abs']: 11.8f} {stake_currency} "
|
||||
f"({results_metrics['profit_total'] * 100: 7.2f}%). "
|
||||
f"Avg duration {results_metrics['holding_avg']} min."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_explanation_string(results, total_epochs) -> str:
|
||||
return (("*" if results['is_initial_point'] else " ") +
|
||||
f"{results['current_epoch']:5d}/{total_epochs}: " +
|
||||
f"{results['results_explanation']} " +
|
||||
f"Objective: {results['loss']:.5f}")
|
||||
|
||||
@staticmethod
|
||||
def prepare_trials_columns(trials, legacy_mode: bool, has_drawdown: bool) -> str:
|
||||
|
||||
trials['Best'] = ''
|
||||
|
||||
if 'results_metrics.winsdrawslosses' not in trials.columns:
|
||||
# Ensure compatibility with older versions of hyperopt results
|
||||
trials['results_metrics.winsdrawslosses'] = 'N/A'
|
||||
|
||||
if not has_drawdown:
|
||||
# Ensure compatibility with older versions of hyperopt results
|
||||
trials['results_metrics.max_drawdown_abs'] = None
|
||||
trials['results_metrics.max_drawdown'] = None
|
||||
|
||||
if not legacy_mode:
|
||||
# New mode, using backtest result for metrics
|
||||
trials['results_metrics.winsdrawslosses'] = trials.apply(
|
||||
lambda x: f"{x['results_metrics.wins']} {x['results_metrics.draws']:>4} "
|
||||
f"{x['results_metrics.losses']:>4}", axis=1)
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.total_trades',
|
||||
'results_metrics.winsdrawslosses',
|
||||
'results_metrics.profit_mean', 'results_metrics.profit_total_abs',
|
||||
'results_metrics.profit_total', 'results_metrics.holding_avg',
|
||||
'results_metrics.max_drawdown', 'results_metrics.max_drawdown_abs',
|
||||
'loss', 'is_initial_point', 'is_best']]
|
||||
|
||||
else:
|
||||
# Legacy mode
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count',
|
||||
'results_metrics.winsdrawslosses', 'results_metrics.avg_profit',
|
||||
'results_metrics.total_profit', 'results_metrics.profit',
|
||||
'results_metrics.duration', 'results_metrics.max_drawdown',
|
||||
'results_metrics.max_drawdown_abs', 'loss', 'is_initial_point',
|
||||
'is_best']]
|
||||
|
||||
trials.columns = ['Best', 'Epoch', 'Trades', ' Win Draw Loss', 'Avg profit',
|
||||
'Total profit', 'Profit', 'Avg duration', 'Max Drawdown',
|
||||
'max_drawdown_abs', 'Objective', 'is_initial_point', 'is_best']
|
||||
|
||||
return trials
|
||||
|
||||
@staticmethod
|
||||
def get_result_table(config: dict, results: list, total_epochs: int, highlight_best: bool,
|
||||
print_colorized: bool, remove_header: int) -> str:
|
||||
"""
|
||||
Log result table
|
||||
"""
|
||||
if not results:
|
||||
return ''
|
||||
|
||||
tabulate.PRESERVE_WHITESPACE = True
|
||||
trials = json_normalize(results, max_level=1)
|
||||
|
||||
legacy_mode = 'results_metrics.total_trades' not in trials
|
||||
has_drawdown = 'results_metrics.max_drawdown_abs' in trials.columns
|
||||
|
||||
trials = HyperoptTools.prepare_trials_columns(trials, legacy_mode, has_drawdown)
|
||||
|
||||
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['Trades'] = trials['Trades'].astype(str)
|
||||
perc_multi = 1 if legacy_mode else 100
|
||||
trials['Epoch'] = trials['Epoch'].apply(
|
||||
lambda x: '{}/{}'.format(str(x).rjust(len(str(total_epochs)), ' '), total_epochs)
|
||||
)
|
||||
trials['Avg profit'] = trials['Avg profit'].apply(
|
||||
lambda x: f'{x * perc_multi:,.2f}%'.rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
|
||||
)
|
||||
trials['Avg duration'] = trials['Avg duration'].apply(
|
||||
lambda x: f'{x:,.1f} m'.rjust(7, ' ') if isinstance(x, float) else f"{x}"
|
||||
if not isna(x) else "--".rjust(7, ' ')
|
||||
)
|
||||
trials['Objective'] = trials['Objective'].apply(
|
||||
lambda x: f'{x:,.5f}'.rjust(8, ' ') if x != 100000 else "N/A".rjust(8, ' ')
|
||||
)
|
||||
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
if has_drawdown:
|
||||
trials['Max Drawdown'] = trials.apply(
|
||||
lambda x: '{} {}'.format(
|
||||
round_coin_value(x['max_drawdown_abs'], stake_currency),
|
||||
'({:,.2f}%)'.format(x['Max Drawdown'] * perc_multi).rjust(10, ' ')
|
||||
).rjust(25 + len(stake_currency))
|
||||
if x['Max Drawdown'] != 0.0 else '--'.rjust(25 + len(stake_currency)),
|
||||
axis=1
|
||||
)
|
||||
else:
|
||||
trials = trials.drop(columns=['Max Drawdown'])
|
||||
|
||||
trials = trials.drop(columns=['max_drawdown_abs'])
|
||||
|
||||
trials['Profit'] = trials.apply(
|
||||
lambda x: '{} {}'.format(
|
||||
round_coin_value(x['Total profit'], stake_currency),
|
||||
'({:,.2f}%)'.format(x['Profit'] * perc_multi).rjust(10, ' ')
|
||||
).rjust(25+len(stake_currency))
|
||||
if x['Total profit'] != 0.0 else '--'.rjust(25+len(stake_currency)),
|
||||
axis=1
|
||||
)
|
||||
trials = trials.drop(columns=['Total profit'])
|
||||
|
||||
if print_colorized:
|
||||
for i in range(len(trials)):
|
||||
if trials.loc[i]['is_profit']:
|
||||
for j in range(len(trials.loc[i])-3):
|
||||
trials.iat[i, j] = "{}{}{}".format(Fore.GREEN,
|
||||
str(trials.loc[i][j]), Fore.RESET)
|
||||
if trials.loc[i]['is_best'] and highlight_best:
|
||||
for j in range(len(trials.loc[i])-3):
|
||||
trials.iat[i, j] = "{}{}{}".format(Style.BRIGHT,
|
||||
str(trials.loc[i][j]), Style.RESET_ALL)
|
||||
|
||||
trials = trials.drop(columns=['is_initial_point', 'is_best', 'is_profit'])
|
||||
if remove_header > 0:
|
||||
table = tabulate.tabulate(
|
||||
trials.to_dict(orient='list'), tablefmt='orgtbl',
|
||||
headers='keys', stralign="right"
|
||||
)
|
||||
|
||||
table = table.split("\n", remove_header)[remove_header]
|
||||
elif remove_header < 0:
|
||||
table = tabulate.tabulate(
|
||||
trials.to_dict(orient='list'), tablefmt='psql',
|
||||
headers='keys', stralign="right"
|
||||
)
|
||||
table = "\n".join(table.split("\n")[0:remove_header])
|
||||
else:
|
||||
table = tabulate.tabulate(
|
||||
trials.to_dict(orient='list'), tablefmt='psql',
|
||||
headers='keys', stralign="right"
|
||||
)
|
||||
return table
|
||||
|
||||
@staticmethod
|
||||
def export_csv_file(config: dict, results: list, total_epochs: int, highlight_best: bool,
|
||||
csv_file: str) -> None:
|
||||
"""
|
||||
Log result to csv-file
|
||||
"""
|
||||
if not results:
|
||||
return
|
||||
|
||||
# Verification for overwrite
|
||||
if Path(csv_file).is_file():
|
||||
logger.error(f"CSV file already exists: {csv_file}")
|
||||
return
|
||||
|
||||
try:
|
||||
io.open(csv_file, 'w+').close()
|
||||
except IOError:
|
||||
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']
|
||||
|
||||
if 'results_metrics.total_trades' in trials:
|
||||
base_metrics = ['Best', 'current_epoch', 'results_metrics.total_trades',
|
||||
'results_metrics.profit_mean', 'results_metrics.profit_median',
|
||||
'results_metrics.profit_total',
|
||||
'Stake currency',
|
||||
'results_metrics.profit_total_abs', 'results_metrics.holding_avg',
|
||||
'loss', 'is_initial_point', 'is_best']
|
||||
perc_multi = 100
|
||||
else:
|
||||
perc_multi = 1
|
||||
base_metrics = ['Best', 'current_epoch', 'results_metrics.trade_count',
|
||||
'results_metrics.avg_profit', 'results_metrics.median_profit',
|
||||
'results_metrics.total_profit',
|
||||
'Stake currency', 'results_metrics.profit', 'results_metrics.duration',
|
||||
'loss', 'is_initial_point', 'is_best']
|
||||
param_metrics = [("params_dict."+param) for param in results[0]['params_dict'].keys()]
|
||||
trials = trials[base_metrics + param_metrics]
|
||||
|
||||
base_columns = ['Best', 'Epoch', 'Trades', 'Avg profit', 'Median 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)
|
||||
trials['Median profit'] = trials['Median profit'] * perc_multi
|
||||
|
||||
trials['Total profit'] = trials['Total profit'].apply(
|
||||
lambda x: f'{x:,.8f}' if x != 0.0 else ""
|
||||
)
|
||||
trials['Profit'] = trials['Profit'].apply(
|
||||
lambda x: f'{x:,.2f}' if not isna(x) else ""
|
||||
)
|
||||
trials['Avg profit'] = trials['Avg profit'].apply(
|
||||
lambda x: f'{x * perc_multi:,.2f}%' if not isna(x) else ""
|
||||
)
|
||||
if perc_multi == 1:
|
||||
trials['Avg duration'] = trials['Avg duration'].apply(
|
||||
lambda x: f'{x:,.1f} m' if isinstance(
|
||||
x, float) else f"{x.total_seconds() // 60:,.1f} m" if not isna(x) else ""
|
||||
)
|
||||
trials['Objective'] = trials['Objective'].apply(
|
||||
lambda x: f'{x:,.5f}' if x != 100000 else ""
|
||||
)
|
||||
|
||||
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')
|
||||
logger.info(f"CSV file created: {csv_file}")
|
@@ -3,13 +3,13 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from arrow import Arrow
|
||||
from numpy import int64
|
||||
from pandas import DataFrame
|
||||
from tabulate import tabulate
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN
|
||||
from freqtrade.data.btanalysis import calculate_market_change, calculate_max_drawdown
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT
|
||||
from freqtrade.data.btanalysis import (calculate_csum, calculate_market_change,
|
||||
calculate_max_drawdown)
|
||||
from freqtrade.misc import decimals_per_coin, file_dump_json, round_coin_value
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def _get_line_floatfmt(stake_currency: str) -> List[str]:
|
||||
Generate floatformat (goes in line with _generate_result_line())
|
||||
"""
|
||||
return ['s', 'd', '.2f', '.2f', f'.{decimals_per_coin(stake_currency)}f',
|
||||
'.2f', 'd', 'd', 'd', 'd']
|
||||
'.2f', 'd', 's', 's']
|
||||
|
||||
|
||||
def _get_line_header(first_column: str, stake_currency: str) -> List[str]:
|
||||
@@ -52,15 +52,26 @@ def _get_line_header(first_column: str, stake_currency: str) -> List[str]:
|
||||
"""
|
||||
return [first_column, 'Buys', 'Avg Profit %', 'Cum Profit %',
|
||||
f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration',
|
||||
'Wins', 'Draws', 'Losses']
|
||||
'Win Draw Loss Win%']
|
||||
|
||||
|
||||
def _generate_result_line(result: DataFrame, max_open_trades: int, first_column: str) -> Dict:
|
||||
def _generate_wins_draws_losses(wins, draws, losses):
|
||||
if wins > 0 and losses == 0:
|
||||
wl_ratio = '100'
|
||||
elif wins == 0:
|
||||
wl_ratio = '0'
|
||||
else:
|
||||
wl_ratio = f'{100.0 / (wins + draws + losses) * wins:.1f}' if losses > 0 else '100'
|
||||
return f'{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}'
|
||||
|
||||
|
||||
def _generate_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict:
|
||||
"""
|
||||
Generate one result dict, with "first_column" as key.
|
||||
"""
|
||||
profit_sum = result['profit_ratio'].sum()
|
||||
profit_total = profit_sum / max_open_trades
|
||||
# (end-capital - starting capital) / starting capital
|
||||
profit_total = result['profit_abs'].sum() / starting_balance
|
||||
|
||||
return {
|
||||
'key': first_column,
|
||||
@@ -87,13 +98,13 @@ def _generate_result_line(result: DataFrame, max_open_trades: int, first_column:
|
||||
}
|
||||
|
||||
|
||||
def generate_pair_metrics(data: Dict[str, Dict], stake_currency: str, max_open_trades: int,
|
||||
def generate_pair_metrics(data: Dict[str, Dict], stake_currency: str, starting_balance: 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 starting_balance: Starting balance
|
||||
:param results: Dataframe containing the backtest results
|
||||
:param skip_nan: Print "left open" open trades
|
||||
:return: List of Dicts containing the metrics per pair
|
||||
@@ -106,10 +117,13 @@ def generate_pair_metrics(data: Dict[str, Dict], stake_currency: str, max_open_t
|
||||
if skip_nan and result['profit_abs'].isnull().all():
|
||||
continue
|
||||
|
||||
tabular_data.append(_generate_result_line(result, max_open_trades, pair))
|
||||
tabular_data.append(_generate_result_line(result, starting_balance, pair))
|
||||
|
||||
# Sort by total profit %:
|
||||
tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True)
|
||||
|
||||
# Append Total
|
||||
tabular_data.append(_generate_result_line(results, max_open_trades, 'TOTAL'))
|
||||
tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL'))
|
||||
return tabular_data
|
||||
|
||||
|
||||
@@ -131,7 +145,7 @@ def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List
|
||||
|
||||
tabular_data.append(
|
||||
{
|
||||
'sell_reason': reason.value,
|
||||
'sell_reason': reason,
|
||||
'trades': count,
|
||||
'wins': len(result[result['profit_abs'] > 0]),
|
||||
'draws': len(result[result['profit_abs'] == 0]),
|
||||
@@ -148,7 +162,7 @@ def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List
|
||||
return tabular_data
|
||||
|
||||
|
||||
def generate_strategy_metrics(all_results: Dict) -> List[Dict]:
|
||||
def generate_strategy_comparison(all_results: Dict) -> List[Dict]:
|
||||
"""
|
||||
Generate summary per strategy
|
||||
:param all_results: Dict of <Strategyname: DataFrame> containing results for all strategies
|
||||
@@ -158,8 +172,19 @@ def generate_strategy_metrics(all_results: Dict) -> List[Dict]:
|
||||
tabular_data = []
|
||||
for strategy, results in all_results.items():
|
||||
tabular_data.append(_generate_result_line(
|
||||
results['results'], results['config']['max_open_trades'], strategy)
|
||||
results['results'], results['config']['dry_run_wallet'], strategy)
|
||||
)
|
||||
try:
|
||||
max_drawdown_per, _, _, _, _ = calculate_max_drawdown(results['results'],
|
||||
value_col='profit_ratio')
|
||||
max_drawdown_abs, _, _, _, _ = calculate_max_drawdown(results['results'],
|
||||
value_col='profit_abs')
|
||||
except ValueError:
|
||||
max_drawdown_per = 0
|
||||
max_drawdown_abs = 0
|
||||
tabular_data[-1]['max_drawdown_per'] = round(max_drawdown_per * 100, 2)
|
||||
tabular_data[-1]['max_drawdown_abs'] = \
|
||||
round_coin_value(max_drawdown_abs, results['config']['stake_currency'], False)
|
||||
return tabular_data
|
||||
|
||||
|
||||
@@ -189,43 +214,221 @@ def generate_edge_table(results: dict) -> str:
|
||||
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore
|
||||
|
||||
|
||||
def generate_trading_stats(results: DataFrame) -> Dict[str, Any]:
|
||||
""" Generate overall trade statistics """
|
||||
if len(results) == 0:
|
||||
return {
|
||||
'wins': 0,
|
||||
'losses': 0,
|
||||
'draws': 0,
|
||||
'holding_avg': timedelta(),
|
||||
'winner_holding_avg': timedelta(),
|
||||
'loser_holding_avg': timedelta(),
|
||||
}
|
||||
|
||||
winning_trades = results.loc[results['profit_ratio'] > 0]
|
||||
draw_trades = results.loc[results['profit_ratio'] == 0]
|
||||
losing_trades = results.loc[results['profit_ratio'] < 0]
|
||||
zero_duration_trades = len(results.loc[(results['trade_duration'] == 0) &
|
||||
(results['sell_reason'] == 'trailing_stop_loss')])
|
||||
|
||||
holding_avg = (timedelta(minutes=round(results['trade_duration'].mean()))
|
||||
if not results.empty else timedelta())
|
||||
winner_holding_avg = (timedelta(minutes=round(winning_trades['trade_duration'].mean()))
|
||||
if not winning_trades.empty else timedelta())
|
||||
loser_holding_avg = (timedelta(minutes=round(losing_trades['trade_duration'].mean()))
|
||||
if not losing_trades.empty else timedelta())
|
||||
|
||||
return {
|
||||
'wins': len(winning_trades),
|
||||
'losses': len(losing_trades),
|
||||
'draws': len(draw_trades),
|
||||
'holding_avg': holding_avg,
|
||||
'holding_avg_s': holding_avg.total_seconds(),
|
||||
'winner_holding_avg': winner_holding_avg,
|
||||
'winner_holding_avg_s': winner_holding_avg.total_seconds(),
|
||||
'loser_holding_avg': loser_holding_avg,
|
||||
'loser_holding_avg_s': loser_holding_avg.total_seconds(),
|
||||
'zero_duration_trades': zero_duration_trades,
|
||||
}
|
||||
|
||||
|
||||
def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
|
||||
""" Generate daily statistics """
|
||||
if len(results) == 0:
|
||||
return {
|
||||
'backtest_best_day': 0,
|
||||
'backtest_worst_day': 0,
|
||||
'backtest_best_day_abs': 0,
|
||||
'backtest_worst_day_abs': 0,
|
||||
'winning_days': 0,
|
||||
'draw_days': 0,
|
||||
'losing_days': 0,
|
||||
'winner_holding_avg': timedelta(),
|
||||
'loser_holding_avg': timedelta(),
|
||||
}
|
||||
daily_profit = results.resample('1d', on='close_date')['profit_ratio'].sum()
|
||||
daily_profit_rel = results.resample('1d', on='close_date')['profit_ratio'].sum()
|
||||
daily_profit = results.resample('1d', on='close_date')['profit_abs'].sum().round(10)
|
||||
worst_rel = min(daily_profit_rel)
|
||||
best_rel = max(daily_profit_rel)
|
||||
worst = min(daily_profit)
|
||||
best = max(daily_profit)
|
||||
winning_days = sum(daily_profit > 0)
|
||||
draw_days = sum(daily_profit == 0)
|
||||
losing_days = sum(daily_profit < 0)
|
||||
|
||||
winning_trades = results.loc[results['profit_ratio'] > 0]
|
||||
losing_trades = results.loc[results['profit_ratio'] < 0]
|
||||
|
||||
return {
|
||||
'backtest_best_day': best,
|
||||
'backtest_worst_day': worst,
|
||||
'backtest_best_day': best_rel,
|
||||
'backtest_worst_day': worst_rel,
|
||||
'backtest_best_day_abs': best,
|
||||
'backtest_worst_day_abs': worst,
|
||||
'winning_days': winning_days,
|
||||
'draw_days': draw_days,
|
||||
'losing_days': losing_days,
|
||||
'winner_holding_avg': (timedelta(minutes=round(winning_trades['trade_duration'].mean()))
|
||||
if not winning_trades.empty else timedelta()),
|
||||
'loser_holding_avg': (timedelta(minutes=round(losing_trades['trade_duration'].mean()))
|
||||
if not losing_trades.empty else timedelta()),
|
||||
}
|
||||
|
||||
|
||||
def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
strategy: str,
|
||||
content: Dict[str, Any],
|
||||
min_date: datetime, max_date: datetime,
|
||||
market_change: float
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
:param btdata: Backtest data
|
||||
:param strategy: Strategy name
|
||||
:param content: Backtest result data in the format:
|
||||
{'results: results, 'config: config}}.
|
||||
:param min_date: Backtest start date
|
||||
:param max_date: Backtest end date
|
||||
:param market_change: float indicating the market change
|
||||
:return: Dictionary containing results per strategy and a stratgy summary.
|
||||
"""
|
||||
results: Dict[str, DataFrame] = content['results']
|
||||
if not isinstance(results, DataFrame):
|
||||
return {}
|
||||
config = content['config']
|
||||
max_open_trades = min(config['max_open_trades'], len(btdata.keys()))
|
||||
starting_balance = config['dry_run_wallet']
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
|
||||
starting_balance=starting_balance,
|
||||
results=results, skip_nan=False)
|
||||
sell_reason_stats = generate_sell_reason_stats(max_open_trades=max_open_trades,
|
||||
results=results)
|
||||
left_open_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
|
||||
starting_balance=starting_balance,
|
||||
results=results.loc[results['is_open']],
|
||||
skip_nan=True)
|
||||
daily_stats = generate_daily_stats(results)
|
||||
trade_stats = generate_trading_stats(results)
|
||||
best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'],
|
||||
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
|
||||
worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'],
|
||||
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
|
||||
results['open_timestamp'] = results['open_date'].astype(int64) // 1e6
|
||||
results['close_timestamp'] = results['close_date'].astype(int64) // 1e6
|
||||
|
||||
backtest_days = (max_date - min_date).days
|
||||
strat_stats = {
|
||||
'trades': results.to_dict(orient='records'),
|
||||
'locks': [lock.to_json() for lock in content['locks']],
|
||||
'best_pair': best_pair,
|
||||
'worst_pair': worst_pair,
|
||||
'results_per_pair': pair_results,
|
||||
'sell_reason_summary': sell_reason_stats,
|
||||
'left_open_trades': left_open_results,
|
||||
'total_trades': len(results),
|
||||
'total_volume': float(results['stake_amount'].sum()),
|
||||
'avg_stake_amount': results['stake_amount'].mean() if len(results) > 0 else 0,
|
||||
'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0,
|
||||
'profit_median': results['profit_ratio'].median() if len(results) > 0 else 0,
|
||||
'profit_total': results['profit_abs'].sum() / starting_balance,
|
||||
'profit_total_abs': results['profit_abs'].sum(),
|
||||
'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT),
|
||||
'backtest_start_ts': int(min_date.timestamp() * 1000),
|
||||
'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT),
|
||||
'backtest_end_ts': int(max_date.timestamp() * 1000),
|
||||
'backtest_days': backtest_days,
|
||||
|
||||
'backtest_run_start_ts': content['backtest_start_time'],
|
||||
'backtest_run_end_ts': content['backtest_end_time'],
|
||||
|
||||
'trades_per_day': round(len(results) / backtest_days, 2) if backtest_days > 0 else 0,
|
||||
'market_change': market_change,
|
||||
'pairlist': list(btdata.keys()),
|
||||
'stake_amount': config['stake_amount'],
|
||||
'stake_currency': config['stake_currency'],
|
||||
'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
|
||||
'starting_balance': starting_balance,
|
||||
'dry_run_wallet': starting_balance,
|
||||
'final_balance': content['final_balance'],
|
||||
'rejected_signals': content['rejected_signals'],
|
||||
'max_open_trades': max_open_trades,
|
||||
'max_open_trades_setting': (config['max_open_trades']
|
||||
if config['max_open_trades'] != float('inf') else -1),
|
||||
'timeframe': config['timeframe'],
|
||||
'timerange': config.get('timerange', ''),
|
||||
'enable_protections': config.get('enable_protections', False),
|
||||
'strategy_name': strategy,
|
||||
# Parameters relevant for backtesting
|
||||
'stoploss': config['stoploss'],
|
||||
'trailing_stop': config.get('trailing_stop', False),
|
||||
'trailing_stop_positive': config.get('trailing_stop_positive'),
|
||||
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0),
|
||||
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False),
|
||||
'use_custom_stoploss': config.get('use_custom_stoploss', False),
|
||||
'minimal_roi': config['minimal_roi'],
|
||||
'use_sell_signal': config['ask_strategy']['use_sell_signal'],
|
||||
'sell_profit_only': config['ask_strategy']['sell_profit_only'],
|
||||
'sell_profit_offset': config['ask_strategy']['sell_profit_offset'],
|
||||
'ignore_roi_if_buy_signal': config['ask_strategy']['ignore_roi_if_buy_signal'],
|
||||
**daily_stats,
|
||||
**trade_stats
|
||||
}
|
||||
|
||||
try:
|
||||
max_drawdown, _, _, _, _ = calculate_max_drawdown(
|
||||
results, value_col='profit_ratio')
|
||||
drawdown_abs, drawdown_start, drawdown_end, high_val, low_val = calculate_max_drawdown(
|
||||
results, value_col='profit_abs')
|
||||
strat_stats.update({
|
||||
'max_drawdown': max_drawdown,
|
||||
'max_drawdown_abs': drawdown_abs,
|
||||
'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT),
|
||||
'drawdown_start_ts': drawdown_start.timestamp() * 1000,
|
||||
'drawdown_end': drawdown_end.strftime(DATETIME_PRINT_FORMAT),
|
||||
'drawdown_end_ts': drawdown_end.timestamp() * 1000,
|
||||
|
||||
'max_drawdown_low': low_val,
|
||||
'max_drawdown_high': high_val,
|
||||
})
|
||||
|
||||
csum_min, csum_max = calculate_csum(results, starting_balance)
|
||||
strat_stats.update({
|
||||
'csum_min': csum_min,
|
||||
'csum_max': csum_max
|
||||
})
|
||||
|
||||
except ValueError:
|
||||
strat_stats.update({
|
||||
'max_drawdown': 0.0,
|
||||
'max_drawdown_abs': 0.0,
|
||||
'max_drawdown_low': 0.0,
|
||||
'max_drawdown_high': 0.0,
|
||||
'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc),
|
||||
'drawdown_start_ts': 0,
|
||||
'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc),
|
||||
'drawdown_end_ts': 0,
|
||||
'csum_min': 0,
|
||||
'csum_max': 0
|
||||
})
|
||||
|
||||
return strat_stats
|
||||
|
||||
|
||||
def generate_backtest_stats(btdata: Dict[str, DataFrame],
|
||||
all_results: Dict[str, Dict[str, Union[DataFrame, Dict]]],
|
||||
min_date: Arrow, max_date: Arrow
|
||||
min_date: datetime, max_date: datetime
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
:param btdata: Backtest data
|
||||
@@ -233,107 +436,17 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame],
|
||||
{ Strategy: {'results: results, 'config: config}}.
|
||||
:param min_date: Backtest start date
|
||||
:param max_date: Backtest end date
|
||||
:return:
|
||||
Dictionary containing results per strategy and a stratgy summary.
|
||||
:return: Dictionary containing results per strategy and a stratgy summary.
|
||||
"""
|
||||
result: Dict[str, Any] = {'strategy': {}}
|
||||
market_change = calculate_market_change(btdata, 'close')
|
||||
|
||||
for strategy, content in all_results.items():
|
||||
results: Dict[str, DataFrame] = content['results']
|
||||
if not isinstance(results, DataFrame):
|
||||
continue
|
||||
config = content['config']
|
||||
max_open_trades = min(config['max_open_trades'], len(btdata.keys()))
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
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['is_open']],
|
||||
skip_nan=True)
|
||||
daily_stats = generate_daily_stats(results)
|
||||
best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'],
|
||||
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
|
||||
worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'],
|
||||
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
|
||||
results['open_timestamp'] = results['open_date'].astype(int64) // 1e6
|
||||
results['close_timestamp'] = results['close_date'].astype(int64) // 1e6
|
||||
|
||||
backtest_days = (max_date - min_date).days
|
||||
strat_stats = {
|
||||
'trades': results.to_dict(orient='records'),
|
||||
'locks': [lock.to_json() for lock in content['locks']],
|
||||
'best_pair': best_pair,
|
||||
'worst_pair': worst_pair,
|
||||
'results_per_pair': pair_results,
|
||||
'sell_reason_summary': sell_reason_stats,
|
||||
'left_open_trades': left_open_results,
|
||||
'total_trades': len(results),
|
||||
'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0,
|
||||
'profit_total': results['profit_ratio'].sum() / max_open_trades,
|
||||
'profit_total_abs': results['profit_abs'].sum(),
|
||||
'backtest_start': min_date.datetime,
|
||||
'backtest_start_ts': min_date.int_timestamp * 1000,
|
||||
'backtest_end': max_date.datetime,
|
||||
'backtest_end_ts': max_date.int_timestamp * 1000,
|
||||
'backtest_days': backtest_days,
|
||||
|
||||
'backtest_run_start_ts': content['backtest_start_time'],
|
||||
'backtest_run_end_ts': content['backtest_end_time'],
|
||||
|
||||
'trades_per_day': round(len(results) / backtest_days, 2) if backtest_days > 0 else 0,
|
||||
'market_change': market_change,
|
||||
'pairlist': list(btdata.keys()),
|
||||
'stake_amount': config['stake_amount'],
|
||||
'stake_currency': config['stake_currency'],
|
||||
'max_open_trades': max_open_trades,
|
||||
'max_open_trades_setting': (config['max_open_trades']
|
||||
if config['max_open_trades'] != float('inf') else -1),
|
||||
'timeframe': config['timeframe'],
|
||||
'timerange': config.get('timerange', ''),
|
||||
'enable_protections': config.get('enable_protections', False),
|
||||
'strategy_name': strategy,
|
||||
# Parameters relevant for backtesting
|
||||
'stoploss': config['stoploss'],
|
||||
'trailing_stop': config.get('trailing_stop', False),
|
||||
'trailing_stop_positive': config.get('trailing_stop_positive'),
|
||||
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0),
|
||||
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False),
|
||||
'use_custom_stoploss': config.get('use_custom_stoploss', False),
|
||||
'minimal_roi': config['minimal_roi'],
|
||||
'use_sell_signal': config['ask_strategy']['use_sell_signal'],
|
||||
'sell_profit_only': config['ask_strategy']['sell_profit_only'],
|
||||
'sell_profit_offset': config['ask_strategy']['sell_profit_offset'],
|
||||
'ignore_roi_if_buy_signal': config['ask_strategy']['ignore_roi_if_buy_signal'],
|
||||
**daily_stats,
|
||||
}
|
||||
strat_stats = generate_strategy_stats(btdata, strategy, content,
|
||||
min_date, max_date, market_change=market_change)
|
||||
result['strategy'][strategy] = strat_stats
|
||||
|
||||
try:
|
||||
max_drawdown, drawdown_start, drawdown_end = calculate_max_drawdown(
|
||||
results, value_col='profit_ratio')
|
||||
strat_stats.update({
|
||||
'max_drawdown': max_drawdown,
|
||||
'drawdown_start': drawdown_start,
|
||||
'drawdown_start_ts': drawdown_start.timestamp() * 1000,
|
||||
'drawdown_end': drawdown_end,
|
||||
'drawdown_end_ts': drawdown_end.timestamp() * 1000,
|
||||
})
|
||||
except ValueError:
|
||||
strat_stats.update({
|
||||
'max_drawdown': 0.0,
|
||||
'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc),
|
||||
'drawdown_start_ts': 0,
|
||||
'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc),
|
||||
'drawdown_end_ts': 0,
|
||||
})
|
||||
|
||||
strategy_results = generate_strategy_metrics(all_results=all_results)
|
||||
strategy_results = generate_strategy_comparison(all_results=all_results)
|
||||
|
||||
result['strategy_comparison'] = strategy_results
|
||||
|
||||
@@ -356,7 +469,8 @@ def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: st
|
||||
floatfmt = _get_line_floatfmt(stake_currency)
|
||||
output = [[
|
||||
t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
|
||||
t['profit_total_pct'], t['duration_avg'], t['wins'], t['draws'], t['losses']
|
||||
t['profit_total_pct'], t['duration_avg'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses'])
|
||||
] for t in pair_results]
|
||||
# Ignore type as floatfmt does allow tuples but mypy does not know that
|
||||
return tabulate(output, headers=headers,
|
||||
@@ -373,9 +487,7 @@ def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_curren
|
||||
headers = [
|
||||
'Sell Reason',
|
||||
'Sells',
|
||||
'Wins',
|
||||
'Draws',
|
||||
'Losses',
|
||||
'Win Draws Loss Win%',
|
||||
'Avg Profit %',
|
||||
'Cum Profit %',
|
||||
f'Tot Profit {stake_currency}',
|
||||
@@ -383,7 +495,8 @@ def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_curren
|
||||
]
|
||||
|
||||
output = [[
|
||||
t['sell_reason'], t['trades'], t['wins'], t['draws'], t['losses'],
|
||||
t['sell_reason'], t['trades'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses']),
|
||||
t['profit_mean_pct'], t['profit_sum_pct'],
|
||||
round_coin_value(t['profit_total_abs'], stake_currency, False),
|
||||
t['profit_total_pct'],
|
||||
@@ -401,11 +514,22 @@ def text_table_strategy(strategy_results, stake_currency: str) -> str:
|
||||
"""
|
||||
floatfmt = _get_line_floatfmt(stake_currency)
|
||||
headers = _get_line_header('Strategy', stake_currency)
|
||||
# _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless
|
||||
# therefore we slip this column in only for strategy summary here.
|
||||
headers.append('Drawdown')
|
||||
|
||||
# Align drawdown string on the center two space separator.
|
||||
drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results]
|
||||
dd_pad_abs = max([len(t['max_drawdown_abs']) for t in strategy_results])
|
||||
dd_pad_per = max([len(dd) for dd in drawdown])
|
||||
drawdown = [f'{t["max_drawdown_abs"]:>{dd_pad_abs}} {stake_currency} {dd:>{dd_pad_per}}%'
|
||||
for t, dd in zip(strategy_results, drawdown)]
|
||||
|
||||
output = [[
|
||||
t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
|
||||
t['profit_total_pct'], t['duration_avg'], t['wins'], t['draws'], t['losses']
|
||||
] for t in strategy_results]
|
||||
t['profit_total_pct'], t['duration_avg'],
|
||||
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), drawdown]
|
||||
for t, drawdown in zip(strategy_results, drawdown)]
|
||||
# Ignore type as floatfmt does allow tuples but mypy does not know that
|
||||
return tabulate(output, headers=headers,
|
||||
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
|
||||
@@ -415,14 +539,36 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
if len(strat_results['trades']) > 0:
|
||||
best_trade = max(strat_results['trades'], key=lambda x: x['profit_ratio'])
|
||||
worst_trade = min(strat_results['trades'], key=lambda x: x['profit_ratio'])
|
||||
|
||||
# Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
|
||||
# command stores these results and newer version of freqtrade must be able to handle old
|
||||
# results with missing new fields.
|
||||
zero_duration_trades = '--'
|
||||
|
||||
if 'zero_duration_trades' in strat_results:
|
||||
zero_duration_trades_per = \
|
||||
100.0 / strat_results['total_trades'] * strat_results['zero_duration_trades']
|
||||
zero_duration_trades = f'{zero_duration_trades_per:.2f}% ' \
|
||||
f'({strat_results["zero_duration_trades"]})'
|
||||
|
||||
metrics = [
|
||||
('Backtesting from', strat_results['backtest_start'].strftime(DATETIME_PRINT_FORMAT)),
|
||||
('Backtesting to', strat_results['backtest_end'].strftime(DATETIME_PRINT_FORMAT)),
|
||||
('Backtesting from', strat_results['backtest_start']),
|
||||
('Backtesting to', strat_results['backtest_end']),
|
||||
('Max open trades', strat_results['max_open_trades']),
|
||||
('', ''), # Empty line to improve readability
|
||||
('Total trades', strat_results['total_trades']),
|
||||
('Total Profit %', f"{round(strat_results['profit_total'] * 100, 2)}%"),
|
||||
('Starting balance', round_coin_value(strat_results['starting_balance'],
|
||||
strat_results['stake_currency'])),
|
||||
('Final balance', round_coin_value(strat_results['final_balance'],
|
||||
strat_results['stake_currency'])),
|
||||
('Absolute profit ', round_coin_value(strat_results['profit_total_abs'],
|
||||
strat_results['stake_currency'])),
|
||||
('Total profit %', f"{round(strat_results['profit_total'] * 100, 2):}%"),
|
||||
('Trades per day', strat_results['trades_per_day']),
|
||||
('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'],
|
||||
strat_results['stake_currency'])),
|
||||
('Total trade volume', round_coin_value(strat_results['total_volume'],
|
||||
strat_results['stake_currency'])),
|
||||
('', ''), # Empty line to improve readability
|
||||
('Best Pair', f"{strat_results['best_pair']['key']} "
|
||||
f"{round(strat_results['best_pair']['profit_sum_pct'], 2)}%"),
|
||||
@@ -432,55 +578,87 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
('Worst trade', f"{worst_trade['pair']} "
|
||||
f"{round(worst_trade['profit_ratio'] * 100, 2)}%"),
|
||||
|
||||
('Best day', f"{round(strat_results['backtest_best_day'] * 100, 2)}%"),
|
||||
('Worst day', f"{round(strat_results['backtest_worst_day'] * 100, 2)}%"),
|
||||
('Best day', round_coin_value(strat_results['backtest_best_day_abs'],
|
||||
strat_results['stake_currency'])),
|
||||
('Worst day', round_coin_value(strat_results['backtest_worst_day_abs'],
|
||||
strat_results['stake_currency'])),
|
||||
('Days win/draw/lose', f"{strat_results['winning_days']} / "
|
||||
f"{strat_results['draw_days']} / {strat_results['losing_days']}"),
|
||||
('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"),
|
||||
('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"),
|
||||
('Zero Duration Trades', zero_duration_trades),
|
||||
('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')),
|
||||
('', ''), # Empty line to improve readability
|
||||
('Max Drawdown', f"{round(strat_results['max_drawdown'] * 100, 2)}%"),
|
||||
('Drawdown Start', strat_results['drawdown_start'].strftime(DATETIME_PRINT_FORMAT)),
|
||||
('Drawdown End', strat_results['drawdown_end'].strftime(DATETIME_PRINT_FORMAT)),
|
||||
|
||||
('Min balance', round_coin_value(strat_results['csum_min'],
|
||||
strat_results['stake_currency'])),
|
||||
('Max balance', round_coin_value(strat_results['csum_max'],
|
||||
strat_results['stake_currency'])),
|
||||
|
||||
('Drawdown', f"{round(strat_results['max_drawdown'] * 100, 2)}%"),
|
||||
('Drawdown', round_coin_value(strat_results['max_drawdown_abs'],
|
||||
strat_results['stake_currency'])),
|
||||
('Drawdown high', round_coin_value(strat_results['max_drawdown_high'],
|
||||
strat_results['stake_currency'])),
|
||||
('Drawdown low', round_coin_value(strat_results['max_drawdown_low'],
|
||||
strat_results['stake_currency'])),
|
||||
('Drawdown Start', strat_results['drawdown_start']),
|
||||
('Drawdown End', strat_results['drawdown_end']),
|
||||
('Market change', f"{round(strat_results['market_change'] * 100, 2)}%"),
|
||||
]
|
||||
|
||||
return tabulate(metrics, headers=["Metric", "Value"], tablefmt="orgtbl")
|
||||
else:
|
||||
return ''
|
||||
start_balance = round_coin_value(strat_results['starting_balance'],
|
||||
strat_results['stake_currency'])
|
||||
stake_amount = round_coin_value(
|
||||
strat_results['stake_amount'], strat_results['stake_currency']
|
||||
) if strat_results['stake_amount'] != UNLIMITED_STAKE_AMOUNT else 'unlimited'
|
||||
|
||||
message = ("No trades made. "
|
||||
f"Your starting balance was {start_balance}, "
|
||||
f"and your stake was {stake_amount}."
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: str):
|
||||
"""
|
||||
Print results for one strategy
|
||||
"""
|
||||
# Print results
|
||||
print(f"Result for strategy {strategy}")
|
||||
table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency)
|
||||
if isinstance(table, str):
|
||||
print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'],
|
||||
stake_currency=stake_currency)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' SELL REASON STATS '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_add_metrics(results)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print('=' * len(table.splitlines()[0]))
|
||||
print()
|
||||
|
||||
|
||||
def show_backtest_results(config: Dict, backtest_stats: Dict):
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
for strategy, results in backtest_stats['strategy'].items():
|
||||
|
||||
# Print results
|
||||
print(f"Result for strategy {strategy}")
|
||||
table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency)
|
||||
if isinstance(table, str):
|
||||
print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'],
|
||||
stake_currency=stake_currency)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' SELL REASON STATS '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
table = text_table_add_metrics(results)
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '='))
|
||||
print(table)
|
||||
|
||||
if isinstance(table, str) and len(table) > 0:
|
||||
print('=' * len(table.splitlines()[0]))
|
||||
print()
|
||||
show_backtest_result(strategy, results, stake_currency)
|
||||
|
||||
if len(backtest_stats['strategy']) > 1:
|
||||
# Print Strategy summary table
|
||||
|
4
freqtrade/optimize/space/__init__.py
Normal file
4
freqtrade/optimize/space/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# flake8: noqa: F401
|
||||
from skopt.space import Categorical, Dimension, Integer, Real
|
||||
|
||||
from .decimalspace import SKDecimal
|
33
freqtrade/optimize/space/decimalspace.py
Normal file
33
freqtrade/optimize/space/decimalspace.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import numpy as np
|
||||
from skopt.space import Integer
|
||||
|
||||
|
||||
class SKDecimal(Integer):
|
||||
|
||||
def __init__(self, low, high, decimals=3, prior="uniform", base=10, transform=None,
|
||||
name=None, dtype=np.int64):
|
||||
self.decimals = decimals
|
||||
_low = int(low * pow(10, self.decimals))
|
||||
_high = int(high * pow(10, self.decimals))
|
||||
# trunc to precision to avoid points out of space
|
||||
self.low_orig = round(_low * pow(0.1, self.decimals), self.decimals)
|
||||
self.high_orig = round(_high * pow(0.1, self.decimals), self.decimals)
|
||||
|
||||
super().__init__(_low, _high, prior, base, transform, name, dtype)
|
||||
|
||||
def __repr__(self):
|
||||
return "Decimal(low={}, high={}, decimals={}, prior='{}', transform='{}')".format(
|
||||
self.low_orig, self.high_orig, self.decimals, self.prior, self.transform_)
|
||||
|
||||
def __contains__(self, point):
|
||||
if isinstance(point, list):
|
||||
point = np.array(point)
|
||||
return self.low_orig <= point <= self.high_orig
|
||||
|
||||
def transform(self, Xt):
|
||||
aa = [int(x * pow(10, self.decimals)) for x in Xt]
|
||||
return super().transform(aa)
|
||||
|
||||
def inverse_transform(self, Xt):
|
||||
res = super().inverse_transform(Xt)
|
||||
return [round(x * pow(0.1, self.decimals), self.decimals) for x in res]
|
@@ -1,4 +1,5 @@
|
||||
# flake8: noqa: F401
|
||||
|
||||
from freqtrade.persistence.models import Order, Trade, clean_dry_run_db, cleanup_db, init_db
|
||||
from freqtrade.persistence.models import (LocalTrade, Order, Trade, clean_dry_run_db, cleanup_db,
|
||||
init_db)
|
||||
from freqtrade.persistence.pairlock_middleware import PairLocks
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,15 +62,17 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
|
||||
amount_requested = get_column_def(cols, 'amount_requested', 'amount')
|
||||
|
||||
# Schema migration necessary
|
||||
engine.execute(f"alter table trades rename to {table_back_name}")
|
||||
# drop indexes on backup table
|
||||
for index in inspector.get_indexes(table_back_name):
|
||||
engine.execute(f"drop index {index['name']}")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"alter table trades rename to {table_back_name}"))
|
||||
# drop indexes on backup table
|
||||
for index in inspector.get_indexes(table_back_name):
|
||||
connection.execute(text(f"drop index {index['name']}"))
|
||||
# let SQLAlchemy create the schema as required
|
||||
decl_base.metadata.create_all(engine)
|
||||
|
||||
# Copy data back - following the correct schema
|
||||
engine.execute(f"""insert into trades
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"""insert into trades
|
||||
(id, exchange, pair, is_open,
|
||||
fee_open, fee_open_cost, fee_open_currency,
|
||||
fee_close, fee_close_cost, fee_open_currency, open_rate,
|
||||
@@ -104,11 +106,12 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
|
||||
{strategy} strategy, {timeframe} timeframe,
|
||||
{open_trade_value} open_trade_value, {close_profit_abs} close_profit_abs
|
||||
from {table_back_name}
|
||||
""")
|
||||
"""))
|
||||
|
||||
|
||||
def migrate_open_orders_to_trades(engine):
|
||||
engine.execute("""
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("""
|
||||
insert into orders (ft_trade_id, ft_pair, order_id, ft_order_side, ft_is_open)
|
||||
select id ft_trade_id, pair ft_pair, open_order_id,
|
||||
case when close_rate_requested is null then 'buy'
|
||||
@@ -120,7 +123,30 @@ def migrate_open_orders_to_trades(engine):
|
||||
'stoploss' ft_order_side, 1 ft_is_open
|
||||
from trades
|
||||
where stoploss_order_id is not null
|
||||
""")
|
||||
"""))
|
||||
|
||||
|
||||
def migrate_orders_table(decl_base, inspector, engine, table_back_name: str, cols: List):
|
||||
# Schema migration necessary
|
||||
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"alter table orders rename to {table_back_name}"))
|
||||
# drop indexes on backup table
|
||||
for index in inspector.get_indexes(table_back_name):
|
||||
connection.execute(text(f"drop index {index['name']}"))
|
||||
|
||||
# let SQLAlchemy create the schema as required
|
||||
decl_base.metadata.create_all(engine)
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"""
|
||||
insert into orders ( id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id,
|
||||
status, symbol, order_type, side, price, amount, filled, average, remaining, cost,
|
||||
order_date, order_filled_date, order_update_date)
|
||||
select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id,
|
||||
status, symbol, order_type, side, price, amount, filled, null average, remaining, cost,
|
||||
order_date, order_filled_date, order_update_date
|
||||
from {table_back_name}
|
||||
"""))
|
||||
|
||||
|
||||
def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
@@ -141,10 +167,15 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
inspector = inspect(engine)
|
||||
cols = inspector.get_columns('trades')
|
||||
|
||||
if 'orders' not in previous_tables:
|
||||
if 'orders' not in previous_tables and 'trades' in previous_tables:
|
||||
logger.info('Moving open orders to Orders table.')
|
||||
migrate_open_orders_to_trades(engine)
|
||||
else:
|
||||
pass
|
||||
# Empty for now - as there is only one iteration of the orders table so far.
|
||||
# table_back_name = get_backup_name(tabs, 'orders_bak')
|
||||
cols_order = inspector.get_columns('orders')
|
||||
|
||||
if not has_column(cols_order, 'average'):
|
||||
tabs = get_table_names_for_table(inspector, 'orders')
|
||||
# Empty for now - as there is only one iteration of the orders table so far.
|
||||
table_back_name = get_backup_name(tabs, 'orders_bak')
|
||||
|
||||
migrate_orders_table(decl_base, inspector, engine, table_back_name, cols)
|
||||
|
@@ -6,18 +6,15 @@ from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import arrow
|
||||
from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer, String,
|
||||
create_engine, desc, func, inspect)
|
||||
from sqlalchemy.exc import NoSuchModuleError
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Query, relationship
|
||||
from sqlalchemy.orm.scoping import scoped_session
|
||||
from sqlalchemy.orm.session import sessionmaker
|
||||
from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.sql.schema import UniqueConstraint
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT
|
||||
from freqtrade.enums import SellType
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
from freqtrade.misc import safe_value_fallback
|
||||
from freqtrade.persistence.migrations import check_migrate
|
||||
@@ -42,16 +39,18 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None:
|
||||
"""
|
||||
kwargs = {}
|
||||
|
||||
# Take care of thread ownership if in-memory db
|
||||
if db_url == 'sqlite://':
|
||||
kwargs.update({
|
||||
'connect_args': {'check_same_thread': False},
|
||||
'poolclass': StaticPool,
|
||||
'echo': False,
|
||||
})
|
||||
# Take care of thread ownership
|
||||
if db_url.startswith('sqlite://'):
|
||||
kwargs.update({
|
||||
'connect_args': {'check_same_thread': False},
|
||||
})
|
||||
|
||||
try:
|
||||
engine = create_engine(db_url, **kwargs)
|
||||
engine = create_engine(db_url, future=True, **kwargs)
|
||||
except NoSuchModuleError:
|
||||
raise OperationalException(f"Given value for db_url: '{db_url}' "
|
||||
f"is no valid database URL! (See {_SQL_DOCS_URL})")
|
||||
@@ -59,13 +58,10 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None:
|
||||
# https://docs.sqlalchemy.org/en/13/orm/contextual.html#thread-local-scope
|
||||
# Scoped sessions proxy requests to the appropriate thread-local session.
|
||||
# We should use the scoped_session object - not a seperately initialized version
|
||||
Trade.session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True))
|
||||
Trade.query = Trade.session.query_property()
|
||||
# Copy session attributes to order object too
|
||||
Order.session = Trade.session
|
||||
Order.query = Order.session.query_property()
|
||||
PairLock.session = Trade.session
|
||||
PairLock.query = PairLock.session.query_property()
|
||||
Trade._session = scoped_session(sessionmaker(bind=engine, autoflush=True))
|
||||
Trade.query = Trade._session.query_property()
|
||||
Order.query = Trade._session.query_property()
|
||||
PairLock.query = Trade._session.query_property()
|
||||
|
||||
previous_tables = inspect(engine).get_table_names()
|
||||
_DECL_BASE.metadata.create_all(engine)
|
||||
@@ -81,7 +77,7 @@ def cleanup_db() -> None:
|
||||
Flushes all pending operations to disk.
|
||||
:return: None
|
||||
"""
|
||||
Trade.session.flush()
|
||||
Trade.commit()
|
||||
|
||||
|
||||
def clean_dry_run_db() -> None:
|
||||
@@ -93,6 +89,7 @@ def clean_dry_run_db() -> None:
|
||||
# Check we are updating only a dry_run order not a prod one
|
||||
if 'dry_run' in trade.open_order_id:
|
||||
trade.open_order_id = None
|
||||
Trade.commit()
|
||||
|
||||
|
||||
class Order(_DECL_BASE):
|
||||
@@ -116,16 +113,17 @@ class Order(_DECL_BASE):
|
||||
|
||||
trade = relationship("Trade", back_populates="orders")
|
||||
|
||||
ft_order_side = Column(String, nullable=False)
|
||||
ft_pair = Column(String, nullable=False)
|
||||
ft_order_side = Column(String(25), nullable=False)
|
||||
ft_pair = Column(String(25), nullable=False)
|
||||
ft_is_open = Column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
order_id = Column(String, nullable=False, index=True)
|
||||
status = Column(String, nullable=True)
|
||||
symbol = Column(String, nullable=True)
|
||||
order_type = Column(String, nullable=True)
|
||||
side = Column(String, nullable=True)
|
||||
order_id = Column(String(255), nullable=False, index=True)
|
||||
status = Column(String(255), nullable=True)
|
||||
symbol = Column(String(25), nullable=True)
|
||||
order_type = Column(String(50), nullable=True)
|
||||
side = Column(String(25), nullable=True)
|
||||
price = Column(Float, nullable=True)
|
||||
average = Column(Float, nullable=True)
|
||||
amount = Column(Float, nullable=True)
|
||||
filled = Column(Float, nullable=True)
|
||||
remaining = Column(Float, nullable=True)
|
||||
@@ -154,6 +152,7 @@ class Order(_DECL_BASE):
|
||||
self.price = order.get('price', self.price)
|
||||
self.amount = order.get('amount', self.amount)
|
||||
self.filled = order.get('filled', self.filled)
|
||||
self.average = order.get('average', self.average)
|
||||
self.remaining = order.get('remaining', self.remaining)
|
||||
self.cost = order.get('cost', self.cost)
|
||||
if 'timestamp' in order and order['timestamp'] is not None:
|
||||
@@ -163,8 +162,8 @@ class Order(_DECL_BASE):
|
||||
if self.status in ('closed', 'canceled', 'cancelled'):
|
||||
self.ft_is_open = False
|
||||
if order.get('filled', 0) > 0:
|
||||
self.order_filled_date = arrow.utcnow().datetime
|
||||
self.order_update_date = arrow.utcnow().datetime
|
||||
self.order_filled_date = datetime.now(timezone.utc)
|
||||
self.order_update_date = datetime.now(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def update_orders(orders: List['Order'], order: Dict[str, Any]):
|
||||
@@ -179,6 +178,7 @@ class Order(_DECL_BASE):
|
||||
if filtered_orders:
|
||||
oobj = filtered_orders[0]
|
||||
oobj.update_from_ccxt_object(order)
|
||||
Order.query.session.commit()
|
||||
else:
|
||||
logger.warning(f"Did not find order for {order}.")
|
||||
|
||||
@@ -199,67 +199,69 @@ class Order(_DECL_BASE):
|
||||
return Order.query.filter(Order.ft_is_open.is_(True)).all()
|
||||
|
||||
|
||||
class Trade(_DECL_BASE):
|
||||
class LocalTrade():
|
||||
"""
|
||||
Trade database model.
|
||||
Also handles updating and querying trades
|
||||
Used in backtesting - must be aligned to Trade model!
|
||||
|
||||
"""
|
||||
__tablename__ = 'trades'
|
||||
|
||||
use_db: bool = True
|
||||
use_db: bool = False
|
||||
# Trades container for backtesting
|
||||
trades: List['Trade'] = []
|
||||
trades: List['LocalTrade'] = []
|
||||
trades_open: List['LocalTrade'] = []
|
||||
total_profit: float = 0
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
id: int = 0
|
||||
|
||||
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan")
|
||||
orders: List[Order] = []
|
||||
|
||||
exchange = Column(String, nullable=False)
|
||||
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)
|
||||
exchange: str = ''
|
||||
pair: str = ''
|
||||
is_open: bool = True
|
||||
fee_open: float = 0.0
|
||||
fee_open_cost: Optional[float] = None
|
||||
fee_open_currency: str = ''
|
||||
fee_close: float = 0.0
|
||||
fee_close_cost: Optional[float] = None
|
||||
fee_close_currency: str = ''
|
||||
open_rate: float = 0.0
|
||||
open_rate_requested: Optional[float] = None
|
||||
# open_trade_value - calculated via _calc_open_trade_value
|
||||
open_trade_value = Column(Float)
|
||||
close_rate = Column(Float)
|
||||
close_rate_requested = Column(Float)
|
||||
close_profit = Column(Float)
|
||||
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)
|
||||
open_trade_value: float = 0.0
|
||||
close_rate: Optional[float] = None
|
||||
close_rate_requested: Optional[float] = None
|
||||
close_profit: Optional[float] = None
|
||||
close_profit_abs: Optional[float] = None
|
||||
stake_amount: float = 0.0
|
||||
amount: float = 0.0
|
||||
amount_requested: Optional[float] = None
|
||||
open_date: datetime
|
||||
close_date: Optional[datetime] = None
|
||||
open_order_id: Optional[str] = None
|
||||
# absolute value of the stop loss
|
||||
stop_loss = Column(Float, nullable=True, default=0.0)
|
||||
stop_loss: float = 0.0
|
||||
# percentage value of the stop loss
|
||||
stop_loss_pct = Column(Float, nullable=True)
|
||||
stop_loss_pct: float = 0.0
|
||||
# absolute value of the initial stop loss
|
||||
initial_stop_loss = Column(Float, nullable=True, default=0.0)
|
||||
initial_stop_loss: float = 0.0
|
||||
# percentage value of the initial stop loss
|
||||
initial_stop_loss_pct = Column(Float, nullable=True)
|
||||
initial_stop_loss_pct: float = 0.0
|
||||
# stoploss order id which is on exchange
|
||||
stoploss_order_id = Column(String, nullable=True, index=True)
|
||||
stoploss_order_id: Optional[str] = None
|
||||
# last update time of the stoploss order on exchange
|
||||
stoploss_last_update = Column(DateTime, nullable=True)
|
||||
stoploss_last_update: Optional[datetime] = None
|
||||
# absolute value of the highest reached price
|
||||
max_rate = Column(Float, nullable=True, default=0.0)
|
||||
max_rate: float = 0.0
|
||||
# Lowest price reached
|
||||
min_rate = Column(Float, nullable=True)
|
||||
sell_reason = Column(String, nullable=True)
|
||||
sell_order_status = Column(String, nullable=True)
|
||||
strategy = Column(String, nullable=True)
|
||||
timeframe = Column(Integer, nullable=True)
|
||||
min_rate: float = 0.0
|
||||
sell_reason: str = ''
|
||||
sell_order_status: str = ''
|
||||
strategy: str = ''
|
||||
timeframe: Optional[int] = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
for key in kwargs:
|
||||
setattr(self, key, kwargs[key])
|
||||
self.recalc_open_trade_value()
|
||||
|
||||
def __repr__(self):
|
||||
@@ -268,6 +270,14 @@ class Trade(_DECL_BASE):
|
||||
return (f'Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, '
|
||||
f'open_rate={self.open_rate:.8f}, open_since={open_since})')
|
||||
|
||||
@property
|
||||
def open_date_utc(self):
|
||||
return self.open_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
@property
|
||||
def close_date_utc(self):
|
||||
return self.close_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'trade_id': self.id,
|
||||
@@ -287,15 +297,12 @@ class Trade(_DECL_BASE):
|
||||
'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(DATETIME_PRINT_FORMAT),
|
||||
'open_timestamp': int(self.open_date.replace(tzinfo=timezone.utc).timestamp() * 1000),
|
||||
'open_rate': self.open_rate,
|
||||
'open_rate_requested': self.open_rate_requested,
|
||||
'open_trade_value': round(self.open_trade_value, 8),
|
||||
|
||||
'close_date_hum': (arrow.get(self.close_date).humanize()
|
||||
if self.close_date else None),
|
||||
'close_date': (self.close_date.strftime(DATETIME_PRINT_FORMAT)
|
||||
if self.close_date else None),
|
||||
'close_timestamp': int(self.close_date.replace(
|
||||
@@ -306,9 +313,9 @@ class Trade(_DECL_BASE):
|
||||
'close_profit_pct': round(self.close_profit * 100, 2) if self.close_profit else None,
|
||||
'close_profit_abs': self.close_profit_abs, # Deprecated
|
||||
|
||||
'trade_duration_s': (int((self.close_date - self.open_date).total_seconds())
|
||||
'trade_duration_s': (int((self.close_date_utc - self.open_date_utc).total_seconds())
|
||||
if self.close_date else None),
|
||||
'trade_duration': (int((self.close_date - self.open_date).total_seconds() // 60)
|
||||
'trade_duration': (int((self.close_date_utc - self.open_date_utc).total_seconds() // 60)
|
||||
if self.close_date else None),
|
||||
|
||||
'profit_ratio': self.close_profit,
|
||||
@@ -341,8 +348,9 @@ class Trade(_DECL_BASE):
|
||||
"""
|
||||
Resets all trades. Only active for backtesting mode.
|
||||
"""
|
||||
if not Trade.use_db:
|
||||
Trade.trades = []
|
||||
LocalTrade.trades = []
|
||||
LocalTrade.trades_open = []
|
||||
LocalTrade.total_profit = 0
|
||||
|
||||
def adjust_min_max_rates(self, current_price: float) -> None:
|
||||
"""
|
||||
@@ -410,8 +418,8 @@ class Trade(_DECL_BASE):
|
||||
|
||||
if order_type in ('market', 'limit') and order['side'] == 'buy':
|
||||
# Update open rate and actual amount
|
||||
self.open_rate = Decimal(safe_value_fallback(order, 'average', 'price'))
|
||||
self.amount = Decimal(safe_value_fallback(order, 'filled', 'amount'))
|
||||
self.open_rate = float(safe_value_fallback(order, 'average', 'price'))
|
||||
self.amount = float(safe_value_fallback(order, 'filled', 'amount'))
|
||||
self.recalc_open_trade_value()
|
||||
if self.is_open:
|
||||
logger.info(f'{order_type.upper()}_BUY has been fulfilled for {self}.')
|
||||
@@ -423,19 +431,20 @@ class Trade(_DECL_BASE):
|
||||
elif order_type in ('stop_loss_limit', 'stop-loss', 'stop-loss-limit', 'stop'):
|
||||
self.stoploss_order_id = None
|
||||
self.close_rate_requested = self.stop_loss
|
||||
self.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value
|
||||
if self.is_open:
|
||||
logger.info(f'{order_type.upper()} is hit for {self}.')
|
||||
self.close(order['average'])
|
||||
self.close(safe_value_fallback(order, 'average', 'price'))
|
||||
else:
|
||||
raise ValueError(f'Unknown order type: {order_type}')
|
||||
cleanup_db()
|
||||
Trade.commit()
|
||||
|
||||
def close(self, rate: float, *, show_msg: bool = True) -> None:
|
||||
"""
|
||||
Sets close_rate to the given rate, calculates total profit
|
||||
and marks trade as closed
|
||||
"""
|
||||
self.close_rate = Decimal(rate)
|
||||
self.close_rate = rate
|
||||
self.close_profit = self.calc_profit_ratio()
|
||||
self.close_profit_abs = self.calc_profit()
|
||||
self.close_date = self.close_date or datetime.utcnow()
|
||||
@@ -480,14 +489,6 @@ class Trade(_DECL_BASE):
|
||||
def update_order(self, order: Dict) -> None:
|
||||
Order.update_orders(self.orders, order)
|
||||
|
||||
def delete(self) -> None:
|
||||
|
||||
for order in self.orders:
|
||||
Order.session.delete(order)
|
||||
|
||||
Trade.session.delete(self)
|
||||
Trade.session.flush()
|
||||
|
||||
def _calc_open_trade_value(self) -> float:
|
||||
"""
|
||||
Calculate the open_rate including open_fee.
|
||||
@@ -517,7 +518,7 @@ class Trade(_DECL_BASE):
|
||||
if rate is None and not self.close_rate:
|
||||
return 0.0
|
||||
|
||||
sell_trade = Decimal(self.amount) * Decimal(rate or self.close_rate)
|
||||
sell_trade = Decimal(self.amount) * Decimal(rate or self.close_rate) # type: ignore
|
||||
fees = sell_trade * Decimal(fee or self.fee_close)
|
||||
return float(sell_trade - fees)
|
||||
|
||||
@@ -551,6 +552,8 @@ class Trade(_DECL_BASE):
|
||||
rate=(rate or self.close_rate),
|
||||
fee=(fee or self.fee_close)
|
||||
)
|
||||
if self.open_trade_value == 0.0:
|
||||
return 0.0
|
||||
profit_ratio = (close_trade_value / self.open_trade_value) - 1
|
||||
return float(f"{profit_ratio:.8f}")
|
||||
|
||||
@@ -569,27 +572,10 @@ class Trade(_DECL_BASE):
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_trades(trade_filter=None) -> Query:
|
||||
"""
|
||||
Helper function to query Trades using filters.
|
||||
:param trade_filter: Optional filter to apply to trades
|
||||
Can be either a Filter object, or a List of filters
|
||||
e.g. `(trade_filter=[Trade.id == trade_id, Trade.is_open.is_(True),])`
|
||||
e.g. `(trade_filter=Trade.id == trade_id)`
|
||||
:return: unsorted query object
|
||||
"""
|
||||
if trade_filter is not None:
|
||||
if not isinstance(trade_filter, list):
|
||||
trade_filter = [trade_filter]
|
||||
return Trade.query.filter(*trade_filter)
|
||||
else:
|
||||
return Trade.query
|
||||
|
||||
@staticmethod
|
||||
def get_trades_proxy(*, pair: str = None, is_open: bool = None,
|
||||
open_date: datetime = None, close_date: datetime = None,
|
||||
) -> List['Trade']:
|
||||
) -> List['LocalTrade']:
|
||||
"""
|
||||
Helper function to query Trades.
|
||||
Returns a List of trades, filtered on the parameters given.
|
||||
@@ -598,110 +584,47 @@ class Trade(_DECL_BASE):
|
||||
|
||||
:return: unsorted List[Trade]
|
||||
"""
|
||||
if Trade.use_db:
|
||||
trade_filter = []
|
||||
if pair:
|
||||
trade_filter.append(Trade.pair == pair)
|
||||
if open_date:
|
||||
trade_filter.append(Trade.open_date > open_date)
|
||||
if close_date:
|
||||
trade_filter.append(Trade.close_date > close_date)
|
||||
if is_open is not None:
|
||||
trade_filter.append(Trade.is_open.is_(is_open))
|
||||
return Trade.get_trades(trade_filter).all()
|
||||
|
||||
# Offline mode - without database
|
||||
if is_open is not None:
|
||||
if is_open:
|
||||
sel_trades = LocalTrade.trades_open
|
||||
else:
|
||||
sel_trades = LocalTrade.trades
|
||||
|
||||
else:
|
||||
# Offline mode - without database
|
||||
sel_trades = [trade for trade in Trade.trades]
|
||||
if pair:
|
||||
sel_trades = [trade for trade in sel_trades if trade.pair == pair]
|
||||
if open_date:
|
||||
sel_trades = [trade for trade in sel_trades if trade.open_date > open_date]
|
||||
if close_date:
|
||||
sel_trades = [trade for trade in sel_trades if trade.close_date
|
||||
and trade.close_date > close_date]
|
||||
if is_open is not None:
|
||||
sel_trades = [trade for trade in sel_trades if trade.is_open == is_open]
|
||||
return sel_trades
|
||||
# Not used during backtesting, but might be used by a strategy
|
||||
sel_trades = list(LocalTrade.trades + LocalTrade.trades_open)
|
||||
|
||||
if pair:
|
||||
sel_trades = [trade for trade in sel_trades if trade.pair == pair]
|
||||
if open_date:
|
||||
sel_trades = [trade for trade in sel_trades if trade.open_date > open_date]
|
||||
if close_date:
|
||||
sel_trades = [trade for trade in sel_trades if trade.close_date
|
||||
and trade.close_date > close_date]
|
||||
|
||||
return sel_trades
|
||||
|
||||
@staticmethod
|
||||
def close_bt_trade(trade):
|
||||
LocalTrade.trades_open.remove(trade)
|
||||
LocalTrade.trades.append(trade)
|
||||
LocalTrade.total_profit += trade.close_profit_abs
|
||||
|
||||
@staticmethod
|
||||
def add_bt_trade(trade):
|
||||
if trade.is_open:
|
||||
LocalTrade.trades_open.append(trade)
|
||||
else:
|
||||
LocalTrade.trades.append(trade)
|
||||
|
||||
@staticmethod
|
||||
def get_open_trades() -> List[Any]:
|
||||
"""
|
||||
Query trades from persistence layer
|
||||
"""
|
||||
return Trade.get_trades(Trade.is_open.is_(True)).all()
|
||||
|
||||
@staticmethod
|
||||
def get_open_order_trades():
|
||||
"""
|
||||
Returns all open trades
|
||||
"""
|
||||
return Trade.get_trades(Trade.open_order_id.isnot(None)).all()
|
||||
|
||||
@staticmethod
|
||||
def get_open_trades_without_assigned_fees():
|
||||
"""
|
||||
Returns all open trades which don't have open fees set correctly
|
||||
"""
|
||||
return Trade.get_trades([Trade.fee_open_currency.is_(None),
|
||||
Trade.orders.any(),
|
||||
Trade.is_open.is_(True),
|
||||
]).all()
|
||||
|
||||
@staticmethod
|
||||
def get_sold_trades_without_assigned_fees():
|
||||
"""
|
||||
Returns all closed trades which don't have fees set correctly
|
||||
"""
|
||||
return Trade.get_trades([Trade.fee_close_currency.is_(None),
|
||||
Trade.orders.any(),
|
||||
Trade.is_open.is_(False),
|
||||
]).all()
|
||||
|
||||
@staticmethod
|
||||
def total_open_trades_stakes() -> float:
|
||||
"""
|
||||
Calculates total invested amount in open trades
|
||||
in stake currency
|
||||
"""
|
||||
total_open_stake_amount = Trade.session.query(func.sum(Trade.stake_amount))\
|
||||
.filter(Trade.is_open.is_(True))\
|
||||
.scalar()
|
||||
return total_open_stake_amount or 0
|
||||
|
||||
@staticmethod
|
||||
def get_overall_performance() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns List of dicts containing all Trades, including profit and trade count
|
||||
"""
|
||||
pair_rates = Trade.session.query(
|
||||
Trade.pair,
|
||||
func.sum(Trade.close_profit).label('profit_sum'),
|
||||
func.count(Trade.pair).label('count')
|
||||
).filter(Trade.is_open.is_(False))\
|
||||
.group_by(Trade.pair) \
|
||||
.order_by(desc('profit_sum')) \
|
||||
.all()
|
||||
return [
|
||||
{
|
||||
'pair': pair,
|
||||
'profit': rate,
|
||||
'count': count
|
||||
}
|
||||
for pair, rate, count in pair_rates
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
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')
|
||||
).filter(Trade.is_open.is_(False)) \
|
||||
.group_by(Trade.pair) \
|
||||
.order_by(desc('profit_sum')).first()
|
||||
return best_pair
|
||||
return Trade.get_trades_proxy(is_open=True)
|
||||
|
||||
@staticmethod
|
||||
def stoploss_reinitialization(desired_stoploss):
|
||||
@@ -723,6 +646,215 @@ class Trade(_DECL_BASE):
|
||||
logger.info(f"New stoploss: {trade.stop_loss}.")
|
||||
|
||||
|
||||
class Trade(_DECL_BASE, LocalTrade):
|
||||
"""
|
||||
Trade database model.
|
||||
Also handles updating and querying trades
|
||||
|
||||
Note: Fields must be aligned with LocalTrade class
|
||||
"""
|
||||
__tablename__ = 'trades'
|
||||
|
||||
use_db: bool = True
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan")
|
||||
|
||||
exchange = Column(String(25), nullable=False)
|
||||
pair = Column(String(25), nullable=False, index=True)
|
||||
is_open = Column(Boolean, nullable=False, default=True, index=True)
|
||||
fee_open = Column(Float, nullable=False, default=0.0)
|
||||
fee_open_cost = Column(Float, nullable=True)
|
||||
fee_open_currency = Column(String(25), nullable=True)
|
||||
fee_close = Column(Float, nullable=False, default=0.0)
|
||||
fee_close_cost = Column(Float, nullable=True)
|
||||
fee_close_currency = Column(String(25), nullable=True)
|
||||
open_rate = Column(Float)
|
||||
open_rate_requested = Column(Float)
|
||||
# open_trade_value - calculated via _calc_open_trade_value
|
||||
open_trade_value = Column(Float)
|
||||
close_rate = Column(Float)
|
||||
close_rate_requested = Column(Float)
|
||||
close_profit = Column(Float)
|
||||
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(255))
|
||||
# absolute value of the stop loss
|
||||
stop_loss = Column(Float, nullable=True, default=0.0)
|
||||
# percentage value of the stop loss
|
||||
stop_loss_pct = Column(Float, nullable=True)
|
||||
# absolute value of the initial stop loss
|
||||
initial_stop_loss = Column(Float, nullable=True, default=0.0)
|
||||
# percentage value of the initial stop loss
|
||||
initial_stop_loss_pct = Column(Float, nullable=True)
|
||||
# stoploss order id which is on exchange
|
||||
stoploss_order_id = Column(String(255), nullable=True, index=True)
|
||||
# last update time of the stoploss order on exchange
|
||||
stoploss_last_update = Column(DateTime, nullable=True)
|
||||
# absolute value of the highest reached price
|
||||
max_rate = Column(Float, nullable=True, default=0.0)
|
||||
# Lowest price reached
|
||||
min_rate = Column(Float, nullable=True)
|
||||
sell_reason = Column(String(100), nullable=True)
|
||||
sell_order_status = Column(String(100), nullable=True)
|
||||
strategy = Column(String(100), nullable=True)
|
||||
timeframe = Column(Integer, nullable=True)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.recalc_open_trade_value()
|
||||
|
||||
def delete(self) -> None:
|
||||
|
||||
for order in self.orders:
|
||||
Order.query.session.delete(order)
|
||||
|
||||
Trade.query.session.delete(self)
|
||||
Trade.commit()
|
||||
|
||||
@staticmethod
|
||||
def commit():
|
||||
Trade.query.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_trades_proxy(*, pair: str = None, is_open: bool = None,
|
||||
open_date: datetime = None, close_date: datetime = None,
|
||||
) -> List['LocalTrade']:
|
||||
"""
|
||||
Helper function to query Trades.j
|
||||
Returns a List of trades, filtered on the parameters given.
|
||||
In live mode, converts the filter to a database query and returns all rows
|
||||
In Backtest mode, uses filters on Trade.trades to get the result.
|
||||
|
||||
:return: unsorted List[Trade]
|
||||
"""
|
||||
if Trade.use_db:
|
||||
trade_filter = []
|
||||
if pair:
|
||||
trade_filter.append(Trade.pair == pair)
|
||||
if open_date:
|
||||
trade_filter.append(Trade.open_date > open_date)
|
||||
if close_date:
|
||||
trade_filter.append(Trade.close_date > close_date)
|
||||
if is_open is not None:
|
||||
trade_filter.append(Trade.is_open.is_(is_open))
|
||||
return Trade.get_trades(trade_filter).all()
|
||||
else:
|
||||
return LocalTrade.get_trades_proxy(
|
||||
pair=pair, is_open=is_open,
|
||||
open_date=open_date,
|
||||
close_date=close_date
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_trades(trade_filter=None) -> Query:
|
||||
"""
|
||||
Helper function to query Trades using filters.
|
||||
NOTE: Not supported in Backtesting.
|
||||
:param trade_filter: Optional filter to apply to trades
|
||||
Can be either a Filter object, or a List of filters
|
||||
e.g. `(trade_filter=[Trade.id == trade_id, Trade.is_open.is_(True),])`
|
||||
e.g. `(trade_filter=Trade.id == trade_id)`
|
||||
:return: unsorted query object
|
||||
"""
|
||||
if not Trade.use_db:
|
||||
raise NotImplementedError('`Trade.get_trades()` not supported in backtesting mode.')
|
||||
if trade_filter is not None:
|
||||
if not isinstance(trade_filter, list):
|
||||
trade_filter = [trade_filter]
|
||||
return Trade.query.filter(*trade_filter)
|
||||
else:
|
||||
return Trade.query
|
||||
|
||||
@staticmethod
|
||||
def get_open_order_trades():
|
||||
"""
|
||||
Returns all open trades
|
||||
NOTE: Not supported in Backtesting.
|
||||
"""
|
||||
return Trade.get_trades(Trade.open_order_id.isnot(None)).all()
|
||||
|
||||
@staticmethod
|
||||
def get_open_trades_without_assigned_fees():
|
||||
"""
|
||||
Returns all open trades which don't have open fees set correctly
|
||||
NOTE: Not supported in Backtesting.
|
||||
"""
|
||||
return Trade.get_trades([Trade.fee_open_currency.is_(None),
|
||||
Trade.orders.any(),
|
||||
Trade.is_open.is_(True),
|
||||
]).all()
|
||||
|
||||
@staticmethod
|
||||
def get_sold_trades_without_assigned_fees():
|
||||
"""
|
||||
Returns all closed trades which don't have fees set correctly
|
||||
NOTE: Not supported in Backtesting.
|
||||
"""
|
||||
return Trade.get_trades([Trade.fee_close_currency.is_(None),
|
||||
Trade.orders.any(),
|
||||
Trade.is_open.is_(False),
|
||||
]).all()
|
||||
|
||||
@staticmethod
|
||||
def total_open_trades_stakes() -> float:
|
||||
"""
|
||||
Calculates total invested amount in open trades
|
||||
in stake currency
|
||||
"""
|
||||
if Trade.use_db:
|
||||
total_open_stake_amount = Trade.query.with_entities(
|
||||
func.sum(Trade.stake_amount)).filter(Trade.is_open.is_(True)).scalar()
|
||||
else:
|
||||
total_open_stake_amount = sum(
|
||||
t.stake_amount for t in LocalTrade.get_trades_proxy(is_open=True))
|
||||
return total_open_stake_amount or 0
|
||||
|
||||
@staticmethod
|
||||
def get_overall_performance() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns List of dicts containing all Trades, including profit and trade count
|
||||
NOTE: Not supported in Backtesting.
|
||||
"""
|
||||
pair_rates = Trade.query.with_entities(
|
||||
Trade.pair,
|
||||
func.sum(Trade.close_profit).label('profit_sum'),
|
||||
func.sum(Trade.close_profit_abs).label('profit_sum_abs'),
|
||||
func.count(Trade.pair).label('count')
|
||||
).filter(Trade.is_open.is_(False))\
|
||||
.group_by(Trade.pair) \
|
||||
.order_by(desc('profit_sum_abs')) \
|
||||
.all()
|
||||
return [
|
||||
{
|
||||
'pair': pair,
|
||||
'profit': profit,
|
||||
'profit_abs': profit_abs,
|
||||
'count': count
|
||||
}
|
||||
for pair, profit, profit_abs, count in pair_rates
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_best_pair():
|
||||
"""
|
||||
Get best pair with closed trade.
|
||||
NOTE: Not supported in Backtesting.
|
||||
:returns: Tuple containing (pair, profit_sum)
|
||||
"""
|
||||
best_pair = Trade.query.with_entities(
|
||||
Trade.pair, func.sum(Trade.close_profit).label('profit_sum')
|
||||
).filter(Trade.is_open.is_(False)) \
|
||||
.group_by(Trade.pair) \
|
||||
.order_by(desc('profit_sum')).first()
|
||||
return best_pair
|
||||
|
||||
|
||||
class PairLock(_DECL_BASE):
|
||||
"""
|
||||
Pair Locks database model.
|
||||
@@ -731,8 +863,8 @@ class PairLock(_DECL_BASE):
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
pair = Column(String, nullable=False, index=True)
|
||||
reason = Column(String, nullable=True)
|
||||
pair = Column(String(25), nullable=False, index=True)
|
||||
reason = Column(String(255), nullable=True)
|
||||
# Time the pair was locked (start time)
|
||||
lock_time = Column(DateTime, nullable=False)
|
||||
# Time until the pair is locked (end time)
|
||||
@@ -765,6 +897,7 @@ class PairLock(_DECL_BASE):
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'pair': self.pair,
|
||||
'lock_time': self.lock_time.strftime(DATETIME_PRINT_FORMAT),
|
||||
'lock_timestamp': int(self.lock_time.replace(tzinfo=timezone.utc).timestamp() * 1000),
|
||||
|
@@ -48,8 +48,8 @@ class PairLocks():
|
||||
active=True
|
||||
)
|
||||
if PairLocks.use_db:
|
||||
PairLock.session.add(lock)
|
||||
PairLock.session.flush()
|
||||
PairLock.query.session.add(lock)
|
||||
PairLock.query.session.commit()
|
||||
else:
|
||||
PairLocks.locks.append(lock)
|
||||
|
||||
@@ -99,7 +99,7 @@ class PairLocks():
|
||||
for lock in locks:
|
||||
lock.active = False
|
||||
if PairLocks.use_db:
|
||||
PairLock.session.flush()
|
||||
PairLock.query.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def is_global_lock(now: Optional[datetime] = None) -> bool:
|
||||
@@ -123,3 +123,11 @@ class PairLocks():
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
return len(PairLocks.get_pair_locks(pair, now)) > 0 or PairLocks.is_global_lock(now)
|
||||
|
||||
@staticmethod
|
||||
def get_all_locks() -> List[PairLock]:
|
||||
|
||||
if PairLocks.use_db:
|
||||
return PairLock.query.all()
|
||||
else:
|
||||
return PairLocks.locks
|
||||
|
@@ -47,7 +47,7 @@ def init_plotscript(config, markets: List, startup_candles: int = 0):
|
||||
data = load_data(
|
||||
datadir=config.get('datadir'),
|
||||
pairs=pairs,
|
||||
timeframe=config.get('timeframe', '5m'),
|
||||
timeframe=config['timeframe'],
|
||||
timerange=timerange,
|
||||
startup_candles=startup_candles,
|
||||
data_format=config.get('dataformat_ohlcv', 'json'),
|
||||
@@ -56,7 +56,7 @@ def init_plotscript(config, markets: List, startup_candles: int = 0):
|
||||
if startup_candles and data:
|
||||
min_date, max_date = get_timerange(data)
|
||||
logger.info(f"Loading data from {min_date} to {max_date}")
|
||||
timerange.adjust_start_if_necessary(timeframe_to_seconds(config.get('timeframe', '5m')),
|
||||
timerange.adjust_start_if_necessary(timeframe_to_seconds(config['timeframe']),
|
||||
startup_candles, min_date)
|
||||
|
||||
no_trades = False
|
||||
@@ -77,7 +77,8 @@ def init_plotscript(config, markets: List, startup_candles: int = 0):
|
||||
)
|
||||
except ValueError as e:
|
||||
raise OperationalException(e) from e
|
||||
trades = trim_dataframe(trades, timerange, 'open_date')
|
||||
if not trades.empty:
|
||||
trades = trim_dataframe(trades, timerange, 'open_date')
|
||||
|
||||
return {"ohlcv": data,
|
||||
"trades": trades,
|
||||
@@ -95,20 +96,34 @@ def add_indicators(fig, row, indicators: Dict[str, Dict], data: pd.DataFrame) ->
|
||||
Dict key must correspond to dataframe column.
|
||||
:param data: candlestick DataFrame
|
||||
"""
|
||||
plot_kinds = {
|
||||
'scatter': go.Scatter,
|
||||
'bar': go.Bar,
|
||||
}
|
||||
for indicator, conf in indicators.items():
|
||||
logger.debug(f"indicator {indicator} with config {conf}")
|
||||
if indicator in data:
|
||||
kwargs = {'x': data['date'],
|
||||
'y': data[indicator].values,
|
||||
'mode': 'lines',
|
||||
'name': indicator
|
||||
}
|
||||
if 'color' in conf:
|
||||
kwargs.update({'line': {'color': conf['color']}})
|
||||
scatter = go.Scatter(
|
||||
**kwargs
|
||||
)
|
||||
fig.add_trace(scatter, row, 1)
|
||||
|
||||
plot_type = conf.get('type', 'scatter')
|
||||
color = conf.get('color')
|
||||
if plot_type == 'bar':
|
||||
kwargs.update({'marker_color': color or 'DarkSlateGrey',
|
||||
'marker_line_color': color or 'DarkSlateGrey'})
|
||||
else:
|
||||
if color:
|
||||
kwargs.update({'line': {'color': color}})
|
||||
kwargs['mode'] = 'lines'
|
||||
if plot_type != 'scatter':
|
||||
logger.warning(f'Indicator {indicator} has unknown plot trace kind {plot_type}'
|
||||
f', assuming "scatter".')
|
||||
|
||||
kwargs.update(conf.get('plotly', {}))
|
||||
trace = plot_kinds[plot_type](**kwargs)
|
||||
fig.add_trace(trace, row, 1)
|
||||
else:
|
||||
logger.info(
|
||||
'Indicator "%s" ignored. Reason: This indicator is not found '
|
||||
@@ -145,7 +160,7 @@ def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame,
|
||||
Add scatter points indicating max drawdown
|
||||
"""
|
||||
try:
|
||||
max_drawdown, highdate, lowdate = calculate_max_drawdown(trades)
|
||||
max_drawdown, highdate, lowdate, _, _ = calculate_max_drawdown(trades)
|
||||
|
||||
drawdown = go.Scatter(
|
||||
x=[highdate, lowdate],
|
||||
@@ -441,7 +456,7 @@ def generate_candlestick_graph(pair: str, data: pd.DataFrame, trades: pd.DataFra
|
||||
|
||||
|
||||
def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
|
||||
trades: pd.DataFrame, timeframe: str) -> go.Figure:
|
||||
trades: pd.DataFrame, timeframe: str, stake_currency: str) -> go.Figure:
|
||||
# Combine close-values for all pairs, rename columns to "pair"
|
||||
df_comb = combine_dataframes_with_mean(data, "close")
|
||||
|
||||
@@ -466,8 +481,8 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
|
||||
subplot_titles=["AVG Close Price", "Combined Profit", "Profit per pair"])
|
||||
fig['layout'].update(title="Freqtrade Profit plot")
|
||||
fig['layout']['yaxis1'].update(title='Price')
|
||||
fig['layout']['yaxis2'].update(title='Profit')
|
||||
fig['layout']['yaxis3'].update(title='Profit')
|
||||
fig['layout']['yaxis2'].update(title=f'Profit {stake_currency}')
|
||||
fig['layout']['yaxis3'].update(title=f'Profit {stake_currency}')
|
||||
fig['layout']['xaxis']['rangeslider'].update(visible=False)
|
||||
|
||||
fig.add_trace(avgclose, 1, 1)
|
||||
@@ -540,8 +555,11 @@ def load_and_plot_trades(config: Dict[str, Any]):
|
||||
|
||||
df_analyzed = strategy.analyze_ticker(data, {'pair': pair})
|
||||
df_analyzed = trim_dataframe(df_analyzed, timerange)
|
||||
trades_pair = trades.loc[trades['pair'] == pair]
|
||||
trades_pair = extract_trades_of_period(df_analyzed, trades_pair)
|
||||
if not trades.empty:
|
||||
trades_pair = trades.loc[trades['pair'] == pair]
|
||||
trades_pair = extract_trades_of_period(df_analyzed, trades_pair)
|
||||
else:
|
||||
trades_pair = trades
|
||||
|
||||
fig = generate_candlestick_graph(
|
||||
pair=pair,
|
||||
@@ -565,6 +583,9 @@ def plot_profit(config: Dict[str, Any]) -> None:
|
||||
But should be somewhat proportional, and therefor useful
|
||||
in helping out to find a good algorithm.
|
||||
"""
|
||||
if 'timeframe' not in config:
|
||||
raise OperationalException('Timeframe must be set in either config or via --timeframe.')
|
||||
|
||||
exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config)
|
||||
plot_elements = init_plotscript(config, list(exchange.markets))
|
||||
trades = plot_elements['trades']
|
||||
@@ -581,6 +602,8 @@ def plot_profit(config: Dict[str, Any]) -> None:
|
||||
# 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('timeframe', '5m'))
|
||||
trades, config['timeframe'],
|
||||
config.get('stake_currency', ''))
|
||||
store_plot_file(fig, filename='freqtrade-profit-plot.html',
|
||||
directory=config['user_data_dir'] / 'plot', auto_open=True)
|
||||
directory=config['user_data_dir'] / 'plot',
|
||||
auto_open=config.get('plot_auto_open', False))
|
||||
|
@@ -30,10 +30,10 @@ class AgeFilter(IPairList):
|
||||
|
||||
if self._min_days_listed < 1:
|
||||
raise OperationalException("AgeFilter requires min_days_listed to be >= 1")
|
||||
if self._min_days_listed > exchange.ohlcv_candle_limit:
|
||||
if self._min_days_listed > exchange.ohlcv_candle_limit('1d'):
|
||||
raise OperationalException("AgeFilter requires min_days_listed to not exceed "
|
||||
"exchange max request size "
|
||||
f"({exchange.ohlcv_candle_limit})")
|
||||
f"({exchange.ohlcv_candle_limit('1d')})")
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
@@ -71,14 +71,14 @@ class AgeFilter(IPairList):
|
||||
daily_candles = candles[(p, '1d')] if (p, '1d') in candles else None
|
||||
if not self._validate_pair_loc(p, daily_candles):
|
||||
pairlist.remove(p)
|
||||
logger.info(f"Validated {len(pairlist)} pairs.")
|
||||
self.log_once(f"Validated {len(pairlist)} pairs.", logger.info)
|
||||
return pairlist
|
||||
|
||||
def _validate_pair_loc(self, pair: str, daily_candles: Optional[DataFrame]) -> bool:
|
||||
"""
|
||||
Validate age for the ticker
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
# Check symbol in cache
|
||||
@@ -86,7 +86,7 @@ class AgeFilter(IPairList):
|
||||
return True
|
||||
|
||||
if daily_candles is not None:
|
||||
if len(daily_candles) > self._min_days_listed:
|
||||
if len(daily_candles) >= self._min_days_listed:
|
||||
# We have fetched at least the minimum required number of daily candles
|
||||
# Add to cache, store the time we last checked this symbol
|
||||
self._symbolsChecked[pair] = int(arrow.utcnow().float_timestamp) * 1000
|
||||
|
@@ -7,7 +7,7 @@ from copy import deepcopy
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import market_is_active
|
||||
from freqtrade.exchange import Exchange, market_is_active
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class IPairList(LoggingMixin, ABC):
|
||||
|
||||
def __init__(self, exchange, pairlistmanager,
|
||||
def __init__(self, exchange: Exchange, pairlistmanager,
|
||||
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
|
||||
pairlist_pos: int) -> None:
|
||||
"""
|
||||
@@ -28,7 +28,7 @@ class IPairList(LoggingMixin, ABC):
|
||||
"""
|
||||
self._enabled = True
|
||||
|
||||
self._exchange = exchange
|
||||
self._exchange: Exchange = exchange
|
||||
self._pairlistmanager = pairlistmanager
|
||||
self._config = config
|
||||
self._pairlistconfig = pairlistconfig
|
||||
@@ -68,12 +68,12 @@ class IPairList(LoggingMixin, ABC):
|
||||
filter_pairlist() method.
|
||||
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def gen_pairlist(self, cached_pairlist: List[str], tickers: Dict) -> List[str]:
|
||||
def gen_pairlist(self, tickers: Dict) -> List[str]:
|
||||
"""
|
||||
Generate the pairlist.
|
||||
|
||||
@@ -84,8 +84,7 @@ class IPairList(LoggingMixin, ABC):
|
||||
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()).
|
||||
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
|
||||
:return: List of pairs
|
||||
"""
|
||||
raise OperationalException("This Pairlist Handler should not be used "
|
||||
|
@@ -2,7 +2,7 @@
|
||||
Performance pair list filter
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
@@ -15,11 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class PerformanceFilter(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)
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
"""
|
||||
@@ -44,7 +39,12 @@ class PerformanceFilter(IPairList):
|
||||
:return: new allowlist
|
||||
"""
|
||||
# Get the trading performance for pairs from database
|
||||
performance = pd.DataFrame(Trade.get_overall_performance())
|
||||
try:
|
||||
performance = pd.DataFrame(Trade.get_overall_performance())
|
||||
except AttributeError:
|
||||
# Performancefilter does not work in backtesting.
|
||||
self.log_once("PerformanceFilter is not available in this mode.", logger.warning)
|
||||
return pairlist
|
||||
|
||||
# Skip performance-based sorting if no performance data is available
|
||||
if len(performance) == 0:
|
||||
|
@@ -48,7 +48,7 @@ class PrecisionFilter(IPairList):
|
||||
Check if pair has enough room to add a stoploss to avoid "unsellable" buys of very
|
||||
low value pairs.
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
stop_price = ticker['ask'] * self._stoploss
|
||||
|
@@ -27,9 +27,13 @@ class PriceFilter(IPairList):
|
||||
self._max_price = pairlistconfig.get('max_price', 0)
|
||||
if self._max_price < 0:
|
||||
raise OperationalException("PriceFilter requires max_price to be >= 0")
|
||||
self._max_value = pairlistconfig.get('max_value', 0)
|
||||
if self._max_value < 0:
|
||||
raise OperationalException("PriceFilter requires max_value to be >= 0")
|
||||
self._enabled = ((self._low_price_ratio > 0) or
|
||||
(self._min_price > 0) or
|
||||
(self._max_price > 0))
|
||||
(self._max_price > 0) or
|
||||
(self._max_value > 0))
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
@@ -51,6 +55,8 @@ class PriceFilter(IPairList):
|
||||
active_price_filters.append(f"below {self._min_price:.8f}")
|
||||
if self._max_price != 0:
|
||||
active_price_filters.append(f"above {self._max_price:.8f}")
|
||||
if self._max_value != 0:
|
||||
active_price_filters.append(f"Value above {self._max_value:.8f}")
|
||||
|
||||
if len(active_price_filters):
|
||||
return f"{self.name} - Filtering pairs priced {' or '.join(active_price_filters)}."
|
||||
@@ -61,10 +67,10 @@ class PriceFilter(IPairList):
|
||||
"""
|
||||
Check if if one price-step (pip) is > than a certain barrier.
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
if ticker['last'] is None or ticker['last'] == 0:
|
||||
if ticker.get('last', None) is None or ticker.get('last') == 0:
|
||||
self.log_once(f"Removed {pair} from whitelist, because "
|
||||
"ticker['last'] is empty (Usually no trade in the last 24h).",
|
||||
logger.info)
|
||||
@@ -79,6 +85,32 @@ class PriceFilter(IPairList):
|
||||
f"because 1 unit is {changeperc * 100:.3f}%", logger.info)
|
||||
return False
|
||||
|
||||
# Perform low_amount check
|
||||
if self._max_value != 0:
|
||||
price = ticker['last']
|
||||
market = self._exchange.markets[pair]
|
||||
limits = market['limits']
|
||||
if ('amount' in limits and 'min' in limits['amount']
|
||||
and limits['amount']['min'] is not None):
|
||||
min_amount = limits['amount']['min']
|
||||
min_precision = market['precision']['amount']
|
||||
|
||||
min_value = min_amount * price
|
||||
if self._exchange.precisionMode == 4:
|
||||
# tick size
|
||||
next_value = (min_amount + min_precision) * price
|
||||
else:
|
||||
# Decimal places
|
||||
min_precision = pow(0.1, min_precision)
|
||||
next_value = (min_amount + min_precision) * price
|
||||
diff = next_value - min_value
|
||||
|
||||
if diff > self._max_value:
|
||||
self.log_once(f"Removed {pair} from whitelist, "
|
||||
f"because min value change of {diff} > {self._max_value}.",
|
||||
logger.info)
|
||||
return False
|
||||
|
||||
# Perform min_price check.
|
||||
if self._min_price != 0:
|
||||
if ticker['last'] < self._min_price:
|
||||
@@ -89,7 +121,7 @@ class PriceFilter(IPairList):
|
||||
# Perform max_price check.
|
||||
if self._max_price != 0:
|
||||
if ticker['last'] > self._max_price:
|
||||
self.log_once(f"Removed {ticker['symbol']} from whitelist, "
|
||||
self.log_once(f"Removed {pair} from whitelist, "
|
||||
f"because last price > {self._max_price:.8f}", logger.info)
|
||||
return False
|
||||
|
||||
|
@@ -40,7 +40,7 @@ class SpreadFilter(IPairList):
|
||||
"""
|
||||
Validate spread for the ticker
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
if 'bid' in ticker and 'ask' in ticker and ticker['ask']:
|
||||
|
@@ -42,11 +42,10 @@ class StaticPairList(IPairList):
|
||||
"""
|
||||
return f"{self.name}"
|
||||
|
||||
def gen_pairlist(self, cached_pairlist: List[str], tickers: Dict) -> List[str]:
|
||||
def gen_pairlist(self, tickers: Dict) -> List[str]:
|
||||
"""
|
||||
Generate the pairlist
|
||||
:param cached_pairlist: Previously generated pairlist (cached)
|
||||
:param tickers: Tickers (from exchange.get_tickers()).
|
||||
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
|
||||
:return: List of pairs
|
||||
"""
|
||||
if self._allow_inactive:
|
||||
|
121
freqtrade/plugins/pairlist/VolatilityFilter.py
Normal file
121
freqtrade/plugins/pairlist/VolatilityFilter.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Volatility pairlist filter
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import arrow
|
||||
import numpy as np
|
||||
from cachetools.ttl import TTLCache
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import plural
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VolatilityFilter(IPairList):
|
||||
'''
|
||||
Filters pairs by volatility
|
||||
'''
|
||||
|
||||
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._days = pairlistconfig.get('lookback_days', 10)
|
||||
self._min_volatility = pairlistconfig.get('min_volatility', 0)
|
||||
self._max_volatility = pairlistconfig.get('max_volatility', sys.maxsize)
|
||||
self._refresh_period = pairlistconfig.get('refresh_period', 1440)
|
||||
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
|
||||
|
||||
if self._days < 1:
|
||||
raise OperationalException("VolatilityFilter requires lookback_days to be >= 1")
|
||||
if self._days > exchange.ohlcv_candle_limit('1d'):
|
||||
raise OperationalException("VolatilityFilter requires lookback_days to not "
|
||||
"exceed exchange max request size "
|
||||
f"({exchange.ohlcv_candle_limit('1d')})")
|
||||
|
||||
@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} - Filtering pairs with volatility range "
|
||||
f"{self._min_volatility}-{self._max_volatility} "
|
||||
f" the last {self._days} {plural(self._days, 'day')}.")
|
||||
|
||||
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
|
||||
"""
|
||||
Validate trading range
|
||||
:param pairlist: pairlist to filter or sort
|
||||
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
|
||||
:return: new allowlist
|
||||
"""
|
||||
needed_pairs = [(p, '1d') for p in pairlist if p not in self._pair_cache]
|
||||
|
||||
since_ms = int(arrow.utcnow()
|
||||
.floor('day')
|
||||
.shift(days=-self._days - 1)
|
||||
.float_timestamp) * 1000
|
||||
# Get all candles
|
||||
candles = {}
|
||||
if needed_pairs:
|
||||
candles = self._exchange.refresh_latest_ohlcv(needed_pairs, since_ms=since_ms,
|
||||
cache=False)
|
||||
|
||||
if self._enabled:
|
||||
for p in deepcopy(pairlist):
|
||||
daily_candles = candles[(p, '1d')] if (p, '1d') in candles else None
|
||||
if not self._validate_pair_loc(p, daily_candles):
|
||||
pairlist.remove(p)
|
||||
return pairlist
|
||||
|
||||
def _validate_pair_loc(self, pair: str, daily_candles: Optional[DataFrame]) -> bool:
|
||||
"""
|
||||
Validate trading range
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
# Check symbol in cache
|
||||
cached_res = self._pair_cache.get(pair, None)
|
||||
if cached_res is not None:
|
||||
return cached_res
|
||||
|
||||
result = False
|
||||
if daily_candles is not None and not daily_candles.empty:
|
||||
returns = (np.log(daily_candles.close / daily_candles.close.shift(-1)))
|
||||
returns.fillna(0, inplace=True)
|
||||
|
||||
volatility_series = returns.rolling(window=self._days).std()*np.sqrt(self._days)
|
||||
volatility_avg = volatility_series.mean()
|
||||
|
||||
if self._min_volatility <= volatility_avg <= self._max_volatility:
|
||||
result = True
|
||||
else:
|
||||
self.log_once(f"Removed {pair} from whitelist, because volatility "
|
||||
f"over {self._days} {plural(self._days, 'day')} "
|
||||
f"is: {volatility_avg:.3f} "
|
||||
f"which is not in the configured range of "
|
||||
f"{self._min_volatility}-{self._max_volatility}.",
|
||||
logger.info)
|
||||
result = False
|
||||
self._pair_cache[pair] = result
|
||||
|
||||
return result
|
@@ -4,9 +4,10 @@ Volume PairList provider
|
||||
Provides dynamic pair list based on trade volumes
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from cachetools.ttl import TTLCache
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.plugins.pairlist.IPairList import IPairList
|
||||
|
||||
@@ -33,7 +34,8 @@ class VolumePairList(IPairList):
|
||||
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)
|
||||
self.refresh_period = self._pairlistconfig.get('refresh_period', 1800)
|
||||
self._refresh_period = self._pairlistconfig.get('refresh_period', 1800)
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
|
||||
|
||||
if not self._exchange.exchange_has('fetchTickers'):
|
||||
raise OperationalException(
|
||||
@@ -63,17 +65,19 @@ 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]:
|
||||
def gen_pairlist(self, tickers: Dict) -> List[str]:
|
||||
"""
|
||||
Generate the pairlist
|
||||
:param cached_pairlist: Previously generated pairlist (cached)
|
||||
:param tickers: Tickers (from exchange.get_tickers()).
|
||||
:param tickers: Tickers (from exchange.get_tickers()). May be cached.
|
||||
: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())
|
||||
pairlist = self._pair_cache.get('pairlist')
|
||||
if pairlist:
|
||||
# Item found - no refresh necessary
|
||||
return pairlist
|
||||
else:
|
||||
|
||||
# Use fresh pairlist
|
||||
# Check if pair quote currency equals to the stake currency.
|
||||
@@ -82,9 +86,9 @@ class VolumePairList(IPairList):
|
||||
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
|
||||
|
||||
pairlist = self.filter_pairlist(pairlist, tickers)
|
||||
self._pair_cache['pairlist'] = pairlist
|
||||
|
||||
return pairlist
|
||||
|
||||
|
@@ -28,14 +28,14 @@ class RangeStabilityFilter(IPairList):
|
||||
self._min_rate_of_change = pairlistconfig.get('min_rate_of_change', 0.01)
|
||||
self._refresh_period = pairlistconfig.get('refresh_period', 1440)
|
||||
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=100, ttl=self._refresh_period)
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
|
||||
|
||||
if self._days < 1:
|
||||
raise OperationalException("RangeStabilityFilter requires lookback_days to be >= 1")
|
||||
if self._days > exchange.ohlcv_candle_limit:
|
||||
if self._days > exchange.ohlcv_candle_limit('1d'):
|
||||
raise OperationalException("RangeStabilityFilter requires lookback_days to not "
|
||||
"exceed exchange max request size "
|
||||
f"({exchange.ohlcv_candle_limit})")
|
||||
f"({exchange.ohlcv_candle_limit('1d')})")
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
@@ -83,12 +83,13 @@ class RangeStabilityFilter(IPairList):
|
||||
"""
|
||||
Validate trading range
|
||||
:param pair: Pair that's currently validated
|
||||
:param ticker: ticker dict as returned from ccxt.load_markets()
|
||||
:param ticker: ticker dict as returned from ccxt.fetch_tickers()
|
||||
:return: True if the pair can stay, false if it should be removed
|
||||
"""
|
||||
# Check symbol in cache
|
||||
if pair in self._pair_cache:
|
||||
return self._pair_cache[pair]
|
||||
cached_res = self._pair_cache.get(pair, None)
|
||||
if cached_res is not None:
|
||||
return cached_res
|
||||
|
||||
result = False
|
||||
if daily_candles is not None and not daily_candles.empty:
|
||||
|
@@ -3,7 +3,7 @@ PairList manager class
|
||||
"""
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List
|
||||
from typing import Dict, List
|
||||
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
@@ -79,11 +79,8 @@ class PairListManager():
|
||||
if self._tickers_needed:
|
||||
tickers = self._get_cached_tickers()
|
||||
|
||||
# Adjust whitelist if filters are using tickers
|
||||
pairlist = self._prepare_whitelist(self._whitelist.copy(), tickers)
|
||||
|
||||
# Generate the pairlist with first Pairlist Handler in the chain
|
||||
pairlist = self._pairlist_handlers[0].gen_pairlist(self._whitelist, tickers)
|
||||
pairlist = self._pairlist_handlers[0].gen_pairlist(tickers)
|
||||
|
||||
# Process all Pairlist Handlers in the chain
|
||||
for pairlist_handler in self._pairlist_handlers:
|
||||
@@ -95,19 +92,6 @@ class PairListManager():
|
||||
|
||||
self._whitelist = pairlist
|
||||
|
||||
def _prepare_whitelist(self, pairlist: List[str], tickers: Dict[str, Any]) -> 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.
|
||||
|
@@ -1,7 +1,6 @@
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
||||
@@ -15,9 +14,6 @@ class CooldownPeriod(IProtection):
|
||||
has_global_stop: bool = False
|
||||
has_local_stop: bool = True
|
||||
|
||||
def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None:
|
||||
super().__init__(config, protection_config)
|
||||
|
||||
def _reason(self) -> str:
|
||||
"""
|
||||
LockReason to use
|
||||
@@ -44,7 +40,8 @@ class CooldownPeriod(IProtection):
|
||||
trades = Trade.get_trades_proxy(pair=pair, is_open=False, close_date=look_back_until)
|
||||
if trades:
|
||||
# Get latest trade
|
||||
trade = sorted(trades, key=lambda t: t.close_date)[-1]
|
||||
# Ignore type error as we know we only get closed trades.
|
||||
trade = sorted(trades, key=lambda t: t.close_date)[-1] # type: ignore
|
||||
self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info)
|
||||
until = self.calculate_lock_end([trade], self._stop_duration)
|
||||
|
||||
|
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from freqtrade.exchange import timeframe_to_minutes
|
||||
from freqtrade.misc import plural
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.persistence import LocalTrade
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -93,11 +93,11 @@ class IProtection(LoggingMixin, ABC):
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_lock_end(trades: List[Trade], stop_minutes: int) -> datetime:
|
||||
def calculate_lock_end(trades: List[LocalTrade], stop_minutes: int) -> datetime:
|
||||
"""
|
||||
Get lock end time
|
||||
"""
|
||||
max_date: datetime = max([trade.close_date for trade in trades])
|
||||
max_date: datetime = max([trade.close_date for trade in trades if trade.close_date])
|
||||
# comming from Database, tzinfo is not set.
|
||||
if max_date.tzinfo is None:
|
||||
max_date = max_date.replace(tzinfo=timezone.utc)
|
||||
|
@@ -53,7 +53,7 @@ class LowProfitPairs(IProtection):
|
||||
# Not enough trades in the relevant period
|
||||
return False, None, None
|
||||
|
||||
profit = sum(trade.close_profit for trade in trades)
|
||||
profit = sum(trade.close_profit for trade in trades if trade.close_profit)
|
||||
if profit < self._required_profit:
|
||||
self.log_once(
|
||||
f"Trading for {pair} stopped due to {profit:.2f} < {self._required_profit} "
|
||||
|
@@ -55,13 +55,13 @@ class MaxDrawdown(IProtection):
|
||||
|
||||
# Drawdown is always positive
|
||||
try:
|
||||
drawdown, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
|
||||
drawdown, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
|
||||
except ValueError:
|
||||
return False, None, None
|
||||
|
||||
if drawdown > self._max_allowed_drawdown:
|
||||
self.log_once(
|
||||
f"Trading stopped due to Max Drawdown {drawdown:.2f} < {self._max_allowed_drawdown}"
|
||||
f"Trading stopped due to Max Drawdown {drawdown:.2f} > {self._max_allowed_drawdown}"
|
||||
f" within {self.lookback_period_str}.", logger.info)
|
||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
||||
|
||||
|
@@ -3,9 +3,9 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
|
||||
from freqtrade.enums import SellType
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
||||
from freqtrade.strategy.interface import SellType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,15 +56,15 @@ class StoplossGuard(IProtection):
|
||||
trades = [trade for trade in trades1 if (str(trade.sell_reason) in (
|
||||
SellType.TRAILING_STOP_LOSS.value, SellType.STOP_LOSS.value,
|
||||
SellType.STOPLOSS_ON_EXCHANGE.value)
|
||||
and trade.close_profit < 0)]
|
||||
and trade.close_profit and trade.close_profit < 0)]
|
||||
|
||||
if len(trades) > self._trade_limit:
|
||||
self.log_once(f"Trading stopped due to {self._trade_limit} "
|
||||
f"stoplosses within {self._lookback_period} minutes.", logger.info)
|
||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
||||
return True, until, self._reason()
|
||||
if len(trades) < self._trade_limit:
|
||||
return False, None, None
|
||||
|
||||
return False, None, None
|
||||
self.log_once(f"Trading stopped due to {self._trade_limit} "
|
||||
f"stoplosses within {self._lookback_period} minutes.", logger.info)
|
||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
||||
return True, until, self._reason()
|
||||
|
||||
def global_stop(self, date_now: datetime) -> ProtectionReturn:
|
||||
"""
|
||||
|
@@ -61,7 +61,7 @@ class IResolver:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module) # type: ignore # importlib does not use typehints
|
||||
except (ModuleNotFoundError, SyntaxError, ImportError) as err:
|
||||
except (ModuleNotFoundError, SyntaxError, ImportError, NameError) as err:
|
||||
# Catch errors in case a specific module is not installed
|
||||
logger.warning(f"Could not import {module_path} due to '{err}'")
|
||||
if enum_failed:
|
||||
|
@@ -6,7 +6,6 @@ This module load custom strategies
|
||||
import logging
|
||||
import tempfile
|
||||
from base64 import urlsafe_b64decode
|
||||
from collections import OrderedDict
|
||||
from inspect import getfullargspec
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -139,7 +138,7 @@ class StrategyResolver(IResolver):
|
||||
|
||||
# Sort and apply type conversions
|
||||
if hasattr(strategy, 'minimal_roi'):
|
||||
strategy.minimal_roi = OrderedDict(sorted(
|
||||
strategy.minimal_roi = dict(sorted(
|
||||
{int(key): value for (key, value) in strategy.minimal_roi.items()}.items(),
|
||||
key=lambda t: t[0]))
|
||||
if hasattr(strategy, 'stoploss'):
|
||||
@@ -196,9 +195,9 @@ class StrategyResolver(IResolver):
|
||||
strategy._populate_fun_len = len(getfullargspec(strategy.populate_indicators).args)
|
||||
strategy._buy_fun_len = len(getfullargspec(strategy.populate_buy_trend).args)
|
||||
strategy._sell_fun_len = len(getfullargspec(strategy.populate_sell_trend).args)
|
||||
if any([x == 2 for x in [strategy._populate_fun_len,
|
||||
strategy._buy_fun_len,
|
||||
strategy._sell_fun_len]]):
|
||||
if any(x == 2 for x in [strategy._populate_fun_len,
|
||||
strategy._buy_fun_len,
|
||||
strategy._sell_fun_len]):
|
||||
strategy.INTERFACE_VERSION = 1
|
||||
|
||||
return strategy
|
||||
|
@@ -1,3 +1,3 @@
|
||||
# flake8: noqa: F401
|
||||
from .rpc import RPC, RPCException, RPCHandler, RPCMessageType
|
||||
from .rpc import RPC, RPCException, RPCHandler
|
||||
from .rpc_manager import RPCManager
|
||||
|
@@ -1,5 +1,5 @@
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional, TypeVar, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -57,19 +57,18 @@ class Count(BaseModel):
|
||||
class PerformanceEntry(BaseModel):
|
||||
pair: str
|
||||
profit: float
|
||||
profit_abs: float
|
||||
count: int
|
||||
|
||||
|
||||
class Profit(BaseModel):
|
||||
profit_closed_coin: float
|
||||
profit_closed_percent: float
|
||||
profit_closed_percent_mean: float
|
||||
profit_closed_ratio_mean: float
|
||||
profit_closed_percent_sum: float
|
||||
profit_closed_ratio_sum: float
|
||||
profit_closed_fiat: float
|
||||
profit_all_coin: float
|
||||
profit_all_percent: float
|
||||
profit_all_percent_mean: float
|
||||
profit_all_ratio_mean: float
|
||||
profit_all_percent_sum: float
|
||||
@@ -113,7 +112,7 @@ class Daily(BaseModel):
|
||||
|
||||
|
||||
class ShowConfig(BaseModel):
|
||||
dry_run: str
|
||||
dry_run: bool
|
||||
stake_currency: str
|
||||
stake_amount: Union[float, str]
|
||||
max_open_trades: int
|
||||
@@ -153,13 +152,11 @@ class TradeSchema(BaseModel):
|
||||
fee_close: Optional[float]
|
||||
fee_close_cost: Optional[float]
|
||||
fee_close_currency: Optional[str]
|
||||
open_date_hum: str
|
||||
open_date: str
|
||||
open_timestamp: int
|
||||
open_rate: float
|
||||
open_rate_requested: Optional[float]
|
||||
open_trade_value: float
|
||||
close_date_hum: Optional[str]
|
||||
close_date: Optional[str]
|
||||
close_timestamp: Optional[int]
|
||||
close_rate: Optional[float]
|
||||
@@ -170,6 +167,7 @@ class TradeSchema(BaseModel):
|
||||
profit_ratio: Optional[float]
|
||||
profit_pct: Optional[float]
|
||||
profit_abs: Optional[float]
|
||||
profit_fiat: Optional[float]
|
||||
sell_reason: Optional[str]
|
||||
sell_order_status: Optional[str]
|
||||
stop_loss_abs: Optional[float]
|
||||
@@ -192,7 +190,6 @@ class OpenTradeSchema(TradeSchema):
|
||||
stoploss_current_dist_ratio: Optional[float]
|
||||
stoploss_entry_dist: Optional[float]
|
||||
stoploss_entry_dist_ratio: Optional[float]
|
||||
base_currency: str
|
||||
current_profit: float
|
||||
current_profit_abs: float
|
||||
current_profit_pct: float
|
||||
@@ -203,12 +200,15 @@ class OpenTradeSchema(TradeSchema):
|
||||
class TradeResponse(BaseModel):
|
||||
trades: List[TradeSchema]
|
||||
trades_count: int
|
||||
total_trades: int
|
||||
|
||||
|
||||
ForceBuyResponse = TypeVar('ForceBuyResponse', TradeSchema, StatusMsg)
|
||||
class ForceBuyResponse(BaseModel):
|
||||
__root__: Union[TradeSchema, StatusMsg]
|
||||
|
||||
|
||||
class LockModel(BaseModel):
|
||||
id: int
|
||||
active: bool
|
||||
lock_end_time: str
|
||||
lock_end_timestamp: int
|
||||
@@ -223,6 +223,11 @@ class Locks(BaseModel):
|
||||
locks: List[LockModel]
|
||||
|
||||
|
||||
class DeleteLockRequest(BaseModel):
|
||||
pair: Optional[str]
|
||||
lockid: Optional[int]
|
||||
|
||||
|
||||
class Logs(BaseModel):
|
||||
log_count: int
|
||||
logs: List[List]
|
||||
@@ -264,10 +269,11 @@ class DeleteTrade(BaseModel):
|
||||
|
||||
class PlotConfig_(BaseModel):
|
||||
main_plot: Dict[str, Any]
|
||||
subplots: Optional[Dict[str, Any]]
|
||||
subplots: Dict[str, Any]
|
||||
|
||||
|
||||
PlotConfig = TypeVar('PlotConfig', PlotConfig_, Dict)
|
||||
class PlotConfig(BaseModel):
|
||||
__root__: Union[PlotConfig_, Dict]
|
||||
|
||||
|
||||
class StrategyListResponse(BaseModel):
|
||||
|
@@ -11,13 +11,13 @@ from freqtrade.data.history import get_datahandler
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.rpc import RPC
|
||||
from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, BlacklistPayload,
|
||||
BlacklistResponse, Count, Daily, DeleteTrade,
|
||||
ForceBuyPayload, ForceBuyResponse,
|
||||
ForceSellPayload, Locks, Logs, OpenTradeSchema,
|
||||
PairHistory, PerformanceEntry, Ping, PlotConfig,
|
||||
Profit, ResultMsg, ShowConfig, Stats, StatusMsg,
|
||||
StrategyListResponse, StrategyResponse,
|
||||
TradeResponse, Version, WhitelistResponse)
|
||||
BlacklistResponse, Count, Daily,
|
||||
DeleteLockRequest, DeleteTrade, ForceBuyPayload,
|
||||
ForceBuyResponse, ForceSellPayload, Locks, Logs,
|
||||
OpenTradeSchema, PairHistory, PerformanceEntry,
|
||||
Ping, PlotConfig, Profit, ResultMsg, ShowConfig,
|
||||
Stats, StatusMsg, StrategyListResponse,
|
||||
StrategyResponse, Version, WhitelistResponse)
|
||||
from freqtrade.rpc.api_server.deps import get_config, get_rpc, get_rpc_optional
|
||||
from freqtrade.rpc.rpc import RPCException
|
||||
|
||||
@@ -82,9 +82,19 @@ def status(rpc: RPC = Depends(get_rpc)):
|
||||
return []
|
||||
|
||||
|
||||
@router.get('/trades', response_model=TradeResponse, tags=['info', 'trading'])
|
||||
def trades(limit: int = 0, rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_trade_history(limit)
|
||||
# Using the responsemodel here will cause a ~100% increase in response time (from 1s to 2s)
|
||||
# on big databases. Correct response model: response_model=TradeResponse,
|
||||
@router.get('/trades', tags=['info', 'trading'])
|
||||
def trades(limit: int = 500, offset: int = 0, rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_trade_history(limit, offset=offset, order_by_id=True)
|
||||
|
||||
|
||||
@router.get('/trade/{tradeid}', response_model=OpenTradeSchema, tags=['info', 'trading'])
|
||||
def trade(tradeid: int = 0, rpc: RPC = Depends(get_rpc)):
|
||||
try:
|
||||
return rpc._rpc_trade_status([tradeid])[0]
|
||||
except (RPCException, KeyError):
|
||||
raise HTTPException(status_code=404, detail='Trade not found.')
|
||||
|
||||
|
||||
@router.delete('/trades/{tradeid}', response_model=DeleteTrade, tags=['info', 'trading'])
|
||||
@@ -111,9 +121,9 @@ def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)):
|
||||
trade = rpc._rpc_forcebuy(payload.pair, payload.price)
|
||||
|
||||
if trade:
|
||||
return trade.to_json()
|
||||
return ForceBuyResponse.parse_obj(trade.to_json())
|
||||
else:
|
||||
return {"status": f"Error buying pair {payload.pair}."}
|
||||
return ForceBuyResponse.parse_obj({"status": f"Error buying pair {payload.pair}."})
|
||||
|
||||
|
||||
@router.post('/forcesell', response_model=ResultMsg, tags=['trading'])
|
||||
@@ -136,11 +146,21 @@ def whitelist(rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_whitelist()
|
||||
|
||||
|
||||
@router.get('/locks', response_model=Locks, tags=['info'])
|
||||
@router.get('/locks', response_model=Locks, tags=['info', 'locks'])
|
||||
def locks(rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_locks()
|
||||
|
||||
|
||||
@router.delete('/locks/{lockid}', response_model=Locks, tags=['info', 'locks'])
|
||||
def delete_lock(lockid: int, rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_delete_lock(lockid=lockid)
|
||||
|
||||
|
||||
@router.post('/locks/delete', response_model=Locks, tags=['info', 'locks'])
|
||||
def delete_lock_pair(payload: DeleteLockRequest, rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_delete_lock(lockid=payload.lockid, pair=payload.pair)
|
||||
|
||||
|
||||
@router.get('/logs', response_model=Logs, tags=['info'])
|
||||
def logs(limit: Optional[int] = None, rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_get_logs(limit)
|
||||
@@ -183,7 +203,7 @@ def pair_history(pair: str, timeframe: str, timerange: str, strategy: str,
|
||||
|
||||
@router.get('/plot_config', response_model=PlotConfig, tags=['candle data'])
|
||||
def plot_config(rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_plot_config()
|
||||
return PlotConfig.parse_obj(rpc._rpc_plot_config())
|
||||
|
||||
|
||||
@router.get('/strategies', response_model=StrategyListResponse, tags=['strategy'])
|
||||
|
@@ -8,12 +8,33 @@ import uvicorn
|
||||
class UvicornServer(uvicorn.Server):
|
||||
"""
|
||||
Multithreaded server - as found in https://github.com/encode/uvicorn/issues/742
|
||||
|
||||
Removed install_signal_handlers() override based on changes from this commit:
|
||||
https://github.com/encode/uvicorn/commit/ce2ef45a9109df8eae038c0ec323eb63d644cbc6
|
||||
|
||||
Cannot rely on asyncio.get_event_loop() to create new event loop because of this check:
|
||||
https://github.com/python/cpython/blob/4d7f11e05731f67fd2c07ec2972c6cb9861d52be/Lib/asyncio/events.py#L638
|
||||
|
||||
Fix by overriding run() and forcing creation of new event loop if uvloop is available
|
||||
"""
|
||||
def install_signal_handlers(self):
|
||||
|
||||
def run(self, sockets=None):
|
||||
import asyncio
|
||||
|
||||
"""
|
||||
In the parent implementation, this starts the thread, therefore we must patch it away here.
|
||||
Parent implementation calls self.config.setup_event_loop(),
|
||||
but we need to create uvloop event loop manually
|
||||
"""
|
||||
pass
|
||||
try:
|
||||
import uvloop # noqa
|
||||
except ImportError: # pragma: no cover
|
||||
from uvicorn.loops.asyncio import asyncio_setup
|
||||
asyncio_setup()
|
||||
else:
|
||||
asyncio.set_event_loop(uvloop.new_event_loop())
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(self.serve(sockets=sockets))
|
||||
|
||||
@contextlib.contextmanager
|
||||
def run_in_thread(self):
|
||||
|
@@ -10,7 +10,12 @@ router_ui = APIRouter()
|
||||
|
||||
@router_ui.get('/favicon.ico', include_in_schema=False)
|
||||
async def favicon():
|
||||
return FileResponse(Path(__file__).parent / 'ui/favicon.ico')
|
||||
return FileResponse(str(Path(__file__).parent / 'ui/favicon.ico'))
|
||||
|
||||
|
||||
@router_ui.get('/fallback_file.html', include_in_schema=False)
|
||||
async def fallback():
|
||||
return FileResponse(str(Path(__file__).parent / 'ui/fallback_file.html'))
|
||||
|
||||
|
||||
@router_ui.get('/{rest_of_path:path}', include_in_schema=False)
|
||||
|
@@ -2,6 +2,7 @@ import logging
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any, Dict
|
||||
|
||||
import rapidjson
|
||||
import uvicorn
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -14,6 +15,17 @@ from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FTJSONResponse(JSONResponse):
|
||||
media_type = "application/json"
|
||||
|
||||
def render(self, content: Any) -> bytes:
|
||||
"""
|
||||
Use rapidjson for responses
|
||||
Handles NaN and Inf / -Inf in a javascript way by default.
|
||||
"""
|
||||
return rapidjson.dumps(content).encode("utf-8")
|
||||
|
||||
|
||||
class ApiServer(RPCHandler):
|
||||
|
||||
_rpc: RPC
|
||||
@@ -32,6 +44,7 @@ class ApiServer(RPCHandler):
|
||||
self.app = FastAPI(title="Freqtrade API",
|
||||
docs_url='/docs' if api_config.get('enable_openapi', False) else None,
|
||||
redoc_url=None,
|
||||
default_response_class=FTJSONResponse,
|
||||
)
|
||||
self.configure_app(self.app, self._config)
|
||||
|
||||
|
@@ -3,11 +3,13 @@ Module that define classes to convert Crypto-currency to FIAT
|
||||
e.g BTC to USD
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List
|
||||
from typing import Dict
|
||||
|
||||
from cachetools.ttl import TTLCache
|
||||
from pycoingecko import CoinGeckoAPI
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from freqtrade.constants import SUPPORTED_FIAT
|
||||
|
||||
@@ -15,51 +17,6 @@ from freqtrade.constants import SUPPORTED_FIAT
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CryptoFiat:
|
||||
"""
|
||||
Object to describe what is the price of Crypto-currency in a FIAT
|
||||
"""
|
||||
# Constants
|
||||
CACHE_DURATION = 6 * 60 * 60 # 6 hours
|
||||
|
||||
def __init__(self, crypto_symbol: str, fiat_symbol: str, price: float) -> None:
|
||||
"""
|
||||
Create an object that will contains the price for a crypto-currency in fiat
|
||||
:param crypto_symbol: Crypto-currency you want to convert (e.g BTC)
|
||||
:param fiat_symbol: FIAT currency you want to convert to (e.g USD)
|
||||
:param price: Price in FIAT
|
||||
"""
|
||||
|
||||
# Public attributes
|
||||
self.crypto_symbol = None
|
||||
self.fiat_symbol = None
|
||||
self.price = 0.0
|
||||
|
||||
# Private attributes
|
||||
self._expiration = 0.0
|
||||
|
||||
self.crypto_symbol = crypto_symbol.lower()
|
||||
self.fiat_symbol = fiat_symbol.lower()
|
||||
self.set_price(price=price)
|
||||
|
||||
def set_price(self, price: float) -> None:
|
||||
"""
|
||||
Set the price of the Crypto-currency in FIAT and set the expiration time
|
||||
:param price: Price of the current Crypto currency in the fiat
|
||||
:return: None
|
||||
"""
|
||||
self.price = price
|
||||
self._expiration = time.time() + self.CACHE_DURATION
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""
|
||||
Return if the current price is still valid or needs to be refreshed
|
||||
:return: bool, true the price is expired and needs to be refreshed, false the price is
|
||||
still valid
|
||||
"""
|
||||
return self._expiration - time.time() <= 0
|
||||
|
||||
|
||||
class CryptoToFiatConverter:
|
||||
"""
|
||||
Main class to initiate Crypto to FIAT.
|
||||
@@ -70,6 +27,7 @@ class CryptoToFiatConverter:
|
||||
_coingekko: CoinGeckoAPI = None
|
||||
|
||||
_cryptomap: Dict = {}
|
||||
_backoff: float = 0.0
|
||||
|
||||
def __new__(cls):
|
||||
"""
|
||||
@@ -84,14 +42,29 @@ class CryptoToFiatConverter:
|
||||
return CryptoToFiatConverter.__instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pairs: List[CryptoFiat] = []
|
||||
# Timeout: 6h
|
||||
self._pair_price: TTLCache = TTLCache(maxsize=500, ttl=6 * 60 * 60)
|
||||
|
||||
self._load_cryptomap()
|
||||
|
||||
def _load_cryptomap(self) -> None:
|
||||
try:
|
||||
coinlistings = self._coingekko.get_coins_list()
|
||||
# Create mapping table from synbol to coingekko_id
|
||||
# Create mapping table from symbol to coingekko_id
|
||||
self._cryptomap = {x['symbol']: x['id'] for x in coinlistings}
|
||||
except RequestException as request_exception:
|
||||
if "429" in str(request_exception):
|
||||
logger.warning(
|
||||
"Too many requests for Coingecko API, backing off and trying again later.")
|
||||
# Set backoff timestamp to 60 seconds in the future
|
||||
self._backoff = datetime.datetime.now().timestamp() + 60
|
||||
return
|
||||
# If the request is not a 429 error we want to raise the normal error
|
||||
logger.error(
|
||||
"Could not load FIAT Cryptocurrency map for the following problem: {}".format(
|
||||
request_exception
|
||||
)
|
||||
)
|
||||
except (Exception) as exception:
|
||||
logger.error(
|
||||
f"Could not load FIAT Cryptocurrency map for the following problem: {exception}")
|
||||
@@ -118,49 +91,31 @@ class CryptoToFiatConverter:
|
||||
"""
|
||||
crypto_symbol = crypto_symbol.lower()
|
||||
fiat_symbol = fiat_symbol.lower()
|
||||
inverse = False
|
||||
|
||||
if crypto_symbol == 'usd':
|
||||
# usd corresponds to "uniswap-state-dollar" for coingecko.
|
||||
# We'll therefore need to "swap" the currencies
|
||||
logger.info(f"reversing Rates {crypto_symbol}, {fiat_symbol}")
|
||||
crypto_symbol = fiat_symbol
|
||||
fiat_symbol = 'usd'
|
||||
inverse = True
|
||||
|
||||
symbol = f"{crypto_symbol}/{fiat_symbol}"
|
||||
# Check if the fiat convertion you want is supported
|
||||
if not self._is_supported_fiat(fiat=fiat_symbol):
|
||||
raise ValueError(f'The fiat {fiat_symbol} is not supported.')
|
||||
|
||||
# Get the pair that interest us and return the price in fiat
|
||||
for pair in self._pairs:
|
||||
if pair.crypto_symbol == crypto_symbol and pair.fiat_symbol == fiat_symbol:
|
||||
# If the price is expired we refresh it, avoid to call the API all the time
|
||||
if pair.is_expired():
|
||||
pair.set_price(
|
||||
price=self._find_price(
|
||||
crypto_symbol=pair.crypto_symbol,
|
||||
fiat_symbol=pair.fiat_symbol
|
||||
)
|
||||
)
|
||||
price = self._pair_price.get(symbol, None)
|
||||
|
||||
# return the last price we have for this pair
|
||||
return pair.price
|
||||
|
||||
# The pair does not exist, so we create it and return the price
|
||||
return self._add_pair(
|
||||
crypto_symbol=crypto_symbol,
|
||||
fiat_symbol=fiat_symbol,
|
||||
price=self._find_price(
|
||||
if not price:
|
||||
price = self._find_price(
|
||||
crypto_symbol=crypto_symbol,
|
||||
fiat_symbol=fiat_symbol
|
||||
)
|
||||
)
|
||||
|
||||
def _add_pair(self, crypto_symbol: str, fiat_symbol: str, price: float) -> float:
|
||||
"""
|
||||
:param crypto_symbol: Crypto-currency you want to convert (e.g BTC)
|
||||
:param fiat_symbol: FIAT currency you want to convert to (e.g USD)
|
||||
:return: price in FIAT
|
||||
"""
|
||||
self._pairs.append(
|
||||
CryptoFiat(
|
||||
crypto_symbol=crypto_symbol,
|
||||
fiat_symbol=fiat_symbol,
|
||||
price=price
|
||||
)
|
||||
)
|
||||
if inverse and price != 0.0:
|
||||
price = 1 / price
|
||||
self._pair_price[symbol] = price
|
||||
|
||||
return price
|
||||
|
||||
@@ -188,6 +143,15 @@ class CryptoToFiatConverter:
|
||||
if crypto_symbol == fiat_symbol:
|
||||
return 1.0
|
||||
|
||||
if self._cryptomap == {}:
|
||||
if self._backoff <= datetime.datetime.now().timestamp():
|
||||
self._load_cryptomap()
|
||||
# return 0.0 if we still dont have data to check, no reason to proceed
|
||||
if self._cryptomap == {}:
|
||||
return 0.0
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
if crypto_symbol not in self._cryptomap:
|
||||
# return 0 for unsupported stake currencies (fiat-convert should not break the bot)
|
||||
logger.warning("unsupported crypto-symbol %s - returning 0.0", crypto_symbol)
|
||||
|
@@ -3,8 +3,7 @@ This module contains class to define a RPC communications
|
||||
"""
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import Enum
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from math import isnan
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
@@ -15,36 +14,21 @@ from pandas import DataFrame
|
||||
from freqtrade.configuration.timerange import TimeRange
|
||||
from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT
|
||||
from freqtrade.data.history import load_data
|
||||
from freqtrade.enums import SellType, State
|
||||
from freqtrade.exceptions import ExchangeError, PricingError
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs
|
||||
from freqtrade.loggers import bufferHandler
|
||||
from freqtrade.misc import shorten_date
|
||||
from freqtrade.persistence import PairLocks, Trade
|
||||
from freqtrade.persistence.models import PairLock
|
||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
|
||||
from freqtrade.state import State
|
||||
from freqtrade.strategy.interface import SellType
|
||||
from freqtrade.strategy.interface import SellCheckTuple
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RPCMessageType(Enum):
|
||||
STATUS_NOTIFICATION = 'status'
|
||||
WARNING_NOTIFICATION = 'warning'
|
||||
STARTUP_NOTIFICATION = 'startup'
|
||||
BUY_NOTIFICATION = 'buy'
|
||||
BUY_CANCEL_NOTIFICATION = 'buy_cancel'
|
||||
SELL_NOTIFICATION = 'sell'
|
||||
SELL_CANCEL_NOTIFICATION = 'sell_cancel'
|
||||
|
||||
def __repr__(self):
|
||||
return self.value
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class RPCException(Exception):
|
||||
"""
|
||||
Should be raised with a rpc-formatted message in an _rpc_* method
|
||||
@@ -166,12 +150,24 @@ class RPC:
|
||||
if trade.open_order_id:
|
||||
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 (ExchangeError, PricingError):
|
||||
current_rate = NAN
|
||||
if trade.is_open:
|
||||
try:
|
||||
current_rate = self._freqtrade.exchange.get_sell_rate(trade.pair, False)
|
||||
except (ExchangeError, PricingError):
|
||||
current_rate = NAN
|
||||
else:
|
||||
current_rate = trade.close_rate
|
||||
current_profit = trade.calc_profit_ratio(current_rate)
|
||||
current_profit_abs = trade.calc_profit(current_rate)
|
||||
current_profit_fiat: Optional[float] = None
|
||||
# Calculate fiat profit
|
||||
if self._fiat_converter:
|
||||
current_profit_fiat = self._fiat_converter.convert_amount(
|
||||
current_profit_abs,
|
||||
self._freqtrade.config['stake_currency'],
|
||||
self._freqtrade.config['fiat_display_currency']
|
||||
)
|
||||
|
||||
# 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)
|
||||
@@ -190,6 +186,7 @@ class RPC:
|
||||
profit_ratio=current_profit,
|
||||
profit_pct=round(current_profit * 100, 2),
|
||||
profit_abs=current_profit_abs,
|
||||
profit_fiat=current_profit_fiat,
|
||||
|
||||
stoploss_current_dist=stoploss_current_dist,
|
||||
stoploss_current_dist_ratio=round(stoploss_current_dist_ratio, 8),
|
||||
@@ -204,16 +201,17 @@ class RPC:
|
||||
return results
|
||||
|
||||
def _rpc_status_table(self, stake_currency: str,
|
||||
fiat_display_currency: str) -> Tuple[List, List]:
|
||||
fiat_display_currency: str) -> Tuple[List, List, float]:
|
||||
trades = Trade.get_open_trades()
|
||||
if not trades:
|
||||
raise RPCException('no active trade')
|
||||
else:
|
||||
trades_list = []
|
||||
fiat_profit_sum = NAN
|
||||
for trade in trades:
|
||||
# calculate profit and send message to user
|
||||
try:
|
||||
current_rate = self._freqtrade.get_sell_rate(trade.pair, False)
|
||||
current_rate = self._freqtrade.exchange.get_sell_rate(trade.pair, False)
|
||||
except (PricingError, ExchangeError):
|
||||
current_rate = NAN
|
||||
trade_percent = (100 * trade.calc_profit_ratio(current_rate))
|
||||
@@ -227,6 +225,8 @@ class RPC:
|
||||
)
|
||||
if fiat_profit and not isnan(fiat_profit):
|
||||
profit_str += f" ({fiat_profit:.2f})"
|
||||
fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \
|
||||
else fiat_profit_sum + fiat_profit
|
||||
trades_list.append([
|
||||
trade.id,
|
||||
trade.pair + ('*' if (trade.open_order_id is not None
|
||||
@@ -240,7 +240,7 @@ class RPC:
|
||||
profitcol += " (" + fiat_display_currency + ")"
|
||||
|
||||
columns = ['ID', 'Pair', 'Since', profitcol]
|
||||
return trades_list, columns
|
||||
return trades_list, columns, fiat_profit_sum
|
||||
|
||||
def _rpc_daily_profit(
|
||||
self, timescale: int,
|
||||
@@ -284,19 +284,22 @@ class RPC:
|
||||
'data': data
|
||||
}
|
||||
|
||||
def _rpc_trade_history(self, limit: int) -> Dict:
|
||||
def _rpc_trade_history(self, limit: int, offset: int = 0, order_by_id: bool = False) -> Dict:
|
||||
""" Returns the X last trades """
|
||||
if limit > 0:
|
||||
order_by = Trade.id if order_by_id else Trade.close_date.desc()
|
||||
if limit:
|
||||
trades = Trade.get_trades([Trade.is_open.is_(False)]).order_by(
|
||||
Trade.id.desc()).limit(limit)
|
||||
order_by).limit(limit).offset(offset)
|
||||
else:
|
||||
trades = Trade.get_trades([Trade.is_open.is_(False)]).order_by(Trade.id.desc()).all()
|
||||
trades = Trade.get_trades([Trade.is_open.is_(False)]).order_by(
|
||||
Trade.close_date.desc()).all()
|
||||
|
||||
output = [trade.to_json() for trade in trades]
|
||||
|
||||
return {
|
||||
"trades": output,
|
||||
"trades_count": len(output)
|
||||
"trades_count": len(output),
|
||||
"total_trades": Trade.get_trades([Trade.is_open.is_(False)]).count(),
|
||||
}
|
||||
|
||||
def _rpc_stats(self) -> Dict[str, Any]:
|
||||
@@ -333,9 +336,10 @@ class RPC:
|
||||
return {'sell_reasons': sell_reasons, 'durations': durations}
|
||||
|
||||
def _rpc_trade_statistics(
|
||||
self, stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
|
||||
self, stake_currency: str, fiat_display_currency: str,
|
||||
start_date: datetime = datetime.fromtimestamp(0)) -> Dict[str, Any]:
|
||||
""" Returns cumulative profit statistics """
|
||||
trades = Trade.get_trades().order_by(Trade.id).all()
|
||||
trades = Trade.get_trades([Trade.open_date >= start_date]).order_by(Trade.id).all()
|
||||
|
||||
profit_all_coin = []
|
||||
profit_all_ratio = []
|
||||
@@ -364,7 +368,7 @@ class RPC:
|
||||
else:
|
||||
# Get current rate
|
||||
try:
|
||||
current_rate = self._freqtrade.get_sell_rate(trade.pair, False)
|
||||
current_rate = self._freqtrade.exchange.get_sell_rate(trade.pair, False)
|
||||
except (PricingError, ExchangeError):
|
||||
current_rate = NAN
|
||||
profit_ratio = trade.calc_profit_ratio(rate=current_rate)
|
||||
@@ -401,14 +405,12 @@ class RPC:
|
||||
num = float(len(durations) or 1)
|
||||
return {
|
||||
'profit_closed_coin': profit_closed_coin_sum,
|
||||
'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': 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),
|
||||
@@ -432,7 +434,7 @@ class RPC:
|
||||
output = []
|
||||
total = 0.0
|
||||
try:
|
||||
tickers = self._freqtrade.exchange.get_tickers()
|
||||
tickers = self._freqtrade.exchange.get_tickers(cached=True)
|
||||
except (ExchangeError):
|
||||
raise RPCException('Error getting current tickers.')
|
||||
|
||||
@@ -536,8 +538,9 @@ class RPC:
|
||||
|
||||
if not fully_canceled:
|
||||
# Get current rate and execute sell
|
||||
current_rate = self._freqtrade.get_sell_rate(trade.pair, False)
|
||||
self._freqtrade.execute_sell(trade, current_rate, SellType.FORCE_SELL)
|
||||
current_rate = self._freqtrade.exchange.get_sell_rate(trade.pair, False)
|
||||
sell_reason = SellCheckTuple(sell_type=SellType.FORCE_SELL)
|
||||
self._freqtrade.execute_sell(trade, current_rate, sell_reason)
|
||||
# ---- EOF def _exec_forcesell ----
|
||||
|
||||
if self._freqtrade.state != State.RUNNING:
|
||||
@@ -548,7 +551,7 @@ class RPC:
|
||||
# Execute sell for all open orders
|
||||
for trade in Trade.get_open_trades():
|
||||
_exec_forcesell(trade)
|
||||
Trade.session.flush()
|
||||
Trade.commit()
|
||||
self._freqtrade.wallets.update()
|
||||
return {'result': 'Created sell orders for all open trades.'}
|
||||
|
||||
@@ -561,7 +564,7 @@ class RPC:
|
||||
raise RPCException('invalid argument')
|
||||
|
||||
_exec_forcesell(trade)
|
||||
Trade.session.flush()
|
||||
Trade.commit()
|
||||
self._freqtrade.wallets.update()
|
||||
return {'result': f'Created sell order for trade {trade_id}.'}
|
||||
|
||||
@@ -590,11 +593,11 @@ class RPC:
|
||||
raise RPCException(f'position for {pair} already open - id: {trade.id}')
|
||||
|
||||
# gen stake amount
|
||||
stakeamount = self._freqtrade.wallets.get_trade_stake_amount(
|
||||
pair, self._freqtrade.get_free_open_trades())
|
||||
stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair)
|
||||
|
||||
# execute buy
|
||||
if self._freqtrade.execute_buy(pair, stakeamount, price):
|
||||
if self._freqtrade.execute_buy(pair, stakeamount, price, forcebuy=True):
|
||||
Trade.commit()
|
||||
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
||||
return trade
|
||||
else:
|
||||
@@ -663,7 +666,7 @@ class RPC:
|
||||
}
|
||||
|
||||
def _rpc_locks(self) -> Dict[str, Any]:
|
||||
""" Returns the current locks"""
|
||||
""" Returns the current locks """
|
||||
|
||||
locks = PairLocks.get_pair_locks(None)
|
||||
return {
|
||||
@@ -671,6 +674,24 @@ class RPC:
|
||||
'locks': [lock.to_json() for lock in locks]
|
||||
}
|
||||
|
||||
def _rpc_delete_lock(self, lockid: Optional[int] = None,
|
||||
pair: Optional[str] = None) -> Dict[str, Any]:
|
||||
""" Delete specific lock(s) """
|
||||
locks = []
|
||||
|
||||
if pair:
|
||||
locks = PairLocks.get_pair_locks(pair)
|
||||
if lockid:
|
||||
locks = PairLock.query.filter(PairLock.id == lockid).all()
|
||||
|
||||
for lock in locks:
|
||||
lock.active = False
|
||||
lock.lock_end_time = datetime.now(timezone.utc)
|
||||
|
||||
PairLock.query.session.commit()
|
||||
|
||||
return self._rpc_locks()
|
||||
|
||||
def _rpc_whitelist(self) -> Dict:
|
||||
""" Returns the currently active whitelist"""
|
||||
res = {'method': self._freqtrade.pairlists.name_list,
|
||||
@@ -809,5 +830,7 @@ class RPC:
|
||||
df_analyzed, arrow.Arrow.utcnow().datetime)
|
||||
|
||||
def _rpc_plot_config(self) -> Dict[str, Any]:
|
||||
|
||||
if (self._freqtrade.strategy.plot_config and
|
||||
'subplots' not in self._freqtrade.strategy.plot_config):
|
||||
self._freqtrade.strategy.plot_config['subplots'] = {}
|
||||
return self._freqtrade.strategy.plot_config
|
||||
|
@@ -4,7 +4,8 @@ This module contains class to manage RPC communications (Telegram, Slack, ...)
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from freqtrade.rpc import RPC, RPCHandler, RPCMessageType
|
||||
from freqtrade.enums import RPCMessageType
|
||||
from freqtrade.rpc import RPC, RPCHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -67,7 +68,7 @@ class RPCManager:
|
||||
def startup_messages(self, config: Dict[str, Any], pairlist, protections) -> None:
|
||||
if config['dry_run']:
|
||||
self.send_msg({
|
||||
'type': RPCMessageType.WARNING_NOTIFICATION,
|
||||
'type': RPCMessageType.WARNING,
|
||||
'status': 'Dry run is enabled. All trades are simulated.'
|
||||
})
|
||||
stake_currency = config['stake_currency']
|
||||
@@ -79,7 +80,7 @@ class RPCManager:
|
||||
exchange_name = config['exchange']['name']
|
||||
strategy_name = config.get('strategy', '')
|
||||
self.send_msg({
|
||||
'type': RPCMessageType.STARTUP_NOTIFICATION,
|
||||
'type': RPCMessageType.STARTUP,
|
||||
'status': f'*Exchange:* `{exchange_name}`\n'
|
||||
f'*Stake per trade:* `{stake_amount} {stake_currency}`\n'
|
||||
f'*Minimum ROI:* `{minimal_roi}`\n'
|
||||
@@ -88,13 +89,13 @@ class RPCManager:
|
||||
f'*Strategy:* `{strategy_name}`'
|
||||
})
|
||||
self.send_msg({
|
||||
'type': RPCMessageType.STARTUP_NOTIFICATION,
|
||||
'type': RPCMessageType.STARTUP,
|
||||
'status': f'Searching for {stake_currency} pairs to buy and sell '
|
||||
f'based on {pairlist.short_desc()}'
|
||||
})
|
||||
if len(protections.name_list) > 0:
|
||||
prots = '\n'.join([p for prot in protections.short_desc() for k, p in prot.items()])
|
||||
self.send_msg({
|
||||
'type': RPCMessageType.STARTUP_NOTIFICATION,
|
||||
'type': RPCMessageType.STARTUP,
|
||||
'status': f'Using Protections: \n{prots}'
|
||||
})
|
||||
|
@@ -5,21 +5,27 @@ This module manage Telegram communication
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta, datetime
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from html import escape
|
||||
from itertools import chain
|
||||
from typing import Any, Callable, Dict, List, Union
|
||||
from math import isnan
|
||||
from typing import Any, Callable, Dict, List, Union, cast
|
||||
|
||||
import arrow
|
||||
from tabulate import tabulate
|
||||
from telegram import KeyboardButton, ParseMode, ReplyKeyboardMarkup, Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.error import NetworkError, TelegramError
|
||||
from telegram.ext import CallbackContext, CommandHandler, Updater, CallbackQueryHandler
|
||||
from telegram import (InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ParseMode,
|
||||
ReplyKeyboardMarkup, Update)
|
||||
from telegram.error import BadRequest, NetworkError, TelegramError
|
||||
from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater
|
||||
from telegram.utils.helpers import escape_markdown
|
||||
|
||||
from freqtrade.__init__ import __version__
|
||||
from freqtrade.constants import DUST_PER_COIN
|
||||
from freqtrade.enums import RPCMessageType
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import round_coin_value
|
||||
from freqtrade.rpc import RPC, RPCException, RPCHandler, RPCMessageType
|
||||
from freqtrade.misc import chunks, round_coin_value
|
||||
from freqtrade.rpc import RPC, RPCException, RPCHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,7 +51,7 @@ def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]:
|
||||
cchat_id = int(update.callback_query.message.chat.id)
|
||||
else:
|
||||
cchat_id = int(update.message.chat_id)
|
||||
|
||||
|
||||
chat_id = int(self._config['telegram']['chat_id'])
|
||||
if cchat_id != chat_id:
|
||||
logger.info(
|
||||
@@ -89,7 +95,7 @@ class Telegram(RPCHandler):
|
||||
Validates the keyboard configuration from telegram config
|
||||
section.
|
||||
"""
|
||||
self._keyboard: List[List[Union[str, KeyboardButton]]] = [
|
||||
self._keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = [
|
||||
['/daily', '/profit', '/balance'],
|
||||
['/status', '/status table', '/performance'],
|
||||
['/count', '/start', '/stop', '/help']
|
||||
@@ -99,23 +105,27 @@ class Telegram(RPCHandler):
|
||||
# TODO: DRY! - its not good to list all valid cmds here. But otherwise
|
||||
# this needs refacoring of the whole telegram module (same
|
||||
# problem in _help()).
|
||||
valid_keys: List[str] = ['/start', '/stop', '/status', '/status table',
|
||||
'/trades', '/profit', '/performance', '/daily',
|
||||
'/stats', '/count', '/locks', '/balance',
|
||||
'/stopbuy', '/reload_config', '/show_config',
|
||||
'/logs', '/whitelist', '/blacklist', '/edge',
|
||||
'/help', '/version']
|
||||
valid_keys: List[str] = [r'/start$', r'/stop$', r'/status$', r'/status table$',
|
||||
r'/trades$', r'/performance$', r'/daily$', r'/daily \d+$',
|
||||
r'/profit$', r'/profit \d+',
|
||||
r'/stats$', r'/count$', r'/locks$', r'/balance$',
|
||||
r'/stopbuy$', r'/reload_config$', r'/show_config$',
|
||||
r'/logs$', r'/whitelist$', r'/blacklist$', r'/edge$',
|
||||
r'/forcebuy$', r'/help$', r'/version$']
|
||||
# Create keys for generation
|
||||
valid_keys_print = [k.replace('$', '') for k in valid_keys]
|
||||
|
||||
# custom keyboard specified in config.json
|
||||
cust_keyboard = self._config['telegram'].get('keyboard', [])
|
||||
if cust_keyboard:
|
||||
combined = "(" + ")|(".join(valid_keys) + ")"
|
||||
# check for valid shortcuts
|
||||
invalid_keys = [b for b in chain.from_iterable(cust_keyboard)
|
||||
if b not in valid_keys]
|
||||
if not re.match(combined, b)]
|
||||
if len(invalid_keys):
|
||||
err_msg = ('config.telegram.keyboard: Invalid commands for '
|
||||
f'custom Telegram keyboard: {invalid_keys}'
|
||||
f'\nvalid commands are: {valid_keys}')
|
||||
f'\nvalid commands are: {valid_keys_print}')
|
||||
raise OperationalException(err_msg)
|
||||
else:
|
||||
self._keyboard = cust_keyboard
|
||||
@@ -147,6 +157,7 @@ class Telegram(RPCHandler):
|
||||
CommandHandler('daily', self._daily),
|
||||
CommandHandler('count', self._count),
|
||||
CommandHandler('locks', self._locks),
|
||||
CommandHandler(['unlock', 'delete_locks'], self._delete_locks),
|
||||
CommandHandler(['reload_config', 'reload_conf'], self._reload_config),
|
||||
CommandHandler(['show_config', 'show_conf'], self._show_config),
|
||||
CommandHandler('stopbuy', self._stopbuy),
|
||||
@@ -163,7 +174,8 @@ class Telegram(RPCHandler):
|
||||
CallbackQueryHandler(self._profit, pattern='update_profit'),
|
||||
CallbackQueryHandler(self._balance, pattern='update_balance'),
|
||||
CallbackQueryHandler(self._performance, pattern='update_performance'),
|
||||
CallbackQueryHandler(self._count, pattern='update_count')
|
||||
CallbackQueryHandler(self._count, pattern='update_count'),
|
||||
CallbackQueryHandler(self._forcebuy_inline),
|
||||
]
|
||||
for handle in handles:
|
||||
self._updater.dispatcher.add_handler(handle)
|
||||
@@ -172,10 +184,10 @@ class Telegram(RPCHandler):
|
||||
self._updater.dispatcher.add_handler(handle)
|
||||
|
||||
self._updater.start_polling(
|
||||
clean=True,
|
||||
bootstrap_retries=-1,
|
||||
timeout=30,
|
||||
read_latency=60,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
logger.info(
|
||||
'rpc.telegram is listening for following commands: %s',
|
||||
@@ -189,79 +201,111 @@ class Telegram(RPCHandler):
|
||||
"""
|
||||
self._updater.stop()
|
||||
|
||||
def _format_buy_msg(self, msg: Dict[str, Any]) -> str:
|
||||
if self._rpc._fiat_converter:
|
||||
msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount(
|
||||
msg['stake_amount'], msg['stake_currency'], msg['fiat_currency'])
|
||||
else:
|
||||
msg['stake_amount_fiat'] = 0
|
||||
|
||||
message = (f"\N{LARGE BLUE CIRCLE} *{msg['exchange']}:* Buying {msg['pair']}"
|
||||
f" (#{msg['trade_id']})\n"
|
||||
f"*Amount:* `{msg['amount']:.8f}`\n"
|
||||
f"*Open Rate:* `{msg['limit']:.8f}`\n"
|
||||
f"*Current Rate:* `{msg['current_rate']:.8f}`\n"
|
||||
f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}")
|
||||
|
||||
if msg.get('fiat_currency', None):
|
||||
message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}"
|
||||
message += ")`"
|
||||
return message
|
||||
|
||||
def _format_sell_msg(self, msg: Dict[str, Any]) -> str:
|
||||
msg['amount'] = round(msg['amount'], 8)
|
||||
msg['profit_percent'] = round(msg['profit_ratio'] * 100, 2)
|
||||
msg['duration'] = msg['close_date'].replace(
|
||||
microsecond=0) - msg['open_date'].replace(microsecond=0)
|
||||
msg['duration_min'] = msg['duration'].total_seconds() / 60
|
||||
|
||||
msg['emoji'] = self._get_sell_emoji(msg)
|
||||
|
||||
# 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._rpc._fiat_converter):
|
||||
msg['profit_fiat'] = self._rpc._fiat_converter.convert_amount(
|
||||
msg['profit_amount'], msg['stake_currency'], msg['fiat_currency'])
|
||||
msg['profit_extra'] = (' ({gain}: {profit_amount:.8f} {stake_currency}'
|
||||
' / {profit_fiat:.3f} {fiat_currency})').format(**msg)
|
||||
else:
|
||||
msg['profit_extra'] = ''
|
||||
|
||||
message = ("{emoji} *{exchange}:* Selling {pair} (#{trade_id})\n"
|
||||
"*Profit:* `{profit_percent:.2f}%{profit_extra}`\n"
|
||||
"*Sell Reason:* `{sell_reason}`\n"
|
||||
"*Duration:* `{duration} ({duration_min:.1f} min)`\n"
|
||||
"*Amount:* `{amount:.8f}`\n"
|
||||
"*Open Rate:* `{open_rate:.8f}`\n"
|
||||
"*Current Rate:* `{current_rate:.8f}`\n"
|
||||
"*Close Rate:* `{limit:.8f}`").format(**msg)
|
||||
|
||||
return message
|
||||
|
||||
def send_msg(self, msg: Dict[str, Any]) -> None:
|
||||
""" Send a message to telegram channel """
|
||||
|
||||
noti = self._config['telegram'].get('notification_settings', {}
|
||||
).get(str(msg['type']), 'on')
|
||||
default_noti = 'on'
|
||||
|
||||
msg_type = msg['type']
|
||||
noti = ''
|
||||
if msg_type == RPCMessageType.SELL:
|
||||
sell_noti = self._config['telegram'] \
|
||||
.get('notification_settings', {}).get(str(msg_type), {})
|
||||
# For backward compatibility sell still can be string
|
||||
if isinstance(sell_noti, str):
|
||||
noti = sell_noti
|
||||
else:
|
||||
noti = sell_noti.get(str(msg['sell_reason']), default_noti)
|
||||
else:
|
||||
noti = self._config['telegram'] \
|
||||
.get('notification_settings', {}).get(str(msg_type), default_noti)
|
||||
|
||||
if noti == 'off':
|
||||
logger.info(f"Notification '{msg['type']}' not sent.")
|
||||
logger.info(f"Notification '{msg_type}' not sent.")
|
||||
# Notification disabled
|
||||
return
|
||||
|
||||
if msg['type'] == RPCMessageType.BUY_NOTIFICATION:
|
||||
if self._rpc._fiat_converter:
|
||||
msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount(
|
||||
msg['stake_amount'], msg['stake_currency'], msg['fiat_currency'])
|
||||
else:
|
||||
msg['stake_amount_fiat'] = 0
|
||||
if msg_type == RPCMessageType.BUY:
|
||||
message = self._format_buy_msg(msg)
|
||||
|
||||
message = (f"\N{LARGE BLUE CIRCLE} *{msg['exchange']}:* Buying {msg['pair']}\n"
|
||||
f"*Amount:* `{msg['amount']:.8f}`\n"
|
||||
f"*Open Rate:* `{msg['limit']:.8f}`\n"
|
||||
f"*Current Rate:* `{msg['current_rate']:.8f}`\n"
|
||||
f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}")
|
||||
|
||||
if msg.get('fiat_currency', None):
|
||||
message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}"
|
||||
message += ")`"
|
||||
|
||||
elif msg['type'] == RPCMessageType.BUY_CANCEL_NOTIFICATION:
|
||||
elif msg_type in (RPCMessageType.BUY_CANCEL, RPCMessageType.SELL_CANCEL):
|
||||
msg['message_side'] = 'buy' if msg_type == RPCMessageType.BUY_CANCEL else 'sell'
|
||||
message = ("\N{WARNING SIGN} *{exchange}:* "
|
||||
"Cancelling open buy Order for {pair}. Reason: {reason}.".format(**msg))
|
||||
"Cancelling open {message_side} Order for {pair} (#{trade_id}). "
|
||||
"Reason: {reason}.".format(**msg))
|
||||
|
||||
elif msg['type'] == RPCMessageType.SELL_NOTIFICATION:
|
||||
msg['amount'] = round(msg['amount'], 8)
|
||||
msg['profit_percent'] = round(msg['profit_ratio'] * 100, 2)
|
||||
msg['duration'] = msg['close_date'].replace(
|
||||
microsecond=0) - msg['open_date'].replace(microsecond=0)
|
||||
msg['duration_min'] = msg['duration'].total_seconds() / 60
|
||||
elif msg_type == RPCMessageType.BUY_FILL:
|
||||
message = ("\N{LARGE CIRCLE} *{exchange}:* "
|
||||
"Buy order for {pair} (#{trade_id}) filled "
|
||||
"for {open_rate}.".format(**msg))
|
||||
elif msg_type == RPCMessageType.SELL_FILL:
|
||||
message = ("\N{LARGE CIRCLE} *{exchange}:* "
|
||||
"Sell order for {pair} (#{trade_id}) filled "
|
||||
"for {close_rate}.".format(**msg))
|
||||
elif msg_type == RPCMessageType.SELL:
|
||||
message = self._format_sell_msg(msg)
|
||||
|
||||
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"
|
||||
"*Close Rate:* `{limit:.8f}`\n"
|
||||
"*Sell Reason:* `{sell_reason}`\n"
|
||||
"*Duration:* `{duration} ({duration_min:.1f} min)`\n"
|
||||
"*Profit:* `{profit_percent:.2f}%`").format(**msg)
|
||||
|
||||
# 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._rpc._fiat_converter):
|
||||
msg['profit_fiat'] = self._rpc._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 = ("\N{WARNING SIGN} *{exchange}:* Cancelling Open Sell Order "
|
||||
"for {pair}. Reason: {reason}").format(**msg)
|
||||
|
||||
elif msg['type'] == RPCMessageType.STATUS_NOTIFICATION:
|
||||
elif msg_type == RPCMessageType.STATUS:
|
||||
message = '*Status:* `{status}`'.format(**msg)
|
||||
|
||||
elif msg['type'] == RPCMessageType.WARNING_NOTIFICATION:
|
||||
elif msg_type == RPCMessageType.WARNING:
|
||||
message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg)
|
||||
|
||||
elif msg['type'] == RPCMessageType.STARTUP_NOTIFICATION:
|
||||
elif msg_type == RPCMessageType.STARTUP:
|
||||
message = '{status}'.format(**msg)
|
||||
|
||||
else:
|
||||
raise NotImplementedError('Unknown message type: {}'.format(msg['type']))
|
||||
raise NotImplementedError('Unknown message type: {}'.format(msg_type))
|
||||
|
||||
self._send_msg(message, disable_notification=(noti == 'silent'))
|
||||
|
||||
@@ -305,6 +349,7 @@ class Telegram(RPCHandler):
|
||||
|
||||
messages = []
|
||||
for r in results:
|
||||
r['open_date_hum'] = arrow.get(r['open_date']).humanize()
|
||||
lines = [
|
||||
"*Trade ID:* `{trade_id}` `(since {open_date_hum})`",
|
||||
"*Current Pair:* {pair}",
|
||||
@@ -351,15 +396,41 @@ class Telegram(RPCHandler):
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
statlist, head = self._rpc._rpc_status_table(
|
||||
self._config['stake_currency'], self._config.get('fiat_display_currency', ''))
|
||||
fiat_currency = self._config.get('fiat_display_currency', '')
|
||||
statlist, head, fiat_profit_sum = self._rpc._rpc_status_table(
|
||||
self._config['stake_currency'], fiat_currency)
|
||||
|
||||
message = tabulate(statlist, headers=head, tablefmt='simple')
|
||||
if(update.callback_query):
|
||||
query = update.callback_query
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id, msg=f"<pre>{message}</pre>", parse_mode=ParseMode.HTML, callback_path="update_status_table", reload_able=True)
|
||||
else:
|
||||
self._send_msg(f"<pre>{message}</pre>", reload_able=True, callback_path="update_status_table", parse_mode=ParseMode.HTML)
|
||||
show_total = not isnan(fiat_profit_sum) and len(statlist) > 1
|
||||
max_trades_per_msg = 50
|
||||
"""
|
||||
Calculate the number of messages of 50 trades per message
|
||||
0.99 is used to make sure that there are no extra (empty) messages
|
||||
As an example with 50 trades, there will be int(50/50 + 0.99) = 1 message
|
||||
"""
|
||||
messages_count = max(int(len(statlist) / max_trades_per_msg + 0.99), 1)
|
||||
for i in range(0, messages_count):
|
||||
trades = statlist[i * max_trades_per_msg:(i + 1) * max_trades_per_msg]
|
||||
if show_total and i == messages_count - 1:
|
||||
# append total line
|
||||
trades.append(["Total", "", "", f"{fiat_profit_sum:.2f} {fiat_currency}"])
|
||||
|
||||
message = tabulate(trades,
|
||||
headers=head,
|
||||
tablefmt='simple')
|
||||
if show_total and i == messages_count - 1:
|
||||
# insert separators line between Total
|
||||
lines = message.split("\n")
|
||||
message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
|
||||
if(messages_count == 1 and update.callback_query):
|
||||
query = update.callback_query
|
||||
self._update_msg(chat_id=query.message.chat_id,
|
||||
message_id=query.message.message_id,
|
||||
msg=f"<pre>{message}</pre>",
|
||||
parse_mode=ParseMode.HTML,
|
||||
callback_path="update_status_table", reload_able=True)
|
||||
else:
|
||||
self._send_msg(f"<pre>{message}</pre>", reload_able=True,
|
||||
callback_path="update_status_table", parse_mode=ParseMode.HTML)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@@ -399,9 +470,12 @@ class Telegram(RPCHandler):
|
||||
message = f'<b>Daily Profit over the last {timescale} days</b>:\n<pre>{stats_tab}</pre>'
|
||||
if(update.callback_query):
|
||||
query = update.callback_query
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id, msg=message, parse_mode=ParseMode.HTML, callback_path="update_daily", reload_able=True)
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id,
|
||||
msg=message, parse_mode=ParseMode.HTML,
|
||||
callback_path="update_daily", reload_able=True)
|
||||
else:
|
||||
self._send_msg(msg=message, parse_mode=ParseMode.HTML, callback_path="update_daily", reload_able=True)
|
||||
self._send_msg(msg=message, parse_mode=ParseMode.HTML, callback_path="update_daily",
|
||||
reload_able=True)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@@ -417,9 +491,20 @@ class Telegram(RPCHandler):
|
||||
stake_cur = self._config['stake_currency']
|
||||
fiat_disp_cur = self._config.get('fiat_display_currency', '')
|
||||
|
||||
start_date = datetime.fromtimestamp(0)
|
||||
timescale = None
|
||||
try:
|
||||
if context.args:
|
||||
timescale = int(context.args[0])
|
||||
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||
start_date = today_start - timedelta(days=timescale)
|
||||
except (TypeError, ValueError, IndexError):
|
||||
pass
|
||||
|
||||
stats = self._rpc._rpc_trade_statistics(
|
||||
stake_cur,
|
||||
fiat_disp_cur)
|
||||
fiat_disp_cur,
|
||||
start_date)
|
||||
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']
|
||||
@@ -447,22 +532,25 @@ class Telegram(RPCHandler):
|
||||
else:
|
||||
markdown_msg = "`No closed trade` \n"
|
||||
|
||||
markdown_msg += (f"*ROI:* All trades\n"
|
||||
f"∙ `{round_coin_value(profit_all_coin, stake_cur)} "
|
||||
f"({profit_all_percent_mean:.2f}%) "
|
||||
f"({profit_all_percent_sum} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
||||
f"∙ `{round_coin_value(profit_all_fiat, 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']}`"
|
||||
)
|
||||
markdown_msg += (
|
||||
f"*ROI:* All trades\n"
|
||||
f"∙ `{round_coin_value(profit_all_coin, stake_cur)} "
|
||||
f"({profit_all_percent_mean:.2f}%) "
|
||||
f"({profit_all_percent_sum} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
||||
f"∙ `{round_coin_value(profit_all_fiat, fiat_disp_cur)}`\n"
|
||||
f"*Total Trade Count:* `{trade_count}`\n"
|
||||
f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* "
|
||||
f"`{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}%`")
|
||||
if(update.callback_query):
|
||||
query = update.callback_query
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id, msg=markdown_msg, callback_path="update_profit", reload_able=True)
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id,
|
||||
msg=markdown_msg, callback_path="update_profit", reload_able=True)
|
||||
else:
|
||||
self._send_msg(msg=markdown_msg, callback_path="update_profit", reload_able=True)
|
||||
|
||||
@@ -515,6 +603,10 @@ class Telegram(RPCHandler):
|
||||
result = self._rpc._rpc_balance(self._config['stake_currency'],
|
||||
self._config.get('fiat_display_currency', ''))
|
||||
|
||||
balance_dust_level = self._config['telegram'].get('balance_dust_level', 0.0)
|
||||
if not balance_dust_level:
|
||||
balance_dust_level = DUST_PER_COIN.get(self._config['stake_currency'], 1.0)
|
||||
|
||||
output = ''
|
||||
if self._config['dry_run']:
|
||||
output += (
|
||||
@@ -524,7 +616,7 @@ class Telegram(RPCHandler):
|
||||
f"`{self._config['dry_run_wallet']}` {self._config['stake_currency']}.\n"
|
||||
)
|
||||
for curr in result['currencies']:
|
||||
if curr['est_stake'] > 0.0001:
|
||||
if curr['est_stake'] > balance_dust_level:
|
||||
curr_output = (
|
||||
f"*{curr['currency']}:*\n"
|
||||
f"\t`Available: {curr['free']:.8f}`\n"
|
||||
@@ -533,7 +625,8 @@ class Telegram(RPCHandler):
|
||||
f"\t`Est. {curr['stake']}: "
|
||||
f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n")
|
||||
else:
|
||||
curr_output = f"*{curr['currency']}:* not showing <1$ amount \n"
|
||||
curr_output = (f"*{curr['currency']}:* not showing <{balance_dust_level} "
|
||||
f"{curr['stake']} amount \n")
|
||||
|
||||
# Handle overflowing messsage length
|
||||
if len(output + curr_output) >= MAX_TELEGRAM_MESSAGE_LENGTH:
|
||||
@@ -548,7 +641,8 @@ class Telegram(RPCHandler):
|
||||
f"{round_coin_value(result['value'], result['symbol'], False)}`\n")
|
||||
if(update.callback_query):
|
||||
query = update.callback_query
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id, msg=output, callback_path="update_balance", reload_able=True)
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id,
|
||||
msg=output, callback_path="update_balance", reload_able=True)
|
||||
else:
|
||||
self._send_msg(msg=output, callback_path="update_balance", reload_able=True)
|
||||
except RPCException as e:
|
||||
@@ -623,6 +717,25 @@ class Telegram(RPCHandler):
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
def _forcebuy_action(self, pair, price=None):
|
||||
try:
|
||||
self._rpc._rpc_forcebuy(pair, price)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
def _forcebuy_inline(self, update: Update, _: CallbackContext) -> None:
|
||||
if update.callback_query:
|
||||
query = update.callback_query
|
||||
pair = query.data
|
||||
query.answer()
|
||||
query.edit_message_text(text=f"Force Buying: {pair}")
|
||||
self._forcebuy_action(pair)
|
||||
|
||||
@staticmethod
|
||||
def _layout_inline_keyboard(buttons: List[InlineKeyboardButton],
|
||||
cols=3) -> List[List[InlineKeyboardButton]]:
|
||||
return [buttons[i:i + cols] for i in range(0, len(buttons), cols)]
|
||||
|
||||
@authorized_only
|
||||
def _forcebuy(self, update: Update, context: CallbackContext) -> None:
|
||||
"""
|
||||
@@ -635,10 +748,13 @@ class Telegram(RPCHandler):
|
||||
if context.args:
|
||||
pair = context.args[0]
|
||||
price = float(context.args[1]) if len(context.args) > 1 else None
|
||||
try:
|
||||
self._rpc._rpc_forcebuy(pair, price)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._forcebuy_action(pair, price)
|
||||
else:
|
||||
whitelist = self._rpc._rpc_whitelist()['whitelist']
|
||||
pairs = [InlineKeyboardButton(text=pair, callback_data=pair) for pair in whitelist]
|
||||
|
||||
self._send_msg(msg="Which pair?",
|
||||
keyboard=self._layout_inline_keyboard(pairs))
|
||||
|
||||
@authorized_only
|
||||
def _trades(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -659,13 +775,13 @@ class Telegram(RPCHandler):
|
||||
nrecent
|
||||
)
|
||||
trades_tab = tabulate(
|
||||
[[arrow.get(trade['open_date']).humanize(),
|
||||
trade['pair'],
|
||||
[[arrow.get(trade['close_date']).humanize(),
|
||||
trade['pair'] + " (#" + str(trade['trade_id']) + ")",
|
||||
f"{(100 * trade['close_profit']):.2f}% ({trade['close_profit_abs']})"]
|
||||
for trade in trades['trades']],
|
||||
headers=[
|
||||
'Open Date',
|
||||
'Pair',
|
||||
'Close Date',
|
||||
'Pair (ID)',
|
||||
f'Profit ({stake_cur})',
|
||||
],
|
||||
tablefmt='simple')
|
||||
@@ -708,18 +824,30 @@ class Telegram(RPCHandler):
|
||||
"""
|
||||
try:
|
||||
trades = self._rpc._rpc_performance()
|
||||
stats = '\n'.join('{index}.\t<code>{pair}\t{profit:.2f}% ({count})</code>'.format(
|
||||
index=i + 1,
|
||||
pair=trade['pair'],
|
||||
profit=trade['profit'],
|
||||
count=trade['count']
|
||||
) for i, trade in enumerate(trades))
|
||||
message = '<b>Performance:</b>\n{}'.format(stats)
|
||||
if(update.callback_query):
|
||||
output = "<b>Performance:</b>\n"
|
||||
sent_messages = 0
|
||||
for i, trade in enumerate(trades):
|
||||
stat_line = (
|
||||
f"{i+1}.\t <code>{trade['pair']}\t"
|
||||
f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} "
|
||||
f"({trade['profit']:.2f}%) "
|
||||
f"({trade['count']})</code>\n")
|
||||
|
||||
if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH:
|
||||
self._send_msg(output, parse_mode=ParseMode.HTML)
|
||||
output = stat_line
|
||||
sent_messages += 1
|
||||
else:
|
||||
output += stat_line
|
||||
|
||||
if(sent_messages == 0 and update.callback_query):
|
||||
query = update.callback_query
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id, msg=message, parse_mode=ParseMode.HTML, callback_path="update_performance", reload_able=True)
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id,
|
||||
msg=output, parse_mode=ParseMode.HTML,
|
||||
callback_path="update_performance", reload_able=True)
|
||||
else:
|
||||
self._send_msg(msg=message, parse_mode=ParseMode.HTML, callback_path="update_performance", reload_able=True)
|
||||
self._send_msg(msg=output, parse_mode=ParseMode.HTML,
|
||||
callback_path="update_performance", reload_able=True)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@@ -741,9 +869,12 @@ class Telegram(RPCHandler):
|
||||
logger.debug(message)
|
||||
if(update.callback_query):
|
||||
query = update.callback_query
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id, msg=message, parse_mode=ParseMode.HTML, callback_path="update_count", reload_able=True)
|
||||
self._update_msg(chat_id=query.message.chat_id, message_id=query.message.message_id,
|
||||
msg=message, parse_mode=ParseMode.HTML,
|
||||
callback_path="update_count", reload_able=True)
|
||||
else:
|
||||
self._send_msg(msg=message, parse_mode=ParseMode.HTML, callback_path="update_count", reload_able=True)
|
||||
self._send_msg(msg=message, parse_mode=ParseMode.HTML,
|
||||
callback_path="update_count", reload_able=True)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@@ -753,19 +884,39 @@ class Telegram(RPCHandler):
|
||||
Handler for /locks.
|
||||
Returns the currently active locks
|
||||
"""
|
||||
try:
|
||||
locks = self._rpc._rpc_locks()
|
||||
rpc_locks = self._rpc._rpc_locks()
|
||||
if not rpc_locks['locks']:
|
||||
self._send_msg('No active locks.', parse_mode=ParseMode.HTML)
|
||||
|
||||
for locks in chunks(rpc_locks['locks'], 25):
|
||||
message = tabulate([[
|
||||
lock['id'],
|
||||
lock['pair'],
|
||||
lock['lock_end_time'],
|
||||
lock['reason']] for lock in locks['locks']],
|
||||
headers=['Pair', 'Until', 'Reason'],
|
||||
lock['reason']] for lock in locks],
|
||||
headers=['ID', 'Pair', 'Until', 'Reason'],
|
||||
tablefmt='simple')
|
||||
message = "<pre>{}</pre>".format(message)
|
||||
message = f"<pre>{escape(message)}</pre>"
|
||||
logger.debug(message)
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@authorized_only
|
||||
def _delete_locks(self, update: Update, context: CallbackContext) -> None:
|
||||
"""
|
||||
Handler for /delete_locks.
|
||||
Returns the currently active locks
|
||||
"""
|
||||
arg = context.args[0] if context.args and len(context.args) > 0 else None
|
||||
lockid = None
|
||||
pair = None
|
||||
if arg:
|
||||
try:
|
||||
lockid = int(arg)
|
||||
except ValueError:
|
||||
pair = arg
|
||||
|
||||
self._rpc._rpc_delete_lock(lockid=lockid, pair=pair)
|
||||
self._locks(update, context)
|
||||
|
||||
@authorized_only
|
||||
def _whitelist(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -847,9 +998,17 @@ class Telegram(RPCHandler):
|
||||
"""
|
||||
try:
|
||||
edge_pairs = self._rpc._rpc_edge()
|
||||
edge_pairs_tab = tabulate(edge_pairs, headers='keys', tablefmt='simple')
|
||||
message = f'<b>Edge only validated following pairs:</b>\n<pre>{edge_pairs_tab}</pre>'
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
if not edge_pairs:
|
||||
message = '<b>Edge only validated following pairs:</b>'
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
for chunk in chunks(edge_pairs, 25):
|
||||
edge_pairs_tab = tabulate(chunk, headers='keys', tablefmt='simple')
|
||||
message = (f'<b>Edge only validated following pairs:</b>\n'
|
||||
f'<pre>{edge_pairs_tab}</pre>')
|
||||
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@@ -873,7 +1032,8 @@ class Telegram(RPCHandler):
|
||||
" `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"
|
||||
"*/profit [<n>]:* `Lists cumulative profit from all finished trades, "
|
||||
"over the last n days`\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 ''}"
|
||||
@@ -884,6 +1044,7 @@ class Telegram(RPCHandler):
|
||||
"Avg. holding durationsfor buys and sells.`\n"
|
||||
"*/count:* `Show number of active trades compared to allowed number of trades`\n"
|
||||
"*/locks:* `Show currently locked pairs`\n"
|
||||
"*/unlock <pair|id>:* `Unlock this Pair (or this lock id if it's numeric)`\n"
|
||||
"*/balance:* `Show account balance per currency`\n"
|
||||
"*/stopbuy:* `Stops buying, but handles open trades gracefully` \n"
|
||||
"*/reload_config:* `Reload configuration file` \n"
|
||||
@@ -945,12 +1106,15 @@ class Telegram(RPCHandler):
|
||||
f"*Current state:* `{val['state']}`"
|
||||
)
|
||||
|
||||
def _update_msg(self, chat_id: str, message_id: str, msg: str, callback_path: str = "", reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None:
|
||||
def _update_msg(self, chat_id: str, message_id: str, msg: str, callback_path: str = "",
|
||||
reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None:
|
||||
if reload_able:
|
||||
reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("Refresh", callback_data=callback_path)]])
|
||||
reply_markup = InlineKeyboardMarkup([
|
||||
[InlineKeyboardButton("Refresh", callback_data=callback_path)],
|
||||
])
|
||||
else:
|
||||
reply_markup = InlineKeyboardMarkup([[]])
|
||||
msg+="\nUpdated: {}".format(datetime.now().ctime())
|
||||
msg += "\nUpdated: {}".format(datetime.now().ctime())
|
||||
try:
|
||||
try:
|
||||
self._updater.bot.edit_message_text(
|
||||
@@ -974,7 +1138,11 @@ class Telegram(RPCHandler):
|
||||
telegram_err.message
|
||||
)
|
||||
|
||||
def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN, disable_notification: bool = False, callback_path: str = "", reload_able: bool = False) -> None:
|
||||
def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN,
|
||||
disable_notification: bool = False,
|
||||
keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = None,
|
||||
callback_path: str = "",
|
||||
reload_able: bool = False) -> None:
|
||||
"""
|
||||
Send given markdown message
|
||||
:param msg: message
|
||||
@@ -982,10 +1150,14 @@ class Telegram(RPCHandler):
|
||||
:param parse_mode: telegram parse mode
|
||||
:return: None
|
||||
"""
|
||||
if reload_able and self._config['telegram'].get('reload',True):
|
||||
reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("Refresh", callback_data=callback_path)]])
|
||||
if reload_able and self._config['telegram'].get('reload', True):
|
||||
reply_markup = InlineKeyboardMarkup([
|
||||
[InlineKeyboardButton("Refresh", callback_data=callback_path)]])
|
||||
else:
|
||||
reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True)
|
||||
if keyboard is not None:
|
||||
reply_markup = InlineKeyboardMarkup(keyboard, resize_keyboard=True)
|
||||
else:
|
||||
reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True)
|
||||
try:
|
||||
try:
|
||||
self._updater.bot.send_message(
|
||||
|
@@ -6,7 +6,8 @@ from typing import Any, Dict
|
||||
|
||||
from requests import RequestException, post
|
||||
|
||||
from freqtrade.rpc import RPC, RPCHandler, RPCMessageType
|
||||
from freqtrade.enums import RPCMessageType
|
||||
from freqtrade.rpc import RPC, RPCHandler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,6 +29,12 @@ class Webhook(RPCHandler):
|
||||
|
||||
self._url = self._config['webhook']['url']
|
||||
|
||||
self._format = self._config['webhook'].get('format', 'form')
|
||||
|
||||
if self._format != 'form' and self._format != 'json':
|
||||
raise NotImplementedError('Unknown webhook format `{}`, possible values are '
|
||||
'`form` (default) and `json`'.format(self._format))
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Cleanup pending module resources.
|
||||
@@ -39,17 +46,21 @@ class Webhook(RPCHandler):
|
||||
""" Send a message to telegram channel """
|
||||
try:
|
||||
|
||||
if msg['type'] == RPCMessageType.BUY_NOTIFICATION:
|
||||
if msg['type'] == RPCMessageType.BUY:
|
||||
valuedict = self._config['webhook'].get('webhookbuy', None)
|
||||
elif msg['type'] == RPCMessageType.BUY_CANCEL_NOTIFICATION:
|
||||
elif msg['type'] == RPCMessageType.BUY_CANCEL:
|
||||
valuedict = self._config['webhook'].get('webhookbuycancel', None)
|
||||
elif msg['type'] == RPCMessageType.SELL_NOTIFICATION:
|
||||
elif msg['type'] == RPCMessageType.BUY_FILL:
|
||||
valuedict = self._config['webhook'].get('webhookbuyfill', None)
|
||||
elif msg['type'] == RPCMessageType.SELL:
|
||||
valuedict = self._config['webhook'].get('webhooksell', None)
|
||||
elif msg['type'] == RPCMessageType.SELL_CANCEL_NOTIFICATION:
|
||||
elif msg['type'] == RPCMessageType.SELL_FILL:
|
||||
valuedict = self._config['webhook'].get('webhooksellfill', None)
|
||||
elif msg['type'] == RPCMessageType.SELL_CANCEL:
|
||||
valuedict = self._config['webhook'].get('webhooksellcancel', None)
|
||||
elif msg['type'] in (RPCMessageType.STATUS_NOTIFICATION,
|
||||
RPCMessageType.STARTUP_NOTIFICATION,
|
||||
RPCMessageType.WARNING_NOTIFICATION):
|
||||
elif msg['type'] in (RPCMessageType.STATUS,
|
||||
RPCMessageType.STARTUP,
|
||||
RPCMessageType.WARNING):
|
||||
valuedict = self._config['webhook'].get('webhookstatus', None)
|
||||
else:
|
||||
raise NotImplementedError('Unknown message type: {}'.format(msg['type']))
|
||||
@@ -66,7 +77,14 @@ class Webhook(RPCHandler):
|
||||
def _send_msg(self, payload: dict) -> None:
|
||||
"""do the actual call to the webhook"""
|
||||
|
||||
if self._format == 'form':
|
||||
kwargs = {'data': payload}
|
||||
elif self._format == 'json':
|
||||
kwargs = {'json': payload}
|
||||
else:
|
||||
raise NotImplementedError('Unknown format: {}'.format(self._format))
|
||||
|
||||
try:
|
||||
post(self._url, data=payload)
|
||||
post(self._url, **kwargs)
|
||||
except RequestException as exc:
|
||||
logger.warning("Could not call webhook url. Exception: %s", exc)
|
||||
|
@@ -1,5 +1,7 @@
|
||||
# flake8: noqa: F401
|
||||
from freqtrade.exchange import (timeframe_to_minutes, timeframe_to_msecs, timeframe_to_next_date,
|
||||
timeframe_to_prev_date, timeframe_to_seconds)
|
||||
from freqtrade.strategy.hyper import (CategoricalParameter, DecimalParameter, IntParameter,
|
||||
RealParameter)
|
||||
from freqtrade.strategy.interface import IStrategy
|
||||
from freqtrade.strategy.strategy_helper import merge_informative_pair
|
||||
from freqtrade.strategy.strategy_helper import merge_informative_pair, stoploss_from_open
|
||||
|
349
freqtrade/strategy/hyper.py
Normal file
349
freqtrade/strategy/hyper.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
IHyperStrategy interface, hyperoptable Parameter class.
|
||||
This module defines a base class for auto-hyperoptable strategies.
|
||||
"""
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import suppress
|
||||
from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
from freqtrade.optimize.hyperopt_tools import HyperoptTools
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
from skopt.space import Integer, Real, Categorical
|
||||
from freqtrade.optimize.space import SKDecimal
|
||||
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseParameter(ABC):
|
||||
"""
|
||||
Defines a parameter that can be optimized by hyperopt.
|
||||
"""
|
||||
category: Optional[str]
|
||||
default: Any
|
||||
value: Any
|
||||
in_space: bool = False
|
||||
name: str
|
||||
|
||||
def __init__(self, *, default: Any, space: Optional[str] = None,
|
||||
optimize: bool = True, load: bool = True, **kwargs):
|
||||
"""
|
||||
Initialize hyperopt-optimizable parameter.
|
||||
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
|
||||
parameter field
|
||||
name is prefixed with 'buy_' or 'sell_'.
|
||||
:param optimize: Include parameter in hyperopt optimizations.
|
||||
:param load: Load parameter value from {space}_params.
|
||||
:param kwargs: Extra parameters to skopt.space.(Integer|Real|Categorical).
|
||||
"""
|
||||
if 'name' in kwargs:
|
||||
raise OperationalException(
|
||||
'Name is determined by parameter field name and can not be specified manually.')
|
||||
self.category = space
|
||||
self._space_params = kwargs
|
||||
self.value = default
|
||||
self.optimize = optimize
|
||||
self.load = load
|
||||
|
||||
def __repr__(self):
|
||||
return f'{self.__class__.__name__}({self.value})'
|
||||
|
||||
@abstractmethod
|
||||
def get_space(self, name: str) -> Union['Integer', 'Real', 'SKDecimal', 'Categorical']:
|
||||
"""
|
||||
Get-space - will be used by Hyperopt to get the hyperopt Space
|
||||
"""
|
||||
|
||||
|
||||
class NumericParameter(BaseParameter):
|
||||
""" Internal parameter used for Numeric purposes """
|
||||
float_or_int = Union[int, float]
|
||||
default: float_or_int
|
||||
value: float_or_int
|
||||
|
||||
def __init__(self, low: Union[float_or_int, Sequence[float_or_int]],
|
||||
high: Optional[float_or_int] = None, *, default: float_or_int,
|
||||
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
|
||||
"""
|
||||
Initialize hyperopt-optimizable numeric parameter.
|
||||
Cannot be instantiated, but provides the validation for other numeric parameters
|
||||
:param low: Lower end (inclusive) of optimization space or [low, high].
|
||||
:param high: Upper end (inclusive) of optimization space.
|
||||
Must be none of entire range is passed first parameter.
|
||||
:param default: A default value.
|
||||
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
|
||||
parameter fieldname is prefixed with 'buy_' or 'sell_'.
|
||||
:param optimize: Include parameter in hyperopt optimizations.
|
||||
:param load: Load parameter value from {space}_params.
|
||||
:param kwargs: Extra parameters to skopt.space.*.
|
||||
"""
|
||||
if high is not None and isinstance(low, Sequence):
|
||||
raise OperationalException(f'{self.__class__.__name__} space invalid.')
|
||||
if high is None or isinstance(low, Sequence):
|
||||
if not isinstance(low, Sequence) or len(low) != 2:
|
||||
raise OperationalException(f'{self.__class__.__name__} space must be [low, high]')
|
||||
self.low, self.high = low
|
||||
else:
|
||||
self.low = low
|
||||
self.high = high
|
||||
|
||||
super().__init__(default=default, space=space, optimize=optimize,
|
||||
load=load, **kwargs)
|
||||
|
||||
|
||||
class IntParameter(NumericParameter):
|
||||
default: int
|
||||
value: int
|
||||
|
||||
def __init__(self, low: Union[int, Sequence[int]], high: Optional[int] = None, *, default: int,
|
||||
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
|
||||
"""
|
||||
Initialize hyperopt-optimizable integer parameter.
|
||||
:param low: Lower end (inclusive) of optimization space or [low, high].
|
||||
:param high: Upper end (inclusive) of optimization space.
|
||||
Must be none of entire range is passed first parameter.
|
||||
:param default: A default value.
|
||||
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
|
||||
parameter fieldname is prefixed with 'buy_' or 'sell_'.
|
||||
:param optimize: Include parameter in hyperopt optimizations.
|
||||
:param load: Load parameter value from {space}_params.
|
||||
:param kwargs: Extra parameters to skopt.space.Integer.
|
||||
"""
|
||||
|
||||
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
|
||||
load=load, **kwargs)
|
||||
|
||||
def get_space(self, name: str) -> 'Integer':
|
||||
"""
|
||||
Create skopt optimization space.
|
||||
:param name: A name of parameter field.
|
||||
"""
|
||||
return Integer(low=self.low, high=self.high, name=name, **self._space_params)
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
"""
|
||||
Get each value in this space as list.
|
||||
Returns a List from low to high (inclusive) in Hyperopt mode.
|
||||
Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid
|
||||
calculating 100ds of indicators.
|
||||
"""
|
||||
if self.in_space and self.optimize:
|
||||
# Scikit-optimize ranges are "inclusive", while python's "range" is exclusive
|
||||
return range(self.low, self.high + 1)
|
||||
else:
|
||||
return range(self.value, self.value + 1)
|
||||
|
||||
|
||||
class RealParameter(NumericParameter):
|
||||
default: float
|
||||
value: float
|
||||
|
||||
def __init__(self, low: Union[float, Sequence[float]], high: Optional[float] = None, *,
|
||||
default: float, space: Optional[str] = None, optimize: bool = True,
|
||||
load: bool = True, **kwargs):
|
||||
"""
|
||||
Initialize hyperopt-optimizable floating point parameter with unlimited precision.
|
||||
:param low: Lower end (inclusive) of optimization space or [low, high].
|
||||
:param high: Upper end (inclusive) of optimization space.
|
||||
Must be none if entire range is passed first parameter.
|
||||
:param default: A default value.
|
||||
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
|
||||
parameter fieldname is prefixed with 'buy_' or 'sell_'.
|
||||
:param optimize: Include parameter in hyperopt optimizations.
|
||||
:param load: Load parameter value from {space}_params.
|
||||
:param kwargs: Extra parameters to skopt.space.Real.
|
||||
"""
|
||||
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
|
||||
load=load, **kwargs)
|
||||
|
||||
def get_space(self, name: str) -> 'Real':
|
||||
"""
|
||||
Create skopt optimization space.
|
||||
:param name: A name of parameter field.
|
||||
"""
|
||||
return Real(low=self.low, high=self.high, name=name, **self._space_params)
|
||||
|
||||
|
||||
class DecimalParameter(NumericParameter):
|
||||
default: float
|
||||
value: float
|
||||
|
||||
def __init__(self, low: Union[float, Sequence[float]], high: Optional[float] = None, *,
|
||||
default: float, decimals: int = 3, space: Optional[str] = None,
|
||||
optimize: bool = True, load: bool = True, **kwargs):
|
||||
"""
|
||||
Initialize hyperopt-optimizable decimal parameter with a limited precision.
|
||||
:param low: Lower end (inclusive) of optimization space or [low, high].
|
||||
:param high: Upper end (inclusive) of optimization space.
|
||||
Must be none if entire range is passed first parameter.
|
||||
:param default: A default value.
|
||||
:param decimals: A number of decimals after floating point to be included in testing.
|
||||
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
|
||||
parameter fieldname is prefixed with 'buy_' or 'sell_'.
|
||||
:param optimize: Include parameter in hyperopt optimizations.
|
||||
:param load: Load parameter value from {space}_params.
|
||||
:param kwargs: Extra parameters to skopt.space.Integer.
|
||||
"""
|
||||
self._decimals = decimals
|
||||
default = round(default, self._decimals)
|
||||
|
||||
super().__init__(low=low, high=high, default=default, space=space, optimize=optimize,
|
||||
load=load, **kwargs)
|
||||
|
||||
def get_space(self, name: str) -> 'SKDecimal':
|
||||
"""
|
||||
Create skopt optimization space.
|
||||
:param name: A name of parameter field.
|
||||
"""
|
||||
return SKDecimal(low=self.low, high=self.high, decimals=self._decimals, name=name,
|
||||
**self._space_params)
|
||||
|
||||
|
||||
class CategoricalParameter(BaseParameter):
|
||||
default: Any
|
||||
value: Any
|
||||
opt_range: Sequence[Any]
|
||||
|
||||
def __init__(self, categories: Sequence[Any], *, default: Optional[Any] = None,
|
||||
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
|
||||
"""
|
||||
Initialize hyperopt-optimizable parameter.
|
||||
:param categories: Optimization space, [a, b, ...].
|
||||
:param default: A default value. If not specified, first item from specified space will be
|
||||
used.
|
||||
:param space: A parameter category. Can be 'buy' or 'sell'. This parameter is optional if
|
||||
parameter field
|
||||
name is prefixed with 'buy_' or 'sell_'.
|
||||
:param optimize: Include parameter in hyperopt optimizations.
|
||||
:param load: Load parameter value from {space}_params.
|
||||
:param kwargs: Extra parameters to skopt.space.Categorical.
|
||||
"""
|
||||
if len(categories) < 2:
|
||||
raise OperationalException(
|
||||
'CategoricalParameter space must be [a, b, ...] (at least two parameters)')
|
||||
self.opt_range = categories
|
||||
super().__init__(default=default, space=space, optimize=optimize,
|
||||
load=load, **kwargs)
|
||||
|
||||
def get_space(self, name: str) -> 'Categorical':
|
||||
"""
|
||||
Create skopt optimization space.
|
||||
:param name: A name of parameter field.
|
||||
"""
|
||||
return Categorical(self.opt_range, name=name, **self._space_params)
|
||||
|
||||
|
||||
class HyperStrategyMixin(object):
|
||||
"""
|
||||
A helper base class which allows HyperOptAuto class to reuse implementations of of buy/sell
|
||||
strategy logic.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], *args, **kwargs):
|
||||
"""
|
||||
Initialize hyperoptable strategy mixin.
|
||||
"""
|
||||
self.config = config
|
||||
self.ft_buy_params: List[BaseParameter] = []
|
||||
self.ft_sell_params: List[BaseParameter] = []
|
||||
|
||||
self._load_hyper_params(config.get('runmode') == RunMode.HYPEROPT)
|
||||
|
||||
def enumerate_parameters(self, category: str = None) -> Iterator[Tuple[str, BaseParameter]]:
|
||||
"""
|
||||
Find all optimizeable parameters and return (name, attr) iterator.
|
||||
:param category:
|
||||
:return:
|
||||
"""
|
||||
if category not in ('buy', 'sell', None):
|
||||
raise OperationalException('Category must be one of: "buy", "sell", None.')
|
||||
|
||||
if category is None:
|
||||
params = self.ft_buy_params + self.ft_sell_params
|
||||
else:
|
||||
params = getattr(self, f"ft_{category}_params")
|
||||
|
||||
for par in params:
|
||||
yield par.name, par
|
||||
|
||||
@classmethod
|
||||
def detect_parameters(cls, category: str) -> Iterator[Tuple[str, BaseParameter]]:
|
||||
""" Detect all parameters for 'category' """
|
||||
for attr_name in dir(cls):
|
||||
if not attr_name.startswith('__'): # Ignore internals, not strictly necessary.
|
||||
attr = getattr(cls, attr_name)
|
||||
if issubclass(attr.__class__, BaseParameter):
|
||||
if (attr_name.startswith(category + '_')
|
||||
and attr.category is not None and attr.category != category):
|
||||
raise OperationalException(
|
||||
f'Inconclusive parameter name {attr_name}, category: {attr.category}.')
|
||||
if (category == attr.category or
|
||||
(attr_name.startswith(category + '_') and attr.category is None)):
|
||||
yield attr_name, attr
|
||||
|
||||
@classmethod
|
||||
def detect_all_parameters(cls) -> Dict:
|
||||
""" Detect all parameters and return them as a list"""
|
||||
params: Dict = {
|
||||
'buy': list(cls.detect_parameters('buy')),
|
||||
'sell': list(cls.detect_parameters('sell')),
|
||||
}
|
||||
params.update({
|
||||
'count': len(params['buy'] + params['sell'])
|
||||
})
|
||||
|
||||
return params
|
||||
|
||||
def _load_hyper_params(self, hyperopt: bool = False) -> None:
|
||||
"""
|
||||
Load Hyperoptable parameters
|
||||
"""
|
||||
self._load_params(getattr(self, 'buy_params', None), 'buy', hyperopt)
|
||||
self._load_params(getattr(self, 'sell_params', None), 'sell', hyperopt)
|
||||
|
||||
def _load_params(self, params: dict, space: str, hyperopt: bool = False) -> None:
|
||||
"""
|
||||
Set optimizeable parameter values.
|
||||
:param params: Dictionary with new parameter values.
|
||||
"""
|
||||
if not params:
|
||||
logger.info(f"No params for {space} found, using default values.")
|
||||
param_container: List[BaseParameter] = getattr(self, f"ft_{space}_params")
|
||||
|
||||
for attr_name, attr in self.detect_parameters(space):
|
||||
attr.name = attr_name
|
||||
attr.in_space = hyperopt and HyperoptTools.has_space(self.config, space)
|
||||
if not attr.category:
|
||||
attr.category = space
|
||||
|
||||
param_container.append(attr)
|
||||
|
||||
if params and attr_name in params:
|
||||
if attr.load:
|
||||
attr.value = params[attr_name]
|
||||
logger.info(f'Strategy Parameter: {attr_name} = {attr.value}')
|
||||
else:
|
||||
logger.warning(f'Parameter "{attr_name}" exists, but is disabled. '
|
||||
f'Default value "{attr.value}" used.')
|
||||
else:
|
||||
logger.info(f'Strategy Parameter(default): {attr_name} = {attr.value}')
|
||||
|
||||
def get_params_dict(self):
|
||||
"""
|
||||
Returns list of Parameters that are not part of the current optimize job
|
||||
"""
|
||||
params = {
|
||||
'buy': {},
|
||||
'sell': {}
|
||||
}
|
||||
for name, p in self.enumerate_parameters():
|
||||
if not p.optimize or not p.in_space:
|
||||
params[p.category][name] = p.value
|
||||
return params
|
@@ -6,60 +6,44 @@ import logging
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Dict, List, NamedTuple, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import arrow
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import ListPairsWithTimeframes
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.enums import SellType, SignalType
|
||||
from freqtrade.exceptions import OperationalException, StrategyError
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
|
||||
from freqtrade.exchange.exchange import timeframe_to_next_date
|
||||
from freqtrade.persistence import PairLocks, Trade
|
||||
from freqtrade.strategy.hyper import HyperStrategyMixin
|
||||
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||
from freqtrade.wallets import Wallets
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CUSTOM_SELL_MAX_LENGTH = 64
|
||||
|
||||
|
||||
class SignalType(Enum):
|
||||
"""
|
||||
Enum to distinguish between buy and sell signals
|
||||
"""
|
||||
BUY = "buy"
|
||||
SELL = "sell"
|
||||
|
||||
|
||||
class SellType(Enum):
|
||||
"""
|
||||
Enum to distinguish between sell reasons
|
||||
"""
|
||||
ROI = "roi"
|
||||
STOP_LOSS = "stop_loss"
|
||||
STOPLOSS_ON_EXCHANGE = "stoploss_on_exchange"
|
||||
TRAILING_STOP_LOSS = "trailing_stop_loss"
|
||||
SELL_SIGNAL = "sell_signal"
|
||||
FORCE_SELL = "force_sell"
|
||||
EMERGENCY_SELL = "emergency_sell"
|
||||
NONE = ""
|
||||
|
||||
def __str__(self):
|
||||
# explicitly convert to String to help with exporting data.
|
||||
return self.value
|
||||
|
||||
|
||||
class SellCheckTuple(NamedTuple):
|
||||
class SellCheckTuple(object):
|
||||
"""
|
||||
NamedTuple for Sell type + reason
|
||||
"""
|
||||
sell_flag: bool
|
||||
sell_type: SellType
|
||||
sell_reason: str = ''
|
||||
|
||||
def __init__(self, sell_type: SellType, sell_reason: str = ''):
|
||||
self.sell_type = sell_type
|
||||
self.sell_reason = sell_reason or sell_type.value
|
||||
|
||||
@property
|
||||
def sell_flag(self):
|
||||
return self.sell_type != SellType.NONE
|
||||
|
||||
|
||||
class IStrategy(ABC):
|
||||
class IStrategy(ABC, HyperStrategyMixin):
|
||||
"""
|
||||
Interface for freqtrade strategies
|
||||
Defines the mandatory structure must follow any custom strategies
|
||||
@@ -140,6 +124,7 @@ class IStrategy(ABC):
|
||||
self.config = config
|
||||
# Dict to determine if analysis is necessary
|
||||
self._last_candle_seen_per_pair: Dict[str, datetime] = {}
|
||||
super().__init__(config)
|
||||
|
||||
@abstractmethod
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
@@ -149,6 +134,7 @@ class IStrategy(ABC):
|
||||
:param metadata: Additional information, like the currently traded pair
|
||||
:return: a Dataframe with all mandatory indicators for the strategies
|
||||
"""
|
||||
return dataframe
|
||||
|
||||
@abstractmethod
|
||||
def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
@@ -158,6 +144,7 @@ class IStrategy(ABC):
|
||||
:param metadata: Additional information, like the currently traded pair
|
||||
:return: DataFrame with buy column
|
||||
"""
|
||||
return dataframe
|
||||
|
||||
@abstractmethod
|
||||
def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
@@ -167,6 +154,7 @@ class IStrategy(ABC):
|
||||
:param metadata: Additional information, like the currently traded pair
|
||||
:return: DataFrame with sell column
|
||||
"""
|
||||
return dataframe
|
||||
|
||||
def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
|
||||
"""
|
||||
@@ -214,7 +202,7 @@ class IStrategy(ABC):
|
||||
pass
|
||||
|
||||
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
time_in_force: str, **kwargs) -> bool:
|
||||
time_in_force: str, current_time: datetime, **kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a buy order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -229,6 +217,7 @@ class IStrategy(ABC):
|
||||
:param amount: Amount in target (quote) currency that's going to be traded.
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the buy-order is placed on the exchange.
|
||||
False aborts the process
|
||||
@@ -236,7 +225,8 @@ class IStrategy(ABC):
|
||||
return True
|
||||
|
||||
def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float,
|
||||
rate: float, time_in_force: str, sell_reason: str, **kwargs) -> bool:
|
||||
rate: float, time_in_force: str, sell_reason: str,
|
||||
current_time: datetime, **kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a regular sell order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -255,6 +245,7 @@ class IStrategy(ABC):
|
||||
:param sell_reason: Sell reason.
|
||||
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
|
||||
'sell_signal', 'force_sell', 'emergency_sell']
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the sell-order is placed on the exchange.
|
||||
False aborts the process
|
||||
@@ -283,6 +274,30 @@ class IStrategy(ABC):
|
||||
"""
|
||||
return self.stoploss
|
||||
|
||||
def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
|
||||
current_profit: float, **kwargs) -> Optional[Union[str, bool]]:
|
||||
"""
|
||||
Custom sell signal logic indicating that specified position should be sold. Returning a
|
||||
string or True from this method is equal to setting sell signal on a candle at specified
|
||||
time. This method is not called when sell signal is set.
|
||||
|
||||
This method should be overridden to create sell signals that depend on trade parameters. For
|
||||
example you could implement a stoploss relative to candle when trade was opened, or a custom
|
||||
1:2 risk-reward ROI.
|
||||
|
||||
Custom sell reason max length is 64. Exceeding this limit will raise OperationalException.
|
||||
|
||||
:param pair: Pair that's currently analyzed
|
||||
:param trade: trade object.
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param current_rate: Rate, calculated based on pricing settings in ask_strategy.
|
||||
:param current_profit: Current profit (as ratio), calculated based on current_rate.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return: To execute sell, return a string with custom sell reason or True. Otherwise return
|
||||
None or False.
|
||||
"""
|
||||
return None
|
||||
|
||||
def informative_pairs(self) -> ListPairsWithTimeframes:
|
||||
"""
|
||||
Define additional, informative pair/interval combinations to be cached from the exchange.
|
||||
@@ -529,12 +544,33 @@ class IStrategy(ABC):
|
||||
and self.min_roi_reached(trade=trade, current_profit=current_profit,
|
||||
current_time=date))
|
||||
|
||||
sell_signal = SellType.NONE
|
||||
custom_reason = ''
|
||||
# use provided rate in backtesting, not high/low.
|
||||
current_rate = rate
|
||||
current_profit = trade.calc_profit_ratio(current_rate)
|
||||
|
||||
if (ask_strategy.get('sell_profit_only', False)
|
||||
and current_profit <= ask_strategy.get('sell_profit_offset', 0)):
|
||||
# sell_profit_only and profit doesn't reach the offset - ignore sell signal
|
||||
sell_signal = False
|
||||
else:
|
||||
sell_signal = sell and not buy and ask_strategy.get('use_sell_signal', True)
|
||||
pass
|
||||
elif ask_strategy.get('use_sell_signal', True) and not buy:
|
||||
if sell:
|
||||
sell_signal = SellType.SELL_SIGNAL
|
||||
else:
|
||||
custom_reason = strategy_safe_wrapper(self.custom_sell, default_retval=False)(
|
||||
pair=trade.pair, trade=trade, current_time=date, current_rate=current_rate,
|
||||
current_profit=current_profit)
|
||||
if custom_reason:
|
||||
sell_signal = SellType.CUSTOM_SELL
|
||||
if isinstance(custom_reason, str):
|
||||
if len(custom_reason) > CUSTOM_SELL_MAX_LENGTH:
|
||||
logger.warning(f'Custom sell reason returned from custom_sell is too '
|
||||
f'long and was trimmed to {CUSTOM_SELL_MAX_LENGTH} '
|
||||
f'characters.')
|
||||
custom_reason = custom_reason[:CUSTOM_SELL_MAX_LENGTH]
|
||||
else:
|
||||
custom_reason = None
|
||||
# TODO: return here if sell-signal should be favored over ROI
|
||||
|
||||
# Start evaluations
|
||||
@@ -543,24 +579,23 @@ class IStrategy(ABC):
|
||||
# Sell-signal
|
||||
# Stoploss
|
||||
if roi_reached and stoplossflag.sell_type != SellType.STOP_LOSS:
|
||||
logger.debug(f"{trade.pair} - Required profit reached. sell_flag=True, "
|
||||
f"sell_type=SellType.ROI")
|
||||
return SellCheckTuple(sell_flag=True, sell_type=SellType.ROI)
|
||||
logger.debug(f"{trade.pair} - Required profit reached. sell_type=SellType.ROI")
|
||||
return SellCheckTuple(sell_type=SellType.ROI)
|
||||
|
||||
if sell_signal:
|
||||
logger.debug(f"{trade.pair} - Sell signal received. sell_flag=True, "
|
||||
f"sell_type=SellType.SELL_SIGNAL")
|
||||
return SellCheckTuple(sell_flag=True, sell_type=SellType.SELL_SIGNAL)
|
||||
if sell_signal != SellType.NONE:
|
||||
logger.debug(f"{trade.pair} - Sell signal received. "
|
||||
f"sell_type=SellType.{sell_signal.name}" +
|
||||
(f", custom_reason={custom_reason}" if custom_reason else ""))
|
||||
return SellCheckTuple(sell_type=sell_signal, sell_reason=custom_reason)
|
||||
|
||||
if stoplossflag.sell_flag:
|
||||
|
||||
logger.debug(f"{trade.pair} - Stoploss hit. sell_flag=True, "
|
||||
f"sell_type={stoplossflag.sell_type}")
|
||||
logger.debug(f"{trade.pair} - Stoploss hit. sell_type={stoplossflag.sell_type}")
|
||||
return stoplossflag
|
||||
|
||||
# This one is noisy, commented out...
|
||||
# logger.debug(f"{trade.pair} - No sell signal. sell_flag=False")
|
||||
return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
|
||||
# logger.debug(f"{trade.pair} - No sell signal.")
|
||||
return SellCheckTuple(sell_type=SellType.NONE)
|
||||
|
||||
def stop_loss_reached(self, current_rate: float, trade: Trade,
|
||||
current_time: datetime, current_profit: float,
|
||||
@@ -624,9 +659,9 @@ class IStrategy(ABC):
|
||||
logger.debug(f"{trade.pair} - Trailing stop saved "
|
||||
f"{trade.stop_loss - trade.initial_stop_loss:.6f}")
|
||||
|
||||
return SellCheckTuple(sell_flag=True, sell_type=sell_type)
|
||||
return SellCheckTuple(sell_type=sell_type)
|
||||
|
||||
return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
|
||||
return SellCheckTuple(sell_type=SellType.NONE)
|
||||
|
||||
def min_roi_reached_entry(self, trade_dur: int) -> Tuple[Optional[int], Optional[float]]:
|
||||
"""
|
||||
@@ -649,7 +684,7 @@ class IStrategy(ABC):
|
||||
:return: True if bot should sell at current rate
|
||||
"""
|
||||
# Check if time matches and current rate is above threshold
|
||||
trade_dur = int((current_time.timestamp() - trade.open_date.timestamp()) // 60)
|
||||
trade_dur = int((current_time.timestamp() - trade.open_date_utc.timestamp()) // 60)
|
||||
_, roi = self.min_roi_reached_entry(trade_dur)
|
||||
if roi is None:
|
||||
return False
|
||||
@@ -659,7 +694,7 @@ class IStrategy(ABC):
|
||||
def ohlcvdata_to_dataframe(self, data: Dict[str, DataFrame]) -> Dict[str, DataFrame]:
|
||||
"""
|
||||
Populates indicators for given candle (OHLCV) data (for multiple pairs)
|
||||
Does not run advice_buy or advise_sell!
|
||||
Does not run advise_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
|
||||
|
@@ -56,3 +56,30 @@ def merge_informative_pair(dataframe: pd.DataFrame, informative: pd.DataFrame,
|
||||
dataframe = dataframe.ffill()
|
||||
|
||||
return dataframe
|
||||
|
||||
|
||||
def stoploss_from_open(open_relative_stop: float, current_profit: float) -> float:
|
||||
"""
|
||||
|
||||
Given the current profit, and a desired stop loss value relative to the open price,
|
||||
return a stop loss value that is relative to the current price, and which can be
|
||||
returned from `custom_stoploss`.
|
||||
|
||||
The requested stop can be positive for a stop above the open price, or negative for
|
||||
a stop below the open price. The return value is always >= 0.
|
||||
|
||||
Returns 0 if the resulting stop price would be above the current price.
|
||||
|
||||
:param open_relative_stop: Desired stop loss percentage relative to open price
|
||||
:param current_profit: The current profit percentage
|
||||
:return: Positive stop loss value relative to current price
|
||||
"""
|
||||
|
||||
# formula is undefined for current_profit -1, return maximum value
|
||||
if current_profit == -1:
|
||||
return 1
|
||||
|
||||
stoploss = 1-((1+open_relative_stop)/(1+current_profit))
|
||||
|
||||
# negative stoploss values indicate the requested stop price is higher than the current price
|
||||
return max(stoploss, 0.0)
|
||||
|
@@ -9,7 +9,8 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 30,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"price_side": "bid",
|
||||
@@ -54,14 +55,15 @@
|
||||
"chat_id": "{{ telegram_chat_id }}"
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": false,
|
||||
"listen_ip_address": "127.0.0.1",
|
||||
"enabled": {{ api_server | lower }},
|
||||
"listen_ip_address": "{{ api_server_listen_addr | default("127.0.0.1", true) }}",
|
||||
"listen_port": 8080,
|
||||
"verbosity": "info",
|
||||
"jwt_secret_key": "somethingrandom",
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "{{ api_server_jwt_key }}",
|
||||
"CORS_origins": [],
|
||||
"username": "",
|
||||
"password": ""
|
||||
"username": "{{ api_server_username }}",
|
||||
"password": "{{ api_server_password }}"
|
||||
},
|
||||
"bot_name": "freqtrade",
|
||||
"initial_state": "running",
|
||||
|
@@ -39,6 +39,15 @@ class {{ hyperopt }}(IHyperOpt):
|
||||
https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/templates/sample_hyperopt_advanced.py.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching buy strategy parameters.
|
||||
"""
|
||||
return [
|
||||
{{ buy_space | indent(12) }}
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def buy_strategy_generator(params: Dict[str, Any]) -> Callable:
|
||||
"""
|
||||
@@ -79,12 +88,12 @@ class {{ hyperopt }}(IHyperOpt):
|
||||
return populate_buy_trend
|
||||
|
||||
@staticmethod
|
||||
def indicator_space() -> List[Dimension]:
|
||||
def sell_indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching buy strategy parameters.
|
||||
Define your Hyperopt space for searching sell strategy parameters.
|
||||
"""
|
||||
return [
|
||||
{{ buy_space | indent(12) }}
|
||||
{{ sell_space | indent(12) }}
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -126,11 +135,3 @@ class {{ hyperopt }}(IHyperOpt):
|
||||
|
||||
return populate_sell_trend
|
||||
|
||||
@staticmethod
|
||||
def sell_indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching sell strategy parameters.
|
||||
"""
|
||||
return [
|
||||
{{ sell_space | indent(12) }}
|
||||
]
|
||||
|
@@ -1,4 +1,5 @@
|
||||
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
|
||||
# flake8: noqa: F401
|
||||
|
||||
# --- Do not remove these libs ---
|
||||
import numpy as np # noqa
|
||||
@@ -6,6 +7,7 @@ import pandas as pd # noqa
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.strategy import IStrategy
|
||||
from freqtrade.strategy import CategoricalParameter, DecimalParameter, IntParameter
|
||||
|
||||
# --------------------------------
|
||||
# Add your lib to import here
|
||||
@@ -16,7 +18,7 @@ import freqtrade.vendor.qtpylib.indicators as qtpylib
|
||||
class {{ strategy }}(IStrategy):
|
||||
"""
|
||||
This is a strategy template to get you started.
|
||||
More information in https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-optimization.md
|
||||
More information in https://www.freqtrade.io/en/latest/strategy-customization/
|
||||
|
||||
You can:
|
||||
:return: a Dataframe with all mandatory indicators for the strategies
|
||||
@@ -26,8 +28,9 @@ class {{ strategy }}(IStrategy):
|
||||
|
||||
You must keep:
|
||||
- the lib in the section "Do not remove these libs"
|
||||
- the prototype for the methods: minimal_roi, stoploss, populate_indicators, populate_buy_trend,
|
||||
populate_sell_trend, hyperopt_space, buy_strategy_generator
|
||||
- the methods: populate_indicators, populate_buy_trend, populate_sell_trend
|
||||
You should keep:
|
||||
- timeframe, minimal_roi, stoploss, trailing_*
|
||||
"""
|
||||
# Strategy interface version - allow new iterations of the strategy interface.
|
||||
# Check the documentation or the Sample strategy to get the latest version.
|
||||
|
@@ -45,6 +45,23 @@ class SampleHyperOpt(IHyperOpt):
|
||||
https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/templates/sample_hyperopt_advanced.py.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching buy strategy parameters.
|
||||
"""
|
||||
return [
|
||||
Integer(10, 25, name='mfi-value'),
|
||||
Integer(15, 45, name='fastd-value'),
|
||||
Integer(20, 50, name='adx-value'),
|
||||
Integer(20, 40, name='rsi-value'),
|
||||
Categorical([True, False], name='mfi-enabled'),
|
||||
Categorical([True, False], name='fastd-enabled'),
|
||||
Categorical([True, False], name='adx-enabled'),
|
||||
Categorical([True, False], name='rsi-enabled'),
|
||||
Categorical(['bb_lower', 'macd_cross_signal', 'sar_reversal'], name='trigger')
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def buy_strategy_generator(params: Dict[str, Any]) -> Callable:
|
||||
"""
|
||||
@@ -92,20 +109,22 @@ class SampleHyperOpt(IHyperOpt):
|
||||
return populate_buy_trend
|
||||
|
||||
@staticmethod
|
||||
def indicator_space() -> List[Dimension]:
|
||||
def sell_indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching buy strategy parameters.
|
||||
Define your Hyperopt space for searching sell strategy parameters.
|
||||
"""
|
||||
return [
|
||||
Integer(10, 25, name='mfi-value'),
|
||||
Integer(15, 45, name='fastd-value'),
|
||||
Integer(20, 50, name='adx-value'),
|
||||
Integer(20, 40, name='rsi-value'),
|
||||
Categorical([True, False], name='mfi-enabled'),
|
||||
Categorical([True, False], name='fastd-enabled'),
|
||||
Categorical([True, False], name='adx-enabled'),
|
||||
Categorical([True, False], name='rsi-enabled'),
|
||||
Categorical(['bb_lower', 'macd_cross_signal', 'sar_reversal'], name='trigger')
|
||||
Integer(75, 100, name='sell-mfi-value'),
|
||||
Integer(50, 100, name='sell-fastd-value'),
|
||||
Integer(50, 100, name='sell-adx-value'),
|
||||
Integer(60, 100, name='sell-rsi-value'),
|
||||
Categorical([True, False], name='sell-mfi-enabled'),
|
||||
Categorical([True, False], name='sell-fastd-enabled'),
|
||||
Categorical([True, False], name='sell-adx-enabled'),
|
||||
Categorical([True, False], name='sell-rsi-enabled'),
|
||||
Categorical(['sell-bb_upper',
|
||||
'sell-macd_cross_signal',
|
||||
'sell-sar_reversal'], name='sell-trigger')
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -153,56 +172,3 @@ class SampleHyperOpt(IHyperOpt):
|
||||
return dataframe
|
||||
|
||||
return populate_sell_trend
|
||||
|
||||
@staticmethod
|
||||
def sell_indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching sell strategy parameters.
|
||||
"""
|
||||
return [
|
||||
Integer(75, 100, name='sell-mfi-value'),
|
||||
Integer(50, 100, name='sell-fastd-value'),
|
||||
Integer(50, 100, name='sell-adx-value'),
|
||||
Integer(60, 100, name='sell-rsi-value'),
|
||||
Categorical([True, False], name='sell-mfi-enabled'),
|
||||
Categorical([True, False], name='sell-fastd-enabled'),
|
||||
Categorical([True, False], name='sell-adx-enabled'),
|
||||
Categorical([True, False], name='sell-rsi-enabled'),
|
||||
Categorical(['sell-bb_upper',
|
||||
'sell-macd_cross_signal',
|
||||
'sell-sar_reversal'], name='sell-trigger')
|
||||
]
|
||||
|
||||
def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
"""
|
||||
Based on TA indicators. Should be a copy of same method from strategy.
|
||||
Must align to populate_indicators in this file.
|
||||
Only used when --spaces does not include buy space.
|
||||
"""
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['close'] < dataframe['bb_lowerband']) &
|
||||
(dataframe['mfi'] < 16) &
|
||||
(dataframe['adx'] > 25) &
|
||||
(dataframe['rsi'] < 21)
|
||||
),
|
||||
'buy'] = 1
|
||||
|
||||
return dataframe
|
||||
|
||||
def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
"""
|
||||
Based on TA indicators. Should be a copy of same method from strategy.
|
||||
Must align to populate_indicators in this file.
|
||||
Only used when --spaces does not include sell space.
|
||||
"""
|
||||
dataframe.loc[
|
||||
(
|
||||
(qtpylib.crossed_above(
|
||||
dataframe['macdsignal'], dataframe['macd']
|
||||
)) &
|
||||
(dataframe['fastd'] > 54)
|
||||
),
|
||||
'sell'] = 1
|
||||
|
||||
return dataframe
|
||||
|
@@ -7,7 +7,7 @@ from typing import Any, Callable, Dict, List
|
||||
import numpy as np # noqa
|
||||
import pandas as pd # noqa
|
||||
from pandas import DataFrame
|
||||
from skopt.space import Categorical, Dimension, Integer, Real # noqa
|
||||
from freqtrade.optimize.space import Categorical, Dimension, Integer, SKDecimal, Real # noqa
|
||||
|
||||
from freqtrade.optimize.hyperopt_interface import IHyperOpt
|
||||
|
||||
@@ -60,6 +60,23 @@ class AdvancedSampleHyperOpt(IHyperOpt):
|
||||
dataframe['sar'] = ta.SAR(dataframe)
|
||||
return dataframe
|
||||
|
||||
@staticmethod
|
||||
def indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching buy strategy parameters.
|
||||
"""
|
||||
return [
|
||||
Integer(10, 25, name='mfi-value'),
|
||||
Integer(15, 45, name='fastd-value'),
|
||||
Integer(20, 50, name='adx-value'),
|
||||
Integer(20, 40, name='rsi-value'),
|
||||
Categorical([True, False], name='mfi-enabled'),
|
||||
Categorical([True, False], name='fastd-enabled'),
|
||||
Categorical([True, False], name='adx-enabled'),
|
||||
Categorical([True, False], name='rsi-enabled'),
|
||||
Categorical(['bb_lower', 'macd_cross_signal', 'sar_reversal'], name='trigger')
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def buy_strategy_generator(params: Dict[str, Any]) -> Callable:
|
||||
"""
|
||||
@@ -106,20 +123,22 @@ class AdvancedSampleHyperOpt(IHyperOpt):
|
||||
return populate_buy_trend
|
||||
|
||||
@staticmethod
|
||||
def indicator_space() -> List[Dimension]:
|
||||
def sell_indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching strategy parameters
|
||||
Define your Hyperopt space for searching sell strategy parameters.
|
||||
"""
|
||||
return [
|
||||
Integer(10, 25, name='mfi-value'),
|
||||
Integer(15, 45, name='fastd-value'),
|
||||
Integer(20, 50, name='adx-value'),
|
||||
Integer(20, 40, name='rsi-value'),
|
||||
Categorical([True, False], name='mfi-enabled'),
|
||||
Categorical([True, False], name='fastd-enabled'),
|
||||
Categorical([True, False], name='adx-enabled'),
|
||||
Categorical([True, False], name='rsi-enabled'),
|
||||
Categorical(['bb_lower', 'macd_cross_signal', 'sar_reversal'], name='trigger')
|
||||
Integer(75, 100, name='sell-mfi-value'),
|
||||
Integer(50, 100, name='sell-fastd-value'),
|
||||
Integer(50, 100, name='sell-adx-value'),
|
||||
Integer(60, 100, name='sell-rsi-value'),
|
||||
Categorical([True, False], name='sell-mfi-enabled'),
|
||||
Categorical([True, False], name='sell-fastd-enabled'),
|
||||
Categorical([True, False], name='sell-adx-enabled'),
|
||||
Categorical([True, False], name='sell-rsi-enabled'),
|
||||
Categorical(['sell-bb_upper',
|
||||
'sell-macd_cross_signal',
|
||||
'sell-sar_reversal'], name='sell-trigger')
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -168,25 +187,6 @@ class AdvancedSampleHyperOpt(IHyperOpt):
|
||||
|
||||
return populate_sell_trend
|
||||
|
||||
@staticmethod
|
||||
def sell_indicator_space() -> List[Dimension]:
|
||||
"""
|
||||
Define your Hyperopt space for searching sell strategy parameters
|
||||
"""
|
||||
return [
|
||||
Integer(75, 100, name='sell-mfi-value'),
|
||||
Integer(50, 100, name='sell-fastd-value'),
|
||||
Integer(50, 100, name='sell-adx-value'),
|
||||
Integer(60, 100, name='sell-rsi-value'),
|
||||
Categorical([True, False], name='sell-mfi-enabled'),
|
||||
Categorical([True, False], name='sell-fastd-enabled'),
|
||||
Categorical([True, False], name='sell-adx-enabled'),
|
||||
Categorical([True, False], name='sell-rsi-enabled'),
|
||||
Categorical(['sell-bb_upper',
|
||||
'sell-macd_cross_signal',
|
||||
'sell-sar_reversal'], name='sell-trigger')
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def generate_roi_table(params: Dict) -> Dict[int, float]:
|
||||
"""
|
||||
@@ -223,9 +223,9 @@ class AdvancedSampleHyperOpt(IHyperOpt):
|
||||
Integer(10, 120, name='roi_t1'),
|
||||
Integer(10, 60, name='roi_t2'),
|
||||
Integer(10, 40, name='roi_t3'),
|
||||
Real(0.01, 0.04, name='roi_p1'),
|
||||
Real(0.01, 0.07, name='roi_p2'),
|
||||
Real(0.01, 0.20, name='roi_p3'),
|
||||
SKDecimal(0.01, 0.04, decimals=3, name='roi_p1'),
|
||||
SKDecimal(0.01, 0.07, decimals=3, name='roi_p2'),
|
||||
SKDecimal(0.01, 0.20, decimals=3, name='roi_p3'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -237,7 +237,7 @@ class AdvancedSampleHyperOpt(IHyperOpt):
|
||||
'stoploss' optimization hyperspace.
|
||||
"""
|
||||
return [
|
||||
Real(-0.35, -0.02, name='stoploss'),
|
||||
SKDecimal(-0.35, -0.02, decimals=3, name='stoploss'),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -256,51 +256,14 @@ class AdvancedSampleHyperOpt(IHyperOpt):
|
||||
# other 'trailing' hyperspace parameters.
|
||||
Categorical([True], name='trailing_stop'),
|
||||
|
||||
Real(0.01, 0.35, name='trailing_stop_positive'),
|
||||
SKDecimal(0.01, 0.35, decimals=3, name='trailing_stop_positive'),
|
||||
|
||||
# 'trailing_stop_positive_offset' should be greater than 'trailing_stop_positive',
|
||||
# so this intermediate parameter is used as the value of the difference between
|
||||
# them. The value of the 'trailing_stop_positive_offset' is constructed in the
|
||||
# generate_trailing_params() method.
|
||||
# This is similar to the hyperspace dimensions used for constructing the ROI tables.
|
||||
Real(0.001, 0.1, name='trailing_stop_positive_offset_p1'),
|
||||
SKDecimal(0.001, 0.1, decimals=3, name='trailing_stop_positive_offset_p1'),
|
||||
|
||||
Categorical([True, False], name='trailing_only_offset_is_reached'),
|
||||
]
|
||||
|
||||
def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
"""
|
||||
Based on TA indicators.
|
||||
Can be a copy of the corresponding method from the strategy,
|
||||
or will be loaded from the strategy.
|
||||
Must align to populate_indicators used (either from this File, or from the strategy)
|
||||
Only used when --spaces does not include buy
|
||||
"""
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['close'] < dataframe['bb_lowerband']) &
|
||||
(dataframe['mfi'] < 16) &
|
||||
(dataframe['adx'] > 25) &
|
||||
(dataframe['rsi'] < 21)
|
||||
),
|
||||
'buy'] = 1
|
||||
|
||||
return dataframe
|
||||
|
||||
def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
"""
|
||||
Based on TA indicators.
|
||||
Can be a copy of the corresponding method from the strategy,
|
||||
or will be loaded from the strategy.
|
||||
Must align to populate_indicators used (either from this File, or from the strategy)
|
||||
Only used when --spaces does not include sell
|
||||
"""
|
||||
dataframe.loc[
|
||||
(
|
||||
(qtpylib.crossed_above(
|
||||
dataframe['macdsignal'], dataframe['macd']
|
||||
)) &
|
||||
(dataframe['fastd'] > 54)
|
||||
),
|
||||
'sell'] = 1
|
||||
return dataframe
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from math import exp
|
||||
from typing import Dict
|
||||
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -35,6 +36,7 @@ class SampleHyperOptLoss(IHyperOptLoss):
|
||||
@staticmethod
|
||||
def hyperopt_loss_function(results: DataFrame, trade_count: int,
|
||||
min_date: datetime, max_date: datetime,
|
||||
config: Dict, processed: Dict[str, DataFrame],
|
||||
*args, **kwargs) -> float:
|
||||
"""
|
||||
Objective function, returns smaller number for better results
|
||||
|
@@ -1,11 +1,13 @@
|
||||
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
|
||||
# flake8: noqa: F401
|
||||
# isort: skip_file
|
||||
# --- Do not remove these libs ---
|
||||
import numpy as np # noqa
|
||||
import pandas as pd # noqa
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.strategy.interface import IStrategy
|
||||
from freqtrade.strategy import IStrategy
|
||||
from freqtrade.strategy import CategoricalParameter, DecimalParameter, IntParameter
|
||||
|
||||
# --------------------------------
|
||||
# Add your lib to import here
|
||||
@@ -27,8 +29,9 @@ class SampleStrategy(IStrategy):
|
||||
|
||||
You must keep:
|
||||
- the lib in the section "Do not remove these libs"
|
||||
- the prototype for the methods: minimal_roi, stoploss, populate_indicators, populate_buy_trend,
|
||||
populate_sell_trend, hyperopt_space, buy_strategy_generator
|
||||
- the methods: populate_indicators, populate_buy_trend, populate_sell_trend
|
||||
You should keep:
|
||||
- timeframe, minimal_roi, stoploss, trailing_*
|
||||
"""
|
||||
# Strategy interface version - allow new iterations of the strategy interface.
|
||||
# Check the documentation or the Sample strategy to get the latest version.
|
||||
@@ -52,7 +55,11 @@ class SampleStrategy(IStrategy):
|
||||
# trailing_stop_positive = 0.01
|
||||
# trailing_stop_positive_offset = 0.0 # Disabled / not configured
|
||||
|
||||
# Optimal ticker interval for the strategy.
|
||||
# Hyperoptable parameters
|
||||
buy_rsi = IntParameter(low=1, high=50, default=30, space='buy', optimize=True, load=True)
|
||||
sell_rsi = IntParameter(low=50, high=100, default=70, space='sell', optimize=True, load=True)
|
||||
|
||||
# Optimal timeframe for the strategy.
|
||||
timeframe = '5m'
|
||||
|
||||
# Run "populate_indicators()" only for new candle.
|
||||
@@ -322,7 +329,7 @@ class SampleStrategy(IStrategy):
|
||||
"""
|
||||
# first check if dataprovider is available
|
||||
if self.dp:
|
||||
if self.dp.runmode in ('live', 'dry_run'):
|
||||
if self.dp.runmode.value in ('live', 'dry_run'):
|
||||
ob = self.dp.orderbook(metadata['pair'], 1)
|
||||
dataframe['best_bid'] = ob['bids'][0][0]
|
||||
dataframe['best_ask'] = ob['asks'][0][0]
|
||||
@@ -339,7 +346,8 @@ class SampleStrategy(IStrategy):
|
||||
"""
|
||||
dataframe.loc[
|
||||
(
|
||||
(qtpylib.crossed_above(dataframe['rsi'], 30)) & # Signal: RSI crosses above 30
|
||||
# Signal: RSI crosses above 30
|
||||
(qtpylib.crossed_above(dataframe['rsi'], self.buy_rsi.value)) &
|
||||
(dataframe['tema'] <= dataframe['bb_middleband']) & # Guard: tema below BB middle
|
||||
(dataframe['tema'] > dataframe['tema'].shift(1)) & # Guard: tema is raising
|
||||
(dataframe['volume'] > 0) # Make sure Volume is not 0
|
||||
@@ -353,11 +361,12 @@ class SampleStrategy(IStrategy):
|
||||
Based on TA indicators, populates the sell signal for the given dataframe
|
||||
:param dataframe: DataFrame populated with indicators
|
||||
:param metadata: Additional information, like the currently traded pair
|
||||
:return: DataFrame with buy column
|
||||
:return: DataFrame with sell column
|
||||
"""
|
||||
dataframe.loc[
|
||||
(
|
||||
(qtpylib.crossed_above(dataframe['rsi'], 70)) & # Signal: RSI crosses above 70
|
||||
# Signal: RSI crosses above 70
|
||||
(qtpylib.crossed_above(dataframe['rsi'], self.sell_rsi.value)) &
|
||||
(dataframe['tema'] > dataframe['bb_middleband']) & # Guard: tema above BB middle
|
||||
(dataframe['tema'] < dataframe['tema'].shift(1)) & # Guard: tema is falling
|
||||
(dataframe['volume'] > 0) # Make sure Volume is not 0
|
||||
|
@@ -40,7 +40,7 @@
|
||||
"# Location of the data\n",
|
||||
"data_location = Path(config['user_data_dir'], 'data', 'binance')\n",
|
||||
"# Pair to analyze - Only use one pair here\n",
|
||||
"pair = \"BTC_USDT\""
|
||||
"pair = \"BTC/USDT\""
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -54,7 +54,9 @@
|
||||
"\n",
|
||||
"candles = load_pair_history(datadir=data_location,\n",
|
||||
" timeframe=config[\"timeframe\"],\n",
|
||||
" pair=pair)\n",
|
||||
" pair=pair,\n",
|
||||
" data_format = \"hdf5\",\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"# Confirm success\n",
|
||||
"print(\"Loaded \" + str(len(candles)) + f\" rows of data for {pair} from {data_location}\")\n",
|
||||
@@ -280,6 +282,28 @@
|
||||
"graph.show(renderer=\"browser\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Plot average profit per trade as distribution graph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import plotly.figure_factory as ff\n",
|
||||
"\n",
|
||||
"hist_data = [trades.profit_ratio]\n",
|
||||
"group_labels = ['profit_ratio'] # name of the dataset\n",
|
||||
"\n",
|
||||
"fig = ff.create_distplot(hist_data, group_labels,bin_size=0.01)\n",
|
||||
"fig.show()\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user