Merge branch 'develop' into pr/samgermain/6780
This commit is contained in:
@@ -6,10 +6,12 @@ Contains all start-commands, subcommands and CLI Interface creation.
|
||||
Note: Be careful with file-scoped imports in these subfiles.
|
||||
as they are parsed on startup, nothing containing optional modules should be loaded.
|
||||
"""
|
||||
from freqtrade.commands.analyze_commands import start_analysis_entries_exits
|
||||
from freqtrade.commands.arguments import Arguments
|
||||
from freqtrade.commands.build_config_commands import start_new_config
|
||||
from freqtrade.commands.data_commands import (start_convert_data, start_convert_trades,
|
||||
start_download_data, start_list_data)
|
||||
from freqtrade.commands.db_commands import start_convert_db
|
||||
from freqtrade.commands.deploy_commands import (start_create_userdir, start_install_ui,
|
||||
start_new_strategy)
|
||||
from freqtrade.commands.hyperopt_commands import start_hyperopt_list, start_hyperopt_show
|
||||
|
69
freqtrade/commands/analyze_commands.py
Executable file
69
freqtrade/commands/analyze_commands.py
Executable file
@@ -0,0 +1,69 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from freqtrade.configuration import setup_utils_configuration
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_analyze_configuration(args: Dict[str, Any], method: RunMode) -> Dict[str, Any]:
|
||||
"""
|
||||
Prepare the configuration for the entry/exit reason analysis module
|
||||
:param args: Cli args from Arguments()
|
||||
:param method: Bot running mode
|
||||
:return: Configuration
|
||||
"""
|
||||
config = setup_utils_configuration(args, method)
|
||||
|
||||
no_unlimited_runmodes = {
|
||||
RunMode.BACKTEST: 'backtesting',
|
||||
}
|
||||
if method in no_unlimited_runmodes.keys():
|
||||
from freqtrade.data.btanalysis import get_latest_backtest_filename
|
||||
|
||||
if 'exportfilename' in config:
|
||||
if config['exportfilename'].is_dir():
|
||||
btfile = Path(get_latest_backtest_filename(config['exportfilename']))
|
||||
signals_file = f"{config['exportfilename']}/{btfile.stem}_signals.pkl"
|
||||
else:
|
||||
if config['exportfilename'].exists():
|
||||
btfile = Path(config['exportfilename'])
|
||||
signals_file = f"{btfile.parent}/{btfile.stem}_signals.pkl"
|
||||
else:
|
||||
raise OperationalException(f"{config['exportfilename']} does not exist.")
|
||||
else:
|
||||
raise OperationalException('exportfilename not in config.')
|
||||
|
||||
if (not Path(signals_file).exists()):
|
||||
raise OperationalException(
|
||||
(f"Cannot find latest backtest signals file: {signals_file}."
|
||||
"Run backtesting with `--export signals`.")
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def start_analysis_entries_exits(args: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Start analysis script
|
||||
:param args: Cli args from Arguments()
|
||||
:return: None
|
||||
"""
|
||||
from freqtrade.data.entryexitanalysis import process_entry_exit_reasons
|
||||
|
||||
# Initialize configuration
|
||||
config = setup_analyze_configuration(args, RunMode.BACKTEST)
|
||||
|
||||
logger.info('Starting freqtrade in analysis mode')
|
||||
|
||||
process_entry_exit_reasons(config['exportfilename'],
|
||||
config['exchange']['pair_whitelist'],
|
||||
config['analysis_groups'],
|
||||
config['enter_reason_list'],
|
||||
config['exit_reason_list'],
|
||||
config['indicator_list']
|
||||
)
|
@@ -82,7 +82,9 @@ ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit",
|
||||
ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url",
|
||||
"trade_source", "timeframe", "plot_auto_open", ]
|
||||
|
||||
ARGS_INSTALL_UI = ["erase_ui_only", 'ui_version']
|
||||
ARGS_CONVERT_DB = ["db_url", "db_url_from"]
|
||||
|
||||
ARGS_INSTALL_UI = ["erase_ui_only", "ui_version"]
|
||||
|
||||
ARGS_SHOW_TRADES = ["db_url", "trade_ids", "print_json"]
|
||||
|
||||
@@ -99,6 +101,9 @@ ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperop
|
||||
"print_json", "hyperoptexportfilename", "hyperopt_show_no_header",
|
||||
"disableparamexport", "backtest_breakdown"]
|
||||
|
||||
ARGS_ANALYZE_ENTRIES_EXITS = ["exportfilename", "analysis_groups", "enter_reason_list",
|
||||
"exit_reason_list", "indicator_list"]
|
||||
|
||||
NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes",
|
||||
"list-markets", "list-pairs", "list-strategies", "list-data",
|
||||
"hyperopt-list", "hyperopt-show", "backtest-filter",
|
||||
@@ -180,8 +185,9 @@ class Arguments:
|
||||
self.parser = argparse.ArgumentParser(description='Free, open source crypto trading bot')
|
||||
self._build_args(optionlist=['version'], parser=self.parser)
|
||||
|
||||
from freqtrade.commands import (start_backtesting, start_backtesting_show,
|
||||
start_convert_data, start_convert_trades,
|
||||
from freqtrade.commands import (start_analysis_entries_exits, start_backtesting,
|
||||
start_backtesting_show, start_convert_data,
|
||||
start_convert_db, start_convert_trades,
|
||||
start_create_userdir, start_download_data, start_edge,
|
||||
start_hyperopt, start_hyperopt_list, start_hyperopt_show,
|
||||
start_install_ui, start_list_data, start_list_exchanges,
|
||||
@@ -281,6 +287,13 @@ class Arguments:
|
||||
backtesting_show_cmd.set_defaults(func=start_backtesting_show)
|
||||
self._build_args(optionlist=ARGS_BACKTEST_SHOW, parser=backtesting_show_cmd)
|
||||
|
||||
# Add backtesting analysis subcommand
|
||||
analysis_cmd = subparsers.add_parser('backtesting-analysis',
|
||||
help='Backtest Analysis module.',
|
||||
parents=[_common_parser])
|
||||
analysis_cmd.set_defaults(func=start_analysis_entries_exits)
|
||||
self._build_args(optionlist=ARGS_ANALYZE_ENTRIES_EXITS, parser=analysis_cmd)
|
||||
|
||||
# Add edge subcommand
|
||||
edge_cmd = subparsers.add_parser('edge', help='Edge module.',
|
||||
parents=[_common_parser, _strategy_parser])
|
||||
@@ -374,6 +387,14 @@ class Arguments:
|
||||
test_pairlist_cmd.set_defaults(func=start_test_pairlist)
|
||||
self._build_args(optionlist=ARGS_TEST_PAIRLIST, parser=test_pairlist_cmd)
|
||||
|
||||
# Add db-convert subcommand
|
||||
convert_db = subparsers.add_parser(
|
||||
"convert-db",
|
||||
help="Migrate database to different system",
|
||||
)
|
||||
convert_db.set_defaults(func=start_convert_db)
|
||||
self._build_args(optionlist=ARGS_CONVERT_DB, parser=convert_db)
|
||||
|
||||
# Add install-ui subcommand
|
||||
install_ui_cmd = subparsers.add_parser(
|
||||
'install-ui',
|
||||
|
@@ -106,6 +106,11 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
f'`{constants.DEFAULT_DB_DRYRUN_URL}` for Dry Run).',
|
||||
metavar='PATH',
|
||||
),
|
||||
"db_url_from": Arg(
|
||||
'--db-url-from',
|
||||
help='Source db url to use when migrating a database.',
|
||||
metavar='PATH',
|
||||
),
|
||||
"sd_notify": Arg(
|
||||
'--sd-notify',
|
||||
help='Notify systemd service manager.',
|
||||
@@ -609,4 +614,37 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
"that do not contain any parameters."),
|
||||
action="store_true",
|
||||
),
|
||||
"analysis_groups": Arg(
|
||||
"--analysis-groups",
|
||||
help=("grouping output - "
|
||||
"0: simple wins/losses by enter tag, "
|
||||
"1: by enter_tag, "
|
||||
"2: by enter_tag and exit_tag, "
|
||||
"3: by pair and enter_tag, "
|
||||
"4: by pair, enter_ and exit_tag (this can get quite large)"),
|
||||
nargs='+',
|
||||
default=['0', '1', '2'],
|
||||
choices=['0', '1', '2', '3', '4'],
|
||||
),
|
||||
"enter_reason_list": Arg(
|
||||
"--enter-reason-list",
|
||||
help=("Comma separated list of entry signals to analyse. Default: all. "
|
||||
"e.g. 'entry_tag_a,entry_tag_b'"),
|
||||
nargs='+',
|
||||
default=['all'],
|
||||
),
|
||||
"exit_reason_list": Arg(
|
||||
"--exit-reason-list",
|
||||
help=("Comma separated list of exit signals to analyse. Default: all. "
|
||||
"e.g. 'exit_tag_a,roi,stop_loss,trailing_stop_loss'"),
|
||||
nargs='+',
|
||||
default=['all'],
|
||||
),
|
||||
"indicator_list": Arg(
|
||||
"--indicator-list",
|
||||
help=("Comma separated list of indicators to analyse. "
|
||||
"e.g. 'close,rsi,bb_lowerband,profit_abs'"),
|
||||
nargs='+',
|
||||
default=[],
|
||||
),
|
||||
}
|
||||
|
@@ -79,6 +79,12 @@ def start_download_data(args: Dict[str, Any]) -> None:
|
||||
data_format_trades=config['dataformat_trades'],
|
||||
)
|
||||
else:
|
||||
if not exchange._ft_has.get('ohlcv_has_history', True):
|
||||
raise OperationalException(
|
||||
f"Historic klines not available for {exchange.name}. "
|
||||
"Please use `--dl-trades` instead for this exchange "
|
||||
"(will unfortunately take a long time)."
|
||||
)
|
||||
pairs_not_available = refresh_backtest_ohlcv_data(
|
||||
exchange, pairs=expanded_pairs, timeframes=config['timeframes'],
|
||||
datadir=config['datadir'], timerange=timerange,
|
||||
|
55
freqtrade/commands/db_commands.py
Normal file
55
freqtrade/commands/db_commands.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from freqtrade.configuration.config_setup import setup_utils_configuration
|
||||
from freqtrade.enums.runmode import RunMode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def start_convert_db(args: Dict[str, Any]) -> None:
|
||||
from sqlalchemy.orm import make_transient
|
||||
|
||||
from freqtrade.persistence import Order, Trade, init_db
|
||||
from freqtrade.persistence.migrations import set_sequence_ids
|
||||
from freqtrade.persistence.pairlock import PairLock
|
||||
|
||||
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
|
||||
|
||||
init_db(config['db_url'])
|
||||
session_target = Trade._session
|
||||
init_db(config['db_url_from'])
|
||||
logger.info("Starting db migration.")
|
||||
|
||||
trade_count = 0
|
||||
pairlock_count = 0
|
||||
for trade in Trade.get_trades():
|
||||
trade_count += 1
|
||||
make_transient(trade)
|
||||
for o in trade.orders:
|
||||
make_transient(o)
|
||||
|
||||
session_target.add(trade)
|
||||
|
||||
session_target.commit()
|
||||
|
||||
for pairlock in PairLock.query:
|
||||
pairlock_count += 1
|
||||
make_transient(pairlock)
|
||||
session_target.add(pairlock)
|
||||
session_target.commit()
|
||||
|
||||
# Update sequences
|
||||
max_trade_id = session_target.query(func.max(Trade.id)).scalar()
|
||||
max_order_id = session_target.query(func.max(Order.id)).scalar()
|
||||
max_pairlock_id = session_target.query(func.max(PairLock.id)).scalar()
|
||||
|
||||
set_sequence_ids(session_target.get_bind(),
|
||||
trade_id=max_trade_id,
|
||||
order_id=max_order_id,
|
||||
pairlock_id=max_pairlock_id)
|
||||
|
||||
logger.info(f"Migrated {trade_count} Trades, and {pairlock_count} Pairlocks.")
|
@@ -24,7 +24,7 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None:
|
||||
|
||||
print_colorized = config.get('print_colorized', False)
|
||||
print_json = config.get('print_json', False)
|
||||
export_csv = config.get('export_csv', None)
|
||||
export_csv = config.get('export_csv')
|
||||
no_details = config.get('hyperopt_list_no_details', False)
|
||||
no_header = False
|
||||
|
||||
|
@@ -212,7 +212,7 @@ def start_show_trades(args: Dict[str, Any]) -> None:
|
||||
raise OperationalException("--db-url is required for this command.")
|
||||
|
||||
logger.info(f'Using DB: "{parse_db_uri_for_logging(config["db_url"])}"')
|
||||
init_db(config['db_url'], clean_open_orders=False)
|
||||
init_db(config['db_url'])
|
||||
tfilter = []
|
||||
|
||||
if config.get('trade_ids'):
|
||||
|
@@ -27,7 +27,7 @@ def check_exchange(config: Dict[str, Any], check_for_bad: bool = True) -> bool:
|
||||
return True
|
||||
logger.info("Checking exchange...")
|
||||
|
||||
exchange = config.get('exchange', {}).get('name').lower()
|
||||
exchange = config.get('exchange', {}).get('name', '').lower()
|
||||
if not exchange:
|
||||
raise OperationalException(
|
||||
f'This command requires a configured exchange. You should either use '
|
||||
|
@@ -95,6 +95,8 @@ class Configuration:
|
||||
|
||||
self._process_data_options(config)
|
||||
|
||||
self._process_analyze_options(config)
|
||||
|
||||
# Check if the exchange set by the user is supported
|
||||
check_exchange(config, config.get('experimental', {}).get('block_bad_exchanges', True))
|
||||
|
||||
@@ -127,7 +129,7 @@ class Configuration:
|
||||
# Default to in-memory db for dry_run if not specified
|
||||
config['db_url'] = constants.DEFAULT_DB_DRYRUN_URL
|
||||
else:
|
||||
if not config.get('db_url', None):
|
||||
if not config.get('db_url'):
|
||||
config['db_url'] = constants.DEFAULT_DB_PROD_URL
|
||||
logger.info('Dry run is disabled')
|
||||
|
||||
@@ -147,6 +149,9 @@ class Configuration:
|
||||
config.update({'db_url': self.args['db_url']})
|
||||
logger.info('Parameter --db-url detected ...')
|
||||
|
||||
self._args_to_config(config, argname='db_url_from',
|
||||
logstring='Parameter --db-url-from detected ...')
|
||||
|
||||
if config.get('force_entry_enable', False):
|
||||
logger.warning('`force_entry_enable` RPC message enabled.')
|
||||
|
||||
@@ -177,7 +182,7 @@ class Configuration:
|
||||
config['user_data_dir'] = create_userdata_dir(config['user_data_dir'], create_dir=False)
|
||||
logger.info('Using user-data directory: %s ...', config['user_data_dir'])
|
||||
|
||||
config.update({'datadir': create_datadir(config, self.args.get('datadir', None))})
|
||||
config.update({'datadir': create_datadir(config, self.args.get('datadir'))})
|
||||
logger.info('Using data directory: %s ...', config.get('datadir'))
|
||||
|
||||
if self.args.get('exportfilename'):
|
||||
@@ -216,7 +221,7 @@ class Configuration:
|
||||
if config.get('max_open_trades') == -1:
|
||||
config['max_open_trades'] = float('inf')
|
||||
|
||||
if self.args.get('stake_amount', None):
|
||||
if self.args.get('stake_amount'):
|
||||
# Convert explicitly to float to support CLI argument for both unlimited and value
|
||||
try:
|
||||
self.args['stake_amount'] = float(self.args['stake_amount'])
|
||||
@@ -430,6 +435,19 @@ class Configuration:
|
||||
self._args_to_config(config, argname='candle_types',
|
||||
logstring='Detected --candle-types: {}')
|
||||
|
||||
def _process_analyze_options(self, config: Dict[str, Any]) -> None:
|
||||
self._args_to_config(config, argname='analysis_groups',
|
||||
logstring='Analysis reason groups: {}')
|
||||
|
||||
self._args_to_config(config, argname='enter_reason_list',
|
||||
logstring='Analysis enter tag list: {}')
|
||||
|
||||
self._args_to_config(config, argname='exit_reason_list',
|
||||
logstring='Analysis exit tag list: {}')
|
||||
|
||||
self._args_to_config(config, argname='indicator_list',
|
||||
logstring='Analysis indicator list: {}')
|
||||
|
||||
def _process_runmode(self, config: Dict[str, Any]) -> None:
|
||||
|
||||
self._args_to_config(config, argname='dry_run',
|
||||
@@ -456,7 +474,7 @@ class Configuration:
|
||||
configuration instead of the content)
|
||||
"""
|
||||
if (argname in self.args and self.args[argname] is not None
|
||||
and self.args[argname] is not False):
|
||||
and self.args[argname] is not False):
|
||||
|
||||
config.update({argname: self.args[argname]})
|
||||
if logfun:
|
||||
@@ -487,7 +505,8 @@ class Configuration:
|
||||
if not pairs_file.exists():
|
||||
raise OperationalException(f'No pairs file found with path "{pairs_file}".')
|
||||
config['pairs'] = load_file(pairs_file)
|
||||
config['pairs'].sort()
|
||||
if isinstance(config['pairs'], list):
|
||||
config['pairs'].sort()
|
||||
return
|
||||
|
||||
if 'config' in self.args and self.args['config']:
|
||||
@@ -498,5 +517,5 @@ class Configuration:
|
||||
pairs_file = config['datadir'] / 'pairs.json'
|
||||
if pairs_file.exists():
|
||||
config['pairs'] = load_file(pairs_file)
|
||||
if 'pairs' in config:
|
||||
if 'pairs' in config and isinstance(config['pairs'], list):
|
||||
config['pairs'].sort()
|
||||
|
@@ -113,7 +113,7 @@ def process_temporary_deprecated_settings(config: Dict[str, Any]) -> None:
|
||||
process_removed_setting(config, 'experimental', 'ignore_roi_if_buy_signal',
|
||||
None, 'ignore_roi_if_entry_signal')
|
||||
|
||||
process_removed_setting(config, 'ask_strategy', 'use_sell_signal', None, 'exit_sell_signal')
|
||||
process_removed_setting(config, 'ask_strategy', 'use_sell_signal', None, 'use_exit_signal')
|
||||
process_removed_setting(config, 'ask_strategy', 'sell_profit_only', None, 'exit_profit_only')
|
||||
process_removed_setting(config, 'ask_strategy', 'sell_profit_offset',
|
||||
None, 'exit_profit_offset')
|
||||
|
@@ -15,7 +15,7 @@ def create_datadir(config: Dict[str, Any], datadir: Optional[str] = None) -> Pat
|
||||
folder = Path(datadir) if datadir else Path(f"{config['user_data_dir']}/data")
|
||||
if not datadir:
|
||||
# set datadir
|
||||
exchange_name = config.get('exchange', {}).get('name').lower()
|
||||
exchange_name = config.get('exchange', {}).get('name', '').lower()
|
||||
folder = folder.joinpath(exchange_name)
|
||||
|
||||
if not folder.is_dir():
|
||||
|
@@ -302,17 +302,21 @@ CONF_SCHEMA = {
|
||||
'exit_fill': {
|
||||
'type': 'string',
|
||||
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||
'default': 'off'
|
||||
'default': 'on'
|
||||
},
|
||||
'protection_trigger': {
|
||||
'type': 'string',
|
||||
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||
'default': 'off'
|
||||
'default': 'on'
|
||||
},
|
||||
'protection_trigger_global': {
|
||||
'type': 'string',
|
||||
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||
},
|
||||
'show_candle': {
|
||||
'type': 'string',
|
||||
'enum': ['off', 'ohlc'],
|
||||
},
|
||||
}
|
||||
},
|
||||
'reload': {'type': 'boolean'},
|
||||
@@ -336,6 +340,47 @@ CONF_SCHEMA = {
|
||||
'webhookstatus': {'type': 'object'},
|
||||
},
|
||||
},
|
||||
'discord': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'enabled': {'type': 'boolean'},
|
||||
'webhook_url': {'type': 'string'},
|
||||
"exit_fill": {
|
||||
'type': 'array', 'items': {'type': 'object'},
|
||||
'default': [
|
||||
{"Trade ID": "{trade_id}"},
|
||||
{"Exchange": "{exchange}"},
|
||||
{"Pair": "{pair}"},
|
||||
{"Direction": "{direction}"},
|
||||
{"Open rate": "{open_rate}"},
|
||||
{"Close rate": "{close_rate}"},
|
||||
{"Amount": "{amount}"},
|
||||
{"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"},
|
||||
{"Close date": "{close_date:%Y-%m-%d %H:%M:%S}"},
|
||||
{"Profit": "{profit_amount} {stake_currency}"},
|
||||
{"Profitability": "{profit_ratio:.2%}"},
|
||||
{"Enter tag": "{enter_tag}"},
|
||||
{"Exit Reason": "{exit_reason}"},
|
||||
{"Strategy": "{strategy}"},
|
||||
{"Timeframe": "{timeframe}"},
|
||||
]
|
||||
},
|
||||
"entry_fill": {
|
||||
'type': 'array', 'items': {'type': 'object'},
|
||||
'default': [
|
||||
{"Trade ID": "{trade_id}"},
|
||||
{"Exchange": "{exchange}"},
|
||||
{"Pair": "{pair}"},
|
||||
{"Direction": "{direction}"},
|
||||
{"Open rate": "{open_rate}"},
|
||||
{"Amount": "{amount}"},
|
||||
{"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"},
|
||||
{"Enter tag": "{enter_tag}"},
|
||||
{"Strategy": "{strategy} {timeframe}"},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
'api_server': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
@@ -483,6 +528,8 @@ CANCEL_REASON = {
|
||||
"ALL_CANCELLED": "cancelled (all unfilled and partially filled open orders cancelled)",
|
||||
"CANCELLED_ON_EXCHANGE": "cancelled on exchange",
|
||||
"FORCE_EXIT": "forcesold",
|
||||
"REPLACE": "cancelled to be replaced by new limit order",
|
||||
"USER_CANCEL": "user requested order cancel"
|
||||
}
|
||||
|
||||
# List of pairs with their timeframes
|
||||
@@ -494,3 +541,4 @@ TradeList = List[List]
|
||||
|
||||
LongShort = Literal['long', 'short']
|
||||
EntryExit = Literal['entry', 'exit']
|
||||
BuySell = Literal['buy', 'sell']
|
||||
|
@@ -26,7 +26,7 @@ BT_DATA_COLUMNS = ['pair', 'stake_amount', 'amount', 'open_date', 'close_date',
|
||||
'profit_ratio', 'profit_abs', 'exit_reason',
|
||||
'initial_stop_loss_abs', 'initial_stop_loss_ratio', 'stop_loss_abs',
|
||||
'stop_loss_ratio', 'min_rate', 'max_rate', 'is_open', 'enter_tag',
|
||||
'is_short'
|
||||
'is_short', 'open_timestamp', 'close_timestamp', 'orders'
|
||||
]
|
||||
|
||||
|
||||
@@ -283,6 +283,8 @@ def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = Non
|
||||
if 'enter_tag' not in df.columns:
|
||||
df['enter_tag'] = df['buy_tag']
|
||||
df = df.drop(['buy_tag'], axis=1)
|
||||
if 'orders' not in df.columns:
|
||||
df.loc[:, 'orders'] = None
|
||||
|
||||
else:
|
||||
# old format - only with lists.
|
||||
@@ -337,7 +339,7 @@ def trade_list_to_dataframe(trades: List[LocalTrade]) -> pd.DataFrame:
|
||||
:param trades: List of trade objects
|
||||
:return: Dataframe with BT_DATA_COLUMNS
|
||||
"""
|
||||
df = pd.DataFrame.from_records([t.to_json() for t in trades], columns=BT_DATA_COLUMNS)
|
||||
df = pd.DataFrame.from_records([t.to_json(True) for t in trades], columns=BT_DATA_COLUMNS)
|
||||
if len(df) > 0:
|
||||
df.loc[:, 'close_date'] = pd.to_datetime(df['close_date'], utc=True)
|
||||
df.loc[:, 'open_date'] = pd.to_datetime(df['open_date'], utc=True)
|
||||
@@ -353,7 +355,7 @@ def load_trades_from_db(db_url: str, strategy: Optional[str] = None) -> pd.DataF
|
||||
Can also serve as protection to load the correct result.
|
||||
:return: Dataframe containing Trades
|
||||
"""
|
||||
init_db(db_url, clean_open_orders=False)
|
||||
init_db(db_url)
|
||||
|
||||
filters = []
|
||||
if strategy:
|
||||
|
227
freqtrade/data/entryexitanalysis.py
Executable file
227
freqtrade/data/entryexitanalysis.py
Executable file
@@ -0,0 +1,227 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import joblib
|
||||
import pandas as pd
|
||||
from tabulate import tabulate
|
||||
|
||||
from freqtrade.data.btanalysis import (get_latest_backtest_filename, load_backtest_data,
|
||||
load_backtest_stats)
|
||||
from freqtrade.exceptions import OperationalException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_signal_candles(backtest_dir: Path):
|
||||
if backtest_dir.is_dir():
|
||||
scpf = Path(backtest_dir,
|
||||
Path(get_latest_backtest_filename(backtest_dir)).stem + "_signals.pkl"
|
||||
)
|
||||
else:
|
||||
scpf = Path(backtest_dir.parent / f"{backtest_dir.stem}_signals.pkl")
|
||||
|
||||
try:
|
||||
scp = open(scpf, "rb")
|
||||
signal_candles = joblib.load(scp)
|
||||
logger.info(f"Loaded signal candles: {str(scpf)}")
|
||||
except Exception as e:
|
||||
logger.error("Cannot load signal candles from pickled results: ", e)
|
||||
|
||||
return signal_candles
|
||||
|
||||
|
||||
def _process_candles_and_indicators(pairlist, strategy_name, trades, signal_candles):
|
||||
analysed_trades_dict = {}
|
||||
analysed_trades_dict[strategy_name] = {}
|
||||
|
||||
try:
|
||||
logger.info(f"Processing {strategy_name} : {len(pairlist)} pairs")
|
||||
|
||||
for pair in pairlist:
|
||||
if pair in signal_candles[strategy_name]:
|
||||
analysed_trades_dict[strategy_name][pair] = _analyze_candles_and_indicators(
|
||||
pair,
|
||||
trades,
|
||||
signal_candles[strategy_name][pair])
|
||||
except Exception as e:
|
||||
print(f"Cannot process entry/exit reasons for {strategy_name}: ", e)
|
||||
|
||||
return analysed_trades_dict
|
||||
|
||||
|
||||
def _analyze_candles_and_indicators(pair, trades, signal_candles):
|
||||
buyf = signal_candles
|
||||
|
||||
if len(buyf) > 0:
|
||||
buyf = buyf.set_index('date', drop=False)
|
||||
trades_red = trades.loc[trades['pair'] == pair].copy()
|
||||
|
||||
trades_inds = pd.DataFrame()
|
||||
|
||||
if trades_red.shape[0] > 0 and buyf.shape[0] > 0:
|
||||
for t, v in trades_red.open_date.items():
|
||||
allinds = buyf.loc[(buyf['date'] < v)]
|
||||
if allinds.shape[0] > 0:
|
||||
tmp_inds = allinds.iloc[[-1]]
|
||||
|
||||
trades_red.loc[t, 'signal_date'] = tmp_inds['date'].values[0]
|
||||
trades_red.loc[t, 'enter_reason'] = trades_red.loc[t, 'enter_tag']
|
||||
tmp_inds.index.rename('signal_date', inplace=True)
|
||||
trades_inds = pd.concat([trades_inds, tmp_inds])
|
||||
|
||||
if 'signal_date' in trades_red:
|
||||
trades_red['signal_date'] = pd.to_datetime(trades_red['signal_date'], utc=True)
|
||||
trades_red.set_index('signal_date', inplace=True)
|
||||
|
||||
try:
|
||||
trades_red = pd.merge(trades_red, trades_inds, on='signal_date', how='outer')
|
||||
except Exception as e:
|
||||
raise e
|
||||
return trades_red
|
||||
else:
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def _do_group_table_output(bigdf, glist):
|
||||
for g in glist:
|
||||
# 0: summary wins/losses grouped by enter tag
|
||||
if g == "0":
|
||||
group_mask = ['enter_reason']
|
||||
wins = bigdf.loc[bigdf['profit_abs'] >= 0] \
|
||||
.groupby(group_mask) \
|
||||
.agg({'profit_abs': ['sum']})
|
||||
|
||||
wins.columns = ['profit_abs_wins']
|
||||
loss = bigdf.loc[bigdf['profit_abs'] < 0] \
|
||||
.groupby(group_mask) \
|
||||
.agg({'profit_abs': ['sum']})
|
||||
loss.columns = ['profit_abs_loss']
|
||||
|
||||
new = bigdf.groupby(group_mask).agg({'profit_abs': [
|
||||
'count',
|
||||
lambda x: sum(x > 0),
|
||||
lambda x: sum(x <= 0)]})
|
||||
new = pd.concat([new, wins, loss], axis=1).fillna(0)
|
||||
|
||||
new['profit_tot'] = new['profit_abs_wins'] - abs(new['profit_abs_loss'])
|
||||
new['wl_ratio_pct'] = (new.iloc[:, 1] / new.iloc[:, 0] * 100).fillna(0)
|
||||
new['avg_win'] = (new['profit_abs_wins'] / new.iloc[:, 1]).fillna(0)
|
||||
new['avg_loss'] = (new['profit_abs_loss'] / new.iloc[:, 2]).fillna(0)
|
||||
|
||||
new.columns = ['total_num_buys', 'wins', 'losses', 'profit_abs_wins', 'profit_abs_loss',
|
||||
'profit_tot', 'wl_ratio_pct', 'avg_win', 'avg_loss']
|
||||
|
||||
sortcols = ['total_num_buys']
|
||||
|
||||
_print_table(new, sortcols, show_index=True)
|
||||
|
||||
else:
|
||||
agg_mask = {'profit_abs': ['count', 'sum', 'median', 'mean'],
|
||||
'profit_ratio': ['sum', 'median', 'mean']}
|
||||
agg_cols = ['num_buys', 'profit_abs_sum', 'profit_abs_median',
|
||||
'profit_abs_mean', 'median_profit_pct', 'mean_profit_pct',
|
||||
'total_profit_pct']
|
||||
sortcols = ['profit_abs_sum', 'enter_reason']
|
||||
|
||||
# 1: profit summaries grouped by enter_tag
|
||||
if g == "1":
|
||||
group_mask = ['enter_reason']
|
||||
|
||||
# 2: profit summaries grouped by enter_tag and exit_tag
|
||||
if g == "2":
|
||||
group_mask = ['enter_reason', 'exit_reason']
|
||||
|
||||
# 3: profit summaries grouped by pair and enter_tag
|
||||
if g == "3":
|
||||
group_mask = ['pair', 'enter_reason']
|
||||
|
||||
# 4: profit summaries grouped by pair, enter_ and exit_tag (this can get quite large)
|
||||
if g == "4":
|
||||
group_mask = ['pair', 'enter_reason', 'exit_reason']
|
||||
if group_mask:
|
||||
new = bigdf.groupby(group_mask).agg(agg_mask).reset_index()
|
||||
new.columns = group_mask + agg_cols
|
||||
new['median_profit_pct'] = new['median_profit_pct'] * 100
|
||||
new['mean_profit_pct'] = new['mean_profit_pct'] * 100
|
||||
new['total_profit_pct'] = new['total_profit_pct'] * 100
|
||||
|
||||
_print_table(new, sortcols)
|
||||
else:
|
||||
logger.warning("Invalid group mask specified.")
|
||||
|
||||
|
||||
def _print_results(analysed_trades, stratname, analysis_groups,
|
||||
enter_reason_list, exit_reason_list,
|
||||
indicator_list, columns=None):
|
||||
if columns is None:
|
||||
columns = ['pair', 'open_date', 'close_date', 'profit_abs', 'enter_reason', 'exit_reason']
|
||||
|
||||
bigdf = pd.DataFrame()
|
||||
for pair, trades in analysed_trades[stratname].items():
|
||||
bigdf = pd.concat([bigdf, trades], ignore_index=True)
|
||||
|
||||
if bigdf.shape[0] > 0 and ('enter_reason' in bigdf.columns):
|
||||
if analysis_groups:
|
||||
_do_group_table_output(bigdf, analysis_groups)
|
||||
|
||||
if enter_reason_list and "all" not in enter_reason_list:
|
||||
bigdf = bigdf.loc[(bigdf['enter_reason'].isin(enter_reason_list))]
|
||||
|
||||
if exit_reason_list and "all" not in exit_reason_list:
|
||||
bigdf = bigdf.loc[(bigdf['exit_reason'].isin(exit_reason_list))]
|
||||
|
||||
if "all" in indicator_list:
|
||||
print(bigdf)
|
||||
elif indicator_list is not None:
|
||||
available_inds = []
|
||||
for ind in indicator_list:
|
||||
if ind in bigdf:
|
||||
available_inds.append(ind)
|
||||
ilist = ["pair", "enter_reason", "exit_reason"] + available_inds
|
||||
_print_table(bigdf[ilist], sortcols=['exit_reason'], show_index=False)
|
||||
else:
|
||||
print("\\_ No trades to show")
|
||||
|
||||
|
||||
def _print_table(df, sortcols=None, show_index=False):
|
||||
if (sortcols is not None):
|
||||
data = df.sort_values(sortcols)
|
||||
else:
|
||||
data = df
|
||||
|
||||
print(
|
||||
tabulate(
|
||||
data,
|
||||
headers='keys',
|
||||
tablefmt='psql',
|
||||
showindex=show_index
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def process_entry_exit_reasons(backtest_dir: Path,
|
||||
pairlist: List[str],
|
||||
analysis_groups: Optional[List[str]] = ["0", "1", "2"],
|
||||
enter_reason_list: Optional[List[str]] = ["all"],
|
||||
exit_reason_list: Optional[List[str]] = ["all"],
|
||||
indicator_list: Optional[List[str]] = []):
|
||||
try:
|
||||
backtest_stats = load_backtest_stats(backtest_dir)
|
||||
for strategy_name, results in backtest_stats['strategy'].items():
|
||||
trades = load_backtest_data(backtest_dir, strategy_name)
|
||||
|
||||
if not trades.empty:
|
||||
signal_candles = _load_signal_candles(backtest_dir)
|
||||
analysed_trades_dict = _process_candles_and_indicators(pairlist, strategy_name,
|
||||
trades, signal_candles)
|
||||
_print_results(analysed_trades_dict,
|
||||
strategy_name,
|
||||
analysis_groups,
|
||||
enter_reason_list,
|
||||
exit_reason_list,
|
||||
indicator_list)
|
||||
|
||||
except ValueError as e:
|
||||
raise OperationalException(e) from e
|
@@ -40,7 +40,7 @@ class HDF5DataHandler(IDataHandler):
|
||||
return [
|
||||
(
|
||||
cls.rebuild_pair_from_filename(match[1]),
|
||||
match[2],
|
||||
cls.rebuild_timeframe_from_filename(match[2]),
|
||||
CandleType.from_string(match[3])
|
||||
) for match in _tmp if match and len(match.groups()) > 1]
|
||||
|
||||
@@ -109,7 +109,11 @@ class HDF5DataHandler(IDataHandler):
|
||||
)
|
||||
|
||||
if not filename.exists():
|
||||
return pd.DataFrame(columns=self._columns)
|
||||
# Fallback mode for 1M files
|
||||
filename = self._pair_data_filename(
|
||||
self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True)
|
||||
if not filename.exists():
|
||||
return pd.DataFrame(columns=self._columns)
|
||||
where = []
|
||||
if timerange:
|
||||
if timerange.starttype == 'date':
|
||||
|
@@ -68,7 +68,8 @@ def load_data(datadir: Path,
|
||||
startup_candles: int = 0,
|
||||
fail_without_data: bool = False,
|
||||
data_format: str = 'json',
|
||||
candle_type: CandleType = CandleType.SPOT
|
||||
candle_type: CandleType = CandleType.SPOT,
|
||||
user_futures_funding_rate: int = None,
|
||||
) -> Dict[str, DataFrame]:
|
||||
"""
|
||||
Load ohlcv history data for a list of pairs.
|
||||
@@ -100,6 +101,10 @@ def load_data(datadir: Path,
|
||||
)
|
||||
if not hist.empty:
|
||||
result[pair] = hist
|
||||
else:
|
||||
if candle_type is CandleType.FUNDING_RATE and user_futures_funding_rate is not None:
|
||||
logger.warn(f"{pair} using user specified [{user_futures_funding_rate}]")
|
||||
result[pair] = DataFrame(columns=["open", "close", "high", "low", "volume"])
|
||||
|
||||
if fail_without_data and not result:
|
||||
raise OperationalException("No data found. Terminating.")
|
||||
@@ -216,7 +221,7 @@ def _download_pair_history(pair: str, *,
|
||||
prepend=prepend)
|
||||
|
||||
logger.info(f'({process}) - Download history data for "{pair}", {timeframe}, '
|
||||
f'{candle_type} and store in {datadir}.'
|
||||
f'{candle_type} and store in {datadir}. '
|
||||
f'From {format_ms_time(since_ms) if since_ms else "start"} to '
|
||||
f'{format_ms_time(until_ms) if until_ms else "now"}'
|
||||
)
|
||||
@@ -277,6 +282,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes
|
||||
pairs_not_available = []
|
||||
data_handler = get_datahandler(datadir, data_format)
|
||||
candle_type = CandleType.get_default(trading_mode)
|
||||
process = ''
|
||||
for idx, pair in enumerate(pairs, start=1):
|
||||
if pair not in exchange.markets:
|
||||
pairs_not_available.append(pair)
|
||||
|
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class IDataHandler(ABC):
|
||||
|
||||
_OHLCV_REGEX = r'^([a-zA-Z_-]+)\-(\d+\S)\-?([a-zA-Z_]*)?(?=\.)'
|
||||
_OHLCV_REGEX = r'^([a-zA-Z_-]+)\-(\d+[a-zA-Z]{1,2})\-?([a-zA-Z_]*)?(?=\.)'
|
||||
|
||||
def __init__(self, datadir: Path) -> None:
|
||||
self._datadir = datadir
|
||||
@@ -193,10 +193,14 @@ class IDataHandler(ABC):
|
||||
datadir: Path,
|
||||
pair: str,
|
||||
timeframe: str,
|
||||
candle_type: CandleType
|
||||
candle_type: CandleType,
|
||||
no_timeframe_modify: bool = False
|
||||
) -> Path:
|
||||
pair_s = misc.pair_to_filename(pair)
|
||||
candle = ""
|
||||
if not no_timeframe_modify:
|
||||
timeframe = cls.timeframe_to_file(timeframe)
|
||||
|
||||
if candle_type != CandleType.SPOT:
|
||||
datadir = datadir.joinpath('futures')
|
||||
candle = f"-{candle_type}"
|
||||
@@ -210,6 +214,18 @@ class IDataHandler(ABC):
|
||||
filename = datadir.joinpath(f'{pair_s}-trades.{cls._get_file_extension()}')
|
||||
return filename
|
||||
|
||||
@staticmethod
|
||||
def timeframe_to_file(timeframe: str):
|
||||
return timeframe.replace('M', 'Mo')
|
||||
|
||||
@staticmethod
|
||||
def rebuild_timeframe_from_filename(timeframe: str) -> str:
|
||||
"""
|
||||
converts timeframe from disk to file
|
||||
Replaces mo with M (to avoid problems on case-insensitive filesystems)
|
||||
"""
|
||||
return re.sub('1mo', '1M', timeframe, flags=re.IGNORECASE)
|
||||
|
||||
@staticmethod
|
||||
def rebuild_pair_from_filename(pair: str) -> str:
|
||||
"""
|
||||
|
@@ -41,7 +41,7 @@ class JsonDataHandler(IDataHandler):
|
||||
return [
|
||||
(
|
||||
cls.rebuild_pair_from_filename(match[1]),
|
||||
match[2],
|
||||
cls.rebuild_timeframe_from_filename(match[2]),
|
||||
CandleType.from_string(match[3])
|
||||
) for match in _tmp if match and len(match.groups()) > 1]
|
||||
|
||||
@@ -103,9 +103,14 @@ class JsonDataHandler(IDataHandler):
|
||||
:param candle_type: Any of the enum CandleType (must match trading mode!)
|
||||
:return: DataFrame with ohlcv data, or empty DataFrame
|
||||
"""
|
||||
filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type=candle_type)
|
||||
filename = self._pair_data_filename(
|
||||
self._datadir, pair, timeframe, candle_type=candle_type)
|
||||
if not filename.exists():
|
||||
return DataFrame(columns=self._columns)
|
||||
# Fallback mode for 1M files
|
||||
filename = self._pair_data_filename(
|
||||
self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True)
|
||||
if not filename.exists():
|
||||
return DataFrame(columns=self._columns)
|
||||
try:
|
||||
pairdata = read_json(filename, orient='values')
|
||||
pairdata.columns = self._columns
|
||||
|
@@ -15,3 +15,9 @@ class ExitCheckTuple:
|
||||
@property
|
||||
def exit_flag(self):
|
||||
return self.exit_type != ExitType.NONE
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.exit_type == other.exit_type and self.exit_reason == other.exit_reason
|
||||
|
||||
def __repr__(self):
|
||||
return f"ExitCheckTuple({self.exit_type}, {self.exit_reason})"
|
||||
|
@@ -52,12 +52,17 @@ class Binance(Exchange):
|
||||
|
||||
ordertype = 'stop' if self.trading_mode == TradingMode.FUTURES else 'stop_loss_limit'
|
||||
|
||||
return order['type'] == ordertype and (
|
||||
(side == "sell" and stop_loss > float(order['info']['stopPrice'])) or
|
||||
(side == "buy" and stop_loss < float(order['info']['stopPrice']))
|
||||
)
|
||||
return (
|
||||
order.get('stopPrice', None) is None
|
||||
or (
|
||||
order['type'] == ordertype
|
||||
and (
|
||||
(side == "sell" and stop_loss > float(order['stopPrice'])) or
|
||||
(side == "buy" and stop_loss < float(order['stopPrice']))
|
||||
)
|
||||
))
|
||||
|
||||
def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict:
|
||||
def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
|
||||
tickers = super().get_tickers(symbols=symbols, cached=cached)
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
# Binance's future result has no bid/ask values.
|
||||
@@ -95,7 +100,7 @@ class Binance(Exchange):
|
||||
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
|
||||
since_ms: int, candle_type: CandleType,
|
||||
is_new_pair: bool = False, raise_: bool = False,
|
||||
until_ms: int = None
|
||||
until_ms: Optional[int] = None
|
||||
) -> Tuple[str, str, str, List]:
|
||||
"""
|
||||
Overwrite to introduce "fast new pair" functionality by detecting the pair's listing date
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -29,3 +29,17 @@ class Bybit(Exchange):
|
||||
# (TradingMode.FUTURES, MarginMode.CROSS),
|
||||
# (TradingMode.FUTURES, MarginMode.ISOLATED)
|
||||
]
|
||||
|
||||
@property
|
||||
def _ccxt_config(self) -> Dict:
|
||||
# Parameters to add directly to ccxt sync/async initialization.
|
||||
# ccxt defaults to swap mode.
|
||||
config = {}
|
||||
if self.trading_mode == TradingMode.SPOT:
|
||||
config.update({
|
||||
"options": {
|
||||
"defaultType": "spot"
|
||||
}
|
||||
})
|
||||
config.update(super()._ccxt_config)
|
||||
return config
|
||||
|
@@ -2,6 +2,7 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Optional, TypeVar, cast, overload
|
||||
|
||||
from freqtrade.exceptions import DDosProtection, RetryableOrderError, TemporaryError
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
@@ -11,6 +12,14 @@ logger = logging.getLogger(__name__)
|
||||
__logging_mixin = None
|
||||
|
||||
|
||||
def _reset_logging_mixin():
|
||||
"""
|
||||
Reset global logging mixin - used in tests only.
|
||||
"""
|
||||
global __logging_mixin
|
||||
__logging_mixin = LoggingMixin(logger)
|
||||
|
||||
|
||||
def _get_logging_mixin():
|
||||
# Logging-mixin to cache kucoin responses
|
||||
# Only to be used in retrier
|
||||
@@ -37,6 +46,7 @@ MAP_EXCHANGE_CHILDCLASS = {
|
||||
'binanceje': 'binance',
|
||||
'binanceusdm': 'binance',
|
||||
'okex': 'okx',
|
||||
'gate': 'gateio',
|
||||
}
|
||||
|
||||
SUPPORTED_EXCHANGES = [
|
||||
@@ -54,17 +64,16 @@ EXCHANGE_HAS_REQUIRED = [
|
||||
'fetchOrder',
|
||||
'cancelOrder',
|
||||
'createOrder',
|
||||
# 'createLimitOrder', 'createMarketOrder',
|
||||
'fetchBalance',
|
||||
|
||||
# Public endpoints
|
||||
'loadMarkets',
|
||||
'fetchOHLCV',
|
||||
]
|
||||
|
||||
EXCHANGE_HAS_OPTIONAL = [
|
||||
# Private
|
||||
'fetchMyTrades', # Trades for order - fee detection
|
||||
'createLimitOrder', 'createMarketOrder', # Either OR for orders
|
||||
# 'setLeverage', # Margin/Futures trading
|
||||
# 'setMarginMode', # Margin/Futures trading
|
||||
# 'fetchFundingHistory', # Futures trading
|
||||
@@ -133,8 +142,22 @@ def retrier_async(f):
|
||||
return wrapper
|
||||
|
||||
|
||||
def retrier(_func=None, retries=API_RETRY_COUNT):
|
||||
def decorator(f):
|
||||
F = TypeVar('F', bound=Callable[..., Any])
|
||||
|
||||
|
||||
# Type shenanigans
|
||||
@overload
|
||||
def retrier(_func: F) -> F:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def retrier(*, retries=API_RETRY_COUNT) -> Callable[[F], F]:
|
||||
...
|
||||
|
||||
|
||||
def retrier(_func: Optional[F] = None, *, retries=API_RETRY_COUNT):
|
||||
def decorator(f: F) -> F:
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
count = kwargs.pop('count', retries)
|
||||
@@ -155,7 +178,7 @@ def retrier(_func=None, retries=API_RETRY_COUNT):
|
||||
else:
|
||||
logger.warning(msg + 'Giving up.')
|
||||
raise ex
|
||||
return wrapper
|
||||
return cast(F, wrapper)
|
||||
# Support both @retrier and @retrier(retries=2) syntax
|
||||
if _func is None:
|
||||
return decorator
|
||||
|
@@ -16,11 +16,10 @@ import arrow
|
||||
import ccxt
|
||||
import ccxt.async_support as ccxt_async
|
||||
from cachetools import TTLCache
|
||||
from ccxt.base.decimal_to_precision import (ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE,
|
||||
decimal_to_precision)
|
||||
from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, Precise, decimal_to_precision
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES,
|
||||
from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, BuySell,
|
||||
EntryExit, ListPairsWithTimeframes, PairWithTimeframe)
|
||||
from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list
|
||||
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode
|
||||
@@ -64,6 +63,7 @@ class Exchange:
|
||||
"time_in_force_parameter": "timeInForce",
|
||||
"ohlcv_params": {},
|
||||
"ohlcv_candle_limit": 500,
|
||||
"ohlcv_has_history": True, # Some exchanges (Kraken) don't provide history via ohlcv
|
||||
"ohlcv_partial_candle": True,
|
||||
"ohlcv_require_since": False,
|
||||
# Check https://github.com/ccxt/ccxt/issues/10767 for removal of ohlcv_volume_currency
|
||||
@@ -77,7 +77,9 @@ class Exchange:
|
||||
"mark_ohlcv_price": "mark",
|
||||
"mark_ohlcv_timeframe": "8h",
|
||||
"ccxt_futures_name": "swap",
|
||||
"fee_cost_in_contracts": False, # Fee cost needs contract conversion
|
||||
"needs_trading_fees": False, # use fetch_trading_fees to cache fees
|
||||
"order_props_in_contracts": ['amount', 'cost', 'filled', 'remaining'],
|
||||
}
|
||||
_ft_has: Dict = {}
|
||||
_ft_has_futures: Dict = {}
|
||||
@@ -92,7 +94,7 @@ class Exchange:
|
||||
it does basic validation whether the specified exchange and pairs are valid.
|
||||
:return: None
|
||||
"""
|
||||
self._api: ccxt.Exchange = None
|
||||
self._api: ccxt.Exchange
|
||||
self._api_async: ccxt_async.Exchange = None
|
||||
self._markets: Dict = {}
|
||||
self._trading_fees: Dict[str, Any] = {}
|
||||
@@ -174,23 +176,11 @@ class Exchange:
|
||||
logger.info(f'Using Exchange "{self.name}"')
|
||||
|
||||
if validate:
|
||||
# Check if timeframe is available
|
||||
self.validate_timeframes(config.get('timeframe'))
|
||||
|
||||
# Initial markets load
|
||||
self._load_markets()
|
||||
|
||||
# Check if all pairs are available
|
||||
self.validate_stakecurrency(config['stake_currency'])
|
||||
if not exchange_config.get('skip_pair_validation'):
|
||||
self.validate_pairs(config['exchange']['pair_whitelist'])
|
||||
self.validate_ordertypes(config.get('order_types', {}))
|
||||
self.validate_order_time_in_force(config.get('order_time_in_force', {}))
|
||||
self.validate_config(config)
|
||||
self.required_candle_call_count = self.validate_required_startup_candles(
|
||||
config.get('startup_candle_count', 0), config.get('timeframe', ''))
|
||||
self.validate_trading_mode_and_margin_mode(self.trading_mode, self.margin_mode)
|
||||
self.validate_pricing(config['exit_pricing'])
|
||||
self.validate_pricing(config['entry_pricing'])
|
||||
|
||||
# Converts the interval provided in minutes in config to seconds
|
||||
self.markets_refresh_interval: int = exchange_config.get(
|
||||
@@ -198,6 +188,7 @@ class Exchange:
|
||||
|
||||
if self.trading_mode != TradingMode.SPOT:
|
||||
self.fill_leverage_tiers()
|
||||
self.additional_exchange_init()
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
@@ -212,6 +203,20 @@ class Exchange:
|
||||
logger.info("Closing async ccxt session.")
|
||||
self.loop.run_until_complete(self._api_async.close())
|
||||
|
||||
def validate_config(self, config):
|
||||
# Check if timeframe is available
|
||||
self.validate_timeframes(config.get('timeframe'))
|
||||
|
||||
# Check if all pairs are available
|
||||
self.validate_stakecurrency(config['stake_currency'])
|
||||
if not config['exchange'].get('skip_pair_validation'):
|
||||
self.validate_pairs(config['exchange']['pair_whitelist'])
|
||||
self.validate_ordertypes(config.get('order_types', {}))
|
||||
self.validate_order_time_in_force(config.get('order_time_in_force', {}))
|
||||
self.validate_trading_mode_and_margin_mode(self.trading_mode, self.margin_mode)
|
||||
self.validate_pricing(config['exit_pricing'])
|
||||
self.validate_pricing(config['entry_pricing'])
|
||||
|
||||
def _init_ccxt(self, exchange_config: Dict[str, Any], ccxt_module: CcxtModuleType = ccxt,
|
||||
ccxt_kwargs: Dict = {}) -> ccxt.Exchange:
|
||||
"""
|
||||
@@ -290,27 +295,38 @@ class Exchange:
|
||||
return self._markets
|
||||
|
||||
@property
|
||||
def precisionMode(self) -> str:
|
||||
def precisionMode(self) -> int:
|
||||
"""exchange ccxt precisionMode"""
|
||||
return self._api.precisionMode
|
||||
|
||||
def additional_exchange_init(self) -> None:
|
||||
"""
|
||||
Additional exchange initialization logic.
|
||||
.api will be available at this point.
|
||||
Must be overridden in child methods if required.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _log_exchange_response(self, endpoint, response) -> None:
|
||||
""" Log exchange responses """
|
||||
if self.log_responses:
|
||||
logger.info(f"API {endpoint}: {response}")
|
||||
|
||||
def ohlcv_candle_limit(self, timeframe: str) -> int:
|
||||
def ohlcv_candle_limit(
|
||||
self, timeframe: str, candle_type: CandleType, since_ms: Optional[int] = None) -> int:
|
||||
"""
|
||||
Exchange ohlcv candle limit
|
||||
Uses ohlcv_candle_limit_per_timeframe if the exchange has different limits
|
||||
per timeframe (e.g. bittrex), otherwise falls back to ohlcv_candle_limit
|
||||
:param timeframe: Timeframe to check
|
||||
:param candle_type: Candle-type
|
||||
:param since_ms: Starting timestamp
|
||||
:return: Candle limit as integer
|
||||
"""
|
||||
return int(self._ft_has.get('ohlcv_candle_limit_per_timeframe', {}).get(
|
||||
timeframe, self._ft_has.get('ohlcv_candle_limit')))
|
||||
|
||||
def get_markets(self, base_currencies: List[str] = None, quote_currencies: List[str] = None,
|
||||
def get_markets(self, base_currencies: List[str] = [], quote_currencies: List[str] = [],
|
||||
spot_only: bool = False, margin_only: bool = False, futures_only: bool = False,
|
||||
tradable_only: bool = True,
|
||||
active_only: bool = False) -> Dict[str, Any]:
|
||||
@@ -375,7 +391,7 @@ class Exchange:
|
||||
and market.get('base', None) is not None
|
||||
and (self.precisionMode != TICK_SIZE
|
||||
# Too low precision will falsify calculations
|
||||
or market.get('precision', {}).get('price', None) > 1e-11)
|
||||
or market.get('precision', {}).get('price') > 1e-11)
|
||||
and ((self.trading_mode == TradingMode.SPOT and self.market_is_spot(market))
|
||||
or (self.trading_mode == TradingMode.MARGIN and self.market_is_margin(market))
|
||||
or (self.trading_mode == TradingMode.FUTURES and self.market_is_future(market)))
|
||||
@@ -410,7 +426,7 @@ class Exchange:
|
||||
if 'symbol' in order and order['symbol'] is not None:
|
||||
contract_size = self._get_contract_size(order['symbol'])
|
||||
if contract_size != 1:
|
||||
for prop in ['amount', 'cost', 'filled', 'remaining']:
|
||||
for prop in self._ft_has.get('order_props_in_contracts', []):
|
||||
if prop in order and order[prop] is not None:
|
||||
order[prop] = order[prop] * contract_size
|
||||
return order
|
||||
@@ -525,7 +541,7 @@ class Exchange:
|
||||
# The internal info array is different for each particular market,
|
||||
# its contents depend on the exchange.
|
||||
# It can also be a string or similar ... so we need to verify that first.
|
||||
elif (isinstance(self.markets[pair].get('info', None), dict)
|
||||
elif (isinstance(self.markets[pair].get('info'), dict)
|
||||
and self.markets[pair].get('info', {}).get('prohibitedIn', False)):
|
||||
# Warn users about restricted pairs in whitelist.
|
||||
# We cannot determine reliably if Users are affected.
|
||||
@@ -606,19 +622,28 @@ class Exchange:
|
||||
Checks if required startup_candles is more than ohlcv_candle_limit().
|
||||
Requires a grace-period of 5 candles - so a startup-period up to 494 is allowed by default.
|
||||
"""
|
||||
candle_limit = self.ohlcv_candle_limit(timeframe)
|
||||
|
||||
candle_limit = self.ohlcv_candle_limit(
|
||||
timeframe, self._config['candle_type_def'],
|
||||
int(date_minus_candles(timeframe, startup_candles).timestamp() * 1000)
|
||||
if timeframe else None)
|
||||
# Require one more candle - to account for the still open candle.
|
||||
candle_count = startup_candles + 1
|
||||
# Allow 5 calls to the exchange per pair
|
||||
required_candle_call_count = int(
|
||||
(candle_count / candle_limit) + (0 if candle_count % candle_limit == 0 else 1))
|
||||
if self._ft_has['ohlcv_has_history']:
|
||||
|
||||
if required_candle_call_count > 5:
|
||||
# Only allow 5 calls per pair to somewhat limit the impact
|
||||
if required_candle_call_count > 5:
|
||||
# Only allow 5 calls per pair to somewhat limit the impact
|
||||
raise OperationalException(
|
||||
f"This strategy requires {startup_candles} candles to start, "
|
||||
"which is more than 5x "
|
||||
f"the amount of candles {self.name} provides for {timeframe}.")
|
||||
elif required_candle_call_count > 1:
|
||||
raise OperationalException(
|
||||
f"This strategy requires {startup_candles} candles to start, which is more than 5x "
|
||||
f"This strategy requires {startup_candles} candles to start, which is more than "
|
||||
f"the amount of candles {self.name} provides for {timeframe}.")
|
||||
|
||||
if required_candle_call_count > 1:
|
||||
logger.warning(f"Using {required_candle_call_count} calls to get OHLCV. "
|
||||
f"This can result in slower operations for the bot. Please check "
|
||||
@@ -682,10 +707,11 @@ class Exchange:
|
||||
# counting_mode=self.precisionMode,
|
||||
# ))
|
||||
if self.precisionMode == TICK_SIZE:
|
||||
precision = self.markets[pair]['precision']['price']
|
||||
missing = price % precision
|
||||
if missing != 0:
|
||||
price = round(price - missing + precision, 10)
|
||||
precision = Precise(str(self.markets[pair]['precision']['price']))
|
||||
price_str = Precise(str(price))
|
||||
missing = price_str % precision
|
||||
if not missing == Precise("0"):
|
||||
price = round(float(str(price_str - missing + precision)), 14)
|
||||
else:
|
||||
symbol_prec = self.markets[pair]['precision']['price']
|
||||
big_price = price * pow(10, symbol_prec)
|
||||
@@ -818,7 +844,7 @@ class Exchange:
|
||||
'price': rate,
|
||||
'average': rate,
|
||||
'amount': _amount,
|
||||
'cost': _amount * rate / leverage,
|
||||
'cost': _amount * rate,
|
||||
'type': ordertype,
|
||||
'side': side,
|
||||
'filled': 0,
|
||||
@@ -965,19 +991,26 @@ class Exchange:
|
||||
order = self.check_dry_limit_order_filled(order)
|
||||
return order
|
||||
except KeyError as e:
|
||||
from freqtrade.persistence import Order
|
||||
order = Order.order_by_id(order_id)
|
||||
if order:
|
||||
ccxt_order = order.to_ccxt_object()
|
||||
self._dry_run_open_orders[order_id] = ccxt_order
|
||||
return ccxt_order
|
||||
# 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
|
||||
|
||||
# Order handling
|
||||
|
||||
def _lev_prep(self, pair: str, leverage: float, side: str):
|
||||
def _lev_prep(self, pair: str, leverage: float, side: BuySell):
|
||||
if self.trading_mode != TradingMode.SPOT:
|
||||
self.set_margin_mode(pair, self.margin_mode)
|
||||
self._set_leverage(leverage, pair)
|
||||
|
||||
def _get_params(
|
||||
self,
|
||||
side: BuySell,
|
||||
ordertype: str,
|
||||
leverage: float,
|
||||
reduceOnly: bool,
|
||||
@@ -996,7 +1029,7 @@ class Exchange:
|
||||
*,
|
||||
pair: str,
|
||||
ordertype: str,
|
||||
side: str,
|
||||
side: BuySell,
|
||||
amount: float,
|
||||
rate: float,
|
||||
leverage: float,
|
||||
@@ -1007,7 +1040,7 @@ class Exchange:
|
||||
dry_order = self.create_dry_run_order(pair, ordertype, side, amount, rate, leverage)
|
||||
return dry_order
|
||||
|
||||
params = self._get_params(ordertype, leverage, reduceOnly, time_in_force)
|
||||
params = self._get_params(side, ordertype, leverage, reduceOnly, time_in_force)
|
||||
|
||||
try:
|
||||
# Set the precision for amount and price(rate) as accepted by the exchange
|
||||
@@ -1092,7 +1125,7 @@ class Exchange:
|
||||
|
||||
@retrier(retries=0)
|
||||
def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict,
|
||||
side: str, leverage: float) -> Dict:
|
||||
side: BuySell, leverage: float) -> Dict:
|
||||
"""
|
||||
creates a stoploss order.
|
||||
requires `_ft_has['stoploss_order_types']` to be set as a dict mapping limit and market
|
||||
@@ -1169,7 +1202,7 @@ class Exchange:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
|
||||
def fetch_order(self, order_id: str, pair: str, params={}) -> Dict:
|
||||
def fetch_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
|
||||
if self._config['dry_run']:
|
||||
return self.fetch_dry_run_order(order_id)
|
||||
try:
|
||||
@@ -1191,8 +1224,8 @@ class Exchange:
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
# Assign method to fetch_stoploss_order to allow easy overriding in other classes
|
||||
fetch_stoploss_order = fetch_order
|
||||
def fetch_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
|
||||
return self.fetch_order(order_id, pair, params)
|
||||
|
||||
def fetch_order_or_stoploss_order(self, order_id: str, pair: str,
|
||||
stoploss_order: bool = False) -> Dict:
|
||||
@@ -1217,7 +1250,7 @@ class Exchange:
|
||||
and order.get('filled') == 0.0)
|
||||
|
||||
@retrier
|
||||
def cancel_order(self, order_id: str, pair: str, params={}) -> Dict:
|
||||
def cancel_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
|
||||
if self._config['dry_run']:
|
||||
try:
|
||||
order = self.fetch_dry_run_order(order_id)
|
||||
@@ -1243,8 +1276,8 @@ class Exchange:
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
# Assign method to cancel_stoploss_order to allow easy overriding in other classes
|
||||
cancel_stoploss_order = cancel_order
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
|
||||
return self.cancel_order(order_id, pair, params)
|
||||
|
||||
def is_cancel_order_result_suitable(self, corder) -> bool:
|
||||
if not isinstance(corder, dict):
|
||||
@@ -1356,7 +1389,7 @@ class Exchange:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@retrier
|
||||
def fetch_bids_asks(self, symbols: List[str] = None, cached: bool = False) -> Dict:
|
||||
def fetch_bids_asks(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
|
||||
"""
|
||||
:param cached: Allow cached result
|
||||
:return: fetch_tickers result
|
||||
@@ -1384,7 +1417,7 @@ class Exchange:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@retrier
|
||||
def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict:
|
||||
def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
|
||||
"""
|
||||
:param cached: Allow cached result
|
||||
:return: fetch_tickers result
|
||||
@@ -1468,6 +1501,23 @@ class Exchange:
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def _get_price_side(self, side: str, is_short: bool, conf_strategy: Dict) -> str:
|
||||
price_side = conf_strategy['price_side']
|
||||
|
||||
if price_side in ('same', 'other'):
|
||||
price_map = {
|
||||
('entry', 'long', 'same'): 'bid',
|
||||
('entry', 'long', 'other'): 'ask',
|
||||
('entry', 'short', 'same'): 'ask',
|
||||
('entry', 'short', 'other'): 'bid',
|
||||
('exit', 'long', 'same'): 'ask',
|
||||
('exit', 'long', 'other'): 'bid',
|
||||
('exit', 'short', 'same'): 'bid',
|
||||
('exit', 'short', 'other'): 'ask',
|
||||
}
|
||||
price_side = price_map[(side, 'short' if is_short else 'long', price_side)]
|
||||
return price_side
|
||||
|
||||
def get_rate(self, pair: str, refresh: bool,
|
||||
side: EntryExit, is_short: bool) -> float:
|
||||
"""
|
||||
@@ -1494,20 +1544,7 @@ class Exchange:
|
||||
|
||||
conf_strategy = self._config.get(strat_name, {})
|
||||
|
||||
price_side = conf_strategy['price_side']
|
||||
|
||||
if price_side in ('same', 'other'):
|
||||
price_map = {
|
||||
('entry', 'long', 'same'): 'bid',
|
||||
('entry', 'long', 'other'): 'ask',
|
||||
('entry', 'short', 'same'): 'ask',
|
||||
('entry', 'short', 'other'): 'bid',
|
||||
('exit', 'long', 'same'): 'ask',
|
||||
('exit', 'long', 'other'): 'bid',
|
||||
('exit', 'short', 'same'): 'bid',
|
||||
('exit', 'short', 'other'): 'ask',
|
||||
}
|
||||
price_side = price_map[(side, 'short' if is_short else 'long', price_side)]
|
||||
price_side = self._get_price_side(side, is_short, conf_strategy)
|
||||
|
||||
price_side_word = price_side.capitalize()
|
||||
|
||||
@@ -1632,27 +1669,35 @@ class Exchange:
|
||||
and order['fee']['cost'] is not None
|
||||
)
|
||||
|
||||
def calculate_fee_rate(self, order: Dict) -> Optional[float]:
|
||||
def calculate_fee_rate(
|
||||
self, fee: Dict, symbol: str, cost: float, amount: float) -> Optional[float]:
|
||||
"""
|
||||
Calculate fee rate if it's not given by the exchange.
|
||||
:param order: Order or trade (one trade) dict
|
||||
:param fee: ccxt Fee dict - must contain cost / currency / rate
|
||||
:param symbol: Symbol of the order
|
||||
:param cost: Total cost of the order
|
||||
:param amount: Amount of the order
|
||||
"""
|
||||
if order['fee'].get('rate') is not None:
|
||||
return order['fee'].get('rate')
|
||||
fee_curr = order['fee']['currency']
|
||||
if fee.get('rate') is not None:
|
||||
return fee.get('rate')
|
||||
fee_curr = fee.get('currency')
|
||||
if fee_curr is None:
|
||||
return None
|
||||
fee_cost = float(fee['cost'])
|
||||
if self._ft_has['fee_cost_in_contracts']:
|
||||
# Convert cost via "contracts" conversion
|
||||
fee_cost = self._contracts_to_amount(symbol, fee['cost'])
|
||||
|
||||
# Calculate fee based on order details
|
||||
if fee_curr in self.get_pair_base_currency(order['symbol']):
|
||||
if fee_curr == self.get_pair_base_currency(symbol):
|
||||
# Base currency - divide by amount
|
||||
return round(
|
||||
order['fee']['cost'] / safe_value_fallback2(order, order, 'filled', 'amount'), 8)
|
||||
elif fee_curr in self.get_pair_quote_currency(order['symbol']):
|
||||
return round(fee_cost / amount, 8)
|
||||
elif fee_curr == self.get_pair_quote_currency(symbol):
|
||||
# Quote currency - divide by cost
|
||||
return round(self._contracts_to_amount(
|
||||
order['symbol'], order['fee']['cost']) / order['cost'],
|
||||
8) if order['cost'] else None
|
||||
return round(fee_cost / cost, 8) if cost else None
|
||||
else:
|
||||
# If Fee currency is a different currency
|
||||
if not order['cost']:
|
||||
if not cost:
|
||||
# If cost is None or 0.0 -> falsy, return None
|
||||
return None
|
||||
try:
|
||||
@@ -1664,19 +1709,28 @@ class Exchange:
|
||||
fee_to_quote_rate = self._config['exchange'].get('unknown_fee_rate', None)
|
||||
if not fee_to_quote_rate:
|
||||
return None
|
||||
return round((self._contracts_to_amount(
|
||||
order['symbol'], order['fee']['cost']) * fee_to_quote_rate) / order['cost'], 8)
|
||||
return round((fee_cost * fee_to_quote_rate) / cost, 8)
|
||||
|
||||
def extract_cost_curr_rate(self, order: Dict) -> Tuple[float, str, Optional[float]]:
|
||||
def extract_cost_curr_rate(self, fee: Dict, symbol: str, cost: float,
|
||||
amount: float) -> Tuple[float, str, Optional[float]]:
|
||||
"""
|
||||
Extract tuple of cost, currency, rate.
|
||||
Requires order_has_fee to run first!
|
||||
:param order: Order or trade (one trade) dict
|
||||
:param fee: ccxt Fee dict - must contain cost / currency / rate
|
||||
:param symbol: Symbol of the order
|
||||
:param cost: Total cost of the order
|
||||
:param amount: Amount of the order
|
||||
:return: Tuple with cost, currency, rate of the given fee dict
|
||||
"""
|
||||
return (order['fee']['cost'],
|
||||
order['fee']['currency'],
|
||||
self.calculate_fee_rate(order))
|
||||
return (float(fee['cost']),
|
||||
fee['currency'],
|
||||
self.calculate_fee_rate(
|
||||
fee,
|
||||
symbol,
|
||||
cost,
|
||||
amount
|
||||
)
|
||||
)
|
||||
|
||||
# Historic data
|
||||
|
||||
@@ -1719,7 +1773,7 @@ class Exchange:
|
||||
async def _async_get_historic_ohlcv(self, pair: str, timeframe: str,
|
||||
since_ms: int, candle_type: CandleType,
|
||||
is_new_pair: bool = False, raise_: bool = False,
|
||||
until_ms: int = None
|
||||
until_ms: Optional[int] = None
|
||||
) -> Tuple[str, str, str, List]:
|
||||
"""
|
||||
Download historic ohlcv
|
||||
@@ -1727,7 +1781,8 @@ class Exchange:
|
||||
:param candle_type: Any of the enum CandleType (must match trading mode!)
|
||||
"""
|
||||
|
||||
one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe)
|
||||
one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(
|
||||
timeframe, candle_type, since_ms)
|
||||
logger.debug(
|
||||
"one_call: %s msecs (%s)",
|
||||
one_call,
|
||||
@@ -1763,7 +1818,8 @@ class Exchange:
|
||||
if (not since_ms
|
||||
and (self._ft_has["ohlcv_require_since"] or self.required_candle_call_count > 1)):
|
||||
# Multiple calls for one pair - to get more history
|
||||
one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe)
|
||||
one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(
|
||||
timeframe, candle_type, since_ms)
|
||||
move_to = one_call * self.required_candle_call_count
|
||||
now = timeframe_to_next_date(timeframe)
|
||||
since_ms = int((now - timedelta(seconds=move_to // 1000)).timestamp() * 1000)
|
||||
@@ -1778,7 +1834,7 @@ class Exchange:
|
||||
|
||||
def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *,
|
||||
since_ms: Optional[int] = None, cache: bool = True,
|
||||
drop_incomplete: bool = None
|
||||
drop_incomplete: Optional[bool] = None
|
||||
) -> Dict[PairWithTimeframe, DataFrame]:
|
||||
"""
|
||||
Refresh in-memory OHLCV asynchronously and set `_klines` with the result
|
||||
@@ -1881,7 +1937,9 @@ class Exchange:
|
||||
pair, timeframe, since_ms, s
|
||||
)
|
||||
params = deepcopy(self._ft_has.get('ohlcv_params', {}))
|
||||
candle_limit = self.ohlcv_candle_limit(timeframe)
|
||||
candle_limit = self.ohlcv_candle_limit(
|
||||
timeframe, candle_type=candle_type, since_ms=since_ms)
|
||||
|
||||
if candle_type != CandleType.SPOT:
|
||||
params.update({'price': candle_type})
|
||||
if candle_type != CandleType.FUNDING_RATE:
|
||||
@@ -2128,10 +2186,11 @@ class Exchange:
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@retrier
|
||||
def get_market_leverage_tiers(self, symbol) -> List[Dict]:
|
||||
@retrier_async
|
||||
async def get_market_leverage_tiers(self, symbol: str) -> Tuple[str, List[Dict]]:
|
||||
try:
|
||||
return self._api.fetch_market_leverage_tiers(symbol)
|
||||
tier = await self._api_async.fetch_market_leverage_tiers(symbol)
|
||||
return symbol, tier
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||
@@ -2165,8 +2224,14 @@ class Exchange:
|
||||
f"Initializing leverage_tiers for {len(symbols)} markets. "
|
||||
"This will take about a minute.")
|
||||
|
||||
for symbol in sorted(symbols):
|
||||
tiers[symbol] = self.get_market_leverage_tiers(symbol)
|
||||
coros = [self.get_market_leverage_tiers(symbol) for symbol in sorted(symbols)]
|
||||
|
||||
for input_coro in chunks(coros, 100):
|
||||
|
||||
results = self.loop.run_until_complete(
|
||||
asyncio.gather(*input_coro, return_exceptions=True))
|
||||
for symbol, res in results:
|
||||
tiers[symbol] = res
|
||||
|
||||
logger.info(f"Done initializing {len(symbols)} markets.")
|
||||
|
||||
@@ -2416,14 +2481,35 @@ class Exchange:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def combine_funding_and_mark(funding_rates: DataFrame, mark_rates: DataFrame) -> DataFrame:
|
||||
def combine_funding_and_mark(funding_rates: DataFrame, mark_rates: DataFrame,
|
||||
futures_funding_rate: Optional[int] = None) -> DataFrame:
|
||||
"""
|
||||
Combine funding-rates and mark-rates dataframes
|
||||
:param funding_rates: Dataframe containing Funding rates (Type FUNDING_RATE)
|
||||
:param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price)
|
||||
:param futures_funding_rate: Fake funding rate to use if funding_rates are not available
|
||||
"""
|
||||
if futures_funding_rate is None:
|
||||
return mark_rates.merge(
|
||||
funding_rates, on='date', how="inner", suffixes=["_mark", "_fund"])
|
||||
else:
|
||||
if len(funding_rates) == 0:
|
||||
# No funding rate candles - full fillup with fallback variable
|
||||
mark_rates['open_fund'] = futures_funding_rate
|
||||
return mark_rates.rename(
|
||||
columns={'open': 'open_mark',
|
||||
'close': 'close_mark',
|
||||
'high': 'high_mark',
|
||||
'low': 'low_mark',
|
||||
'volume': 'volume_mark'})
|
||||
|
||||
return funding_rates.merge(mark_rates, on='date', how="inner", suffixes=["_fund", "_mark"])
|
||||
else:
|
||||
# Fill up missing funding_rate candles with fallback value
|
||||
combined = mark_rates.merge(
|
||||
funding_rates, on='date', how="outer", suffixes=["_mark", "_fund"]
|
||||
)
|
||||
combined['open_fund'] = combined['open_fund'].fillna(futures_funding_rate)
|
||||
return combined
|
||||
|
||||
def calculate_funding_fees(
|
||||
self,
|
||||
@@ -2698,9 +2784,10 @@ def timeframe_to_msecs(timeframe: str) -> int:
|
||||
|
||||
def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime:
|
||||
"""
|
||||
Use Timeframe and determine last possible candle.
|
||||
Use Timeframe and determine the candle start date for this date.
|
||||
Does not round when given a candle start date.
|
||||
:param timeframe: timeframe in string format (e.g. "5m")
|
||||
:param date: date to use. Defaults to utcnow()
|
||||
:param date: date to use. Defaults to now(utc)
|
||||
:returns: date of previous candle (with utc timezone)
|
||||
"""
|
||||
if not date:
|
||||
@@ -2715,7 +2802,7 @@ def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime:
|
||||
"""
|
||||
Use Timeframe and determine next candle.
|
||||
:param timeframe: timeframe in string format (e.g. "5m")
|
||||
:param date: date to use. Defaults to utcnow()
|
||||
:param date: date to use. Defaults to now(utc)
|
||||
:returns: date of next candle (with utc timezone)
|
||||
"""
|
||||
if not date:
|
||||
@@ -2725,6 +2812,23 @@ def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime:
|
||||
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
|
||||
|
||||
|
||||
def date_minus_candles(
|
||||
timeframe: str, candle_count: int, date: Optional[datetime] = None) -> datetime:
|
||||
"""
|
||||
subtract X candles from a date.
|
||||
:param timeframe: timeframe in string format (e.g. "5m")
|
||||
:param candle_count: Amount of candles to subtract.
|
||||
:param date: date to use. Defaults to now(utc)
|
||||
|
||||
"""
|
||||
if not date:
|
||||
date = datetime.now(timezone.utc)
|
||||
|
||||
tf_min = timeframe_to_minutes(timeframe)
|
||||
new_date = timeframe_to_prev_date(timeframe, date) - timedelta(minutes=tf_min * candle_count)
|
||||
return new_date
|
||||
|
||||
|
||||
def market_is_active(market: Dict) -> bool:
|
||||
"""
|
||||
Return True if the market is active.
|
||||
|
@@ -4,6 +4,7 @@ from typing import Any, Dict, List, Tuple
|
||||
|
||||
import ccxt
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import MarginMode, TradingMode
|
||||
from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, InvalidOrderException,
|
||||
OperationalException, TemporaryError)
|
||||
@@ -44,7 +45,7 @@ class Ftx(Exchange):
|
||||
|
||||
@retrier(retries=0)
|
||||
def stoploss(self, pair: str, amount: float, stop_price: float,
|
||||
order_types: Dict, side: str, leverage: float) -> Dict:
|
||||
order_types: Dict, side: BuySell, leverage: float) -> Dict:
|
||||
"""
|
||||
Creates a stoploss order.
|
||||
depending on order_types.stoploss configuration, uses 'market' or limit order.
|
||||
@@ -103,7 +104,7 @@ class Ftx(Exchange):
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
|
||||
def fetch_stoploss_order(self, order_id: str, pair: str) -> Dict:
|
||||
def fetch_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
|
||||
if self._config['dry_run']:
|
||||
return self.fetch_dry_run_order(order_id)
|
||||
|
||||
@@ -144,7 +145,7 @@ class Ftx(Exchange):
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@retrier
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str) -> Dict:
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
|
||||
if self._config['dry_run']:
|
||||
return {}
|
||||
try:
|
||||
|
@@ -1,11 +1,13 @@
|
||||
""" Gate.io exchange subclass """
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import MarginMode, TradingMode
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.misc import safe_value_fallback2
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,12 +26,16 @@ class Gateio(Exchange):
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 1000,
|
||||
"ohlcv_volume_currency": "quote",
|
||||
"time_in_force_parameter": "timeInForce",
|
||||
"order_time_in_force": ['gtc', 'ioc'],
|
||||
"stoploss_order_types": {"limit": "limit"},
|
||||
"stoploss_on_exchange": True,
|
||||
}
|
||||
|
||||
_ft_has_futures: Dict = {
|
||||
"needs_trading_fees": True
|
||||
"needs_trading_fees": True,
|
||||
"fee_cost_in_contracts": False, # Set explicitly to false for clarity
|
||||
"order_props_in_contracts": ['amount', 'filled', 'remaining'],
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [
|
||||
@@ -40,13 +46,33 @@ class Gateio(Exchange):
|
||||
]
|
||||
|
||||
def validate_ordertypes(self, order_types: Dict) -> None:
|
||||
super().validate_ordertypes(order_types)
|
||||
|
||||
if self.trading_mode != TradingMode.FUTURES:
|
||||
if any(v == 'market' for k, v in order_types.items()):
|
||||
raise OperationalException(
|
||||
f'Exchange {self.name} does not support market orders.')
|
||||
|
||||
def _get_params(
|
||||
self,
|
||||
side: BuySell,
|
||||
ordertype: str,
|
||||
leverage: float,
|
||||
reduceOnly: bool,
|
||||
time_in_force: str = 'gtc',
|
||||
) -> Dict:
|
||||
params = super()._get_params(
|
||||
side=side,
|
||||
ordertype=ordertype,
|
||||
leverage=leverage,
|
||||
reduceOnly=reduceOnly,
|
||||
time_in_force=time_in_force,
|
||||
)
|
||||
if ordertype == 'market' and self.trading_mode == TradingMode.FUTURES:
|
||||
params['type'] = 'market'
|
||||
param = self._ft_has.get('time_in_force_parameter', '')
|
||||
params.update({param: 'ioc'})
|
||||
return params
|
||||
|
||||
def get_trades_for_order(self, order_id: str, pair: str, since: datetime,
|
||||
params: Optional[Dict] = None) -> List:
|
||||
trades = super().get_trades_for_order(order_id, pair, since, params)
|
||||
@@ -61,7 +87,8 @@ class Gateio(Exchange):
|
||||
pair_fees = self._trading_fees.get(pair, {})
|
||||
if pair_fees:
|
||||
for idx, trade in enumerate(trades):
|
||||
if trade.get('fee', {}).get('cost') is None:
|
||||
fee = trade.get('fee', {})
|
||||
if fee and fee.get('cost') is None:
|
||||
takerOrMaker = trade.get('takerOrMaker', 'taker')
|
||||
if pair_fees.get(takerOrMaker) is not None:
|
||||
trades[idx]['fee'] = {
|
||||
@@ -71,14 +98,31 @@ class Gateio(Exchange):
|
||||
}
|
||||
return trades
|
||||
|
||||
def fetch_stoploss_order(self, order_id: str, pair: str, params={}) -> Dict:
|
||||
return self.fetch_order(
|
||||
def get_order_id_conditional(self, order: Dict[str, Any]) -> str:
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
return safe_value_fallback2(order, order, 'id_stop', 'id')
|
||||
return order['id']
|
||||
|
||||
def fetch_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
|
||||
order = self.fetch_order(
|
||||
order_id=order_id,
|
||||
pair=pair,
|
||||
params={'stop': True}
|
||||
)
|
||||
if self.trading_mode == TradingMode.FUTURES:
|
||||
if order['status'] == 'closed':
|
||||
# Places a real order - which we need to fetch explicitly.
|
||||
new_orderid = order.get('info', {}).get('trade_id')
|
||||
if new_orderid:
|
||||
order1 = self.fetch_order(order_id=new_orderid, pair=pair, params=params)
|
||||
order1['id_stop'] = order1['id']
|
||||
order1['id'] = order_id
|
||||
order1['stopPrice'] = order.get('stopPrice')
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params={}) -> Dict:
|
||||
return order1
|
||||
return order
|
||||
|
||||
def cancel_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
|
||||
return self.cancel_order(
|
||||
order_id=order_id,
|
||||
pair=pair,
|
||||
@@ -90,5 +134,7 @@ class Gateio(Exchange):
|
||||
Verify stop_loss against stoploss-order value (limit or price)
|
||||
Returns True if adjustment is necessary.
|
||||
"""
|
||||
return ((side == "sell" and stop_loss > float(order['stopPrice'])) or
|
||||
(side == "buy" and stop_loss < float(order['stopPrice'])))
|
||||
return (order.get('stopPrice', None) is None or (
|
||||
side == "sell" and stop_loss > float(order['stopPrice'])) or
|
||||
(side == "buy" and stop_loss < float(order['stopPrice']))
|
||||
)
|
||||
|
@@ -27,7 +27,13 @@ class Huobi(Exchange):
|
||||
Verify stop_loss against stoploss-order value (limit or price)
|
||||
Returns True if adjustment is necessary.
|
||||
"""
|
||||
return order['type'] == 'stop' and stop_loss > float(order['stopPrice'])
|
||||
return (
|
||||
order.get('stopPrice', None) is None
|
||||
or (
|
||||
order['type'] == 'stop'
|
||||
and stop_loss > float(order['stopPrice'])
|
||||
)
|
||||
)
|
||||
|
||||
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
|
||||
|
||||
|
@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
import ccxt
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import MarginMode, TradingMode
|
||||
from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, InvalidOrderException,
|
||||
OperationalException, TemporaryError)
|
||||
@@ -22,6 +23,7 @@ class Kraken(Exchange):
|
||||
_ft_has: Dict = {
|
||||
"stoploss_on_exchange": True,
|
||||
"ohlcv_candle_limit": 720,
|
||||
"ohlcv_has_history": False,
|
||||
"trades_pagination": "id",
|
||||
"trades_pagination_arg": "since",
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
@@ -43,7 +45,7 @@ class Kraken(Exchange):
|
||||
return (parent_check and
|
||||
market.get('darkpool', False) is False)
|
||||
|
||||
def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict:
|
||||
def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
|
||||
# Only fetch tickers for current stake currency
|
||||
# Otherwise the request for kraken becomes too large.
|
||||
symbols = list(self.get_markets(quote_currencies=[self._config['stake_currency']]))
|
||||
@@ -95,7 +97,7 @@ class Kraken(Exchange):
|
||||
|
||||
@retrier(retries=0)
|
||||
def stoploss(self, pair: str, amount: float, stop_price: float,
|
||||
order_types: Dict, side: str, leverage: float) -> Dict:
|
||||
order_types: Dict, side: BuySell, leverage: float) -> Dict:
|
||||
"""
|
||||
Creates a stoploss market order.
|
||||
Stoploss market orders is the only stoploss type supported by kraken.
|
||||
@@ -165,12 +167,14 @@ class Kraken(Exchange):
|
||||
|
||||
def _get_params(
|
||||
self,
|
||||
side: BuySell,
|
||||
ordertype: str,
|
||||
leverage: float,
|
||||
reduceOnly: bool,
|
||||
time_in_force: str = 'gtc'
|
||||
) -> Dict:
|
||||
params = super()._get_params(
|
||||
side=side,
|
||||
ordertype=ordertype,
|
||||
leverage=leverage,
|
||||
reduceOnly=reduceOnly,
|
||||
|
@@ -33,7 +33,10 @@ class Kucoin(Exchange):
|
||||
Verify stop_loss against stoploss-order value (limit or price)
|
||||
Returns True if adjustment is necessary.
|
||||
"""
|
||||
return order['info'].get('stop') is not None and stop_loss > float(order['stopPrice'])
|
||||
return (
|
||||
order.get('stopPrice', None) is None
|
||||
or stop_loss > float(order['stopPrice'])
|
||||
)
|
||||
|
||||
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
|
||||
|
||||
|
@@ -1,12 +1,15 @@
|
||||
import logging
|
||||
from typing import Dict, List, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import ccxt
|
||||
|
||||
from freqtrade.constants import BuySell
|
||||
from freqtrade.enums import MarginMode, TradingMode
|
||||
from freqtrade.enums.candletype import CandleType
|
||||
from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError
|
||||
from freqtrade.exchange import Exchange
|
||||
from freqtrade.exchange.common import retrier
|
||||
from freqtrade.exchange.exchange import date_minus_candles
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,12 +22,13 @@ class Okx(Exchange):
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 300,
|
||||
"ohlcv_candle_limit": 100, # Warning, special case with data prior to X months
|
||||
"mark_ohlcv_timeframe": "4h",
|
||||
"funding_fee_timeframe": "8h",
|
||||
}
|
||||
_ft_has_futures: Dict = {
|
||||
"tickers_have_quoteVolume": False,
|
||||
"fee_cost_in_contracts": True,
|
||||
}
|
||||
|
||||
_supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [
|
||||
@@ -34,14 +38,69 @@ class Okx(Exchange):
|
||||
(TradingMode.FUTURES, MarginMode.ISOLATED),
|
||||
]
|
||||
|
||||
net_only = True
|
||||
|
||||
def ohlcv_candle_limit(
|
||||
self, timeframe: str, candle_type: CandleType, since_ms: Optional[int] = None) -> int:
|
||||
"""
|
||||
Exchange ohlcv candle limit
|
||||
OKX has the following behaviour:
|
||||
* 300 candles for uptodate data
|
||||
* 100 candles for historic data
|
||||
* 100 candles for additional candles (not futures or spot).
|
||||
:param timeframe: Timeframe to check
|
||||
:param candle_type: Candle-type
|
||||
:param since_ms: Starting timestamp
|
||||
:return: Candle limit as integer
|
||||
"""
|
||||
if (
|
||||
candle_type in (CandleType.FUTURES, CandleType.SPOT) and
|
||||
(not since_ms or since_ms > (date_minus_candles(timeframe, 300).timestamp() * 1000))
|
||||
):
|
||||
return 300
|
||||
|
||||
return super().ohlcv_candle_limit(timeframe, candle_type, since_ms)
|
||||
|
||||
@retrier
|
||||
def additional_exchange_init(self) -> None:
|
||||
"""
|
||||
Additional exchange initialization logic.
|
||||
.api will be available at this point.
|
||||
Must be overridden in child methods if required.
|
||||
"""
|
||||
try:
|
||||
if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']:
|
||||
accounts = self._api.fetch_accounts()
|
||||
if len(accounts) > 0:
|
||||
self.net_only = accounts[0].get('info', {}).get('posMode') == 'net_mode'
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||
raise TemporaryError(
|
||||
f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e
|
||||
except ccxt.BaseError as e:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
def _get_posSide(self, side: BuySell, reduceOnly: bool):
|
||||
if self.net_only:
|
||||
return 'net'
|
||||
if not reduceOnly:
|
||||
# Enter
|
||||
return 'long' if side == 'buy' else 'short'
|
||||
else:
|
||||
# Exit
|
||||
return 'long' if side == 'sell' else 'short'
|
||||
|
||||
def _get_params(
|
||||
self,
|
||||
side: BuySell,
|
||||
ordertype: str,
|
||||
leverage: float,
|
||||
reduceOnly: bool,
|
||||
time_in_force: str = 'gtc',
|
||||
) -> Dict:
|
||||
params = super()._get_params(
|
||||
side=side,
|
||||
ordertype=ordertype,
|
||||
leverage=leverage,
|
||||
reduceOnly=reduceOnly,
|
||||
@@ -49,10 +108,11 @@ class Okx(Exchange):
|
||||
)
|
||||
if self.trading_mode == TradingMode.FUTURES and self.margin_mode:
|
||||
params['tdMode'] = self.margin_mode.value
|
||||
params['posSide'] = self._get_posSide(side, reduceOnly)
|
||||
return params
|
||||
|
||||
@retrier
|
||||
def _lev_prep(self, pair: str, leverage: float, side: str):
|
||||
def _lev_prep(self, pair: str, leverage: float, side: BuySell):
|
||||
if self.trading_mode != TradingMode.SPOT and self.margin_mode is not None:
|
||||
try:
|
||||
# TODO-lev: Test me properly (check mgnMode passed)
|
||||
@@ -61,7 +121,7 @@ class Okx(Exchange):
|
||||
symbol=pair,
|
||||
params={
|
||||
"mgnMode": self.margin_mode.value,
|
||||
# "posSide": "net"",
|
||||
"posSide": self._get_posSide(side, False),
|
||||
})
|
||||
except ccxt.DDoSProtection as e:
|
||||
raise DDosProtection(e) from e
|
||||
|
@@ -4,7 +4,7 @@ Freqtrade is the main module of this bot. It contains the class Freqtrade()
|
||||
import copy
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime, time, timezone
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from math import isclose
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -13,7 +13,7 @@ from schedule import Scheduler
|
||||
|
||||
from freqtrade import __version__, constants
|
||||
from freqtrade.configuration import validate_config_consistency
|
||||
from freqtrade.constants import LongShort
|
||||
from freqtrade.constants import BuySell, LongShort
|
||||
from freqtrade.data.converter import order_book_to_dataframe
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.edge import Edge
|
||||
@@ -22,6 +22,7 @@ from freqtrade.enums import (ExitCheckTuple, ExitType, RPCMessageType, RunMode,
|
||||
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
|
||||
InvalidOrderException, PricingError)
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
|
||||
from freqtrade.exchange.exchange import timeframe_to_next_date
|
||||
from freqtrade.misc import safe_value_fallback, safe_value_fallback2
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db
|
||||
@@ -66,14 +67,12 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
|
||||
|
||||
init_db(self.config.get('db_url', None), clean_open_orders=self.config['dry_run'])
|
||||
init_db(self.config['db_url'])
|
||||
|
||||
self.wallets = Wallets(self.config, self.exchange)
|
||||
|
||||
PairLocks.timeframe = self.config['timeframe']
|
||||
|
||||
self.protections = ProtectionManager(self.config, self.strategy.protections)
|
||||
|
||||
# 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
|
||||
@@ -122,7 +121,9 @@ class FreqtradeBot(LoggingMixin):
|
||||
self._schedule.every().day.at(t).do(update)
|
||||
self.last_process = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
self.strategy.bot_start()
|
||||
self.strategy.ft_bot_start()
|
||||
# Initialize protections AFTER bot start - otherwise parameters are not loaded.
|
||||
self.protections = ProtectionManager(self.config, self.strategy.protections)
|
||||
|
||||
def notify_status(self, msg: str) -> None:
|
||||
"""
|
||||
@@ -190,8 +191,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
self.strategy.analyze(self.active_pair_whitelist)
|
||||
|
||||
with self._exit_lock:
|
||||
# Check and handle any timed out open orders
|
||||
self.check_handle_timedout()
|
||||
# Check for exchange cancelations, timeouts and user requested replace
|
||||
self.manage_open_orders()
|
||||
|
||||
# Protect from collisions with force_exit.
|
||||
# Without this, freqtrade my try to recreate stoploss_on_exchange orders
|
||||
@@ -226,7 +227,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
Notify the user when the bot is stopped (not reloaded)
|
||||
and there are still open trades active.
|
||||
"""
|
||||
open_trades = Trade.get_trades([Trade.is_open.is_(True)]).all()
|
||||
open_trades = Trade.get_open_trades()
|
||||
|
||||
if len(open_trades) != 0 and self.state != State.RELOAD_CONFIG:
|
||||
msg = {
|
||||
@@ -298,7 +299,17 @@ class FreqtradeBot(LoggingMixin):
|
||||
fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair,
|
||||
order.ft_order_side == 'stoploss')
|
||||
|
||||
self.update_trade_state(order.trade, order.order_id, fo)
|
||||
self.update_trade_state(order.trade, order.order_id, fo,
|
||||
stoploss_order=(order.ft_order_side == 'stoploss'))
|
||||
|
||||
except InvalidOrderException as e:
|
||||
logger.warning(f"Error updating Order {order.order_id} due to {e}.")
|
||||
if order.order_date_utc - timedelta(days=5) < datetime.now(timezone.utc):
|
||||
logger.warning(
|
||||
"Order is older than 5 days. Assuming order was fully cancelled.")
|
||||
fo = order.to_ccxt_object()
|
||||
fo['status'] = 'canceled'
|
||||
self.handle_timedout_order(fo, order.trade)
|
||||
|
||||
except ExchangeError as e:
|
||||
|
||||
@@ -321,6 +332,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
if not trade.is_open and not trade.fee_updated(trade.exit_side):
|
||||
# Get sell fee
|
||||
order = trade.select_order(trade.exit_side, False)
|
||||
if not order:
|
||||
order = trade.select_order('stoploss', False)
|
||||
if order:
|
||||
logger.info(
|
||||
f"Updating {trade.exit_side}-fee on trade {trade}"
|
||||
@@ -535,7 +548,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
if stake_amount is not None and stake_amount > 0.0:
|
||||
# We should increase our position
|
||||
self.execute_entry(trade.pair, stake_amount, trade=trade, is_short=trade.is_short)
|
||||
self.execute_entry(trade.pair, stake_amount, price=current_rate,
|
||||
trade=trade, is_short=trade.is_short)
|
||||
|
||||
if stake_amount is not None and stake_amount < 0.0:
|
||||
# We should decrease our position
|
||||
@@ -585,6 +599,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
ordertype: Optional[str] = None,
|
||||
enter_tag: Optional[str] = None,
|
||||
trade: Optional[Trade] = None,
|
||||
order_adjust: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Executes a limit buy for the given pair
|
||||
@@ -594,12 +609,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
"""
|
||||
time_in_force = self.strategy.order_time_in_force['entry']
|
||||
|
||||
[side, name] = ['sell', 'Short'] if is_short else ['buy', 'Long']
|
||||
side: BuySell = 'sell' if is_short else 'buy'
|
||||
name = 'Short' if is_short else 'Long'
|
||||
trade_side: LongShort = 'short' if is_short else 'long'
|
||||
pos_adjust = trade is not None
|
||||
|
||||
enter_limit_requested, stake_amount, leverage = self.get_valid_enter_price_and_stake(
|
||||
pair, price, stake_amount, trade_side, enter_tag, trade)
|
||||
pair, price, stake_amount, trade_side, enter_tag, trade, order_adjust)
|
||||
|
||||
if not stake_amount:
|
||||
return False
|
||||
@@ -620,7 +636,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested,
|
||||
time_in_force=time_in_force, current_time=datetime.now(timezone.utc),
|
||||
entry_tag=enter_tag, side=trade_side):
|
||||
logger.info(f"User requested abortion of buying {pair}")
|
||||
logger.info(f"User denied entry for {pair}.")
|
||||
return False
|
||||
order = self.exchange.create_order(
|
||||
pair=pair,
|
||||
@@ -634,7 +650,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
)
|
||||
order_obj = Order.parse_from_ccxt_object(order, pair, side)
|
||||
order_id = order['id']
|
||||
order_status = order.get('status', None)
|
||||
order_status = order.get('status')
|
||||
logger.info(f"Order #{order_id} was created for {pair} and status is {order_status}.")
|
||||
|
||||
# we assume the order is executed at the price requested
|
||||
@@ -744,23 +760,26 @@ class FreqtradeBot(LoggingMixin):
|
||||
self, pair: str, price: Optional[float], stake_amount: float,
|
||||
trade_side: LongShort,
|
||||
entry_tag: Optional[str],
|
||||
trade: Optional[Trade]
|
||||
trade: Optional[Trade],
|
||||
order_adjust: bool,
|
||||
) -> Tuple[float, float, float]:
|
||||
|
||||
if price:
|
||||
enter_limit_requested = price
|
||||
else:
|
||||
# Calculate price
|
||||
proposed_enter_rate = self.exchange.get_rate(
|
||||
enter_limit_requested = self.exchange.get_rate(
|
||||
pair, side='entry', is_short=(trade_side == 'short'), refresh=True)
|
||||
if not order_adjust:
|
||||
# Don't call custom_entry_price in order-adjust scenario
|
||||
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
|
||||
default_retval=proposed_enter_rate)(
|
||||
default_retval=enter_limit_requested)(
|
||||
pair=pair, current_time=datetime.now(timezone.utc),
|
||||
proposed_rate=proposed_enter_rate, entry_tag=entry_tag,
|
||||
proposed_rate=enter_limit_requested, entry_tag=entry_tag,
|
||||
side=trade_side,
|
||||
)
|
||||
|
||||
enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate)
|
||||
enter_limit_requested = self.get_valid_price(custom_entry_price, enter_limit_requested)
|
||||
|
||||
if not enter_limit_requested:
|
||||
raise PricingError('Could not determine entry price.')
|
||||
@@ -773,7 +792,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
current_rate=enter_limit_requested,
|
||||
proposed_leverage=1.0,
|
||||
max_leverage=max_leverage,
|
||||
side=trade_side,
|
||||
side=trade_side, entry_tag=entry_tag,
|
||||
) if self.trading_mode != TradingMode.SPOT else 1.0
|
||||
# Cap leverage between 1.0 and max_leverage.
|
||||
leverage = min(max(leverage, 1.0), max_leverage)
|
||||
@@ -797,7 +816,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
pair=pair, current_time=datetime.now(timezone.utc),
|
||||
current_rate=enter_limit_requested, proposed_stake=stake_amount,
|
||||
min_stake=min_stake_amount, max_stake=min(max_stake_amount, stake_available),
|
||||
entry_tag=entry_tag, side=trade_side
|
||||
leverage=leverage, entry_tag=entry_tag, side=trade_side
|
||||
)
|
||||
|
||||
stake_amount = self.wallets.validate_stake_amount(
|
||||
@@ -829,7 +848,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
'type': msg_type,
|
||||
'buy_tag': trade.enter_tag,
|
||||
'enter_tag': trade.enter_tag,
|
||||
'exchange': self.exchange.name.capitalize(),
|
||||
'exchange': trade.exchange.capitalize(),
|
||||
'pair': trade.pair,
|
||||
'leverage': trade.leverage if trade.leverage else None,
|
||||
'direction': 'Short' if trade.is_short else 'Long',
|
||||
@@ -859,7 +878,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
'type': RPCMessageType.ENTRY_CANCEL,
|
||||
'buy_tag': trade.enter_tag,
|
||||
'enter_tag': trade.enter_tag,
|
||||
'exchange': self.exchange.name.capitalize(),
|
||||
'exchange': trade.exchange.capitalize(),
|
||||
'pair': trade.pair,
|
||||
'leverage': trade.leverage,
|
||||
'direction': 'Short' if trade.is_short else 'Long',
|
||||
@@ -942,6 +961,29 @@ class FreqtradeBot(LoggingMixin):
|
||||
logger.debug(f'Found no {exit_signal_type} signal for %s.', trade)
|
||||
return False
|
||||
|
||||
def _check_and_execute_exit(self, trade: Trade, exit_rate: float,
|
||||
enter: bool, exit_: bool, exit_tag: Optional[str]) -> bool:
|
||||
"""
|
||||
Check and execute trade exit
|
||||
"""
|
||||
exits: List[ExitCheckTuple] = self.strategy.should_exit(
|
||||
trade,
|
||||
exit_rate,
|
||||
datetime.now(timezone.utc),
|
||||
enter=enter,
|
||||
exit_=exit_,
|
||||
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
|
||||
)
|
||||
for should_exit in exits:
|
||||
if should_exit.exit_flag:
|
||||
exit_tag1 = exit_tag if should_exit.exit_type == ExitType.EXIT_SIGNAL else None
|
||||
logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.exit_type}'
|
||||
f'{f" Tag: {exit_tag1}" if exit_tag1 is not None else ""}')
|
||||
exited = self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag1)
|
||||
if exited:
|
||||
return True
|
||||
return False
|
||||
|
||||
def create_stoploss_order(self, trade: Trade, stop_price: float) -> bool:
|
||||
"""
|
||||
Abstracts creating stoploss orders from the logic.
|
||||
@@ -1011,7 +1053,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Lock pair for one candle to prevent immediate rebuys
|
||||
self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc),
|
||||
reason='Auto lock')
|
||||
self._notify_exit(trade, "stoploss")
|
||||
self._notify_exit(trade, "stoploss", True)
|
||||
return True
|
||||
|
||||
if trade.open_order_id or not trade.is_open:
|
||||
@@ -1093,34 +1135,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
logger.warning(f"Could not create trailing stoploss order "
|
||||
f"for pair {trade.pair}.")
|
||||
|
||||
def _check_and_execute_exit(self, trade: Trade, exit_rate: float,
|
||||
enter: bool, exit_: bool, exit_tag: Optional[str]) -> bool:
|
||||
def manage_open_orders(self) -> None:
|
||||
"""
|
||||
Check and execute trade exit
|
||||
"""
|
||||
should_exit: ExitCheckTuple = self.strategy.should_exit(
|
||||
trade,
|
||||
exit_rate,
|
||||
datetime.now(timezone.utc),
|
||||
enter=enter,
|
||||
exit_=exit_,
|
||||
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
|
||||
)
|
||||
|
||||
if should_exit.exit_flag:
|
||||
logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.exit_type}'
|
||||
f'Tag: {exit_tag if exit_tag is not None else "None"}')
|
||||
self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag)
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_handle_timedout(self) -> None:
|
||||
"""
|
||||
Check if any orders are timed out and cancel if necessary
|
||||
:param timeoutvalue: Number of minutes until order is considered timed out
|
||||
Management of open orders on exchange. Unfilled orders might be cancelled if timeout
|
||||
was met or replaced if there's a new candle and user has requested it.
|
||||
Timeout setting takes priority over limit order adjustment request.
|
||||
:return: None
|
||||
"""
|
||||
|
||||
for trade in Trade.get_open_order_trades():
|
||||
try:
|
||||
if not trade.open_order_id:
|
||||
@@ -1131,33 +1152,88 @@ class FreqtradeBot(LoggingMixin):
|
||||
continue
|
||||
|
||||
fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order)
|
||||
is_entering = order['side'] == trade.entry_side
|
||||
not_closed = order['status'] == 'open' or fully_cancelled
|
||||
max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0)
|
||||
|
||||
order_obj = trade.select_order_by_order_id(trade.open_order_id)
|
||||
|
||||
if not_closed and (fully_cancelled or (order_obj and self.strategy.ft_check_timed_out(
|
||||
trade, order_obj, datetime.now(timezone.utc)))
|
||||
):
|
||||
if is_entering:
|
||||
self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT'])
|
||||
if not_closed:
|
||||
if fully_cancelled or (order_obj and self.strategy.ft_check_timed_out(
|
||||
trade, order_obj, datetime.now(timezone.utc))):
|
||||
self.handle_timedout_order(order, trade)
|
||||
else:
|
||||
canceled = self.handle_cancel_exit(
|
||||
trade, order, constants.CANCEL_REASON['TIMEOUT'])
|
||||
canceled_count = trade.get_exit_order_count()
|
||||
max_timeouts = self.config.get(
|
||||
'unfilledtimeout', {}).get('exit_timeout_count', 0)
|
||||
if canceled and max_timeouts > 0 and canceled_count >= max_timeouts:
|
||||
logger.warning(f'Emergency exiting trade {trade}, as the exit order '
|
||||
f'timed out {max_timeouts} times.')
|
||||
try:
|
||||
self.execute_trade_exit(
|
||||
trade, order.get('price'),
|
||||
exit_check=ExitCheckTuple(exit_type=ExitType.EMERGENCY_EXIT))
|
||||
except DependencyException as exception:
|
||||
logger.warning(
|
||||
f'Unable to emergency sell trade {trade.pair}: {exception}')
|
||||
self.replace_order(order, order_obj, trade)
|
||||
|
||||
def handle_timedout_order(self, order: Dict, trade: Trade) -> None:
|
||||
"""
|
||||
Check if current analyzed order timed out and cancel if necessary.
|
||||
:param order: Order dict grabbed with exchange.fetch_order()
|
||||
:param trade: Trade object.
|
||||
:return: None
|
||||
"""
|
||||
if order['side'] == trade.entry_side:
|
||||
self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT'])
|
||||
else:
|
||||
canceled = self.handle_cancel_exit(
|
||||
trade, order, constants.CANCEL_REASON['TIMEOUT'])
|
||||
canceled_count = trade.get_exit_order_count()
|
||||
max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0)
|
||||
if canceled and max_timeouts > 0 and canceled_count >= max_timeouts:
|
||||
logger.warning(f'Emergency exiting trade {trade}, as the exit order '
|
||||
f'timed out {max_timeouts} times.')
|
||||
try:
|
||||
self.execute_trade_exit(
|
||||
trade, order['price'],
|
||||
exit_check=ExitCheckTuple(exit_type=ExitType.EMERGENCY_EXIT))
|
||||
except DependencyException as exception:
|
||||
logger.warning(
|
||||
f'Unable to emergency sell trade {trade.pair}: {exception}')
|
||||
|
||||
def replace_order(self, order: Dict, order_obj: Optional[Order], trade: Trade) -> None:
|
||||
"""
|
||||
Check if current analyzed entry order should be replaced or simply cancelled.
|
||||
To simply cancel the existing order(no replacement) adjust_entry_price() should return None
|
||||
To maintain existing order adjust_entry_price() should return order_obj.price
|
||||
To replace existing order adjust_entry_price() should return desired price for limit order
|
||||
:param order: Order dict grabbed with exchange.fetch_order()
|
||||
:param order_obj: Order object.
|
||||
:param trade: Trade object.
|
||||
:return: None
|
||||
"""
|
||||
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair,
|
||||
self.strategy.timeframe)
|
||||
latest_candle_open_date = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None
|
||||
latest_candle_close_date = timeframe_to_next_date(self.strategy.timeframe,
|
||||
latest_candle_open_date)
|
||||
# Check if new candle
|
||||
if order_obj and latest_candle_close_date > order_obj.order_date_utc:
|
||||
# New candle
|
||||
proposed_rate = self.exchange.get_rate(
|
||||
trade.pair, side='entry', is_short=trade.is_short, refresh=True)
|
||||
adjusted_entry_price = strategy_safe_wrapper(self.strategy.adjust_entry_price,
|
||||
default_retval=order_obj.price)(
|
||||
trade=trade, order=order_obj, pair=trade.pair,
|
||||
current_time=datetime.now(timezone.utc), proposed_rate=proposed_rate,
|
||||
current_order_rate=order_obj.price, entry_tag=trade.enter_tag,
|
||||
side=trade.entry_side)
|
||||
|
||||
replacing = True
|
||||
cancel_reason = constants.CANCEL_REASON['REPLACE']
|
||||
if not adjusted_entry_price:
|
||||
replacing = False
|
||||
cancel_reason = constants.CANCEL_REASON['USER_CANCEL']
|
||||
if order_obj.price != adjusted_entry_price:
|
||||
# cancel existing order if new price is supplied or None
|
||||
self.handle_cancel_enter(trade, order, cancel_reason,
|
||||
replacing=replacing)
|
||||
if adjusted_entry_price:
|
||||
# place new order only if new price is supplied
|
||||
self.execute_entry(
|
||||
pair=trade.pair,
|
||||
stake_amount=(order_obj.remaining * order_obj.price),
|
||||
price=adjusted_entry_price,
|
||||
trade=trade,
|
||||
is_short=trade.is_short,
|
||||
order_adjust=True,
|
||||
)
|
||||
|
||||
def cancel_all_open_orders(self) -> None:
|
||||
"""
|
||||
@@ -1179,9 +1255,13 @@ class FreqtradeBot(LoggingMixin):
|
||||
self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['ALL_CANCELLED'])
|
||||
Trade.commit()
|
||||
|
||||
def handle_cancel_enter(self, trade: Trade, order: Dict, reason: str) -> bool:
|
||||
def handle_cancel_enter(
|
||||
self, trade: Trade, order: Dict, reason: str,
|
||||
replacing: Optional[bool] = False
|
||||
) -> bool:
|
||||
"""
|
||||
Buy cancel - cancel order
|
||||
:param replacing: Replacing order - prevent trade deletion.
|
||||
:return: True if order was fully cancelled
|
||||
"""
|
||||
was_trade_fully_canceled = False
|
||||
@@ -1217,9 +1297,10 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Using filled to determine the filled amount
|
||||
filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled')
|
||||
if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC):
|
||||
logger.info(f'{side} order fully cancelled. Removing {trade} from database.')
|
||||
# if trade is not partially completed and it's the only order, just delete the trade
|
||||
if len(trade.orders) <= 1:
|
||||
open_order_count = len([order for order in trade.orders if order.status == 'open'])
|
||||
if open_order_count <= 1 and trade.nr_of_successful_entries == 0 and not replacing:
|
||||
logger.info(f'{side} order fully cancelled. Removing {trade} from database.')
|
||||
trade.delete()
|
||||
was_trade_fully_canceled = True
|
||||
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
|
||||
@@ -1227,7 +1308,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
# FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below.
|
||||
self.update_trade_state(trade, trade.open_order_id, corder)
|
||||
trade.open_order_id = None
|
||||
logger.info(f'Partial {side} order timeout for {trade}.')
|
||||
logger.info(f'{side} Order timeout for {trade}.')
|
||||
else:
|
||||
# if trade is partially complete, edit the stake details for the trade
|
||||
# and close the order
|
||||
@@ -1339,7 +1420,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
:param trade: Trade instance
|
||||
:param limit: limit rate for the sell order
|
||||
:param exit_check: CheckTuple with signal and reason
|
||||
:return: True if it succeeds (supported) False (not supported)
|
||||
:return: True if it succeeds False
|
||||
"""
|
||||
trade.funding_fees = self.exchange.get_funding_fees(
|
||||
pair=trade.pair,
|
||||
@@ -1348,6 +1429,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
open_date=trade.open_date_utc,
|
||||
)
|
||||
exit_type = 'exit'
|
||||
exit_reason = exit_tag or exit_check.exit_reason
|
||||
if exit_check.exit_type in (ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS):
|
||||
exit_type = 'stoploss'
|
||||
|
||||
@@ -1365,7 +1447,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
pair=trade.pair, trade=trade,
|
||||
current_time=datetime.now(timezone.utc),
|
||||
proposed_rate=proposed_limit_rate, current_profit=current_profit,
|
||||
exit_tag=exit_check.exit_reason)
|
||||
exit_tag=exit_reason)
|
||||
|
||||
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
|
||||
|
||||
@@ -1382,10 +1464,10 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
|
||||
pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit,
|
||||
time_in_force=time_in_force, exit_reason=exit_check.exit_reason,
|
||||
sell_reason=exit_check.exit_reason, # sellreason -> compatibility
|
||||
time_in_force=time_in_force, exit_reason=exit_reason,
|
||||
sell_reason=exit_reason, # sellreason -> compatibility
|
||||
current_time=datetime.now(timezone.utc)):
|
||||
logger.info(f"User requested abortion of exiting {trade.pair}")
|
||||
logger.info(f"User denied exit for {trade.pair}.")
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -1412,7 +1494,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.open_order_id = order['id']
|
||||
trade.exit_order_status = ''
|
||||
trade.close_rate_requested = limit
|
||||
trade.exit_reason = exit_tag or exit_check.exit_reason
|
||||
trade.exit_reason = exit_reason
|
||||
|
||||
# Lock pair for one candle to prevent immediate re-trading
|
||||
self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc),
|
||||
@@ -1462,7 +1544,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
'open_date': trade.open_date,
|
||||
'close_date': trade.close_date or datetime.utcnow(),
|
||||
'stake_currency': self.config['stake_currency'],
|
||||
'fiat_currency': self.config.get('fiat_display_currency', None),
|
||||
'fiat_currency': self.config.get('fiat_display_currency'),
|
||||
}
|
||||
|
||||
if 'fiat_display_currency' in self.config:
|
||||
@@ -1573,12 +1655,11 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
if order['status'] in constants.NON_OPEN_EXCHANGE_STATES:
|
||||
# If a entry order was closed, force update on stoploss on exchange
|
||||
if order.get('side', None) == trade.entry_side:
|
||||
if order.get('side') == trade.entry_side:
|
||||
trade = self.cancel_stoploss_on_exchange(trade)
|
||||
# TODO: Margin will need to use interest_rate as well.
|
||||
# interest_rate = self.exchange.get_interest_rate()
|
||||
trade.set_isolated_liq(self.exchange.get_liquidation_price(
|
||||
|
||||
leverage=trade.leverage,
|
||||
pair=trade.pair,
|
||||
amount=trade.amount,
|
||||
@@ -1597,7 +1678,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
if send_msg and not stoploss_order and not trade.open_order_id:
|
||||
self._notify_exit(trade, '', True)
|
||||
self.handle_protections(trade.pair, trade.trade_direction)
|
||||
elif send_msg and not trade.open_order_id:
|
||||
elif send_msg and not trade.open_order_id and not stoploss_order:
|
||||
# Enter fill
|
||||
self._notify_enter(trade, order, fill=True)
|
||||
|
||||
@@ -1663,7 +1744,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade_base_currency = self.exchange.get_pair_base_currency(trade.pair)
|
||||
# use fee from order-dict if possible
|
||||
if self.exchange.order_has_fee(order):
|
||||
fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(order)
|
||||
fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(
|
||||
order['fee'], order['symbol'], order['cost'], order_obj.safe_filled)
|
||||
logger.info(f"Fee for Trade {trade} [{order_obj.ft_order_side}]: "
|
||||
f"{fee_cost:.8g} {fee_currency} - rate: {fee_rate}")
|
||||
if fee_rate is None or fee_rate < 0.02:
|
||||
@@ -1701,7 +1783,15 @@ class FreqtradeBot(LoggingMixin):
|
||||
for exectrade in trades:
|
||||
amount += exectrade['amount']
|
||||
if self.exchange.order_has_fee(exectrade):
|
||||
fee_cost_, fee_currency, fee_rate_ = self.exchange.extract_cost_curr_rate(exectrade)
|
||||
# Prefer singular fee
|
||||
fees = [exectrade['fee']]
|
||||
else:
|
||||
fees = exectrade.get('fees', [])
|
||||
for fee in fees:
|
||||
|
||||
fee_cost_, fee_currency, fee_rate_ = self.exchange.extract_cost_curr_rate(
|
||||
fee, exectrade['symbol'], exectrade['cost'], exectrade['amount']
|
||||
)
|
||||
fee_cost += fee_cost_
|
||||
if fee_rate_ is not None:
|
||||
fee_rate_array.append(fee_rate_)
|
||||
|
@@ -87,7 +87,7 @@ class Backtesting:
|
||||
self.exchange = ExchangeResolver.load_exchange(self._exchange_name, self.config)
|
||||
self.dataprovider = DataProvider(self.config, self.exchange)
|
||||
|
||||
if self.config.get('strategy_list', None):
|
||||
if self.config.get('strategy_list'):
|
||||
for strat in list(self.config['strategy_list']):
|
||||
stratconf = deepcopy(self.config)
|
||||
stratconf['strategy'] = strat
|
||||
@@ -187,7 +187,9 @@ class Backtesting:
|
||||
# since a "perfect" stoploss-exit is assumed anyway
|
||||
# And the regular "stoploss" function would not apply to that case
|
||||
self.strategy.order_types['stoploss_on_exchange'] = False
|
||||
self.strategy.bot_start()
|
||||
|
||||
self.strategy.ft_bot_start()
|
||||
strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)()
|
||||
|
||||
def _load_protections(self, strategy: IStrategy):
|
||||
if self.config.get('enable_protections', False):
|
||||
@@ -275,8 +277,12 @@ class Backtesting:
|
||||
if pair not in self.exchange._leverage_tiers:
|
||||
unavailable_pairs.append(pair)
|
||||
continue
|
||||
self.futures_data[pair] = funding_rates_dict[pair].merge(
|
||||
mark_rates_dict[pair], on='date', how="inner", suffixes=["_fund", "_mark"])
|
||||
|
||||
self.futures_data[pair] = self.exchange.combine_funding_and_mark(
|
||||
funding_rates=funding_rates_dict[pair],
|
||||
mark_rates=mark_rates_dict[pair],
|
||||
futures_funding_rate=self.config.get('futures_funding_rate', None),
|
||||
)
|
||||
|
||||
if unavailable_pairs:
|
||||
raise OperationalException(
|
||||
@@ -297,6 +303,9 @@ class Backtesting:
|
||||
self.rejected_trades = 0
|
||||
self.timedout_entry_orders = 0
|
||||
self.timedout_exit_orders = 0
|
||||
self.canceled_trade_entries = 0
|
||||
self.canceled_entry_orders = 0
|
||||
self.replaced_entry_orders = 0
|
||||
self.dataprovider.clear_cache()
|
||||
if enable_protections:
|
||||
self._load_protections(self.strategy)
|
||||
@@ -493,7 +502,8 @@ class Backtesting:
|
||||
stake_available = self.wallets.get_available_stake_amount()
|
||||
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
|
||||
default_retval=None)(
|
||||
trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX],
|
||||
trade=trade, # type: ignore[arg-type]
|
||||
current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX],
|
||||
current_profit=current_profit, min_stake=min_stake,
|
||||
max_stake=min(max_stake, stake_available))
|
||||
|
||||
@@ -524,64 +534,76 @@ class Backtesting:
|
||||
if check_adjust_entry:
|
||||
trade = self._get_adjust_trade_entry_for_candle(trade, row)
|
||||
|
||||
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
|
||||
enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
|
||||
exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX]
|
||||
exit_ = self.strategy.should_exit(
|
||||
trade, row[OPEN_IDX], exit_candle_time, # type: ignore
|
||||
exits = self.strategy.should_exit(
|
||||
trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(), # type: ignore
|
||||
enter=enter, exit_=exit_sig,
|
||||
low=row[LOW_IDX], high=row[HIGH_IDX]
|
||||
)
|
||||
for exit_ in exits:
|
||||
t = self._get_exit_for_signal(trade, row, exit_)
|
||||
if t:
|
||||
return t
|
||||
return None
|
||||
|
||||
def _get_exit_for_signal(self, trade: LocalTrade, row: Tuple,
|
||||
exit_: ExitCheckTuple) -> Optional[LocalTrade]:
|
||||
|
||||
exit_candle_time: datetime = row[DATE_IDX].to_pydatetime()
|
||||
if exit_.exit_flag:
|
||||
trade.close_date = exit_candle_time
|
||||
exit_reason = exit_.exit_reason
|
||||
|
||||
trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
|
||||
try:
|
||||
closerate = self._get_close_rate(row, trade, exit_, trade_dur)
|
||||
close_rate = self._get_close_rate(row, trade, exit_, trade_dur)
|
||||
except ValueError:
|
||||
return None
|
||||
# call the custom exit price,with default value as previous closerate
|
||||
current_profit = trade.calc_profit_ratio(closerate)
|
||||
# call the custom exit price,with default value as previous close_rate
|
||||
current_profit = trade.calc_profit_ratio(close_rate)
|
||||
order_type = self.strategy.order_types['exit']
|
||||
if exit_.exit_type in (ExitType.EXIT_SIGNAL, ExitType.CUSTOM_EXIT):
|
||||
# Checks and adds an exit tag, after checking that the length of the
|
||||
# row has the length for an exit tag column
|
||||
if(
|
||||
len(row) > EXIT_TAG_IDX
|
||||
and row[EXIT_TAG_IDX] is not None
|
||||
and len(row[EXIT_TAG_IDX]) > 0
|
||||
and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
|
||||
):
|
||||
exit_reason = row[EXIT_TAG_IDX]
|
||||
# Custom exit pricing only for exit-signals
|
||||
if order_type == 'limit':
|
||||
closerate = strategy_safe_wrapper(self.strategy.custom_exit_price,
|
||||
default_retval=closerate)(
|
||||
pair=trade.pair, trade=trade,
|
||||
close_rate = strategy_safe_wrapper(self.strategy.custom_exit_price,
|
||||
default_retval=close_rate)(
|
||||
pair=trade.pair,
|
||||
trade=trade, # type: ignore[arg-type]
|
||||
current_time=exit_candle_time,
|
||||
proposed_rate=closerate, current_profit=current_profit,
|
||||
exit_tag=exit_.exit_reason)
|
||||
proposed_rate=close_rate, current_profit=current_profit,
|
||||
exit_tag=exit_reason)
|
||||
# We can't place orders lower than current low.
|
||||
# freqtrade does not support this in live, and the order would fill immediately
|
||||
if trade.is_short:
|
||||
closerate = min(closerate, row[HIGH_IDX])
|
||||
close_rate = min(close_rate, row[HIGH_IDX])
|
||||
else:
|
||||
closerate = max(closerate, row[LOW_IDX])
|
||||
close_rate = max(close_rate, row[LOW_IDX])
|
||||
# Confirm trade exit:
|
||||
time_in_force = self.strategy.order_time_in_force['exit']
|
||||
|
||||
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,
|
||||
pair=trade.pair,
|
||||
trade=trade, # type: ignore[arg-type]
|
||||
order_type='limit',
|
||||
amount=trade.amount,
|
||||
rate=close_rate,
|
||||
time_in_force=time_in_force,
|
||||
sell_reason=exit_.exit_reason, # deprecated
|
||||
exit_reason=exit_.exit_reason,
|
||||
sell_reason=exit_reason, # deprecated
|
||||
exit_reason=exit_reason,
|
||||
current_time=exit_candle_time):
|
||||
return None
|
||||
|
||||
trade.exit_reason = exit_.exit_reason
|
||||
|
||||
# Checks and adds an exit tag, after checking that the length of the
|
||||
# row has the length for an exit tag column
|
||||
if(
|
||||
len(row) > EXIT_TAG_IDX
|
||||
and row[EXIT_TAG_IDX] is not None
|
||||
and len(row[EXIT_TAG_IDX]) > 0
|
||||
and exit_.exit_type in (ExitType.EXIT_SIGNAL,)
|
||||
):
|
||||
trade.exit_reason = row[EXIT_TAG_IDX]
|
||||
trade.exit_reason = exit_reason
|
||||
|
||||
self.order_id_counter += 1
|
||||
order = Order(
|
||||
@@ -597,12 +619,12 @@ class Backtesting:
|
||||
side=trade.exit_side,
|
||||
order_type=order_type,
|
||||
status="open",
|
||||
price=closerate,
|
||||
average=closerate,
|
||||
price=close_rate,
|
||||
average=close_rate,
|
||||
amount=trade.amount,
|
||||
filled=0,
|
||||
remaining=trade.amount,
|
||||
cost=trade.amount * closerate,
|
||||
cost=trade.amount * close_rate,
|
||||
)
|
||||
trade.orders.append(order)
|
||||
return trade
|
||||
@@ -649,7 +671,7 @@ class Backtesting:
|
||||
return self._get_exit_trade_entry_for_candle(trade, row)
|
||||
|
||||
def get_valid_price_and_stake(
|
||||
self, pair: str, row: Tuple, propose_rate: float, stake_amount: Optional[float],
|
||||
self, pair: str, row: Tuple, propose_rate: float, stake_amount: float,
|
||||
direction: LongShort, current_time: datetime, entry_tag: Optional[str],
|
||||
trade: Optional[LocalTrade], order_type: str
|
||||
) -> Tuple[float, float, float, float]:
|
||||
@@ -683,7 +705,7 @@ class Backtesting:
|
||||
current_rate=row[OPEN_IDX],
|
||||
proposed_leverage=1.0,
|
||||
max_leverage=max_leverage,
|
||||
side=direction,
|
||||
side=direction, entry_tag=entry_tag,
|
||||
) if self._can_short else 1.0
|
||||
# Cap leverage between 1.0 and max_leverage.
|
||||
leverage = min(max(leverage, 1.0), max_leverage)
|
||||
@@ -700,7 +722,7 @@ class Backtesting:
|
||||
pair=pair, current_time=current_time, current_rate=propose_rate,
|
||||
proposed_stake=stake_amount, min_stake=min_stake_amount,
|
||||
max_stake=min(stake_available, max_stake_amount),
|
||||
entry_tag=entry_tag, side=direction)
|
||||
leverage=leverage, entry_tag=entry_tag, side=direction)
|
||||
|
||||
stake_amount_val = self.wallets.validate_stake_amount(
|
||||
pair=pair,
|
||||
@@ -713,19 +735,26 @@ class Backtesting:
|
||||
|
||||
def _enter_trade(self, pair: str, row: Tuple, direction: LongShort,
|
||||
stake_amount: Optional[float] = None,
|
||||
trade: Optional[LocalTrade] = None) -> Optional[LocalTrade]:
|
||||
trade: Optional[LocalTrade] = None,
|
||||
requested_rate: Optional[float] = None,
|
||||
requested_stake: Optional[float] = None) -> Optional[LocalTrade]:
|
||||
|
||||
current_time = row[DATE_IDX].to_pydatetime()
|
||||
entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None
|
||||
# let's call the custom entry price, using the open price as default price
|
||||
order_type = self.strategy.order_types['entry']
|
||||
pos_adjust = trade is not None
|
||||
pos_adjust = trade is not None and requested_rate is None
|
||||
|
||||
stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
|
||||
propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
|
||||
pair, row, row[OPEN_IDX], stake_amount, direction, current_time, entry_tag, trade,
|
||||
pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade,
|
||||
order_type
|
||||
)
|
||||
|
||||
# replace proposed rate if another rate was requested
|
||||
propose_rate = requested_rate if requested_rate else propose_rate
|
||||
stake_amount = requested_stake if requested_stake else stake_amount
|
||||
|
||||
if not stake_amount:
|
||||
# In case of pos adjust, still return the original trade
|
||||
# If not pos adjust, trade is None
|
||||
@@ -806,11 +835,11 @@ class Backtesting:
|
||||
remaining=amount,
|
||||
cost=stake_amount + trade.fee_open,
|
||||
)
|
||||
trade.orders.append(order)
|
||||
if pos_adjust and self._get_order_filled(order.price, row):
|
||||
order.close_bt_order(current_time)
|
||||
order.close_bt_order(current_time, trade)
|
||||
else:
|
||||
trade.open_order_id = str(self.order_id_counter)
|
||||
trade.orders.append(order)
|
||||
trade.recalc_trade_from_orders()
|
||||
|
||||
return trade
|
||||
@@ -867,28 +896,90 @@ class Backtesting:
|
||||
self.protections.stop_per_pair(pair, current_time, side)
|
||||
self.protections.global_stop(current_time, side)
|
||||
|
||||
def check_order_cancel(self, trade: LocalTrade, current_time) -> bool:
|
||||
def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: Tuple) -> bool:
|
||||
"""
|
||||
Check if an order has been canceled.
|
||||
Returns True if the trade should be Deleted (initial order was canceled).
|
||||
Check if any open order needs to be cancelled or replaced.
|
||||
Returns True if the trade should be deleted.
|
||||
"""
|
||||
for order in [o for o in trade.orders if o.ft_is_open]:
|
||||
oc = self.check_order_cancel(trade, order, current_time)
|
||||
if oc:
|
||||
# delete trade due to order timeout
|
||||
return True
|
||||
elif oc is None and self.check_order_replace(trade, order, current_time, row):
|
||||
# delete trade due to user request
|
||||
self.canceled_trade_entries += 1
|
||||
return True
|
||||
# default maintain trade
|
||||
return False
|
||||
|
||||
timedout = self.strategy.ft_check_timed_out(trade, order, current_time)
|
||||
if timedout:
|
||||
if order.side == trade.entry_side:
|
||||
self.timedout_entry_orders += 1
|
||||
if trade.nr_of_successful_entries == 0:
|
||||
# Remove trade due to entry timeout expiration.
|
||||
return True
|
||||
else:
|
||||
# Close additional entry order
|
||||
del trade.orders[trade.orders.index(order)]
|
||||
if order.side == trade.exit_side:
|
||||
self.timedout_exit_orders += 1
|
||||
# Close exit order and retry exiting on next signal.
|
||||
def check_order_cancel(
|
||||
self, trade: LocalTrade, order: Order, current_time: datetime) -> Optional[bool]:
|
||||
"""
|
||||
Check if current analyzed order has to be canceled.
|
||||
Returns True if the trade should be Deleted (initial order was canceled),
|
||||
False if it's Canceled
|
||||
None if the order is still active.
|
||||
"""
|
||||
timedout = self.strategy.ft_check_timed_out(
|
||||
trade, # type: ignore[arg-type]
|
||||
order, current_time)
|
||||
if timedout:
|
||||
if order.side == trade.entry_side:
|
||||
self.timedout_entry_orders += 1
|
||||
if trade.nr_of_successful_entries == 0:
|
||||
# Remove trade due to entry timeout expiration.
|
||||
return True
|
||||
else:
|
||||
# Close additional entry order
|
||||
del trade.orders[trade.orders.index(order)]
|
||||
trade.open_order_id = None
|
||||
return False
|
||||
if order.side == trade.exit_side:
|
||||
self.timedout_exit_orders += 1
|
||||
# Close exit order and retry exiting on next signal.
|
||||
del trade.orders[trade.orders.index(order)]
|
||||
trade.open_order_id = None
|
||||
return False
|
||||
return None
|
||||
|
||||
def check_order_replace(self, trade: LocalTrade, order: Order, current_time,
|
||||
row: Tuple) -> bool:
|
||||
"""
|
||||
Check if current analyzed entry order has to be replaced and do so.
|
||||
If user requested cancellation and there are no filled orders in the trade will
|
||||
instruct caller to delete the trade.
|
||||
Returns True if the trade should be deleted.
|
||||
"""
|
||||
# only check on new candles for open entry orders
|
||||
if order.side == trade.entry_side and current_time > order.order_date_utc:
|
||||
requested_rate = strategy_safe_wrapper(self.strategy.adjust_entry_price,
|
||||
default_retval=order.price)(
|
||||
trade=trade, # type: ignore[arg-type]
|
||||
order=order, pair=trade.pair, current_time=current_time,
|
||||
proposed_rate=row[OPEN_IDX], current_order_rate=order.price,
|
||||
entry_tag=trade.enter_tag, side=trade.trade_direction
|
||||
) # default value is current order price
|
||||
|
||||
# cancel existing order whenever a new rate is requested (or None)
|
||||
if requested_rate == order.price:
|
||||
# assumption: there can't be multiple open entry orders at any given time
|
||||
return False
|
||||
else:
|
||||
del trade.orders[trade.orders.index(order)]
|
||||
trade.open_order_id = None
|
||||
self.canceled_entry_orders += 1
|
||||
|
||||
# place new order if result was not None
|
||||
if requested_rate:
|
||||
self._enter_trade(pair=trade.pair, row=row, trade=trade,
|
||||
requested_rate=requested_rate,
|
||||
requested_stake=(order.remaining * order.price),
|
||||
direction='short' if trade.is_short else 'long')
|
||||
self.replaced_entry_orders += 1
|
||||
else:
|
||||
# assumption: there can't be multiple open entry orders at any given time
|
||||
return (trade.nr_of_successful_entries == 0)
|
||||
return False
|
||||
|
||||
def validate_row(
|
||||
@@ -960,11 +1051,12 @@ class Backtesting:
|
||||
self.dataprovider._set_dataframe_max_index(row_index)
|
||||
|
||||
for t in list(open_trades[pair]):
|
||||
# 1. Cancel expired entry/exit orders.
|
||||
if self.check_order_cancel(t, current_time):
|
||||
# Close trade due to entry timeout expiration.
|
||||
# 1. Manage currently open orders of active trades
|
||||
if self.manage_open_orders(t, current_time, row):
|
||||
# Close trade
|
||||
open_trade_count -= 1
|
||||
open_trades[pair].remove(t)
|
||||
LocalTrade.trades_open.remove(t)
|
||||
self.wallets.update()
|
||||
|
||||
# 2. Process entries.
|
||||
@@ -988,14 +1080,15 @@ class Backtesting:
|
||||
open_trade_count += 1
|
||||
# logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
|
||||
open_trades[pair].append(trade)
|
||||
LocalTrade.add_bt_trade(trade)
|
||||
self.wallets.update()
|
||||
|
||||
for trade in list(open_trades[pair]):
|
||||
# 3. Process entry orders.
|
||||
order = trade.select_order(trade.entry_side, is_open=True)
|
||||
if order and self._get_order_filled(order.price, row):
|
||||
order.close_bt_order(current_time)
|
||||
order.close_bt_order(current_time, trade)
|
||||
trade.open_order_id = None
|
||||
LocalTrade.add_bt_trade(trade)
|
||||
self.wallets.update()
|
||||
|
||||
# 4. Create exit orders (if any)
|
||||
@@ -1005,6 +1098,7 @@ class Backtesting:
|
||||
# 5. Process exit orders.
|
||||
order = trade.select_order(trade.exit_side, is_open=True)
|
||||
if order and self._get_order_filled(order.price, row):
|
||||
order.close_bt_order(current_time, trade)
|
||||
trade.open_order_id = None
|
||||
trade.close_date = current_time
|
||||
trade.close(order.price, show_msg=False)
|
||||
@@ -1033,6 +1127,9 @@ class Backtesting:
|
||||
'rejected_signals': self.rejected_trades,
|
||||
'timedout_entry_orders': self.timedout_entry_orders,
|
||||
'timedout_exit_orders': self.timedout_exit_orders,
|
||||
'canceled_trade_entries': self.canceled_trade_entries,
|
||||
'canceled_entry_orders': self.canceled_entry_orders,
|
||||
'replaced_entry_orders': self.replaced_entry_orders,
|
||||
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
|
||||
}
|
||||
|
||||
@@ -1044,8 +1141,6 @@ class Backtesting:
|
||||
backtest_start_time = datetime.now(timezone.utc)
|
||||
self._set_strategy(strat)
|
||||
|
||||
strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)()
|
||||
|
||||
# Use max_open_trades in backtesting, except --disable-max-market-positions is set
|
||||
if self.config.get('use_max_market_positions', True):
|
||||
# Must come from strategy config, as the strategy may modify this setting.
|
||||
@@ -1170,13 +1265,14 @@ class Backtesting:
|
||||
self.results['strategy_comparison'].extend(results['strategy_comparison'])
|
||||
else:
|
||||
self.results = results
|
||||
|
||||
dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
if self.config.get('export', 'none') in ('trades', 'signals'):
|
||||
store_backtest_stats(self.config['exportfilename'], self.results)
|
||||
store_backtest_stats(self.config['exportfilename'], self.results, dt_appendix)
|
||||
|
||||
if (self.config.get('export', 'none') == 'signals' and
|
||||
self.dataprovider.runmode == RunMode.BACKTEST):
|
||||
store_backtest_signal_candles(self.config['exportfilename'], self.processed_dfs)
|
||||
store_backtest_signal_candles(
|
||||
self.config['exportfilename'], self.processed_dfs, dt_appendix)
|
||||
|
||||
# Results may be mixed up now. Sort them so they follow --strategy-list order.
|
||||
if 'strategy_list' in self.config and len(self.results) > 0:
|
||||
|
@@ -44,7 +44,7 @@ class EdgeCli:
|
||||
|
||||
self.edge._timerange = TimeRange.parse_timerange(None if self.config.get(
|
||||
'timerange') is None else str(self.config.get('timerange')))
|
||||
self.strategy.bot_start()
|
||||
self.strategy.ft_bot_start()
|
||||
|
||||
def start(self) -> None:
|
||||
result = self.edge.calculate(self.config['exchange']['pair_whitelist'])
|
||||
|
@@ -6,6 +6,7 @@ This module contains the hyperopt logic
|
||||
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
import warnings
|
||||
from datetime import datetime, timezone
|
||||
from math import ceil
|
||||
@@ -17,6 +18,7 @@ import rapidjson
|
||||
from colorama import Fore, Style
|
||||
from colorama import init as colorama_init
|
||||
from joblib import Parallel, cpu_count, delayed, dump, load, wrap_non_picklable_objects
|
||||
from joblib.externals import cloudpickle
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, FTHYPT_FILEVERSION, LAST_BT_RESULT_FN
|
||||
@@ -27,8 +29,7 @@ from freqtrade.misc import deep_merge_dicts, 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_loss_interface import IHyperOptLoss
|
||||
from freqtrade.optimize.hyperopt_tools import HyperoptTools, hyperopt_serializer
|
||||
from freqtrade.optimize.optimize_reports import generate_strategy_stats
|
||||
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver
|
||||
@@ -62,7 +63,6 @@ class Hyperopt:
|
||||
hyperopt = Hyperopt(config)
|
||||
hyperopt.start()
|
||||
"""
|
||||
custom_hyperopt: IHyperOpt
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
self.buy_space: List[Dimension] = []
|
||||
@@ -77,6 +77,7 @@ class Hyperopt:
|
||||
|
||||
self.backtesting = Backtesting(self.config)
|
||||
self.pairlist = self.backtesting.pairlists.whitelist
|
||||
self.custom_hyperopt: HyperOptAuto
|
||||
|
||||
if not self.config.get('hyperopt'):
|
||||
self.custom_hyperopt = HyperOptAuto(self.config)
|
||||
@@ -88,7 +89,9 @@ class Hyperopt:
|
||||
self.backtesting._set_strategy(self.backtesting.strategylist[0])
|
||||
self.custom_hyperopt.strategy = self.backtesting.strategy
|
||||
|
||||
self.custom_hyperoptloss = HyperOptLossResolver.load_hyperoptloss(self.config)
|
||||
self.hyperopt_pickle_magic(self.backtesting.strategy.__class__.__bases__)
|
||||
self.custom_hyperoptloss: IHyperOptLoss = HyperOptLossResolver.load_hyperoptloss(
|
||||
self.config)
|
||||
self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function
|
||||
time_now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
strategy = str(self.config['strategy'])
|
||||
@@ -137,6 +140,17 @@ class Hyperopt:
|
||||
logger.info(f"Removing `{p}`.")
|
||||
p.unlink()
|
||||
|
||||
def hyperopt_pickle_magic(self, bases) -> None:
|
||||
"""
|
||||
Hyperopt magic to allow strategy inheritance across files.
|
||||
For this to properly work, we need to register the module of the imported class
|
||||
to pickle as value.
|
||||
"""
|
||||
for modules in bases:
|
||||
if modules.__name__ != 'IStrategy':
|
||||
cloudpickle.register_pickle_by_value(sys.modules[modules.__module__])
|
||||
self.hyperopt_pickle_magic(modules.__bases__)
|
||||
|
||||
def _get_params_dict(self, dimensions: List[Dimension], raw_params: List[Any]) -> Dict:
|
||||
|
||||
# Ensure the number of dimensions match
|
||||
@@ -429,7 +443,7 @@ class Hyperopt:
|
||||
return new_list
|
||||
i = 0
|
||||
asked_non_tried: List[List[Any]] = []
|
||||
is_random: List[bool] = []
|
||||
is_random_non_tried: List[bool] = []
|
||||
while i < 5 and len(asked_non_tried) < n_points:
|
||||
if i < 3:
|
||||
self.opt.cache_ = {}
|
||||
@@ -438,9 +452,9 @@ class Hyperopt:
|
||||
else:
|
||||
asked = unique_list(self.opt.space.rvs(n_samples=n_points * 5))
|
||||
is_random = [True for _ in range(len(asked))]
|
||||
is_random += [rand for x, rand in zip(asked, is_random)
|
||||
if x not in self.opt.Xi
|
||||
and x not in asked_non_tried]
|
||||
is_random_non_tried += [rand for x, rand in zip(asked, is_random)
|
||||
if x not in self.opt.Xi
|
||||
and x not in asked_non_tried]
|
||||
asked_non_tried += [x for x in asked
|
||||
if x not in self.opt.Xi
|
||||
and x not in asked_non_tried]
|
||||
@@ -449,13 +463,13 @@ class Hyperopt:
|
||||
if asked_non_tried:
|
||||
return (
|
||||
asked_non_tried[:min(len(asked_non_tried), n_points)],
|
||||
is_random[:min(len(asked_non_tried), n_points)]
|
||||
is_random_non_tried[:min(len(asked_non_tried), n_points)]
|
||||
)
|
||||
else:
|
||||
return self.opt.ask(n_points=n_points), [False for _ in range(n_points)]
|
||||
|
||||
def start(self) -> None:
|
||||
self.random_state = self._set_random_state(self.config.get('hyperopt_random_state', None))
|
||||
self.random_state = self._set_random_state(self.config.get('hyperopt_random_state'))
|
||||
logger.info(f"Using optimizer random state: {self.random_state}")
|
||||
self.hyperopt_table_header = -1
|
||||
# Initialize spaces ...
|
||||
|
@@ -127,14 +127,14 @@ class HyperoptTools():
|
||||
'only_profitable': config.get('hyperopt_list_profitable', False),
|
||||
'filter_min_trades': config.get('hyperopt_list_min_trades', 0),
|
||||
'filter_max_trades': config.get('hyperopt_list_max_trades', 0),
|
||||
'filter_min_avg_time': config.get('hyperopt_list_min_avg_time', None),
|
||||
'filter_max_avg_time': config.get('hyperopt_list_max_avg_time', None),
|
||||
'filter_min_avg_profit': config.get('hyperopt_list_min_avg_profit', None),
|
||||
'filter_max_avg_profit': config.get('hyperopt_list_max_avg_profit', None),
|
||||
'filter_min_total_profit': config.get('hyperopt_list_min_total_profit', None),
|
||||
'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None),
|
||||
'filter_min_objective': config.get('hyperopt_list_min_objective', None),
|
||||
'filter_max_objective': config.get('hyperopt_list_max_objective', None),
|
||||
'filter_min_avg_time': config.get('hyperopt_list_min_avg_time'),
|
||||
'filter_max_avg_time': config.get('hyperopt_list_max_avg_time'),
|
||||
'filter_min_avg_profit': config.get('hyperopt_list_min_avg_profit'),
|
||||
'filter_max_avg_profit': config.get('hyperopt_list_max_avg_profit'),
|
||||
'filter_min_total_profit': config.get('hyperopt_list_min_total_profit'),
|
||||
'filter_max_total_profit': config.get('hyperopt_list_max_total_profit'),
|
||||
'filter_min_objective': config.get('hyperopt_list_min_objective'),
|
||||
'filter_max_objective': config.get('hyperopt_list_max_objective'),
|
||||
}
|
||||
if not HyperoptTools._test_hyperopt_results_exist(results_file):
|
||||
# No file found.
|
||||
|
@@ -4,7 +4,6 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from numpy import int64
|
||||
from pandas import DataFrame, to_datetime
|
||||
from tabulate import tabulate
|
||||
|
||||
@@ -18,21 +17,21 @@ from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> None:
|
||||
def store_backtest_stats(
|
||||
recordfilename: Path, stats: Dict[str, DataFrame], dtappendix: str) -> None:
|
||||
"""
|
||||
Stores backtest results
|
||||
:param recordfilename: Path object, which can either be a filename or a directory.
|
||||
Filenames will be appended with a timestamp right before the suffix
|
||||
while for directories, <directory>/backtest-result-<datetime>.json will be used as filename
|
||||
:param stats: Dataframe containing the backtesting statistics
|
||||
:param dtappendix: Datetime to use for the filename
|
||||
"""
|
||||
if recordfilename.is_dir():
|
||||
filename = (recordfilename /
|
||||
f'backtest-result-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.json')
|
||||
filename = (recordfilename / f'backtest-result-{dtappendix}.json')
|
||||
else:
|
||||
filename = Path.joinpath(
|
||||
recordfilename.parent,
|
||||
f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
|
||||
recordfilename.parent, f'{recordfilename.stem}-{dtappendix}'
|
||||
).with_suffix(recordfilename.suffix)
|
||||
|
||||
# Store metadata separately.
|
||||
@@ -45,7 +44,8 @@ def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> N
|
||||
file_dump_json(latest_filename, {'latest_backtest': str(filename.name)})
|
||||
|
||||
|
||||
def store_backtest_signal_candles(recordfilename: Path, candles: Dict[str, Dict]) -> Path:
|
||||
def store_backtest_signal_candles(
|
||||
recordfilename: Path, candles: Dict[str, Dict], dtappendix: str) -> Path:
|
||||
"""
|
||||
Stores backtest trade signal candles
|
||||
:param recordfilename: Path object, which can either be a filename or a directory.
|
||||
@@ -53,14 +53,13 @@ def store_backtest_signal_candles(recordfilename: Path, candles: Dict[str, Dict]
|
||||
while for directories, <directory>/backtest-result-<datetime>_signals.pkl will be used
|
||||
as filename
|
||||
:param stats: Dict containing the backtesting signal candles
|
||||
:param dtappendix: Datetime to use for the filename
|
||||
"""
|
||||
if recordfilename.is_dir():
|
||||
filename = (recordfilename /
|
||||
f'backtest-result-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}_signals.pkl')
|
||||
filename = (recordfilename / f'backtest-result-{dtappendix}_signals.pkl')
|
||||
else:
|
||||
filename = Path.joinpath(
|
||||
recordfilename.parent,
|
||||
f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}_signals.pkl'
|
||||
recordfilename.parent, f'{recordfilename.stem}-{dtappendix}_signals.pkl'
|
||||
)
|
||||
|
||||
file_dump_joblib(filename, candles)
|
||||
@@ -417,9 +416,9 @@ def generate_strategy_stats(pairlist: List[str],
|
||||
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
|
||||
if not results.empty:
|
||||
results['open_timestamp'] = results['open_date'].view(int64) // 1e6
|
||||
results['close_timestamp'] = results['close_date'].view(int64) // 1e6
|
||||
winning_profit = results.loc[results['profit_abs'] > 0, 'profit_abs'].sum()
|
||||
losing_profit = results.loc[results['profit_abs'] < 0, 'profit_abs'].sum()
|
||||
profit_factor = winning_profit / abs(losing_profit) if losing_profit else 0.0
|
||||
|
||||
backtest_days = (max_date - min_date).days or 1
|
||||
strat_stats = {
|
||||
@@ -447,6 +446,7 @@ def generate_strategy_stats(pairlist: List[str],
|
||||
'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(),
|
||||
'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(),
|
||||
'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']),
|
||||
'profit_factor': profit_factor,
|
||||
'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT),
|
||||
'backtest_start_ts': int(min_date.timestamp() * 1000),
|
||||
'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT),
|
||||
@@ -468,6 +468,9 @@ def generate_strategy_stats(pairlist: List[str],
|
||||
'rejected_signals': content['rejected_signals'],
|
||||
'timedout_entry_orders': content['timedout_entry_orders'],
|
||||
'timedout_exit_orders': content['timedout_exit_orders'],
|
||||
'canceled_trade_entries': content['canceled_trade_entries'],
|
||||
'canceled_entry_orders': content['canceled_entry_orders'],
|
||||
'replaced_entry_orders': content['replaced_entry_orders'],
|
||||
'max_open_trades': max_open_trades,
|
||||
'max_open_trades_setting': (config['max_open_trades']
|
||||
if config['max_open_trades'] != float('inf') else -1),
|
||||
@@ -498,8 +501,10 @@ def generate_strategy_stats(pairlist: List[str],
|
||||
(drawdown_abs, drawdown_start, drawdown_end, high_val, low_val,
|
||||
max_drawdown) = calculate_max_drawdown(
|
||||
results, value_col='profit_abs', starting_balance=start_balance)
|
||||
# max_relative_drawdown = Underwater
|
||||
(_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown(
|
||||
results, value_col='profit_abs', starting_balance=start_balance, relative=True)
|
||||
|
||||
strat_stats.update({
|
||||
'max_drawdown': max_drawdown_legacy, # Deprecated - do not use
|
||||
'max_drawdown_account': max_drawdown,
|
||||
@@ -753,6 +758,12 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
('Drawdown End', strat_results['drawdown_end']),
|
||||
])
|
||||
|
||||
entry_adjustment_metrics = [
|
||||
('Canceled Trade Entries', strat_results.get('canceled_trade_entries', 'N/A')),
|
||||
('Canceled Entry Orders', strat_results.get('canceled_entry_orders', 'N/A')),
|
||||
('Replaced Entry Orders', strat_results.get('replaced_entry_orders', 'N/A')),
|
||||
] if strat_results.get('canceled_entry_orders', 0) > 0 else []
|
||||
|
||||
# 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.
|
||||
@@ -772,6 +783,8 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
strat_results['stake_currency'])),
|
||||
('Total profit %', f"{strat_results['profit_total']:.2%}"),
|
||||
('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'),
|
||||
('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor'
|
||||
in strat_results else 'N/A'),
|
||||
('Trades per day', strat_results['trades_per_day']),
|
||||
('Avg. daily profit %',
|
||||
f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"),
|
||||
@@ -801,6 +814,7 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
('Entry/Exit Timeouts',
|
||||
f"{strat_results.get('timedout_entry_orders', 'N/A')} / "
|
||||
f"{strat_results.get('timedout_exit_orders', 'N/A')}"),
|
||||
*entry_adjustment_metrics,
|
||||
('', ''), # Empty line to improve readability
|
||||
|
||||
('Min balance', round_coin_value(strat_results['csum_min'],
|
||||
|
@@ -1,5 +1,5 @@
|
||||
# flake8: noqa: F401
|
||||
|
||||
from freqtrade.persistence.models import (LocalTrade, Order, Trade, clean_dry_run_db, cleanup_db,
|
||||
init_db)
|
||||
from freqtrade.persistence.models import cleanup_db, init_db
|
||||
from freqtrade.persistence.pairlock_middleware import PairLocks
|
||||
from freqtrade.persistence.trade_model import LocalTrade, Order, Trade
|
||||
|
7
freqtrade/persistence/base.py
Normal file
7
freqtrade/persistence/base.py
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
|
||||
_DECL_BASE: Any = declarative_base()
|
@@ -1,9 +1,10 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy import inspect, select, text, tuple_, update
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.persistence.trade_model import Order, Trade
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,7 +47,7 @@ def get_last_sequence_ids(engine, trade_back_name, order_back_name):
|
||||
return order_id, trade_id
|
||||
|
||||
|
||||
def set_sequence_ids(engine, order_id, trade_id):
|
||||
def set_sequence_ids(engine, order_id, trade_id, pairlock_id=None):
|
||||
|
||||
if engine.name == 'postgresql':
|
||||
with engine.begin() as connection:
|
||||
@@ -54,6 +55,9 @@ def set_sequence_ids(engine, order_id, trade_id):
|
||||
connection.execute(text(f"ALTER SEQUENCE orders_id_seq RESTART WITH {order_id}"))
|
||||
if trade_id:
|
||||
connection.execute(text(f"ALTER SEQUENCE trades_id_seq RESTART WITH {trade_id}"))
|
||||
if pairlock_id:
|
||||
connection.execute(
|
||||
text(f"ALTER SEQUENCE pairlocks_id_seq RESTART WITH {pairlock_id}"))
|
||||
|
||||
|
||||
def drop_index_on_table(engine, inspector, table_bak_name):
|
||||
@@ -99,7 +103,10 @@ def migrate_trades_and_orders_table(
|
||||
liquidation_price = get_column_def(cols, 'liquidation_price',
|
||||
get_column_def(cols, 'isolated_liq', 'null'))
|
||||
# sqlite does not support literals for booleans
|
||||
is_short = get_column_def(cols, 'is_short', '0')
|
||||
if engine.name == 'postgresql':
|
||||
is_short = get_column_def(cols, 'is_short', 'false')
|
||||
else:
|
||||
is_short = get_column_def(cols, 'is_short', '0')
|
||||
|
||||
# Margin Properties
|
||||
interest_rate = get_column_def(cols, 'interest_rate', '0.0')
|
||||
@@ -195,16 +202,18 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List):
|
||||
|
||||
ft_fee_base = get_column_def(cols_order, 'ft_fee_base', 'null')
|
||||
average = get_column_def(cols_order, 'average', 'null')
|
||||
stop_price = get_column_def(cols_order, 'stop_price', 'null')
|
||||
|
||||
# sqlite does not support literals for booleans
|
||||
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, ft_fee_base)
|
||||
stop_price, order_date, order_filled_date, order_update_date, ft_fee_base)
|
||||
select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id,
|
||||
status, symbol, order_type, side, price, amount, filled, {average} average, remaining,
|
||||
cost, order_date, order_filled_date, order_update_date, {ft_fee_base} ft_fee_base
|
||||
cost, {stop_price} stop_price, order_date, order_filled_date,
|
||||
order_update_date, {ft_fee_base} ft_fee_base
|
||||
from {table_back_name}
|
||||
"""))
|
||||
|
||||
@@ -241,6 +250,35 @@ def set_sqlite_to_wal(engine):
|
||||
connection.execute(text("PRAGMA journal_mode=wal"))
|
||||
|
||||
|
||||
def fix_old_dry_orders(engine):
|
||||
with engine.begin() as connection:
|
||||
stmt = update(Order).where(
|
||||
Order.ft_is_open.is_(True),
|
||||
tuple_(Order.ft_trade_id, Order.order_id).not_in(
|
||||
select(
|
||||
Trade.id, Trade.stoploss_order_id
|
||||
).where(Trade.stoploss_order_id.is_not(None))
|
||||
),
|
||||
Order.ft_order_side == 'stoploss',
|
||||
Order.order_id.like('dry%'),
|
||||
|
||||
).values(ft_is_open=False)
|
||||
connection.execute(stmt)
|
||||
|
||||
stmt = update(Order).where(
|
||||
Order.ft_is_open.is_(True),
|
||||
tuple_(Order.ft_trade_id, Order.order_id).not_in(
|
||||
select(
|
||||
Trade.id, Trade.open_order_id
|
||||
).where(Trade.open_order_id.is_not(None))
|
||||
),
|
||||
Order.ft_order_side != 'stoploss',
|
||||
Order.order_id.like('dry%')
|
||||
|
||||
).values(ft_is_open=False)
|
||||
connection.execute(stmt)
|
||||
|
||||
|
||||
def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
"""
|
||||
Checks if migration is necessary and migrates if necessary
|
||||
@@ -259,9 +297,8 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
|
||||
# Check if migration necessary
|
||||
# Migrates both trades and orders table!
|
||||
# if ('orders' not in previous_tables
|
||||
# or not has_column(cols_orders, 'leverage')):
|
||||
if not has_column(cols_trades, 'base_currency'):
|
||||
if not has_column(cols_orders, 'stop_price'):
|
||||
# if not has_column(cols_trades, 'base_currency'):
|
||||
logger.info(f"Running database migration for trades - "
|
||||
f"backup: {table_back_name}, {order_table_bak_name}")
|
||||
migrate_trades_and_orders_table(
|
||||
@@ -282,3 +319,4 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
"start with a fresh database.")
|
||||
|
||||
set_sqlite_to_wal(engine)
|
||||
fix_old_dry_orders(engine)
|
||||
|
File diff suppressed because it is too large
Load Diff
70
freqtrade/persistence/pairlock.py
Normal file
70
freqtrade/persistence/pairlock.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String, or_
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT
|
||||
from freqtrade.persistence.base import _DECL_BASE
|
||||
|
||||
|
||||
class PairLock(_DECL_BASE):
|
||||
"""
|
||||
Pair Locks database model.
|
||||
"""
|
||||
__tablename__ = 'pairlocks'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
pair = Column(String(25), nullable=False, index=True)
|
||||
# lock direction - long, short or * (for both)
|
||||
side = Column(String(25), nullable=False, default="*")
|
||||
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)
|
||||
lock_end_time = Column(DateTime, nullable=False, index=True)
|
||||
|
||||
active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
def __repr__(self):
|
||||
lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT)
|
||||
lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT)
|
||||
return (
|
||||
f'PairLock(id={self.id}, pair={self.pair}, side={self.side}, lock_time={lock_time}, '
|
||||
f'lock_end_time={lock_end_time}, reason={self.reason}, active={self.active})')
|
||||
|
||||
@staticmethod
|
||||
def query_pair_locks(pair: Optional[str], now: datetime, side: str = '*') -> Query:
|
||||
"""
|
||||
Get all currently active locks for this pair
|
||||
:param pair: Pair to check for. Returns all current locks if pair is empty
|
||||
:param now: Datetime object (generated via datetime.now(timezone.utc)).
|
||||
"""
|
||||
filters = [PairLock.lock_end_time > now,
|
||||
# Only active locks
|
||||
PairLock.active.is_(True), ]
|
||||
if pair:
|
||||
filters.append(PairLock.pair == pair)
|
||||
if side != '*':
|
||||
filters.append(or_(PairLock.side == side, PairLock.side == '*'))
|
||||
else:
|
||||
filters.append(PairLock.side == '*')
|
||||
|
||||
return PairLock.query.filter(
|
||||
*filters
|
||||
)
|
||||
|
||||
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),
|
||||
'lock_end_time': self.lock_end_time.strftime(DATETIME_PRINT_FORMAT),
|
||||
'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc
|
||||
).timestamp() * 1000),
|
||||
'reason': self.reason,
|
||||
'side': self.side,
|
||||
'active': self.active,
|
||||
}
|
1377
freqtrade/persistence/trade_model.py
Normal file
1377
freqtrade/persistence/trade_model.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -633,7 +633,8 @@ def load_and_plot_trades(config: Dict[str, Any]):
|
||||
|
||||
exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config)
|
||||
IStrategy.dp = DataProvider(config, exchange)
|
||||
strategy.bot_start()
|
||||
strategy.ft_bot_start()
|
||||
strategy.bot_loop_start()
|
||||
plot_elements = init_plotscript(config, list(exchange.markets), strategy.startup_candle_count)
|
||||
timerange = plot_elements['timerange']
|
||||
trades = plot_elements['trades']
|
||||
|
@@ -30,20 +30,21 @@ class AgeFilter(IPairList):
|
||||
self._symbolsCheckFailed = PeriodicCache(maxsize=1000, ttl=86_400)
|
||||
|
||||
self._min_days_listed = pairlistconfig.get('min_days_listed', 10)
|
||||
self._max_days_listed = pairlistconfig.get('max_days_listed', None)
|
||||
self._max_days_listed = pairlistconfig.get('max_days_listed')
|
||||
|
||||
candle_limit = exchange.ohlcv_candle_limit('1d', self._config['candle_type_def'])
|
||||
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('1d'):
|
||||
if self._min_days_listed > candle_limit:
|
||||
raise OperationalException("AgeFilter requires min_days_listed to not exceed "
|
||||
"exchange max request size "
|
||||
f"({exchange.ohlcv_candle_limit('1d')})")
|
||||
f"({candle_limit})")
|
||||
if self._max_days_listed and self._max_days_listed <= self._min_days_listed:
|
||||
raise OperationalException("AgeFilter max_days_listed <= min_days_listed not permitted")
|
||||
if self._max_days_listed and self._max_days_listed > exchange.ohlcv_candle_limit('1d'):
|
||||
if self._max_days_listed and self._max_days_listed > candle_limit:
|
||||
raise OperationalException("AgeFilter requires max_days_listed to not exceed "
|
||||
"exchange max request size "
|
||||
f"({exchange.ohlcv_candle_limit('1d')})")
|
||||
f"({candle_limit})")
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
|
@@ -19,6 +19,7 @@ class OffsetFilter(IPairList):
|
||||
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
|
||||
|
||||
self._offset = pairlistconfig.get('offset', 0)
|
||||
self._number_pairs = pairlistconfig.get('number_assets', 0)
|
||||
|
||||
if self._offset < 0:
|
||||
raise OperationalException("OffsetFilter requires offset to be >= 0")
|
||||
@@ -36,7 +37,9 @@ class OffsetFilter(IPairList):
|
||||
"""
|
||||
Short whitelist method description - used for startup-messages
|
||||
"""
|
||||
return f"{self.name} - Offseting pairs by {self._offset}."
|
||||
if self._number_pairs:
|
||||
return f"{self.name} - Taking {self._number_pairs} Pairs, starting from {self._offset}."
|
||||
return f"{self.name} - Offsetting pairs by {self._offset}."
|
||||
|
||||
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]:
|
||||
"""
|
||||
@@ -50,5 +53,9 @@ class OffsetFilter(IPairList):
|
||||
self.log_once(f"Offset of {self._offset} is larger than " +
|
||||
f"pair count of {len(pairlist)}", logger.warning)
|
||||
pairs = pairlist[self._offset:]
|
||||
if self._number_pairs:
|
||||
pairs = pairs[:self._number_pairs]
|
||||
|
||||
self.log_once(f"Searching {len(pairs)} pairs: {pairs}", logger.info)
|
||||
|
||||
return pairs
|
||||
|
@@ -21,7 +21,7 @@ class PerformanceFilter(IPairList):
|
||||
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
|
||||
|
||||
self._minutes = pairlistconfig.get('minutes', 0)
|
||||
self._min_profit = pairlistconfig.get('min_profit', None)
|
||||
self._min_profit = pairlistconfig.get('min_profit')
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
|
@@ -50,7 +50,7 @@ class SpreadFilter(IPairList):
|
||||
: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']:
|
||||
if 'bid' in ticker and 'ask' in ticker and ticker['ask'] and ticker['bid']:
|
||||
spread = 1 - ticker['bid'] / ticker['ask']
|
||||
if spread > self._max_spread_ratio:
|
||||
self.log_once(f"Removed {pair} from whitelist, because spread "
|
||||
|
@@ -38,12 +38,12 @@ class VolatilityFilter(IPairList):
|
||||
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
|
||||
|
||||
candle_limit = exchange.ohlcv_candle_limit('1d', self._config['candle_type_def'])
|
||||
if self._days < 1:
|
||||
raise OperationalException("VolatilityFilter requires lookback_days to be >= 1")
|
||||
if self._days > exchange.ohlcv_candle_limit('1d'):
|
||||
if self._days > candle_limit:
|
||||
raise OperationalException("VolatilityFilter requires lookback_days to not "
|
||||
"exceed exchange max request size "
|
||||
f"({exchange.ohlcv_candle_limit('1d')})")
|
||||
f"exceed exchange max request size ({candle_limit})")
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
|
@@ -84,12 +84,13 @@ class VolumePairList(IPairList):
|
||||
raise OperationalException(
|
||||
f'key {self._sort_key} not in {SORT_VALUES}')
|
||||
|
||||
candle_limit = exchange.ohlcv_candle_limit(
|
||||
self._lookback_timeframe, self._config['candle_type_def'])
|
||||
if self._lookback_period < 0:
|
||||
raise OperationalException("VolumeFilter requires lookback_period to be >= 0")
|
||||
if self._lookback_period > exchange.ohlcv_candle_limit(self._lookback_timeframe):
|
||||
if self._lookback_period > candle_limit:
|
||||
raise OperationalException("VolumeFilter requires lookback_period to not "
|
||||
"exceed exchange max request size "
|
||||
f"({exchange.ohlcv_candle_limit(self._lookback_timeframe)})")
|
||||
f"exceed exchange max request size ({candle_limit})")
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
|
@@ -27,18 +27,18 @@ class RangeStabilityFilter(IPairList):
|
||||
|
||||
self._days = pairlistconfig.get('lookback_days', 10)
|
||||
self._min_rate_of_change = pairlistconfig.get('min_rate_of_change', 0.01)
|
||||
self._max_rate_of_change = pairlistconfig.get('max_rate_of_change', None)
|
||||
self._max_rate_of_change = pairlistconfig.get('max_rate_of_change')
|
||||
self._refresh_period = pairlistconfig.get('refresh_period', 1440)
|
||||
self._def_candletype = self._config['candle_type_def']
|
||||
|
||||
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
|
||||
|
||||
candle_limit = exchange.ohlcv_candle_limit('1d', self._config['candle_type_def'])
|
||||
if self._days < 1:
|
||||
raise OperationalException("RangeStabilityFilter requires lookback_days to be >= 1")
|
||||
if self._days > exchange.ohlcv_candle_limit('1d'):
|
||||
if self._days > candle_limit:
|
||||
raise OperationalException("RangeStabilityFilter requires lookback_days to not "
|
||||
"exceed exchange max request size "
|
||||
f"({exchange.ohlcv_candle_limit('1d')})")
|
||||
f"exceed exchange max request size ({candle_limit})")
|
||||
|
||||
@property
|
||||
def needstickers(self) -> bool:
|
||||
|
@@ -28,7 +28,7 @@ class PairListManager(LoggingMixin):
|
||||
self._blacklist = self._config['exchange'].get('pair_blacklist', [])
|
||||
self._pairlist_handlers: List[IPairList] = []
|
||||
self._tickers_needed = False
|
||||
for pairlist_handler_config in self._config.get('pairlists', None):
|
||||
for pairlist_handler_config in self._config.get('pairlists', []):
|
||||
pairlist_handler = PairListResolver.load_pairlist(
|
||||
pairlist_handler_config['method'],
|
||||
exchange=exchange,
|
||||
|
@@ -21,6 +21,7 @@ class LowProfitPairs(IProtection):
|
||||
|
||||
self._trade_limit = protection_config.get('trade_limit', 1)
|
||||
self._required_profit = protection_config.get('required_profit', 0.0)
|
||||
self._only_per_side = protection_config.get('only_per_side', False)
|
||||
|
||||
def short_desc(self) -> str:
|
||||
"""
|
||||
@@ -36,7 +37,8 @@ class LowProfitPairs(IProtection):
|
||||
return (f'{profit} < {self._required_profit} in {self.lookback_period_str}, '
|
||||
f'locking for {self.stop_duration_str}.')
|
||||
|
||||
def _low_profit(self, date_now: datetime, pair: str) -> Optional[ProtectionReturn]:
|
||||
def _low_profit(
|
||||
self, date_now: datetime, pair: str, side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Evaluate recent trades for pair
|
||||
"""
|
||||
@@ -54,7 +56,10 @@ class LowProfitPairs(IProtection):
|
||||
# Not enough trades in the relevant period
|
||||
return None
|
||||
|
||||
profit = sum(trade.close_profit for trade in trades if trade.close_profit)
|
||||
profit = sum(
|
||||
trade.close_profit for trade in trades if trade.close_profit
|
||||
and (not self._only_per_side or trade.trade_direction == side)
|
||||
)
|
||||
if profit < self._required_profit:
|
||||
self.log_once(
|
||||
f"Trading for {pair} stopped due to {profit:.2f} < {self._required_profit} "
|
||||
@@ -65,6 +70,7 @@ class LowProfitPairs(IProtection):
|
||||
lock=True,
|
||||
until=until,
|
||||
reason=self._reason(profit),
|
||||
lock_side=(side if self._only_per_side else '*')
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -86,4 +92,4 @@ class LowProfitPairs(IProtection):
|
||||
:return: Tuple of [bool, until, reason].
|
||||
If true, this pair will be locked with <reason> until <until>
|
||||
"""
|
||||
return self._low_profit(date_now, pair=pair)
|
||||
return self._low_profit(date_now, pair=pair, side=side)
|
||||
|
@@ -38,8 +38,8 @@ class StoplossGuard(IProtection):
|
||||
return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, '
|
||||
f'locking for {self._stop_duration} min.')
|
||||
|
||||
def _stoploss_guard(
|
||||
self, date_now: datetime, pair: Optional[str], side: str) -> Optional[ProtectionReturn]:
|
||||
def _stoploss_guard(self, date_now: datetime, pair: Optional[str],
|
||||
side: LongShort) -> Optional[ProtectionReturn]:
|
||||
"""
|
||||
Evaluate recent trades
|
||||
"""
|
||||
|
@@ -47,26 +47,7 @@ class StrategyResolver(IResolver):
|
||||
strategy: IStrategy = StrategyResolver._load_strategy(
|
||||
strategy_name, config=config,
|
||||
extra_dir=config.get('strategy_path'))
|
||||
|
||||
if strategy._ft_params_from_file:
|
||||
# Set parameters from Hyperopt results file
|
||||
params = strategy._ft_params_from_file
|
||||
strategy.minimal_roi = params.get('roi', getattr(strategy, 'minimal_roi', {}))
|
||||
|
||||
strategy.stoploss = params.get('stoploss', {}).get(
|
||||
'stoploss', getattr(strategy, 'stoploss', -0.1))
|
||||
trailing = params.get('trailing', {})
|
||||
strategy.trailing_stop = trailing.get(
|
||||
'trailing_stop', getattr(strategy, 'trailing_stop', False))
|
||||
strategy.trailing_stop_positive = trailing.get(
|
||||
'trailing_stop_positive', getattr(strategy, 'trailing_stop_positive', None))
|
||||
strategy.trailing_stop_positive_offset = trailing.get(
|
||||
'trailing_stop_positive_offset',
|
||||
getattr(strategy, 'trailing_stop_positive_offset', 0))
|
||||
strategy.trailing_only_offset_is_reached = trailing.get(
|
||||
'trailing_only_offset_is_reached',
|
||||
getattr(strategy, 'trailing_only_offset_is_reached', 0.0))
|
||||
|
||||
strategy.ft_load_params_from_file()
|
||||
# Set attributes
|
||||
# Check if we need to override configuration
|
||||
# (Attribute name, default, subkey)
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
@@ -102,7 +103,10 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
min_date=min_date, max_date=max_date)
|
||||
|
||||
if btconfig.get('export', 'none') == 'trades':
|
||||
store_backtest_stats(btconfig['exportfilename'], ApiServer._bt.results)
|
||||
store_backtest_stats(
|
||||
btconfig['exportfilename'], ApiServer._bt.results,
|
||||
datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
)
|
||||
|
||||
logger.info("Backtest finished.")
|
||||
|
||||
@@ -172,6 +176,7 @@ def api_delete_backtest(ws_mode=Depends(is_webserver_mode)):
|
||||
"status_msg": "Backtest running",
|
||||
}
|
||||
if ApiServer._bt:
|
||||
ApiServer._bt.cleanup()
|
||||
del ApiServer._bt
|
||||
ApiServer._bt = None
|
||||
del ApiServer._bt_data
|
||||
|
@@ -104,6 +104,10 @@ class Profit(BaseModel):
|
||||
best_pair_profit_ratio: float
|
||||
winning_trades: int
|
||||
losing_trades: int
|
||||
profit_factor: float
|
||||
max_drawdown: float
|
||||
max_drawdown_abs: float
|
||||
trading_volume: Optional[float]
|
||||
|
||||
|
||||
class SellReason(BaseModel):
|
||||
@@ -120,6 +124,8 @@ class Stats(BaseModel):
|
||||
class DailyRecord(BaseModel):
|
||||
date: date
|
||||
abs_profit: float
|
||||
rel_profit: float
|
||||
starting_balance: float
|
||||
fiat_value: float
|
||||
trade_count: int
|
||||
|
||||
@@ -166,7 +172,7 @@ class ShowConfig(BaseModel):
|
||||
trailing_stop_positive: Optional[float]
|
||||
trailing_stop_positive_offset: Optional[float]
|
||||
trailing_only_offset_is_reached: Optional[bool]
|
||||
unfilledtimeout: UnfilledTimeout
|
||||
unfilledtimeout: Optional[UnfilledTimeout] # Empty in webserver mode
|
||||
order_types: Optional[OrderTypes]
|
||||
use_custom_stoploss: Optional[bool]
|
||||
timeframe: Optional[str]
|
||||
@@ -256,6 +262,7 @@ class TradeSchema(BaseModel):
|
||||
|
||||
leverage: Optional[float]
|
||||
interest_rate: Optional[float]
|
||||
liquidation_price: Optional[float]
|
||||
funding_fees: Optional[float]
|
||||
trading_mode: Optional[TradingMode]
|
||||
|
||||
@@ -276,6 +283,7 @@ class OpenTradeSchema(TradeSchema):
|
||||
class TradeResponse(BaseModel):
|
||||
trades: List[TradeSchema]
|
||||
trades_count: int
|
||||
offset: int
|
||||
total_trades: int
|
||||
|
||||
|
||||
|
@@ -36,7 +36,8 @@ logger = logging.getLogger(__name__)
|
||||
# versions 2.xx -> futures/short branch
|
||||
# 2.14: Add entry/exit orders to trade response
|
||||
# 2.15: Add backtest history endpoints
|
||||
API_VERSION = 2.15
|
||||
# 2.16: Additional daily metrics
|
||||
API_VERSION = 2.16
|
||||
|
||||
# Public API, requires no auth.
|
||||
router_public = APIRouter()
|
||||
@@ -86,8 +87,8 @@ def stats(rpc: RPC = Depends(get_rpc)):
|
||||
|
||||
@router.get('/daily', response_model=Daily, tags=['info'])
|
||||
def daily(timescale: int = 7, rpc: RPC = Depends(get_rpc), config=Depends(get_config)):
|
||||
return rpc._rpc_daily_profit(timescale, config['stake_currency'],
|
||||
config.get('fiat_display_currency', ''))
|
||||
return rpc._rpc_timeunit_profit(timescale, config['stake_currency'],
|
||||
config.get('fiat_display_currency', ''))
|
||||
|
||||
|
||||
@router.get('/status', response_model=List[OpenTradeSchema], tags=['info'])
|
||||
@@ -281,7 +282,7 @@ def get_strategy(strategy: str, config=Depends(get_config)):
|
||||
def list_available_pairs(timeframe: Optional[str] = None, stake_currency: Optional[str] = None,
|
||||
candletype: Optional[CandleType] = None, config=Depends(get_config)):
|
||||
|
||||
dh = get_datahandler(config['datadir'], config.get('dataformat_ohlcv', None))
|
||||
dh = get_datahandler(config['datadir'], config.get('dataformat_ohlcv'))
|
||||
trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT)
|
||||
pair_interval = dh.ohlcv_get_available_data(config['datadir'], trading_mode)
|
||||
|
||||
|
59
freqtrade/rpc/discord.py
Normal file
59
freqtrade/rpc/discord.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from freqtrade.enums.rpcmessagetype import RPCMessageType
|
||||
from freqtrade.rpc import RPC
|
||||
from freqtrade.rpc.webhook import Webhook
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Discord(Webhook):
|
||||
def __init__(self, rpc: 'RPC', config: Dict[str, Any]):
|
||||
# super().__init__(rpc, config)
|
||||
self.rpc = rpc
|
||||
self.config = config
|
||||
self.strategy = config.get('strategy', '')
|
||||
self.timeframe = config.get('timeframe', '')
|
||||
|
||||
self._url = self.config['discord']['webhook_url']
|
||||
self._format = 'json'
|
||||
self._retries = 1
|
||||
self._retry_delay = 0.1
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Cleanup pending module resources.
|
||||
This will do nothing for webhooks, they will simply not be called anymore
|
||||
"""
|
||||
pass
|
||||
|
||||
def send_msg(self, msg) -> None:
|
||||
logger.info(f"Sending discord message: {msg}")
|
||||
|
||||
if msg['type'].value in self.config['discord']:
|
||||
|
||||
msg['strategy'] = self.strategy
|
||||
msg['timeframe'] = self.timeframe
|
||||
fields = self.config['discord'].get(msg['type'].value)
|
||||
color = 0x0000FF
|
||||
if msg['type'] in (RPCMessageType.EXIT, RPCMessageType.EXIT_FILL):
|
||||
profit_ratio = msg.get('profit_ratio')
|
||||
color = (0x00FF00 if profit_ratio > 0 else 0xFF0000)
|
||||
|
||||
embeds = [{
|
||||
'title': f"Trade: {msg['pair']} {msg['type'].value}",
|
||||
'color': color,
|
||||
'fields': [],
|
||||
|
||||
}]
|
||||
for f in fields:
|
||||
for k, v in f.items():
|
||||
v = v.format(**msg)
|
||||
embeds[0]['fields'].append( # type: ignore
|
||||
{'name': k, 'value': v, 'inline': True})
|
||||
|
||||
# Send the message to discord channel
|
||||
payload = {'embeds': embeds}
|
||||
self._send_msg(payload)
|
@@ -18,6 +18,7 @@ from freqtrade import __version__
|
||||
from freqtrade.configuration.timerange import TimeRange
|
||||
from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT
|
||||
from freqtrade.data.history import load_data
|
||||
from freqtrade.data.metrics import calculate_max_drawdown
|
||||
from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, State,
|
||||
TradingMode)
|
||||
from freqtrade.exceptions import ExchangeError, PricingError
|
||||
@@ -96,7 +97,7 @@ class RPC:
|
||||
"""
|
||||
self._freqtrade = freqtrade
|
||||
self._config: Dict[str, Any] = freqtrade.config
|
||||
if self._config.get('fiat_display_currency', None):
|
||||
if self._config.get('fiat_display_currency'):
|
||||
self._fiat_converter = CryptoToFiatConverter()
|
||||
|
||||
@staticmethod
|
||||
@@ -177,16 +178,19 @@ class RPC:
|
||||
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']
|
||||
)
|
||||
if len(trade.select_filled_orders(trade.entry_side)) > 0:
|
||||
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']
|
||||
)
|
||||
else:
|
||||
current_profit = current_profit_abs = current_profit_fiat = 0.0
|
||||
|
||||
# Calculate guaranteed profit (in case of trailing stop)
|
||||
stoploss_entry_dist = trade.calc_profit(trade.stop_loss)
|
||||
@@ -235,8 +239,12 @@ class RPC:
|
||||
trade.pair, side='exit', is_short=trade.is_short, refresh=False)
|
||||
except (PricingError, ExchangeError):
|
||||
current_rate = NAN
|
||||
trade_profit = trade.calc_profit(current_rate)
|
||||
profit_str = f'{trade.calc_profit_ratio(current_rate):.2%}'
|
||||
if len(trade.select_filled_orders(trade.entry_side)) > 0:
|
||||
trade_profit = trade.calc_profit(current_rate)
|
||||
profit_str = f'{trade.calc_profit_ratio(current_rate):.2%}'
|
||||
else:
|
||||
trade_profit = 0.0
|
||||
profit_str = f'{0.0:.2f}'
|
||||
direction_str = ('S' if trade.is_short else 'L') if nonspot else ''
|
||||
if self._fiat_converter:
|
||||
fiat_profit = self._fiat_converter.convert_amount(
|
||||
@@ -244,7 +252,7 @@ class RPC:
|
||||
stake_currency,
|
||||
fiat_display_currency
|
||||
)
|
||||
if fiat_profit and not isnan(fiat_profit):
|
||||
if 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
|
||||
@@ -276,33 +284,57 @@ class RPC:
|
||||
columns.append('# Entries')
|
||||
return trades_list, columns, fiat_profit_sum
|
||||
|
||||
def _rpc_daily_profit(
|
||||
def _rpc_timeunit_profit(
|
||||
self, timescale: int,
|
||||
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
profit_days: Dict[date, Dict] = {}
|
||||
stake_currency: str, fiat_display_currency: str,
|
||||
timeunit: str = 'days') -> Dict[str, Any]:
|
||||
"""
|
||||
:param timeunit: Valid entries are 'days', 'weeks', 'months'
|
||||
"""
|
||||
start_date = datetime.now(timezone.utc).date()
|
||||
if timeunit == 'weeks':
|
||||
# weekly
|
||||
start_date = start_date - timedelta(days=start_date.weekday()) # Monday
|
||||
if timeunit == 'months':
|
||||
start_date = start_date.replace(day=1)
|
||||
|
||||
def time_offset(step: int):
|
||||
if timeunit == 'months':
|
||||
return relativedelta(months=step)
|
||||
return timedelta(**{timeunit: step})
|
||||
|
||||
if not (isinstance(timescale, int) and timescale > 0):
|
||||
raise RPCException('timescale must be an integer greater than 0')
|
||||
|
||||
profit_units: Dict[date, Dict] = {}
|
||||
daily_stake = self._freqtrade.wallets.get_total_stake_amount()
|
||||
|
||||
for day in range(0, timescale):
|
||||
profitday = today - timedelta(days=day)
|
||||
trades = Trade.get_trades(trade_filter=[
|
||||
profitday = start_date - time_offset(day)
|
||||
# Only query for necessary columns for performance reasons.
|
||||
trades = Trade.query.session.query(Trade.close_profit_abs).filter(
|
||||
Trade.is_open.is_(False),
|
||||
Trade.close_date >= profitday,
|
||||
Trade.close_date < (profitday + timedelta(days=1))
|
||||
]).order_by(Trade.close_date).all()
|
||||
Trade.close_date < (profitday + time_offset(1))
|
||||
).order_by(Trade.close_date).all()
|
||||
|
||||
curdayprofit = sum(
|
||||
trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None)
|
||||
profit_days[profitday] = {
|
||||
# Calculate this periods starting balance
|
||||
daily_stake = daily_stake - curdayprofit
|
||||
profit_units[profitday] = {
|
||||
'amount': curdayprofit,
|
||||
'trades': len(trades)
|
||||
'daily_stake': daily_stake,
|
||||
'rel_profit': round(curdayprofit / daily_stake, 8) if daily_stake > 0 else 0,
|
||||
'trades': len(trades),
|
||||
}
|
||||
|
||||
data = [
|
||||
{
|
||||
'date': key,
|
||||
'date': f"{key.year}-{key.month:02d}" if timeunit == 'months' else key,
|
||||
'abs_profit': value["amount"],
|
||||
'starting_balance': value["daily_stake"],
|
||||
'rel_profit': value["rel_profit"],
|
||||
'fiat_value': self._fiat_converter.convert_amount(
|
||||
value['amount'],
|
||||
stake_currency,
|
||||
@@ -310,92 +342,7 @@ class RPC:
|
||||
) if self._fiat_converter else 0,
|
||||
'trade_count': value["trades"],
|
||||
}
|
||||
for key, value in profit_days.items()
|
||||
]
|
||||
return {
|
||||
'stake_currency': stake_currency,
|
||||
'fiat_display_currency': fiat_display_currency,
|
||||
'data': data
|
||||
}
|
||||
|
||||
def _rpc_weekly_profit(
|
||||
self, timescale: int,
|
||||
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
first_iso_day_of_week = today - timedelta(days=today.weekday()) # Monday
|
||||
profit_weeks: Dict[date, Dict] = {}
|
||||
|
||||
if not (isinstance(timescale, int) and timescale > 0):
|
||||
raise RPCException('timescale must be an integer greater than 0')
|
||||
|
||||
for week in range(0, timescale):
|
||||
profitweek = first_iso_day_of_week - timedelta(weeks=week)
|
||||
trades = Trade.get_trades(trade_filter=[
|
||||
Trade.is_open.is_(False),
|
||||
Trade.close_date >= profitweek,
|
||||
Trade.close_date < (profitweek + timedelta(weeks=1))
|
||||
]).order_by(Trade.close_date).all()
|
||||
curweekprofit = sum(
|
||||
trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None)
|
||||
profit_weeks[profitweek] = {
|
||||
'amount': curweekprofit,
|
||||
'trades': len(trades)
|
||||
}
|
||||
|
||||
data = [
|
||||
{
|
||||
'date': key,
|
||||
'abs_profit': value["amount"],
|
||||
'fiat_value': self._fiat_converter.convert_amount(
|
||||
value['amount'],
|
||||
stake_currency,
|
||||
fiat_display_currency
|
||||
) if self._fiat_converter else 0,
|
||||
'trade_count': value["trades"],
|
||||
}
|
||||
for key, value in profit_weeks.items()
|
||||
]
|
||||
return {
|
||||
'stake_currency': stake_currency,
|
||||
'fiat_display_currency': fiat_display_currency,
|
||||
'data': data
|
||||
}
|
||||
|
||||
def _rpc_monthly_profit(
|
||||
self, timescale: int,
|
||||
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
|
||||
first_day_of_month = datetime.now(timezone.utc).date().replace(day=1)
|
||||
profit_months: Dict[date, Dict] = {}
|
||||
|
||||
if not (isinstance(timescale, int) and timescale > 0):
|
||||
raise RPCException('timescale must be an integer greater than 0')
|
||||
|
||||
for month in range(0, timescale):
|
||||
profitmonth = first_day_of_month - relativedelta(months=month)
|
||||
trades = Trade.get_trades(trade_filter=[
|
||||
Trade.is_open.is_(False),
|
||||
Trade.close_date >= profitmonth,
|
||||
Trade.close_date < (profitmonth + relativedelta(months=1))
|
||||
]).order_by(Trade.close_date).all()
|
||||
curmonthprofit = sum(
|
||||
trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None)
|
||||
profit_months[profitmonth] = {
|
||||
'amount': curmonthprofit,
|
||||
'trades': len(trades)
|
||||
}
|
||||
|
||||
data = [
|
||||
{
|
||||
'date': f"{key.year}-{key.month:02d}",
|
||||
'abs_profit': value["amount"],
|
||||
'fiat_value': self._fiat_converter.convert_amount(
|
||||
value['amount'],
|
||||
stake_currency,
|
||||
fiat_display_currency
|
||||
) if self._fiat_converter else 0,
|
||||
'trade_count': value["trades"],
|
||||
}
|
||||
for key, value in profit_months.items()
|
||||
for key, value in profit_units.items()
|
||||
]
|
||||
return {
|
||||
'stake_currency': stake_currency,
|
||||
@@ -418,6 +365,7 @@ class RPC:
|
||||
return {
|
||||
"trades": output,
|
||||
"trades_count": len(output),
|
||||
"offset": offset,
|
||||
"total_trades": Trade.get_trades([Trade.is_open.is_(False)]).count(),
|
||||
}
|
||||
|
||||
@@ -432,7 +380,7 @@ class RPC:
|
||||
return 'losses'
|
||||
else:
|
||||
return 'draws'
|
||||
trades: List[Trade] = Trade.get_trades([Trade.is_open.is_(False)])
|
||||
trades: List[Trade] = Trade.get_trades([Trade.is_open.is_(False)], include_orders=False)
|
||||
# Sell reason
|
||||
exit_reasons = {}
|
||||
for trade in trades:
|
||||
@@ -460,7 +408,8 @@ class RPC:
|
||||
""" Returns cumulative profit statistics """
|
||||
trade_filter = ((Trade.is_open.is_(False) & (Trade.close_date >= start_date)) |
|
||||
Trade.is_open.is_(True))
|
||||
trades: List[Trade] = Trade.get_trades(trade_filter).order_by(Trade.id).all()
|
||||
trades: List[Trade] = Trade.get_trades(
|
||||
trade_filter, include_orders=False).order_by(Trade.id).all()
|
||||
|
||||
profit_all_coin = []
|
||||
profit_all_ratio = []
|
||||
@@ -469,6 +418,8 @@ class RPC:
|
||||
durations = []
|
||||
winning_trades = 0
|
||||
losing_trades = 0
|
||||
winning_profit = 0.0
|
||||
losing_profit = 0.0
|
||||
|
||||
for trade in trades:
|
||||
current_rate: float = 0.0
|
||||
@@ -484,8 +435,10 @@ class RPC:
|
||||
profit_closed_ratio.append(profit_ratio)
|
||||
if trade.close_profit >= 0:
|
||||
winning_trades += 1
|
||||
winning_profit += trade.close_profit_abs
|
||||
else:
|
||||
losing_trades += 1
|
||||
losing_profit += trade.close_profit_abs
|
||||
else:
|
||||
# Get current rate
|
||||
try:
|
||||
@@ -501,6 +454,7 @@ class RPC:
|
||||
profit_all_ratio.append(profit_ratio)
|
||||
|
||||
best_pair = Trade.get_best_pair(start_date)
|
||||
trading_volume = Trade.get_trading_volume(start_date)
|
||||
|
||||
# Prepare data to display
|
||||
profit_closed_coin_sum = round(sum(profit_closed_coin), 8)
|
||||
@@ -524,6 +478,21 @@ class RPC:
|
||||
profit_closed_ratio_fromstart = profit_closed_coin_sum / starting_balance
|
||||
profit_all_ratio_fromstart = profit_all_coin_sum / starting_balance
|
||||
|
||||
profit_factor = winning_profit / abs(losing_profit) if losing_profit else float('inf')
|
||||
|
||||
trades_df = DataFrame([{'close_date': trade.close_date.strftime(DATETIME_PRINT_FORMAT),
|
||||
'profit_abs': trade.close_profit_abs}
|
||||
for trade in trades if not trade.is_open])
|
||||
max_drawdown_abs = 0.0
|
||||
max_drawdown = 0.0
|
||||
if len(trades_df) > 0:
|
||||
try:
|
||||
(max_drawdown_abs, _, _, _, _, max_drawdown) = calculate_max_drawdown(
|
||||
trades_df, value_col='profit_abs', starting_balance=starting_balance)
|
||||
except ValueError:
|
||||
# ValueError if no losing trade.
|
||||
pass
|
||||
|
||||
profit_all_fiat = self._fiat_converter.convert_amount(
|
||||
profit_all_coin_sum,
|
||||
stake_currency,
|
||||
@@ -562,11 +531,15 @@ class RPC:
|
||||
'best_pair_profit_ratio': best_pair[1] if best_pair else 0,
|
||||
'winning_trades': winning_trades,
|
||||
'losing_trades': losing_trades,
|
||||
'profit_factor': profit_factor,
|
||||
'max_drawdown': max_drawdown,
|
||||
'max_drawdown_abs': max_drawdown_abs,
|
||||
'trading_volume': trading_volume,
|
||||
}
|
||||
|
||||
def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> Dict:
|
||||
""" Returns current account balance per crypto """
|
||||
currencies = []
|
||||
currencies: List[Dict] = []
|
||||
total = 0.0
|
||||
try:
|
||||
tickers = self._freqtrade.exchange.get_tickers(cached=True)
|
||||
@@ -593,7 +566,7 @@ class RPC:
|
||||
else:
|
||||
try:
|
||||
pair = self._freqtrade.exchange.get_valid_pair_combination(coin, stake_currency)
|
||||
rate = tickers.get(pair, {}).get('last', None)
|
||||
rate = tickers.get(pair, {}).get('last')
|
||||
if rate:
|
||||
if pair.startswith(stake_currency) and not pair.endswith(stake_currency):
|
||||
rate = 1.0 / rate
|
||||
@@ -601,13 +574,12 @@ class RPC:
|
||||
except (ExchangeError):
|
||||
logger.warning(f" Could not get rate for pair {coin}.")
|
||||
continue
|
||||
total = total + (est_stake or 0)
|
||||
total = total + est_stake
|
||||
currencies.append({
|
||||
'currency': coin,
|
||||
# TODO: The below can be simplified if we don't assign None to values.
|
||||
'free': balance.free if balance.free is not None else 0,
|
||||
'balance': balance.total if balance.total is not None else 0,
|
||||
'used': balance.used if balance.used is not None else 0,
|
||||
'free': balance.free,
|
||||
'balance': balance.total,
|
||||
'used': balance.used,
|
||||
'est_stake': est_stake or 0,
|
||||
'stake': stake_currency,
|
||||
'side': 'long',
|
||||
@@ -637,7 +609,6 @@ class RPC:
|
||||
total, stake_currency, fiat_display_currency) if self._fiat_converter else 0
|
||||
|
||||
trade_count = len(Trade.get_trades_proxy())
|
||||
starting_capital_ratio = 0.0
|
||||
starting_capital_ratio = (total / starting_capital) - 1 if starting_capital else 0.0
|
||||
starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0
|
||||
|
||||
@@ -925,7 +896,7 @@ class RPC:
|
||||
else:
|
||||
errors[pair] = {
|
||||
'error_msg': f"Pair {pair} is not in the current blacklist."
|
||||
}
|
||||
}
|
||||
resp = self._rpc_blacklist()
|
||||
resp['errors'] = errors
|
||||
return resp
|
||||
|
@@ -27,6 +27,12 @@ class RPCManager:
|
||||
from freqtrade.rpc.telegram import Telegram
|
||||
self.registered_modules.append(Telegram(self._rpc, config))
|
||||
|
||||
# Enable discord
|
||||
if config.get('discord', {}).get('enabled', False):
|
||||
logger.info('Enabling rpc.discord ...')
|
||||
from freqtrade.rpc.discord import Discord
|
||||
self.registered_modules.append(Discord(self._rpc, config))
|
||||
|
||||
# Enable Webhook
|
||||
if config.get('webhook', {}).get('enabled', False):
|
||||
logger.info('Enabling rpc.webhook ...')
|
||||
|
@@ -6,6 +6,7 @@ This module manage Telegram communication
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from functools import partial
|
||||
from html import escape
|
||||
@@ -37,6 +38,15 @@ logger.debug('Included module rpc.telegram ...')
|
||||
MAX_TELEGRAM_MESSAGE_LENGTH = 4096
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeunitMappings:
|
||||
header: str
|
||||
message: str
|
||||
message2: str
|
||||
callback: str
|
||||
default: int
|
||||
|
||||
|
||||
def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator to check if the message comes from the correct chat_id
|
||||
@@ -225,6 +235,30 @@ class Telegram(RPCHandler):
|
||||
# This can take up to `timeout` from the call to `start_polling`.
|
||||
self._updater.stop()
|
||||
|
||||
def _exchange_from_msg(self, msg: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Extracts the exchange name from the given message.
|
||||
:param msg: The message to extract the exchange name from.
|
||||
:return: The exchange name.
|
||||
"""
|
||||
return f"{msg['exchange']}{' (dry)' if self._config['dry_run'] else ''}"
|
||||
|
||||
def _add_analyzed_candle(self, pair: str) -> str:
|
||||
candle_val = self._config['telegram'].get(
|
||||
'notification_settings', {}).get('show_candle', 'off')
|
||||
if candle_val != 'off':
|
||||
if candle_val == 'ohlc':
|
||||
analyzed_df, _ = self._rpc._freqtrade.dataprovider.get_analyzed_dataframe(
|
||||
pair, self._config['timeframe'])
|
||||
candle = analyzed_df.iloc[-1].squeeze() if len(analyzed_df) > 0 else None
|
||||
if candle is not None:
|
||||
return (
|
||||
f"*Candle OHLC*: `{candle['open']}, {candle['high']}, "
|
||||
f"{candle['low']}, {candle['close']}`\n"
|
||||
)
|
||||
|
||||
return ''
|
||||
|
||||
def _format_entry_msg(self, msg: Dict[str, Any]) -> str:
|
||||
if self._rpc._fiat_converter:
|
||||
msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount(
|
||||
@@ -237,11 +271,12 @@ class Telegram(RPCHandler):
|
||||
entry_side = ({'enter': 'Long', 'entered': 'Longed'} if msg['direction'] == 'Long'
|
||||
else {'enter': 'Short', 'entered': 'Shorted'})
|
||||
message = (
|
||||
f"{emoji} *{msg['exchange']}:*"
|
||||
f"{emoji} *{self._exchange_from_msg(msg)}:*"
|
||||
f" {entry_side['entered'] if is_fill else entry_side['enter']} {msg['pair']}"
|
||||
f" (#{msg['trade_id']})\n"
|
||||
)
|
||||
message += f"*Enter Tag:* `{msg['enter_tag']}`\n" if msg.get('enter_tag', None) else ""
|
||||
message += self._add_analyzed_candle(msg['pair'])
|
||||
message += f"*Enter Tag:* `{msg['enter_tag']}`\n" if msg.get('enter_tag') else ""
|
||||
message += f"*Amount:* `{msg['amount']:.8f}`\n"
|
||||
if msg.get('leverage') and msg.get('leverage', 1.0) != 1.0:
|
||||
message += f"*Leverage:* `{msg['leverage']}`\n"
|
||||
@@ -254,7 +289,7 @@ class Telegram(RPCHandler):
|
||||
|
||||
message += f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}"
|
||||
|
||||
if msg.get('fiat_currency', None):
|
||||
if msg.get('fiat_currency'):
|
||||
message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}"
|
||||
|
||||
message += ")`"
|
||||
@@ -270,7 +305,7 @@ class Telegram(RPCHandler):
|
||||
msg['enter_tag'] = msg['enter_tag'] if "enter_tag" in msg.keys() else None
|
||||
msg['emoji'] = self._get_sell_emoji(msg)
|
||||
msg['leverage_text'] = (f"*Leverage:* `{msg['leverage']:.1f}`\n"
|
||||
if msg.get('leverage', None) and msg.get('leverage', 1.0) != 1.0
|
||||
if msg.get('leverage') and msg.get('leverage', 1.0) != 1.0
|
||||
else "")
|
||||
|
||||
# Check if all sell properties are available.
|
||||
@@ -286,8 +321,9 @@ class Telegram(RPCHandler):
|
||||
msg['profit_extra'] = ''
|
||||
is_fill = msg['type'] == RPCMessageType.EXIT_FILL
|
||||
message = (
|
||||
f"{msg['emoji']} *{msg['exchange']}:* "
|
||||
f"{msg['emoji']} *{self._exchange_from_msg(msg)}:* "
|
||||
f"{'Exited' if is_fill else 'Exiting'} {msg['pair']} (#{msg['trade_id']})\n"
|
||||
f"{self._add_analyzed_candle(msg['pair'])}"
|
||||
f"*{'Profit' if is_fill else 'Unrealized Profit'}:* "
|
||||
f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n"
|
||||
f"*Enter Tag:* `{msg['enter_tag']}`\n"
|
||||
@@ -316,33 +352,33 @@ class Telegram(RPCHandler):
|
||||
|
||||
elif msg_type in (RPCMessageType.ENTRY_CANCEL, RPCMessageType.EXIT_CANCEL):
|
||||
msg['message_side'] = 'enter' if msg_type in [RPCMessageType.ENTRY_CANCEL] else 'exit'
|
||||
message = ("\N{WARNING SIGN} *{exchange}:* "
|
||||
"Cancelling {message_side} Order for {pair} (#{trade_id}). "
|
||||
"Reason: {reason}.".format(**msg))
|
||||
message = (f"\N{WARNING SIGN} *{self._exchange_from_msg(msg)}:* "
|
||||
f"Cancelling {msg['message_side']} Order for {msg['pair']} "
|
||||
f"(#{msg['trade_id']}). Reason: {msg['reason']}.")
|
||||
|
||||
elif msg_type == RPCMessageType.PROTECTION_TRIGGER:
|
||||
message = (
|
||||
"*Protection* triggered due to {reason}. "
|
||||
"`{pair}` will be locked until `{lock_end_time}`."
|
||||
).format(**msg)
|
||||
f"*Protection* triggered due to {msg['reason']}. "
|
||||
f"`{msg['pair']}` will be locked until `{msg['lock_end_time']}`."
|
||||
)
|
||||
|
||||
elif msg_type == RPCMessageType.PROTECTION_TRIGGER_GLOBAL:
|
||||
message = (
|
||||
"*Protection* triggered due to {reason}. "
|
||||
"*All pairs* will be locked until `{lock_end_time}`."
|
||||
).format(**msg)
|
||||
f"*Protection* triggered due to {msg['reason']}. "
|
||||
f"*All pairs* will be locked until `{msg['lock_end_time']}`."
|
||||
)
|
||||
|
||||
elif msg_type == RPCMessageType.STATUS:
|
||||
message = '*Status:* `{status}`'.format(**msg)
|
||||
message = f"*Status:* `{msg['status']}`"
|
||||
|
||||
elif msg_type == RPCMessageType.WARNING:
|
||||
message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg)
|
||||
message = f"\N{WARNING SIGN} *Warning:* `{msg['status']}`"
|
||||
|
||||
elif msg_type == RPCMessageType.STARTUP:
|
||||
message = '{status}'.format(**msg)
|
||||
message = f"{msg['status']}"
|
||||
|
||||
else:
|
||||
raise NotImplementedError('Unknown message type: {}'.format(msg_type))
|
||||
raise NotImplementedError(f"Unknown message type: {msg_type}")
|
||||
return message
|
||||
|
||||
def send_msg(self, msg: Dict[str, Any]) -> None:
|
||||
@@ -396,7 +432,7 @@ class Telegram(RPCHandler):
|
||||
first_avg = filled_orders[0]["safe_price"]
|
||||
|
||||
for x, order in enumerate(filled_orders):
|
||||
if not order['ft_is_entry']:
|
||||
if not order['ft_is_entry'] or order['is_open'] is True:
|
||||
continue
|
||||
cur_entry_datetime = arrow.get(order["order_filled_date"])
|
||||
cur_entry_amount = order["amount"]
|
||||
@@ -563,6 +599,60 @@ class Telegram(RPCHandler):
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@authorized_only
|
||||
def _timeunit_stats(self, update: Update, context: CallbackContext, unit: str) -> None:
|
||||
"""
|
||||
Handler for /daily <n>
|
||||
Returns a daily profit (in BTC) over the last n days.
|
||||
:param bot: telegram bot
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
|
||||
vals = {
|
||||
'days': TimeunitMappings('Day', 'Daily', 'days', 'update_daily', 7),
|
||||
'weeks': TimeunitMappings('Monday', 'Weekly', 'weeks (starting from Monday)',
|
||||
'update_weekly', 8),
|
||||
'months': TimeunitMappings('Month', 'Monthly', 'months', 'update_monthly', 6),
|
||||
}
|
||||
val = vals[unit]
|
||||
|
||||
stake_cur = self._config['stake_currency']
|
||||
fiat_disp_cur = self._config.get('fiat_display_currency', '')
|
||||
try:
|
||||
timescale = int(context.args[0]) if context.args else val.default
|
||||
except (TypeError, ValueError, IndexError):
|
||||
timescale = val.default
|
||||
try:
|
||||
stats = self._rpc._rpc_timeunit_profit(
|
||||
timescale,
|
||||
stake_cur,
|
||||
fiat_disp_cur,
|
||||
unit
|
||||
)
|
||||
stats_tab = tabulate(
|
||||
[[f"{period['date']} ({period['trade_count']})",
|
||||
f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}",
|
||||
f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}",
|
||||
f"{period['rel_profit']:.2%}",
|
||||
] for period in stats['data']],
|
||||
headers=[
|
||||
f"{val.header} (count)",
|
||||
f'{stake_cur}',
|
||||
f'{fiat_disp_cur}',
|
||||
'Profit %',
|
||||
'Trades',
|
||||
],
|
||||
tablefmt='simple')
|
||||
message = (
|
||||
f'<b>{val.message} Profit over the last {timescale} {val.message2}</b>:\n'
|
||||
f'<pre>{stats_tab}</pre>'
|
||||
)
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
|
||||
callback_path=val.callback, query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@authorized_only
|
||||
def _daily(self, update: Update, context: CallbackContext) -> None:
|
||||
"""
|
||||
@@ -572,35 +662,7 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
stake_cur = self._config['stake_currency']
|
||||
fiat_disp_cur = self._config.get('fiat_display_currency', '')
|
||||
try:
|
||||
timescale = int(context.args[0]) if context.args else 7
|
||||
except (TypeError, ValueError, IndexError):
|
||||
timescale = 7
|
||||
try:
|
||||
stats = self._rpc._rpc_daily_profit(
|
||||
timescale,
|
||||
stake_cur,
|
||||
fiat_disp_cur
|
||||
)
|
||||
stats_tab = tabulate(
|
||||
[[day['date'],
|
||||
f"{round_coin_value(day['abs_profit'], stats['stake_currency'])}",
|
||||
f"{day['fiat_value']:.3f} {stats['fiat_display_currency']}",
|
||||
f"{day['trade_count']} trades"] for day in stats['data']],
|
||||
headers=[
|
||||
'Day',
|
||||
f'Profit {stake_cur}',
|
||||
f'Profit {fiat_disp_cur}',
|
||||
'Trades',
|
||||
],
|
||||
tablefmt='simple')
|
||||
message = f'<b>Daily Profit over the last {timescale} days</b>:\n<pre>{stats_tab}</pre>'
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
|
||||
callback_path="update_daily", query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._timeunit_stats(update, context, 'days')
|
||||
|
||||
@authorized_only
|
||||
def _weekly(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -611,36 +673,7 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
stake_cur = self._config['stake_currency']
|
||||
fiat_disp_cur = self._config.get('fiat_display_currency', '')
|
||||
try:
|
||||
timescale = int(context.args[0]) if context.args else 8
|
||||
except (TypeError, ValueError, IndexError):
|
||||
timescale = 8
|
||||
try:
|
||||
stats = self._rpc._rpc_weekly_profit(
|
||||
timescale,
|
||||
stake_cur,
|
||||
fiat_disp_cur
|
||||
)
|
||||
stats_tab = tabulate(
|
||||
[[week['date'],
|
||||
f"{round_coin_value(week['abs_profit'], stats['stake_currency'])}",
|
||||
f"{week['fiat_value']:.3f} {stats['fiat_display_currency']}",
|
||||
f"{week['trade_count']} trades"] for week in stats['data']],
|
||||
headers=[
|
||||
'Monday',
|
||||
f'Profit {stake_cur}',
|
||||
f'Profit {fiat_disp_cur}',
|
||||
'Trades',
|
||||
],
|
||||
tablefmt='simple')
|
||||
message = f'<b>Weekly Profit over the last {timescale} weeks ' \
|
||||
f'(starting from Monday)</b>:\n<pre>{stats_tab}</pre> '
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
|
||||
callback_path="update_weekly", query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._timeunit_stats(update, context, 'weeks')
|
||||
|
||||
@authorized_only
|
||||
def _monthly(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -651,36 +684,7 @@ class Telegram(RPCHandler):
|
||||
:param update: message update
|
||||
:return: None
|
||||
"""
|
||||
stake_cur = self._config['stake_currency']
|
||||
fiat_disp_cur = self._config.get('fiat_display_currency', '')
|
||||
try:
|
||||
timescale = int(context.args[0]) if context.args else 6
|
||||
except (TypeError, ValueError, IndexError):
|
||||
timescale = 6
|
||||
try:
|
||||
stats = self._rpc._rpc_monthly_profit(
|
||||
timescale,
|
||||
stake_cur,
|
||||
fiat_disp_cur
|
||||
)
|
||||
stats_tab = tabulate(
|
||||
[[month['date'],
|
||||
f"{round_coin_value(month['abs_profit'], stats['stake_currency'])}",
|
||||
f"{month['fiat_value']:.3f} {stats['fiat_display_currency']}",
|
||||
f"{month['trade_count']} trades"] for month in stats['data']],
|
||||
headers=[
|
||||
'Month',
|
||||
f'Profit {stake_cur}',
|
||||
f'Profit {fiat_disp_cur}',
|
||||
'Trades',
|
||||
],
|
||||
tablefmt='simple')
|
||||
message = f'<b>Monthly Profit over the last {timescale} months' \
|
||||
f'</b>:\n<pre>{stats_tab}</pre> '
|
||||
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
|
||||
callback_path="update_monthly", query=update.callback_query)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
self._timeunit_stats(update, context, 'months')
|
||||
|
||||
@authorized_only
|
||||
def _profit(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -744,12 +748,18 @@ class Telegram(RPCHandler):
|
||||
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"*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_pair_profit_ratio:.2%}`")
|
||||
markdown_msg += (
|
||||
f"\n*Avg. Duration:* `{avg_duration}`\n"
|
||||
f"*Best Performing:* `{best_pair}: {best_pair_profit_ratio:.2%}`\n"
|
||||
f"*Trading volume:* `{round_coin_value(stats['trading_volume'], stake_cur)}`\n"
|
||||
f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
|
||||
f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
|
||||
f"({round_coin_value(stats['max_drawdown_abs'], stake_cur)})`"
|
||||
)
|
||||
self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit",
|
||||
query=update.callback_query)
|
||||
|
||||
@@ -785,7 +795,7 @@ class Telegram(RPCHandler):
|
||||
headers=['Exit Reason', 'Exits', 'Wins', 'Losses']
|
||||
)
|
||||
if len(exit_reasons_tabulate) > 25:
|
||||
self._send_msg(exit_reasons_msg, ParseMode.MARKDOWN)
|
||||
self._send_msg(f"```\n{exit_reasons_msg}```", ParseMode.MARKDOWN)
|
||||
exit_reasons_msg = ''
|
||||
|
||||
durations = stats['durations']
|
||||
@@ -889,7 +899,7 @@ class Telegram(RPCHandler):
|
||||
:return: None
|
||||
"""
|
||||
msg = self._rpc._rpc_start()
|
||||
self._send_msg('Status: `{status}`'.format(**msg))
|
||||
self._send_msg(f"Status: `{msg['status']}`")
|
||||
|
||||
@authorized_only
|
||||
def _stop(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -901,7 +911,7 @@ class Telegram(RPCHandler):
|
||||
:return: None
|
||||
"""
|
||||
msg = self._rpc._rpc_stop()
|
||||
self._send_msg('Status: `{status}`'.format(**msg))
|
||||
self._send_msg(f"Status: `{msg['status']}`")
|
||||
|
||||
@authorized_only
|
||||
def _reload_config(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -913,7 +923,7 @@ class Telegram(RPCHandler):
|
||||
:return: None
|
||||
"""
|
||||
msg = self._rpc._rpc_reload_config()
|
||||
self._send_msg('Status: `{status}`'.format(**msg))
|
||||
self._send_msg(f"Status: `{msg['status']}`")
|
||||
|
||||
@authorized_only
|
||||
def _stopbuy(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -925,7 +935,7 @@ class Telegram(RPCHandler):
|
||||
:return: None
|
||||
"""
|
||||
msg = self._rpc._rpc_stopbuy()
|
||||
self._send_msg('Status: `{status}`'.format(**msg))
|
||||
self._send_msg(f"Status: `{msg['status']}`")
|
||||
|
||||
@authorized_only
|
||||
def _force_exit(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1087,9 +1097,9 @@ class Telegram(RPCHandler):
|
||||
trade_id = int(context.args[0])
|
||||
msg = self._rpc._rpc_delete(trade_id)
|
||||
self._send_msg((
|
||||
'`{result_msg}`\n'
|
||||
f"`{msg['result_msg']}`\n"
|
||||
'Please make sure to take care of this asset on the exchange manually.'
|
||||
).format(**msg))
|
||||
))
|
||||
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
@@ -1410,14 +1420,14 @@ class Telegram(RPCHandler):
|
||||
"Optionally takes a rate at which to sell "
|
||||
"(only applies to limit orders).` \n")
|
||||
message = (
|
||||
"_BotControl_\n"
|
||||
"_Bot Control_\n"
|
||||
"------------\n"
|
||||
"*/start:* `Starts the trader`\n"
|
||||
"*/stop:* Stops the trader\n"
|
||||
"*/stopbuy:* `Stops buying, but handles open trades gracefully` \n"
|
||||
"*/forceexit <trade_id>|all:* `Instantly exits the given trade or all trades, "
|
||||
"regardless of profit`\n"
|
||||
"*/fe <trade_id>|all:* `Alias to /forceexit`"
|
||||
"*/fx <trade_id>|all:* `Alias to /forceexit`\n"
|
||||
f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}"
|
||||
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
|
||||
"*/whitelist:* `Show current whitelist` \n"
|
||||
|
@@ -45,21 +45,21 @@ class Webhook(RPCHandler):
|
||||
try:
|
||||
whconfig = self._config['webhook']
|
||||
if msg['type'] in [RPCMessageType.ENTRY]:
|
||||
valuedict = whconfig.get('webhookentry', None)
|
||||
valuedict = whconfig.get('webhookentry')
|
||||
elif msg['type'] in [RPCMessageType.ENTRY_CANCEL]:
|
||||
valuedict = whconfig.get('webhookentrycancel', None)
|
||||
valuedict = whconfig.get('webhookentrycancel')
|
||||
elif msg['type'] in [RPCMessageType.ENTRY_FILL]:
|
||||
valuedict = whconfig.get('webhookentryfill', None)
|
||||
valuedict = whconfig.get('webhookentryfill')
|
||||
elif msg['type'] == RPCMessageType.EXIT:
|
||||
valuedict = whconfig.get('webhookexit', None)
|
||||
valuedict = whconfig.get('webhookexit')
|
||||
elif msg['type'] == RPCMessageType.EXIT_FILL:
|
||||
valuedict = whconfig.get('webhookexitfill', None)
|
||||
valuedict = whconfig.get('webhookexitfill')
|
||||
elif msg['type'] == RPCMessageType.EXIT_CANCEL:
|
||||
valuedict = whconfig.get('webhookexitcancel', None)
|
||||
valuedict = whconfig.get('webhookexitcancel')
|
||||
elif msg['type'] in (RPCMessageType.STATUS,
|
||||
RPCMessageType.STARTUP,
|
||||
RPCMessageType.WARNING):
|
||||
valuedict = whconfig.get('webhookstatus', None)
|
||||
valuedict = whconfig.get('webhookstatus')
|
||||
else:
|
||||
raise NotImplementedError('Unknown message type: {}'.format(msg['type']))
|
||||
if not valuedict:
|
||||
|
@@ -1,9 +1,9 @@
|
||||
# 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 (BooleanParameter, CategoricalParameter, DecimalParameter,
|
||||
IntParameter, RealParameter)
|
||||
from freqtrade.strategy.informative_decorator import informative
|
||||
from freqtrade.strategy.interface import IStrategy
|
||||
from freqtrade.strategy.parameters import (BooleanParameter, CategoricalParameter, DecimalParameter,
|
||||
IntParameter, RealParameter)
|
||||
from freqtrade.strategy.strategy_helper import (merge_informative_pair, stoploss_from_absolute,
|
||||
stoploss_from_open)
|
||||
|
@@ -3,295 +3,18 @@ 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 pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union
|
||||
from typing import Any, Dict, Iterator, List, Tuple, Type, Union
|
||||
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import deep_merge_dicts, json_load
|
||||
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
|
||||
from freqtrade.strategy.parameters import BaseParameter
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@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:
|
||||
low = int(self.low * pow(10, self._decimals))
|
||||
high = int(self.high * pow(10, self._decimals)) + 1
|
||||
return [round(n * pow(0.1, self._decimals), self._decimals) for n in range(low, high)]
|
||||
else:
|
||||
return [self.value]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
"""
|
||||
Get each value in this space as list.
|
||||
Returns a List of categories 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:
|
||||
return self.opt_range
|
||||
else:
|
||||
return [self.value]
|
||||
|
||||
|
||||
class BooleanParameter(CategoricalParameter):
|
||||
|
||||
def __init__(self, *, default: Optional[Any] = None,
|
||||
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
|
||||
"""
|
||||
Initialize hyperopt-optimizable Boolean Parameter.
|
||||
It's a shortcut to `CategoricalParameter([True, False])`.
|
||||
: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.
|
||||
"""
|
||||
|
||||
categories = [True, False]
|
||||
super().__init__(categories=categories, default=default, space=space, optimize=optimize,
|
||||
load=load, **kwargs)
|
||||
|
||||
|
||||
class HyperStrategyMixin:
|
||||
"""
|
||||
A helper base class which allows HyperOptAuto class to reuse implementations of buy/sell
|
||||
@@ -307,7 +30,10 @@ class HyperStrategyMixin:
|
||||
self.ft_sell_params: List[BaseParameter] = []
|
||||
self.ft_protection_params: List[BaseParameter] = []
|
||||
|
||||
self._load_hyper_params(config.get('runmode') == RunMode.HYPEROPT)
|
||||
params = self.load_params_from_file()
|
||||
params = params.get('params', {})
|
||||
self._ft_params_from_file = params
|
||||
# Init/loading of parameters is done as part of ft_bot_start().
|
||||
|
||||
def enumerate_parameters(self, category: str = None) -> Iterator[Tuple[str, BaseParameter]]:
|
||||
"""
|
||||
@@ -327,28 +53,13 @@ class HyperStrategyMixin:
|
||||
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')),
|
||||
'protection': list(cls.detect_parameters('protection')),
|
||||
params: Dict[str, Any] = {
|
||||
'buy': list(detect_parameters(cls, 'buy')),
|
||||
'sell': list(detect_parameters(cls, 'sell')),
|
||||
'protection': list(detect_parameters(cls, 'protection')),
|
||||
}
|
||||
params.update({
|
||||
'count': len(params['buy'] + params['sell'] + params['protection'])
|
||||
@@ -356,21 +67,49 @@ class HyperStrategyMixin:
|
||||
|
||||
return params
|
||||
|
||||
def _load_hyper_params(self, hyperopt: bool = False) -> None:
|
||||
def ft_load_params_from_file(self) -> None:
|
||||
"""
|
||||
Load Parameters from parameter file
|
||||
Should/must run before config values are loaded in strategy_resolver.
|
||||
"""
|
||||
if self._ft_params_from_file:
|
||||
# Set parameters from Hyperopt results file
|
||||
params = self._ft_params_from_file
|
||||
self.minimal_roi = params.get('roi', getattr(self, 'minimal_roi', {}))
|
||||
|
||||
self.stoploss = params.get('stoploss', {}).get(
|
||||
'stoploss', getattr(self, 'stoploss', -0.1))
|
||||
trailing = params.get('trailing', {})
|
||||
self.trailing_stop = trailing.get(
|
||||
'trailing_stop', getattr(self, 'trailing_stop', False))
|
||||
self.trailing_stop_positive = trailing.get(
|
||||
'trailing_stop_positive', getattr(self, 'trailing_stop_positive', None))
|
||||
self.trailing_stop_positive_offset = trailing.get(
|
||||
'trailing_stop_positive_offset',
|
||||
getattr(self, 'trailing_stop_positive_offset', 0))
|
||||
self.trailing_only_offset_is_reached = trailing.get(
|
||||
'trailing_only_offset_is_reached',
|
||||
getattr(self, 'trailing_only_offset_is_reached', 0.0))
|
||||
|
||||
def ft_load_hyper_params(self, hyperopt: bool = False) -> None:
|
||||
"""
|
||||
Load Hyperoptable parameters
|
||||
Prevalence:
|
||||
* Parameters from parameter file
|
||||
* Parameters defined in parameters objects (buy_params, sell_params, ...)
|
||||
* Parameter defaults
|
||||
"""
|
||||
params = self.load_params_from_file()
|
||||
params = params.get('params', {})
|
||||
self._ft_params_from_file = params
|
||||
buy_params = deep_merge_dicts(params.get('buy', {}), getattr(self, 'buy_params', {}))
|
||||
sell_params = deep_merge_dicts(params.get('sell', {}), getattr(self, 'sell_params', {}))
|
||||
protection_params = deep_merge_dicts(params.get('protection', {}),
|
||||
|
||||
buy_params = deep_merge_dicts(self._ft_params_from_file.get('buy', {}),
|
||||
getattr(self, 'buy_params', {}))
|
||||
sell_params = deep_merge_dicts(self._ft_params_from_file.get('sell', {}),
|
||||
getattr(self, 'sell_params', {}))
|
||||
protection_params = deep_merge_dicts(self._ft_params_from_file.get('protection', {}),
|
||||
getattr(self, 'protection_params', {}))
|
||||
|
||||
self._load_params(buy_params, 'buy', hyperopt)
|
||||
self._load_params(sell_params, 'sell', hyperopt)
|
||||
self._load_params(protection_params, 'protection', hyperopt)
|
||||
self._ft_load_params(buy_params, 'buy', hyperopt)
|
||||
self._ft_load_params(sell_params, 'sell', hyperopt)
|
||||
self._ft_load_params(protection_params, 'protection', hyperopt)
|
||||
|
||||
def load_params_from_file(self) -> Dict:
|
||||
filename_str = getattr(self, '__file__', '')
|
||||
@@ -393,7 +132,7 @@ class HyperStrategyMixin:
|
||||
|
||||
return {}
|
||||
|
||||
def _load_params(self, params: Dict, space: str, hyperopt: bool = False) -> None:
|
||||
def _ft_load_params(self, params: Dict, space: str, hyperopt: bool = False) -> None:
|
||||
"""
|
||||
Set optimizable parameter values.
|
||||
:param params: Dictionary with new parameter values.
|
||||
@@ -402,7 +141,7 @@ class HyperStrategyMixin:
|
||||
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):
|
||||
for attr_name, attr in detect_parameters(self, space):
|
||||
attr.name = attr_name
|
||||
attr.in_space = hyperopt and HyperoptTools.has_space(self.config, space)
|
||||
if not attr.category:
|
||||
@@ -424,7 +163,7 @@ class HyperStrategyMixin:
|
||||
"""
|
||||
Returns list of Parameters that are not part of the current optimize job
|
||||
"""
|
||||
params = {
|
||||
params: Dict[str, Dict] = {
|
||||
'buy': {},
|
||||
'sell': {},
|
||||
'protection': {},
|
||||
@@ -433,3 +172,26 @@ class HyperStrategyMixin:
|
||||
if not p.optimize or not p.in_space:
|
||||
params[p.category][name] = p.value
|
||||
return params
|
||||
|
||||
|
||||
def detect_parameters(
|
||||
obj: Union[HyperStrategyMixin, Type[HyperStrategyMixin]],
|
||||
category: str
|
||||
) -> Iterator[Tuple[str, BaseParameter]]:
|
||||
"""
|
||||
Detect all parameters for 'category' for "obj"
|
||||
:param obj: Strategy object or class
|
||||
:param category: category - usually `'buy', 'sell', 'protection',...
|
||||
"""
|
||||
for attr_name in dir(obj):
|
||||
if not attr_name.startswith('__'): # Ignore internals, not strictly necessary.
|
||||
attr = getattr(obj, 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
|
||||
|
@@ -14,11 +14,10 @@ from freqtrade.constants import ListPairsWithTimeframes
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, SignalTagType,
|
||||
SignalType, TradingMode)
|
||||
from freqtrade.enums.runmode import RunMode
|
||||
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.persistence.models import LocalTrade, Order
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds
|
||||
from freqtrade.persistence import Order, PairLocks, Trade
|
||||
from freqtrade.strategy.hyper import HyperStrategyMixin
|
||||
from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators,
|
||||
_create_and_merge_informative_pair,
|
||||
@@ -84,7 +83,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
}
|
||||
|
||||
# run "populate_indicators" only for new candle
|
||||
process_only_new_candles: bool = False
|
||||
process_only_new_candles: bool = True
|
||||
|
||||
use_exit_signal: bool
|
||||
exit_profit_only: bool
|
||||
@@ -146,6 +145,15 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
informative_data.candle_type = config['candle_type_def']
|
||||
self._ft_informative.append((informative_data, cls_method))
|
||||
|
||||
def ft_bot_start(self, **kwargs) -> None:
|
||||
"""
|
||||
Strategy init - runs after dataprovider has been added.
|
||||
Must call bot_start()
|
||||
"""
|
||||
strategy_safe_wrapper(self.bot_start)()
|
||||
|
||||
self.ft_load_hyper_params(self.config.get('runmode') == RunMode.HYPEROPT)
|
||||
|
||||
@abstractmethod
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
"""
|
||||
@@ -279,8 +287,9 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
|
||||
:param pair: Pair that's about to be bought/shorted.
|
||||
:param order_type: Order type (as configured in order_types). usually limit or market.
|
||||
:param amount: Amount in target (quote) currency that's going to be traded.
|
||||
:param amount: Amount in target (base) currency that's going to be traded.
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
or current rate for market orders.
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
@@ -306,8 +315,9 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param pair: Pair for trade that's about to be exited.
|
||||
:param trade: trade object.
|
||||
:param order_type: Order type (as configured in order_types). usually limit or market.
|
||||
:param amount: Amount in quote currency.
|
||||
:param amount: Amount in base currency.
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
or current rate for market orders.
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param exit_reason: Exit reason.
|
||||
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
|
||||
@@ -431,8 +441,9 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
return self.custom_sell(pair, trade, current_time, current_rate, current_profit, **kwargs)
|
||||
|
||||
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_stake: float, min_stake: float, max_stake: float,
|
||||
entry_tag: Optional[str], side: str, **kwargs) -> float:
|
||||
proposed_stake: float, min_stake: Optional[float], max_stake: float,
|
||||
leverage: float, entry_tag: Optional[str], side: str,
|
||||
**kwargs) -> float:
|
||||
"""
|
||||
Customize stake size for each new trade.
|
||||
|
||||
@@ -442,6 +453,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param proposed_stake: A stake amount proposed by the bot.
|
||||
:param min_stake: Minimal stake size allowed by exchange.
|
||||
:param max_stake: Balance available for trading.
|
||||
:param leverage: Leverage selected for this trade.
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param side: 'long' or 'short' - indicating the direction of the proposed trade
|
||||
:return: A stake size, which is between min_stake and max_stake.
|
||||
@@ -449,8 +461,9 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
return proposed_stake
|
||||
|
||||
def adjust_trade_position(self, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, min_stake: float,
|
||||
max_stake: float, **kwargs) -> Optional[float]:
|
||||
current_rate: float, current_profit: float,
|
||||
min_stake: Optional[float], max_stake: float,
|
||||
**kwargs) -> Optional[float]:
|
||||
"""
|
||||
Custom trade adjustment logic, returning the stake amount that a trade should be increased.
|
||||
This means extra buy orders with additional fees.
|
||||
@@ -471,9 +484,37 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
"""
|
||||
return None
|
||||
|
||||
def adjust_entry_price(self, trade: Trade, order: Optional[Order], pair: str,
|
||||
current_time: datetime, proposed_rate: float, current_order_rate: float,
|
||||
entry_tag: Optional[str], side: str, **kwargs) -> float:
|
||||
"""
|
||||
Entry price re-adjustment logic, returning the user desired limit price.
|
||||
This only executes when a order was already placed, still open (unfilled fully or partially)
|
||||
and not timed out on subsequent candles after entry trigger.
|
||||
|
||||
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/
|
||||
|
||||
When not implemented by a strategy, returns current_order_rate as default.
|
||||
If current_order_rate is returned then the existing order is maintained.
|
||||
If None is returned then order gets canceled but not replaced by a new one.
|
||||
|
||||
:param pair: Pair that's currently analyzed
|
||||
:param trade: Trade object.
|
||||
:param order: Order object
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param proposed_rate: Rate, calculated based on pricing settings in entry_pricing.
|
||||
:param current_order_rate: Rate of the existing order in place.
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param side: 'long' or 'short' - indicating the direction of the proposed trade
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: New entry price value if provided
|
||||
|
||||
"""
|
||||
return current_order_rate
|
||||
|
||||
def leverage(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, side: str,
|
||||
**kwargs) -> float:
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||
side: str, **kwargs) -> float:
|
||||
"""
|
||||
Customize leverage for each new trade. This method is only called in futures mode.
|
||||
|
||||
@@ -482,6 +523,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param current_rate: Rate, calculated based on pricing settings in exit_pricing.
|
||||
:param proposed_leverage: A leverage proposed by the bot.
|
||||
:param max_leverage: Max leverage allowed on this pair
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param side: 'long' or 'short' - indicating the direction of the proposed trade
|
||||
:return: A leverage amount, which is between 1.0 and max_leverage.
|
||||
"""
|
||||
@@ -852,16 +894,16 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
def should_exit(self, trade: Trade, rate: float, current_time: datetime, *,
|
||||
enter: bool, exit_: bool,
|
||||
low: float = None, high: float = None,
|
||||
force_stoploss: float = 0) -> ExitCheckTuple:
|
||||
force_stoploss: float = 0) -> List[ExitCheckTuple]:
|
||||
"""
|
||||
This function evaluates if one of the conditions required to trigger an exit order
|
||||
has been reached, which can either be a stop-loss, ROI or exit-signal.
|
||||
:param low: Only used during backtesting to simulate (long)stoploss/(short)ROI
|
||||
:param high: Only used during backtesting, to simulate (short)stoploss/(long)ROI
|
||||
:param force_stoploss: Externally provided stoploss
|
||||
:return: True if trade should be exited, False otherwise
|
||||
:return: List of exit reasons - or empty list.
|
||||
"""
|
||||
|
||||
exits: List[ExitCheckTuple] = []
|
||||
current_rate = rate
|
||||
current_profit = trade.calc_profit_ratio(current_rate)
|
||||
|
||||
@@ -891,19 +933,20 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
if exit_ and not enter:
|
||||
exit_signal = ExitType.EXIT_SIGNAL
|
||||
else:
|
||||
custom_reason = strategy_safe_wrapper(self.custom_exit, default_retval=False)(
|
||||
reason_cust = strategy_safe_wrapper(self.custom_exit, default_retval=False)(
|
||||
pair=trade.pair, trade=trade, current_time=current_time,
|
||||
current_rate=current_rate, current_profit=current_profit)
|
||||
if custom_reason:
|
||||
if reason_cust:
|
||||
exit_signal = ExitType.CUSTOM_EXIT
|
||||
if isinstance(custom_reason, str):
|
||||
if len(custom_reason) > CUSTOM_EXIT_MAX_LENGTH:
|
||||
if isinstance(reason_cust, str):
|
||||
custom_reason = reason_cust
|
||||
if len(reason_cust) > CUSTOM_EXIT_MAX_LENGTH:
|
||||
logger.warning(f'Custom exit reason returned from '
|
||||
f'custom_exit is too long and was trimmed'
|
||||
f'to {CUSTOM_EXIT_MAX_LENGTH} characters.')
|
||||
custom_reason = custom_reason[:CUSTOM_EXIT_MAX_LENGTH]
|
||||
custom_reason = reason_cust[:CUSTOM_EXIT_MAX_LENGTH]
|
||||
else:
|
||||
custom_reason = None
|
||||
custom_reason = ''
|
||||
if (
|
||||
exit_signal == ExitType.CUSTOM_EXIT
|
||||
or (exit_signal == ExitType.EXIT_SIGNAL
|
||||
@@ -912,24 +955,29 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
logger.debug(f"{trade.pair} - Sell signal received. "
|
||||
f"exit_type=ExitType.{exit_signal.name}" +
|
||||
(f", custom_reason={custom_reason}" if custom_reason else ""))
|
||||
return ExitCheckTuple(exit_type=exit_signal, exit_reason=custom_reason)
|
||||
exits.append(ExitCheckTuple(exit_type=exit_signal, exit_reason=custom_reason))
|
||||
|
||||
# Sequence:
|
||||
# Exit-signal
|
||||
# ROI (if not stoploss)
|
||||
# Stoploss
|
||||
if roi_reached and stoplossflag.exit_type != ExitType.STOP_LOSS:
|
||||
logger.debug(f"{trade.pair} - Required profit reached. exit_type=ExitType.ROI")
|
||||
return ExitCheckTuple(exit_type=ExitType.ROI)
|
||||
# ROI
|
||||
# Trailing stoploss
|
||||
|
||||
if stoplossflag.exit_flag:
|
||||
if stoplossflag.exit_type == ExitType.STOP_LOSS:
|
||||
|
||||
logger.debug(f"{trade.pair} - Stoploss hit. exit_type={stoplossflag.exit_type}")
|
||||
return stoplossflag
|
||||
exits.append(stoplossflag)
|
||||
|
||||
# This one is noisy, commented out...
|
||||
# logger.debug(f"{trade.pair} - No exit signal.")
|
||||
return ExitCheckTuple(exit_type=ExitType.NONE)
|
||||
if roi_reached:
|
||||
logger.debug(f"{trade.pair} - Required profit reached. exit_type=ExitType.ROI")
|
||||
exits.append(ExitCheckTuple(exit_type=ExitType.ROI))
|
||||
|
||||
if stoplossflag.exit_type == ExitType.TRAILING_STOP_LOSS:
|
||||
|
||||
logger.debug(f"{trade.pair} - Trailing stoploss hit.")
|
||||
exits.append(stoplossflag)
|
||||
|
||||
return exits
|
||||
|
||||
def stop_loss_reached(self, current_rate: float, trade: Trade,
|
||||
current_time: datetime, current_profit: float,
|
||||
@@ -1044,7 +1092,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
else:
|
||||
return current_profit > roi
|
||||
|
||||
def ft_check_timed_out(self, trade: LocalTrade, order: Order,
|
||||
def ft_check_timed_out(self, trade: Trade, order: Order,
|
||||
current_time: datetime) -> bool:
|
||||
"""
|
||||
FT Internal method.
|
||||
|
289
freqtrade/strategy/parameters.py
Normal file
289
freqtrade/strategy/parameters.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
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, Optional, Sequence, Union
|
||||
|
||||
|
||||
with suppress(ImportError):
|
||||
from skopt.space import Integer, Real, Categorical
|
||||
from freqtrade.optimize.space import SKDecimal
|
||||
|
||||
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
|
||||
low: int
|
||||
high: 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)
|
||||
|
||||
@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:
|
||||
low = int(self.low * pow(10, self._decimals))
|
||||
high = int(self.high * pow(10, self._decimals)) + 1
|
||||
return [round(n * pow(0.1, self._decimals), self._decimals) for n in range(low, high)]
|
||||
else:
|
||||
return [self.value]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
"""
|
||||
Get each value in this space as list.
|
||||
Returns a List of categories 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:
|
||||
return self.opt_range
|
||||
else:
|
||||
return [self.value]
|
||||
|
||||
|
||||
class BooleanParameter(CategoricalParameter):
|
||||
|
||||
def __init__(self, *, default: Optional[Any] = None,
|
||||
space: Optional[str] = None, optimize: bool = True, load: bool = True, **kwargs):
|
||||
"""
|
||||
Initialize hyperopt-optimizable Boolean Parameter.
|
||||
It's a shortcut to `CategoricalParameter([True, False])`.
|
||||
: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.
|
||||
"""
|
||||
|
||||
categories = [True, False]
|
||||
super().__init__(categories=categories, default=default, space=space, optimize=optimize,
|
||||
load=load, **kwargs)
|
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, TypeVar, cast
|
||||
|
||||
from freqtrade.exceptions import StrategyError
|
||||
|
||||
@@ -7,12 +9,16 @@ from freqtrade.exceptions import StrategyError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def strategy_safe_wrapper(f, message: str = "", default_retval=None, supress_error=False):
|
||||
F = TypeVar('F', bound=Callable[..., Any])
|
||||
|
||||
|
||||
def strategy_safe_wrapper(f: F, message: str = "", default_retval=None, supress_error=False) -> F:
|
||||
"""
|
||||
Wrapper around user-provided methods and functions.
|
||||
Caches all exceptions and returns either the default_retval (if it's not None) or raises
|
||||
a StrategyError exception, which then needs to be handled by the calling method.
|
||||
"""
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
if 'trade' in kwargs:
|
||||
@@ -37,4 +43,4 @@ def strategy_safe_wrapper(f, message: str = "", default_retval=None, supress_err
|
||||
raise StrategyError(str(error)) from error
|
||||
return default_retval
|
||||
|
||||
return wrapper
|
||||
return cast(F, wrapper)
|
||||
|
@@ -4,7 +4,9 @@
|
||||
# --- Do not remove these libs ---
|
||||
import numpy as np # noqa
|
||||
import pandas as pd # noqa
|
||||
from pandas import DataFrame
|
||||
from pandas import DataFrame # noqa
|
||||
from datetime import datetime # noqa
|
||||
from typing import Optional, Union # noqa
|
||||
|
||||
from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter,
|
||||
IStrategy, IntParameter)
|
||||
@@ -62,7 +64,7 @@ class {{ strategy }}(IStrategy):
|
||||
# trailing_stop_positive_offset = 0.0 # Disabled / not configured
|
||||
|
||||
# Run "populate_indicators()" only for new candle.
|
||||
process_only_new_candles = False
|
||||
process_only_new_candles = True
|
||||
|
||||
# These values can be overridden in the config.
|
||||
use_exit_signal = True
|
||||
|
@@ -62,7 +62,7 @@ class SampleStrategy(IStrategy):
|
||||
timeframe = '5m'
|
||||
|
||||
# Run "populate_indicators()" only for new candle.
|
||||
process_only_new_candles = False
|
||||
process_only_new_candles = True
|
||||
|
||||
# These values can be overridden in the config.
|
||||
use_exit_signal = True
|
||||
|
@@ -51,11 +51,13 @@
|
||||
"source": [
|
||||
"# Load data using values set above\n",
|
||||
"from freqtrade.data.history import load_pair_history\n",
|
||||
"from freqtrade.enums import CandleType\n",
|
||||
"\n",
|
||||
"candles = load_pair_history(datadir=data_location,\n",
|
||||
" timeframe=config[\"timeframe\"],\n",
|
||||
" pair=pair,\n",
|
||||
" data_format = \"hdf5\",\n",
|
||||
" candle_type=CandleType.SPOT,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
"# Confirm success\n",
|
||||
|
@@ -13,7 +13,7 @@ def bot_loop_start(self, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
def custom_entry_price(self, pair: str, current_time: 'datetime', proposed_rate: float,
|
||||
entry_tag: 'Optional[str]', **kwargs) -> float:
|
||||
entry_tag: 'Optional[str]', side: str, **kwargs) -> float:
|
||||
"""
|
||||
Custom entry price logic, returning the new entry price.
|
||||
|
||||
@@ -30,6 +30,34 @@ def custom_entry_price(self, pair: str, current_time: 'datetime', proposed_rate:
|
||||
"""
|
||||
return proposed_rate
|
||||
|
||||
def adjust_entry_price(self, trade: 'Trade', order: 'Optional[Order]', pair: str,
|
||||
current_time: datetime, proposed_rate: float, current_order_rate: float,
|
||||
entry_tag: Optional[str], side: str, **kwargs) -> float:
|
||||
"""
|
||||
Entry price re-adjustment logic, returning the user desired limit price.
|
||||
This only executes when a order was already placed, still open (unfilled fully or partially)
|
||||
and not timed out on subsequent candles after entry trigger.
|
||||
|
||||
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-callbacks/
|
||||
|
||||
When not implemented by a strategy, returns current_order_rate as default.
|
||||
If current_order_rate is returned then the existing order is maintained.
|
||||
If None is returned then order gets canceled but not replaced by a new one.
|
||||
|
||||
:param pair: Pair that's currently analyzed
|
||||
:param trade: Trade object.
|
||||
:param order: Order object
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param proposed_rate: Rate, calculated based on pricing settings in entry_pricing.
|
||||
:param current_order_rate: Rate of the existing order in place.
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param side: 'long' or 'short' - indicating the direction of the proposed trade
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: New entry price value if provided
|
||||
|
||||
"""
|
||||
return current_order_rate
|
||||
|
||||
def custom_exit_price(self, pair: str, trade: 'Trade',
|
||||
current_time: 'datetime', proposed_rate: float,
|
||||
current_profit: float, exit_tag: Optional[str], **kwargs) -> float:
|
||||
@@ -51,9 +79,10 @@ def custom_exit_price(self, pair: str, trade: 'Trade',
|
||||
"""
|
||||
return proposed_rate
|
||||
|
||||
def custom_stake_amount(self, pair: str, current_time: 'datetime', current_rate: float,
|
||||
proposed_stake: float, min_stake: float, max_stake: float,
|
||||
side: str, entry_tag: 'Optional[str]', **kwargs) -> float:
|
||||
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_stake: float, min_stake: Optional[float], max_stake: float,
|
||||
leverage: float, entry_tag: Optional[str], side: str,
|
||||
**kwargs) -> float:
|
||||
"""
|
||||
Customize stake size for each new trade.
|
||||
|
||||
@@ -63,6 +92,7 @@ def custom_stake_amount(self, pair: str, current_time: 'datetime', current_rate:
|
||||
:param proposed_stake: A stake amount proposed by the bot.
|
||||
:param min_stake: Minimal stake size allowed by exchange.
|
||||
:param max_stake: Balance available for trading.
|
||||
:param leverage: Leverage selected for this trade.
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param side: 'long' or 'short' - indicating the direction of the proposed trade
|
||||
:return: A stake size, which is between min_stake and max_stake.
|
||||
@@ -118,7 +148,7 @@ def custom_exit(self, pair: str, trade: 'Trade', current_time: 'datetime', curre
|
||||
return None
|
||||
|
||||
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
time_in_force: str, current_time: datetime, entry_tag: 'Optional[str]',
|
||||
time_in_force: str, current_time: datetime, entry_tag: Optional[str],
|
||||
side: str, **kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a entry order.
|
||||
@@ -131,8 +161,9 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f
|
||||
|
||||
:param pair: Pair that's about to be bought/shorted.
|
||||
:param order_type: Order type (as configured in order_types). usually limit or market.
|
||||
:param amount: Amount in target (quote) currency that's going to be traded.
|
||||
:param amount: Amount in target (base) currency that's going to be traded.
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
or current rate for market orders.
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
@@ -147,7 +178,7 @@ def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount:
|
||||
rate: float, time_in_force: str, exit_reason: str,
|
||||
current_time: 'datetime', **kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a regular sell order.
|
||||
Called right before placing a regular exit order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
network requests in this method.
|
||||
|
||||
@@ -155,18 +186,19 @@ def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount:
|
||||
|
||||
When not implemented by a strategy, returns True (always confirming).
|
||||
|
||||
:param pair: Pair that's currently analyzed
|
||||
:param pair: Pair for trade that's about to be exited.
|
||||
:param trade: trade object.
|
||||
:param order_type: Order type (as configured in order_types). usually limit or market.
|
||||
:param amount: Amount in quote currency.
|
||||
:param amount: Amount in base currency.
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
or current rate for market orders.
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param exit_reason: Exit reason.
|
||||
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
|
||||
'exit_signal', 'force_exit', 'emergency_exit']
|
||||
: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 exit-order is placed on the exchange.
|
||||
:return bool: When True, then the exit-order is placed on the exchange.
|
||||
False aborts the process
|
||||
"""
|
||||
return True
|
||||
@@ -216,7 +248,7 @@ def check_exit_timeout(self, pair: str, trade: 'Trade', order: 'Order',
|
||||
return False
|
||||
|
||||
def adjust_trade_position(self, trade: 'Trade', current_time: 'datetime',
|
||||
current_rate: float, current_profit: float, min_stake: float,
|
||||
current_rate: float, current_profit: float, min_stake: Optional[float],
|
||||
max_stake: float, **kwargs) -> 'Optional[float]':
|
||||
"""
|
||||
Custom trade adjustment logic, returning the stake amount that a trade should be increased.
|
||||
@@ -239,8 +271,8 @@ def adjust_trade_position(self, trade: 'Trade', current_time: 'datetime',
|
||||
return None
|
||||
|
||||
def leverage(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, side: str,
|
||||
**kwargs) -> float:
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str],
|
||||
side: str, **kwargs) -> float:
|
||||
"""
|
||||
Customize leverage for each new trade. This method is only called in futures mode.
|
||||
|
||||
@@ -249,6 +281,7 @@ def leverage(self, pair: str, current_time: datetime, current_rate: float,
|
||||
:param current_rate: Rate, calculated based on pricing settings in exit_pricing.
|
||||
:param proposed_leverage: A leverage proposed by the bot.
|
||||
:param max_leverage: Max leverage allowed on this pair
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param side: 'long' or 'short' - indicating the direction of the proposed trade
|
||||
:return: A leverage amount, which is between 1.0 and max_leverage.
|
||||
"""
|
||||
|
@@ -131,9 +131,9 @@ class Wallets:
|
||||
if isinstance(balances[currency], dict):
|
||||
self._wallets[currency] = Wallet(
|
||||
currency,
|
||||
balances[currency].get('free', None),
|
||||
balances[currency].get('used', None),
|
||||
balances[currency].get('total', None)
|
||||
balances[currency].get('free'),
|
||||
balances[currency].get('used'),
|
||||
balances[currency].get('total')
|
||||
)
|
||||
# Remove currencies no longer in get_balances output
|
||||
for currency in deepcopy(self._wallets):
|
||||
@@ -300,7 +300,8 @@ class Wallets:
|
||||
|
||||
if min_stake_amount is not None and min_stake_amount > max_stake_amount:
|
||||
if self._log:
|
||||
logger.warning("Minimum stake amount > available balance.")
|
||||
logger.warning("Minimum stake amount > available balance. "
|
||||
f"{min_stake_amount} > {max_stake_amount}")
|
||||
return 0
|
||||
if min_stake_amount is not None and stake_amount < min_stake_amount:
|
||||
if self._log:
|
||||
|
Reference in New Issue
Block a user