From fc837c4daa27a18ff0e86128f4d52089b88fa5fb Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 3 May 2022 10:14:17 +0200 Subject: [PATCH 001/130] add freqao backend machinery, user interface, documentation --- .gitignore | 3 + config_examples/config_freqai.example.json | 100 ++++ docs/freqai.md | 265 +++++++++++ freqtrade/commands/__init__.py | 1 + freqtrade/commands/arguments.py | 5 +- freqtrade/commands/cli_options.py | 12 + freqtrade/commands/freqai_commands.py | 24 + freqtrade/configuration/configuration.py | 12 + freqtrade/constants.py | 1 + freqtrade/enums/runmode.py | 3 +- freqtrade/freqai/data_handler.py | 434 ++++++++++++++++++ freqtrade/freqai/freqai_interface.py | 158 +++++++ freqtrade/freqai/strategy_bridge.py | 12 + freqtrade/optimize/backtesting.py | 6 + freqtrade/resolvers/freqaimodel_resolver.py | 45 ++ freqtrade/templates/ExamplePredictionModel.py | 139 ++++++ freqtrade/templates/FreqaiExampleStrategy.py | 179 ++++++++ mkdocs.yml | 1 + requirements-freqai.txt | 8 + 19 files changed, 1405 insertions(+), 3 deletions(-) create mode 100644 config_examples/config_freqai.example.json create mode 100644 docs/freqai.md create mode 100644 freqtrade/commands/freqai_commands.py create mode 100644 freqtrade/freqai/data_handler.py create mode 100644 freqtrade/freqai/freqai_interface.py create mode 100644 freqtrade/freqai/strategy_bridge.py create mode 100644 freqtrade/resolvers/freqaimodel_resolver.py create mode 100644 freqtrade/templates/ExamplePredictionModel.py create mode 100644 freqtrade/templates/FreqaiExampleStrategy.py create mode 100644 requirements-freqai.txt diff --git a/.gitignore b/.gitignore index 97f77f779..17823f642 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ logfile.txt user_data/* !user_data/strategy/sample_strategy.py !user_data/notebooks +!user_data/models +user_data/models/* user_data/notebooks/* freqtrade-plot.html freqtrade-profit-plot.html @@ -105,3 +107,4 @@ target/ !config_examples/config_ftx.example.json !config_examples/config_full.example.json !config_examples/config_kraken.example.json +!config_examples/config_freqai.example.json diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json new file mode 100644 index 000000000..0092a8c51 --- /dev/null +++ b/config_examples/config_freqai.example.json @@ -0,0 +1,100 @@ +{ + "max_open_trades": 1, + "stake_currency": "USDT", + "stake_amount": 800, + "tradable_balance_ratio": 1, + "fiat_display_currency": "USD", + "dry_run": true, + "timeframe": "5m", + "dry_run_wallet":1000, + "cancel_open_orders_on_exit": true, + "unfilledtimeout": { + "entry": 10, + "exit": 30 + }, + "exchange": { + "name": "ftx", + "key": "", + "secret": "", + "ccxt_config": {"enableRateLimit": true}, + "ccxt_async_config": { + "enableRateLimit": true, + "rateLimit": 200 + }, + "pair_whitelist": [ + "BTC/USDT" + ], + "pair_blacklist": [ + ] + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1 + }, + "pairlists": [ + {"method": "StaticPairList"} + ], + + "freqai": { + "btc_pair" : "BTC/USDT", + "timeframes" : ["5m","15m","1h"], + "full_timerange" : "20210601-20220101", + "train_period" : 30, + "backtest_period" : 7, + "identifier" : "example", + "base_features": [ + "rsi", + "close_over_20sma", + "relative_volume", + "bb_width", + "mfi", + "roc", + "pct-change", + "adx", + "macd" + ], + "corr_pairlist": [ + "ETH/USDT", + "LINK/USDT", + "DOT/USDT" + ], + "training_timerange" : "20211220-20220117", + + "feature_parameters" : { + "period": 12, + "shift": 2, + "drop_features": false, + "DI_threshold": 1, + "weight_factor": 0, + "principal_component_analysis": false, + "remove_outliers": false + }, + "data_split_parameters" : { + "test_size": 0.25, + "random_state": 1 + }, + "model_training_parameters" : { + "n_estimators": 2000, + "random_state": 1, + "learning_rate": 0.02, + "task_type": "CPU" + } + }, + "bot_name": "", + "initial_state": "running", + "forcebuy_enable": false, + "internals": { + "process_throttle_secs": 5 + } +} diff --git a/docs/freqai.md b/docs/freqai.md new file mode 100644 index 000000000..6bc1e9365 --- /dev/null +++ b/docs/freqai.md @@ -0,0 +1,265 @@ +# Freqai + +!!! Note + Freqai is still experimental, and should be used at the user's own discretion. + +Freqai is a module designed to automate a variety of tasks associated with +training a regressor to predict signals based on input features. Among the +the features includes: + +* Easy large feature set construction based on simple user input +* Sweep model training and backtesting to simulate consistent model retraining through time +* Smart outlier removal of data points from prediction sets using a Dissimilarity Index. +* Data dimensionality reduction with Principal Component Analysis +* Automatic file management for storage of models to be reused during live +* Smart and safe data standardization +* Cleaning of NaNs from the data set before training and prediction. + +TODO: +* live is not automated, still some architectural work to be done + +## Background and vocabulary + +**Features** are the quantities with which a model is trained. $X_i$ represents the +vector of all features for a single candle. In Freqai, the user +builds the features from anything they can construct in the strategy. + +**Labels** are the target values with which the weights inside a model are trained +toward. Each set of features is associated with a single label, which is also +defined within the strategy by the user. These labels look forward into the +future, and are not available to the model during dryrun/live/backtesting. + +**Training** refers to the process of feeding individual feature sets into the +model with associated labels with the goal of matching input feature sets to +associated labels. + +**Train data** is a subset of the historic data which is fed to the model during +training to adjust weights. This data directly influences weight connections +in the model. + +**Test data** is a subset of the historic data which is used to evaluate the +intermediate performance of the model during training. This data does not +directly influence nodal weights within the model. + +## Configuring the bot +### Example config file +The user interface is isolated to the typical config file. A typical Freqai +config setup includes: + +```json + "freqai": { + "timeframes" : ["5m","15m","4h"], + "full_timerange" : "20211220-20220220", + "train_period" : "month", + "backtest_period" : "week", + "identifier" : "unique-id", + "base_features": [ + "rsi", + "mfi", + "roc", + ], + "corr_pairlist": [ + "ETH/USD", + "LINK/USD", + "BNB/USD" + ], + "train_params" : { + "period": 24, + "shift": 2, + "drop_features": false, + "DI_threshold": 1, + "weight_factor": 0, + }, + "SPLIT_PARAMS" : { + "test_size": 0.25, + "random_state": 42 + }, + "CLASSIFIER_PARAMS" : { + "n_estimators": 100, + "random_state": 42, + "learning_rate": 0.02, + "task_type": "CPU", + }, + }, + +``` + +### Building the feature set + +Most of these parameters are controlling the feature data set. The `base_features` +indicates the basic indicators the user wishes to include in the feature set. +The `timeframes` are the timeframes of each base_feature that the user wishes to +include in the feature set. In the present case, the user is asking for the +`5m`, `15m`, and `4h` timeframes of the `rsi`, `mfi`, `roc`, etc. to be included +in the feature set. + +In addition, the user can ask for each of these features to be included from +informative pairs using the `corr_pairlist`. This means that the present feature +set will include all the `base_features` on all the `timeframes` for each of +`ETH/USD`, `LINK/USD`, and `BNB/USD`. + +`shift` is another user controlled parameter which indicates the number of previous +candles to include in the present feature set. In other words, `shift: 2`, tells +Freqai to include the the past 2 candles for each of the features included +in the dataset. + +In total, the number of features the present user has created is:_ + +no. `timeframes` * no. `base_features` * no. `corr_pairlist` * no. `shift`_ +3 * 3 * 3 * 2 = 54._ + +### Deciding the sliding training window and backtesting duration + +`full_timerange` lets the user set the full backtesting range to train and +backtest through. Meanwhile `train_period` is the sliding training window and +`backtest_period` is the sliding backtesting window. In the present example, +the user is asking Freqai to train and backtest the range of `20211220-20220220` (`month`). +The user wishes to backtest each `week` with a newly trained model. This means that +Freqai will train 8 separate models (because the full range comprises 8 weeks), +and then backtest the subsequent week associated with each of the 8 training +data set timerange months. Users can think of this as a "sliding window" which +emulates Freqai retraining itself once per week in live using the previous +month of data. + + +## Running Freqai +### Training and backtesting + +The freqai training/backtesting module can be executed with the following command: + +```bash +freqtrade backtesting --strategy FreqaiExampleStrategy --config config_freqai.example.json --freqaimodel ExamplePredictionModel +``` + +where the user needs to have a FreqaiExampleStrategy that fits to the requirements outlined +below. The ExamplePredictionModel is a user built class which lets users design their +own training procedures and data analysis. + +### Building a freqai strategy + +The Freqai strategy requires the user to include the following lines of code in `populate_ any _indicators()` + +```python + from freqtrade.freqai.strategy_bridge import CustomModel + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + # the configuration file parameters are stored here + self.freqai_info = self.config['freqai'] + + # the model is instantiated here + self.model = CustomModel(self.config) + + print('Populating indicators...') + + # the following loops are necessary for building the features + # indicated by the user in the configuration file. + for tf in self.freqai_info['timeframes']: + dataframe = self.populate_any_indicators(metadata['pair'], + dataframe.copy(), tf) + for i in self.freqai_info['corr_pairlist']: + dataframe = self.populate_any_indicators(i, + dataframe.copy(), tf, coin=i.split("/")[0]+'-') + + # the model will return 4 values, its prediction, an indication of whether or not the prediction + # should be accepted, the target mean/std values from the labels used during each training period. + (dataframe['prediction'], dataframe['do_predict'], + dataframe['target_mean'], dataframe['target_std']) = self.model.bridge.start(dataframe, metadata) + + return dataframe +``` +The user should also include `populate_any_indicators()` from `templates/FreqaiExampleStrategy.py` which builds +the feature set with a proper naming convention for the IFreqaiModel to use later. + +### Building an IFreqaiModel + +Freqai has a base example model in `templates/ExamplePredictionModel.py`, but users can customize and create +their own prediction models using the `IFreqaiModel` class. Users are encouraged to inherit `train()`, `predict()`, +and `make_labels()` to let them customize various aspects of their training procedures. + +### Running the model live + +After the user has designed a desirable featureset, Freqai can be run in dry/live +using the typical trade command: + +```bash +freqtrade trade --strategy FreqaiExampleStrategy --config config_freqai.example.json --training_timerange '20211220-20220120' +``` + +Where the user has now specified exactly which of the models from the sliding window +that they wish to run live using `--training_timerange` (typically this would be the most +recent model trained). As of right now, freqai will +not automatically retain itself, so the user needs to manually retrain and then +reload the config file with a new `--training_timerange` in order to update the +model. + + +## Data anylsis techniques +### Controlling the model learning process + +The user can define model settings for the data split `data_split_parameters` and learning parameters +`model_training_parameters`. Users are encouraged to visit the Catboost documentation +for more information on how to select these values. `n_estimators` increases the +computational effort and the fit to the training data. If a user has a GPU +installed in their system, they may benefit from changing `task_type` to `GPU`. +The `weight_factor` allows the user to weight more recent data more strongly +than past data via an exponential function: + +$$ W_i = \exp(\frac{-i}{\alpha*n}) $$ + +where $W_i$ is the weight of data point $i$ in a total set of $n$ data points._ + +`drop_features` tells Freqai to train the model on the user defined features, +followed by a feature importance evaluation where it drops the top and bottom +performing features (there is evidence to suggest the top features may not be +helpful in equity/crypto trading since the ultimate objective is to predict low +frequency patterns, source: numerai)._ + +Finally, `period` defines the offset used for the `labels`. In the present example, +the user is asking for `labels` that are 24 candles in the future. + +### Removing outliers with the Dissimilarity Index + +The Dissimilarity Index (DI) aims to quantiy the uncertainty associated with each +prediction by the model. To do so, Freqai measures the distance between each training +data point and all other training data points: + +$$ d_{ab} = \sqrt{\sum_{j=1}^p(X_{a,j}-X_{b,j})^2} $$ + +where $d_{ab}$ is the distance between the standardized points $a$ and $b$. $p$ +is the number of features i.e. the length of the vector $X$. The +characteristic distance, $\overline{d}$ for a set of training data points is simply the mean +of the average distances: + +$$ \overline{d} = \sum_{a=1}^n(\sum_{b=1}^n(d_{ab}/n)/n) $$ + +$\overline{d}$ quantifies the spread of the training data, which is compared to +the distance between the new prediction feature vectors, $X_k$ and all the training +data: + +$$ d_k = \argmin_i d_{k,i} $$ + +which enables the estimation of a Dissimilarity Index: + +$$ DI_k = d_k/\overline{d} $$ + +Equity and crypto markets suffer from a high level of non-patterned noise in the +form of outlier data points. The dissimilarity index allows predictions which +are outliers and not existent in the model feature space, to be thrown out due +to low levels of certainty. The user can tweak the DI with `DI_threshold` to increase +or decrease the extrapolation of the trained model. + +### Reducing data dimensionality with Principal Component Analysis + +TO BE WRITTEN + +## Additional information +### Feature standardization + +The feature set created by the user is automatically standardized to the training +data only. This includes all test data and unseen prediction data (dry/live/backtest). + +### File structure + +`user_data_dir/models/` contains all the data associated with the trainings and +backtestings. This file structure is heavily controlled and read by the `DataHandler()` +and should thus not be modified. diff --git a/freqtrade/commands/__init__.py b/freqtrade/commands/__init__.py index 0e637c487..d5aea62be 100644 --- a/freqtrade/commands/__init__.py +++ b/freqtrade/commands/__init__.py @@ -19,6 +19,7 @@ from freqtrade.commands.list_commands import (start_list_exchanges, start_list_m start_show_trades) from freqtrade.commands.optimize_commands import (start_backtesting, start_backtesting_show, start_edge, start_hyperopt) +from freqtrade.commands.freqai_commands import (start_training) from freqtrade.commands.pairlist_commands import start_test_pairlist from freqtrade.commands.plot_commands import start_plot_dataframe, start_plot_profit from freqtrade.commands.trade_commands import start_trading diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index 815e28175..4388e84e4 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -12,7 +12,7 @@ from freqtrade.constants import DEFAULT_CONFIG ARGS_COMMON = ["verbosity", "logfile", "version", "config", "datadir", "user_data_dir"] -ARGS_STRATEGY = ["strategy", "strategy_path", "recursive_strategy_search"] +ARGS_STRATEGY = ["strategy", "strategy_path", "recursive_strategy_search", "freqaimodel", "freqaimodel_path"] ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "dry_run_wallet", "fee"] @@ -190,7 +190,8 @@ class Arguments: start_list_markets, start_list_strategies, start_list_timeframes, start_new_config, start_new_strategy, start_plot_dataframe, start_plot_profit, start_show_trades, - start_test_pairlist, start_trading, start_webserver) + start_test_pairlist, start_trading, start_webserver, + start_training) subparsers = self.parser.add_subparsers(dest='command', # Use custom message when no subhandler is added diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index aac9f5713..4061418f7 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -614,4 +614,16 @@ AVAILABLE_CLI_OPTIONS = { "that do not contain any parameters."), action="store_true", ), + + "freqaimodel": Arg( + '--freqaimodel', + help='Specify a custom freqaimodels.', + metavar='NAME', + ), + + "freqaimodel_path": Arg( + '--freqaimodel-path', + help='Specify additional lookup path for freqaimodels.', + metavar='PATH', + ), } diff --git a/freqtrade/commands/freqai_commands.py b/freqtrade/commands/freqai_commands.py new file mode 100644 index 000000000..2733c851a --- /dev/null +++ b/freqtrade/commands/freqai_commands.py @@ -0,0 +1,24 @@ +import logging +from typing import Any, Dict + +from freqtrade import constants +from freqtrade.configuration import setup_utils_configuration +from freqtrade.enums import RunMode +from freqtrade.exceptions import OperationalException +from freqtrade.misc import round_coin_value + + +logger = logging.getLogger(__name__) + +def start_training(args: Dict[str, Any]) -> None: + """ + Train a model for predicting signals + :param args: Cli args from Arguments() + :return: None + """ + from freqtrade.freqai.training import Training + + config = setup_utils_configuration(args, RunMode.FREQAI) + + training = Training(config) + training.start() diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index 96b585cd1..e13985270 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -95,6 +95,8 @@ class Configuration: self._process_data_options(config) + self._process_freqai_options(config) + # Check if the exchange set by the user is supported check_exchange(config, config.get('experimental', {}).get('block_bad_exchanges', True)) @@ -446,6 +448,16 @@ class Configuration: config.update({'runmode': self.runmode}) + def _process_freqai_options(self, config: Dict[str, Any]) -> None: + + self._args_to_config(config, argname='freqaimodel', + logstring='Using freqaimodel class name: {}') + + self._args_to_config(config, argname='freqaimodel_path', + logstring='Using freqaimodel path: {}') + + return + def _args_to_config(self, config: Dict[str, Any], argname: str, logstring: str, logfun: Optional[Callable] = None, deprecated_msg: Optional[str] = None) -> None: diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 372472db8..f8a9dc06d 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -55,6 +55,7 @@ FTHYPT_FILEVERSION = 'fthypt_fileversion' USERPATH_HYPEROPTS = 'hyperopts' USERPATH_STRATEGIES = 'strategies' USERPATH_NOTEBOOKS = 'notebooks' +USERPATH_FREQAIMODELS = 'freqaimodels' TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] diff --git a/freqtrade/enums/runmode.py b/freqtrade/enums/runmode.py index 6545aaec7..c280edf7c 100644 --- a/freqtrade/enums/runmode.py +++ b/freqtrade/enums/runmode.py @@ -15,9 +15,10 @@ class RunMode(Enum): UTIL_NO_EXCHANGE = "util_no_exchange" PLOT = "plot" WEBSERVER = "webserver" + FREQAI = "freqai" OTHER = "other" TRADING_MODES = [RunMode.LIVE, RunMode.DRY_RUN] -OPTIMIZE_MODES = [RunMode.BACKTEST, RunMode.EDGE, RunMode.HYPEROPT] +OPTIMIZE_MODES = [RunMode.BACKTEST, RunMode.EDGE, RunMode.HYPEROPT, RunMode.FREQAI] NON_UTIL_MODES = TRADING_MODES + OPTIMIZE_MODES diff --git a/freqtrade/freqai/data_handler.py b/freqtrade/freqai/data_handler.py new file mode 100644 index 000000000..d399cd12b --- /dev/null +++ b/freqtrade/freqai/data_handler.py @@ -0,0 +1,434 @@ +import json +import os +import copy +import numpy as np +import pandas as pd +from pandas import DataFrame +from joblib import dump +from joblib import load +from sklearn.model_selection import train_test_split +from sklearn.metrics.pairwise import pairwise_distances +import datetime +from typing import Any, Dict, List, Tuple +import pickle as pk +from freqtrade.configuration import TimeRange + +SECONDS_IN_DAY = 86400 + +class DataHandler: + """ + Class designed to handle all the data for the IFreqaiModel class model. + Functionalities include holding, saving, loading, and analyzing the data. + """ + + def __init__(self, config: Dict[str, Any], dataframe: DataFrame, data: List): + self.full_dataframe = dataframe + (self.training_timeranges, + self.backtesting_timeranges) = self.split_timerange( + config['freqai']['full_timerange'], + config['freqai']['train_period'], + config['freqai']['backtest_period']) + self.data = data + self.data_dictionary = {} + self.config = config + self.freq_config = config['freqai'] + + def save_data(self, model: Any) -> None: + """ + Saves all data associated with a model for a single sub-train time range + :params: + :model: User trained model which can be reused for inferencing to generate + predictions + """ + + if not os.path.exists(self.model_path): os.mkdir(self.model_path) + save_path = self.model_path + self.model_filename + # Save the trained model + dump(model, save_path+"_model.joblib") + self.data['model_path'] = self.model_path + self.data['model_filename'] = self.model_filename + self.data['training_features_list'] = list(self.data_dictionary['train_features'].columns) + # store the metadata + with open(save_path+"_metadata.json", 'w') as fp: + json.dump(self.data, fp, default=self.np_encoder) + + # save the train data to file so we can check preds for area of applicability later + self.data_dictionary['train_features'].to_pickle(save_path+"_trained_df.pkl") + + return + + def load_data(self) -> Any: + """ + loads all data required to make a prediction on a sub-train time range + :returns: + :model: User trained model which can be inferenced for new predictions + """ + model = load(self.model_path+self.model_filename+"_model.joblib") + + with open(self.model_path+self.model_filename+"_metadata.json", 'r') as fp: + self.data = json.load(fp) + if self.data.get('training_features_list'): + self.training_features_list = [*self.data.get('training_features_list')] + + self.data_dictionary['train_features'] = pd.read_pickle(self.model_path+ + self.model_filename+"_trained_df.pkl") + + self.model_path = self.data['model_path'] + self.model_filename = self.data['model_filename'] + if self.config['freqai']['feature_parameters']['principal_component_analysis']: + self.pca = pk.load(open(self.model_path+self.model_filename+"_pca_object.pkl","rb")) + + return model + + def make_train_test_datasets(self, filtered_dataframe: DataFrame, labels: DataFrame) -> None: + ''' + Given the dataframe for the full history for training, split the data into + training and test data according to user specified parameters in configuration + file. + :filtered_dataframe: cleaned dataframe ready to be split. + :labels: cleaned labels ready to be split. + ''' + + if self.config['freqai']['feature_parameters']['weight_factor'] > 0: + weights = self.set_weights_higher_recent(len(filtered_dataframe)) + else: weights = np.ones(len(filtered_dataframe)) + + (train_features, test_features, train_labels, + test_labels, train_weights, test_weights) = train_test_split( + filtered_dataframe[:filtered_dataframe.shape[0]], + labels, + weights, + **self.config['freqai']['data_split_parameters'] + ) + + return self.build_data_dictionary( + train_features,test_features, + train_labels,test_labels, + train_weights,test_weights) + + + + def filter_features(self, unfiltered_dataframe: DataFrame, training_feature_list: List, + labels: DataFrame = None, training_filter: bool=True) -> Tuple[DataFrame, DataFrame]: + ''' + Filter the unfiltered dataframe to extract the user requested features and properly + remove all NaNs. Any row with a NaN is removed from training dataset or replaced with + 0s in the prediction dataset. However, prediction dataset do_predict will reflect any + row that had a NaN and will shield user from that prediction. + :params: + :unfiltered_dataframe: the full dataframe for the present training period + :training_feature_list: list, the training feature list constructed by self.build_feature_list() + according to user specified parameters in the configuration file. + :labels: the labels for the dataset + :training_filter: boolean which lets the function know if it is training data or + prediction data to be filtered. + :returns: + :filtered_dataframe: dataframe cleaned of NaNs and only containing the user + requested feature set. + :labels: labels cleaned of NaNs. + ''' + filtered_dataframe = unfiltered_dataframe.filter(training_feature_list, axis=1) + drop_index = pd.isnull(filtered_dataframe).any(1) # get the rows that have NaNs, + + if training_filter: # we don't care about total row number (total no. datapoints) in training, we only care about removing any row with NaNs + drop_index_labels = pd.isnull(labels) + filtered_dataframe = filtered_dataframe[(drop_index==False) & (drop_index_labels==False)] # dropping values + labels = labels[(drop_index==False) & (drop_index_labels==False)] # assuming the labels depend entirely on the dataframe here. + print('dropped',len(unfiltered_dataframe)-len(filtered_dataframe), + 'training data points due to NaNs, ensure you have downloaded all historical training data') + self.data['filter_drop_index_training'] = drop_index + + else: # we are backtesting so we need to preserve row number to send back to strategy, so now we use do_predict to avoid any prediction based on a NaN + drop_index = pd.isnull(filtered_dataframe).any(1) + self.data['filter_drop_index_prediction'] = drop_index + filtered_dataframe.fillna(0, inplace=True) # replacing all NaNs with zeros to avoid issues in 'prediction', but any prediction that was based on a single NaN is ultimately protected from buys with do_predict + drop_index = ~drop_index + self.do_predict = np.array(drop_index.replace(True,1).replace(False,0)) + print('dropped',len(self.do_predict) - self.do_predict.sum(),'of',len(filtered_dataframe), + 'prediction data points due to NaNs. These are protected from prediction with do_predict vector returned to strategy.') + + + return filtered_dataframe, labels + + def build_data_dictionary(self, train_df: DataFrame, test_df: DataFrame, + train_labels: DataFrame, test_labels: DataFrame, + train_weights: Any, test_weights: Any) -> Dict: + + self.data_dictionary = {'train_features': train_df, + 'test_features': test_df, + 'train_labels': train_labels, + 'test_labels': test_labels, + 'train_weights': train_weights, + 'test_weights': test_weights} + + return self.data_dictionary + + def standardize_data(self, data_dictionary: Dict) -> None: + ''' + Standardize all data in the data_dictionary according to the training dataset + :params: + :data_dictionary: dictionary containing the cleaned and split training/test data/labels + :returns: + :data_dictionary: updated dictionary with standardized values. + ''' + # standardize the data by training stats + train_mean = data_dictionary['train_features'].mean() + train_std = data_dictionary['train_features'].std() + data_dictionary['train_features'] = (data_dictionary['train_features'] - train_mean) / train_std + data_dictionary['test_features'] = (data_dictionary['test_features'] - train_mean) / train_std + + train_labels_std = data_dictionary['train_labels'].std() + train_labels_mean = data_dictionary['train_labels'].mean() + data_dictionary['train_labels'] = (data_dictionary['train_labels'] - train_labels_mean) / train_labels_std + data_dictionary['test_labels'] = (data_dictionary['test_labels'] - train_labels_mean) / train_labels_std + + for item in train_std.keys(): + self.data[item+'_std'] = train_std[item] + self.data[item+'_mean'] = train_mean[item] + + self.data['labels_std'] = train_labels_std + self.data['labels_mean'] = train_labels_mean + + return data_dictionary + + def standardize_data_from_metadata(self, df: DataFrame) -> DataFrame: + ''' + Standardizes a set of data using the mean and standard deviation from + the associated training data. + :params: + :df: Dataframe to be standardized + ''' + + for item in df.keys(): + df[item] = (df[item] - self.data[item+'_mean']) / self.data[item+'_std'] + + return df + + def split_timerange(self, tr: Dict, train_split: int=28, bt_split: int=7) -> list: + ''' + Function which takes a single time range (tr) and splits it + into sub timeranges to train and backtest on based on user input + tr: str, full timerange to train on + train_split: the period length for the each training (days). Specified in user + configuration file + bt_split: the backtesting length (dats). Specified in user configuration file + ''' + + train_period = train_split * SECONDS_IN_DAY + bt_period = bt_split * SECONDS_IN_DAY + + full_timerange = TimeRange.parse_timerange(tr) + timerange_train = copy.deepcopy(full_timerange) + timerange_backtest = copy.deepcopy(full_timerange) + + tr_training_list = [] + tr_backtesting_list = [] + first = True + while True: + if not first: timerange_train.startts = timerange_train.startts + bt_period + timerange_train.stopts = timerange_train.startts + train_period + + # if a full training period doesnt fit, we stop + if timerange_train.stopts > full_timerange.stopts: break + first = False + start = datetime.datetime.utcfromtimestamp(timerange_train.startts) + stop = datetime.datetime.utcfromtimestamp(timerange_train.stopts) + tr_training_list.append(start.strftime("%Y%m%d")+'-'+stop.strftime("%Y%m%d")) + + ## associated backtest period + timerange_backtest.startts = timerange_train.stopts + timerange_backtest.stopts = timerange_backtest.startts + bt_period + start = datetime.datetime.utcfromtimestamp(timerange_backtest.startts) + stop = datetime.datetime.utcfromtimestamp(timerange_backtest.stopts) + tr_backtesting_list.append(start.strftime("%Y%m%d")+'-'+stop.strftime("%Y%m%d")) + + return tr_training_list, tr_backtesting_list + + def slice_dataframe(self, tr: str, df: DataFrame) -> DataFrame: + """ + Given a full dataframe, extract the user desired window + :params: + :tr: timerange string that we wish to extract from df + :df: Dataframe containing all candles to run the entire backtest. Here + it is sliced down to just the present training period. + """ + timerange = TimeRange.parse_timerange(tr) + start = datetime.datetime.fromtimestamp(timerange.startts, tz=datetime.timezone.utc) + stop = datetime.datetime.fromtimestamp(timerange.stopts, tz=datetime.timezone.utc) + df = df.loc[df['date'] >= start, :] + df = df.loc[df['date'] <= stop, :] + + return df + + def principal_component_analysis(self) -> None: + """ + Performs Principal Component Analysis on the data for dimensionality reduction + and outlier detection (see self.remove_outliers()) + No parameters or returns, it acts on the data_dictionary held by the DataHandler. + """ + + from sklearn.decomposition import PCA # avoid importing if we dont need it + + n_components = self.data_dictionary['train_features'].shape[1] + pca = PCA(n_components=n_components) + pca = pca.fit(self.data_dictionary['train_features']) + n_keep_components = np.argmin(pca.explained_variance_ratio_.cumsum() < 0.999) + pca2 = PCA(n_components=n_keep_components) + self.data['n_kept_components'] = n_keep_components + pca2 = pca2.fit(self.data_dictionary['train_features']) + print('reduced feature dimension by',n_components-n_keep_components) + print("explained variance",np.sum(pca2.explained_variance_ratio_)) + train_components = pca2.transform(self.data_dictionary['train_features']) + test_components = pca2.transform(self.data_dictionary['test_features']) + + self.data_dictionary['train_features'] = pd.DataFrame(data=train_components, + columns = ['PC'+str(i) for i in range(0,n_keep_components)], + index = self.data_dictionary['train_features'].index) + + self.data_dictionary['test_features'] = pd.DataFrame(data=test_components, + columns = ['PC'+str(i) for i in range(0,n_keep_components)], + index = self.data_dictionary['test_features'].index) + + self.data['n_kept_components'] = n_keep_components + self.pca = pca2 + if not os.path.exists(self.model_path): os.mkdir(self.model_path) + pk.dump(pca2, open(self.model_path + self.model_filename+"_pca_object.pkl","wb")) + + return None + + def compute_distances(self) -> float: + print('computing average mean distance for all training points') + pairwise = pairwise_distances(self.data_dictionary['train_features'],n_jobs=-1) + avg_mean_dist = pairwise.mean(axis=1).mean() + print('avg_mean_dist',avg_mean_dist) + + return avg_mean_dist + + def remove_outliers(self,predict: bool) -> None: + """ + Remove data that looks like an outlier based on the distribution of each + variable. + :params: + :predict: boolean which tells the function if this is prediction data or + training data coming in. + """ + + lower_quantile = self.data_dictionary['train_features'].quantile(0.001) + upper_quantile = self.data_dictionary['train_features'].quantile(0.999) + + if predict: + + df = self.data_dictionary['prediction_features'][(self.data_dictionary['prediction_features']lower_quantile)] + drop_index = pd.isnull(df).any(1) + self.data_dictionary['prediction_features'].fillna(0,inplace=True) + drop_index = ~drop_index + do_predict = np.array(drop_index.replace(True,1).replace(False,0)) + + print('remove_outliers() tossed',len(do_predict)-do_predict.sum(),'predictions because they were beyond 3 std deviations from training data.') + self.do_predict += do_predict + self.do_predict -= 1 + + else: + + filter_train_df = self.data_dictionary['train_features'][(self.data_dictionary['train_features']lower_quantile)] + drop_index = pd.isnull(filter_train_df).any(1) + self.data_dictionary['train_features'] = self.data_dictionary['train_features'][(drop_index==False)] + self.data_dictionary['train_labels'] = self.data_dictionary['train_labels'][(drop_index==False)] + self.data_dictionary['train_weights'] = self.data_dictionary['train_weights'][(drop_index==False)] + + # do the same for the test data + filter_test_df = self.data_dictionary['test_features'][(self.data_dictionary['test_features']lower_quantile)] + drop_index = pd.isnull(filter_test_df).any(1) + #pdb.set_trace() + self.data_dictionary['test_labels'] = self.data_dictionary['test_labels'][(drop_index==False)] + self.data_dictionary['test_features'] = self.data_dictionary['test_features'][(drop_index==False)] + self.data_dictionary['test_weights'] = self.data_dictionary['test_weights'][(drop_index==False)] + + return + + def build_feature_list(self, config: dict) -> int: + """ + Build the list of features that will be used to filter + the full dataframe. Feature list is construced from the + user configuration file. + :params: + :config: Canonical freqtrade config file containing all + user defined input in config['freqai] dictionary. + """ + features = [] + for tf in config['freqai']['timeframes']: + for ft in config['freqai']['base_features']: + for n in range(config['freqai']['feature_parameters']['shift']+1): + shift='' + if n>0: shift = '_shift-'+str(n) + features.append(ft+shift+'_'+tf) + for p in config['freqai']['corr_pairlist']: + features.append(p.split("/")[0]+'-'+ft+shift+'_'+tf) + + print('number of features',len(features)) + return features + + def check_if_pred_in_training_spaces(self) -> None: + """ + Compares the distance from each prediction point to each training data + point. It uses this information to estimate a Dissimilarity Index (DI) + and avoid making predictions on any points that are too far away + from the training data set. + """ + + print('checking if prediction features are in AOA') + distance = pairwise_distances(self.data_dictionary['train_features'], + self.data_dictionary['prediction_features'],n_jobs=-1) + + do_predict = np.where(distance.min(axis=0) / + self.data['avg_mean_dist'] < self.config['freqai']['feature_parameters']['DI_threshold'],1,0) + + print('Distance checker tossed',len(do_predict)-do_predict.sum(), + 'predictions for being too far from training data') + + self.do_predict += do_predict + self.do_predict -= 1 + + def set_weights_higher_recent(self, num_weights: int) -> int: + """ + Set weights so that recent data is more heavily weighted during + training than older data. + """ + weights = np.zeros(num_weights) + for i in range(1, len(weights)): + weights[len(weights) - i] = np.exp(-i/ + (self.config['freqai']['feature_parameters']['weight_factor']*num_weights)) + return weights + + def append_predictions(self, predictions, do_predict, len_dataframe): + """ + Append backtest prediction from current backtest period to all previous periods + """ + + ones = np.ones(len_dataframe) + s_mean, s_std = ones*self.data['s_mean'], ones*self.data['s_std'] + + self.predictions = np.append(self.predictions,predictions) + self.do_predict = np.append(self.do_predict,do_predict) + self.target_mean = np.append(self.target_mean,s_mean) + self.target_std = np.append(self.target_std,s_std) + + return + + def fill_predictions(self, len_dataframe): + """ + Back fill values to before the backtesting range so that the dataframe matches size + when it goes back to the strategy. These rows are not included in the backtest. + """ + + filler = np.zeros(len_dataframe -len(self.predictions)) # startup_candle_count + self.predictions = np.append(filler,self.predictions) + self.do_predict = np.append(filler,self.do_predict) + self.target_mean = np.append(filler,self.target_mean) + self.target_std = np.append(filler,self.target_std) + + return + + def np_encoder(self, object): + if isinstance(object, np.generic): + return object.item() diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py new file mode 100644 index 000000000..eb15e7e49 --- /dev/null +++ b/freqtrade/freqai/freqai_interface.py @@ -0,0 +1,158 @@ + +import os +import numpy as np +import pandas as pd +from pandas import DataFrame +import shutil +import gc +from typing import Any, Dict, Optional, Tuple +from abc import ABC +from freqtrade.freqai.data_handler import DataHandler + +pd.options.mode.chained_assignment = None + +class IFreqaiModel(ABC): + """ + Class containing all tools for training and prediction in the strategy. + User models should inherit from this class as shown in + templates/ExamplePredictionModel.py where the user overrides + train(), predict(), fit(), and make_labels(). + """ + + def __init__(self, config: Dict[str, Any]) -> None: + + self.config = config + self.freqai_info = config['freqai'] + self.data_split_parameters = config['freqai']['data_split_parameters'] + self.model_training_parameters = config['freqai']['model_training_parameters'] + self.feature_parameters = config['freqai']['feature_parameters'] + self.full_path = (str(config['user_data_dir'])+ + "/models/"+self.freqai_info['full_timerange']+ + '-'+self.freqai_info['identifier']) + self.metadata = {} + self.data = {} + self.time_last_trained = None + self.current_time = None + self.model = None + self.predictions = None + + if not os.path.exists(self.full_path): + os.mkdir(self.full_path) + shutil.copy(self.config['config_files'][0],self.full_path+"/"+self.config['config_files'][0]) + + def start(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Entry point to the FreqaiModel, it will train a new model if + necesssary before making the prediction. + The backtesting and training paradigm is a sliding training window + with a following backtest window. Both windows slide according to the + length of the backtest window. This function is not intended to be + overridden by children of IFreqaiModel, but technically, it can be + if the user wishes to make deeper changes to the sliding window + logic. + :params: + :dataframe: Full dataframe coming from strategy - it contains entire + backtesting timerange + additional historical data necessary to train + the model. + :metadata: pair metadataa coming from strategy. + """ + self.pair = metadata['pair'] + self.dh = DataHandler(self.config, dataframe, self.data) + + print('going to train',len(self.dh.training_timeranges), + 'timeranges:',self.dh.training_timeranges) + predictions = np.array([]) + do_predict = np.array([]) + target_mean = np.array([]) + target_std = np.array([]) + + # Loop enforcing the sliding window training/backtesting paragigm + # tr_train is the training time range e.g. 1 historical month + # tr_backtest is the backtesting time range e.g. the week directly + # following tr_train. Both of these windows slide through the + # entire backtest + for tr_train, tr_backtest in zip(self.dh.training_timeranges, + self.dh.backtesting_timeranges): + gc.collect() + #self.config['timerange'] = tr_train + self.dh.data = {} # clean the pair specific data between models + self.freqai_info['training_timerange'] = tr_train + dataframe_train = self.dh.slice_dataframe(tr_train, dataframe) + dataframe_backtest = self.dh.slice_dataframe(tr_backtest, dataframe) + print("training",self.pair,"for",tr_train) + self.dh.model_path = self.full_path+"/"+ 'sub-train'+'-'+str(tr_train)+'/' + if not self.model_exists(self.pair, training_timerange=tr_train): + self.model = self.train(dataframe_train, metadata) + self.dh.save_data(self.model) + else: + self.model = self.dh.load_data(self.dh.model_path) + + preds, do_preds = self.predict(dataframe_backtest) + + self.dh.append_predictions(preds,do_preds,len(dataframe_backtest)) + + self.dh.fill_predictions(len(dataframe)) + + return self.dh.predictions, self.dh.do_predict, self.dh.target_mean, self.dh.target_std + + def make_labels(self, dataframe: DataFrame) -> DataFrame: + """ + User defines the labels here (target values). + :params: + :dataframe: the full dataframe for the present training period + """ + + return dataframe + + def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: + """ + Filter the training data and train a model to it. Train makes heavy use of the datahandler + for storing, saving, loading, and managed. + :params: + :unfiltered_dataframe: Full dataframe for the current training period + :metadata: pair metadata from strategy. + :returns: + :model: Trained model which can be used to inference (self.predict) + """ + + return unfiltered_dataframe, unfiltered_dataframe + + def fit(self) -> Any: + """ + Most regressors use the same function names and arguments e.g. user + can drop in LGBMRegressor in place of CatBoostRegressor and all data + management will be properly handled by Freqai. + :params: + :data_dictionary: the dictionary constructed by DataHandler to hold + all the training and test data/labels. + """ + + return None + + def predict(self) -> Optional[Tuple[DataFrame, DataFrame]]: + """ + Filter the prediction features data and predict with it. + :param: unfiltered_dataframe: Full dataframe for the current backtest period. + :return: + :predictions: np.array of predictions + :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove + data (NaNs) or felt uncertain about data (PCA and DI index) + """ + + return None + + def model_exists(self, pair: str, training_timerange: str = None) -> bool: + """ + Given a pair and path, check if a model already exists + :param pair: pair e.g. BTC/USD + :param path: path to model + """ + coin,_ = pair.split('/') + self.dh.model_filename = f"cb_"+coin.lower()+"_"+self.freqai_info['trained_stake']+"_"+training_timerange + file_exists = os.path.isfile(self.dh.model_path+ + self.dh.model_filename+"_model.joblib") + if file_exists: + print("Found model at", self.dh.model_path+self.dh.model_filename) + else: print("Could not find model at", + self.dh.model_path+self.dh.model_filename) + return file_exists diff --git a/freqtrade/freqai/strategy_bridge.py b/freqtrade/freqai/strategy_bridge.py new file mode 100644 index 000000000..c336e3c84 --- /dev/null +++ b/freqtrade/freqai/strategy_bridge.py @@ -0,0 +1,12 @@ +from freqtrade.resolvers.freqaimodel_resolver import FreqaiModelResolver + + +class CustomModel: + """ + A bridge between the user defined IFreqaiModel class + and the strategy. + """ + + def __init__(self,config): + + self.bridge = FreqaiModelResolver.load_freqaimodel(config) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 621812b0a..5051a8db0 100755 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -204,6 +204,12 @@ class Backtesting: """ self.progress.init_step(BacktestState.DATALOAD, 1) + if self.config['freqaimodel']: + self.required_startup += int((self.config['freqai']['train_period']*86400) / + timeframe_to_seconds(self.config['timeframe'])) + self.config['startup_candle_count'] = self.required_startup + + data = history.load_data( datadir=self.config['datadir'], pairs=self.pairlists.whitelist, diff --git a/freqtrade/resolvers/freqaimodel_resolver.py b/freqtrade/resolvers/freqaimodel_resolver.py new file mode 100644 index 000000000..9545afd24 --- /dev/null +++ b/freqtrade/resolvers/freqaimodel_resolver.py @@ -0,0 +1,45 @@ +# pragma pylint: disable=attribute-defined-outside-init + +""" +This module load a custom model for freqai +""" +import logging +from pathlib import Path +from typing import Dict + +from freqtrade.constants import USERPATH_FREQAIMODELS +from freqtrade.exceptions import OperationalException +from freqtrade.freqai.freqai_interface import IFreqaiModel +from freqtrade.resolvers import IResolver + +logger = logging.getLogger(__name__) + + +class FreqaiModelResolver(IResolver): + """ + This class contains all the logic to load custom hyperopt loss class + """ + object_type = IFreqaiModel + object_type_str = "FreqaiModel" + user_subdir = USERPATH_FREQAIMODELS + initial_search_path = Path(__file__).parent.parent.joinpath('optimize').resolve() + + @staticmethod + def load_freqaimodel(config: Dict) -> IFreqaiModel: + """ + Load the custom class from config parameter + :param config: configuration dictionary + """ + + freqaimodel_name = config.get('freqaimodel') + if not freqaimodel_name: + raise OperationalException( + "No freqaimodel set. Please use `--freqaimodel` to " + "specify the FreqaiModel class to use.\n" + ) + freqaimodel = FreqaiModelResolver.load_object(freqaimodel_name, + config, kwargs={'config': config}, + extra_dir=config.get('freqaimodel_path')) + + + return freqaimodel diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py new file mode 100644 index 000000000..a5370b5ac --- /dev/null +++ b/freqtrade/templates/ExamplePredictionModel.py @@ -0,0 +1,139 @@ +import numpy as np +import pandas as pd +from catboost import CatBoostRegressor, Pool +from pandas import DataFrame +from typing import Any, Dict, Tuple +from freqtrade.freqai.freqai_interface import IFreqaiModel + +class ExamplePredictionModel(IFreqaiModel): + """ + User created prediction model. The class needs to override three necessary + functions, predict(), train(), fit(). The class inherits ModelHandler which + has its own DataHandler where data is held, saved, loaded, and managed. + """ + + def make_labels(self, dataframe: DataFrame) -> DataFrame: + """ + User defines the labels here (target values). + :params: + :dataframe: the full dataframe for the present training period + """ + + dataframe['s'] = (dataframe['close'].shift(-self.feature_parameters['period']).rolling( + self.feature_parameters['period']).max() / dataframe['close'] - 1) + self.dh.data['s_mean'] = dataframe['s'].mean() + self.dh.data['s_std'] = dataframe['s'].std() + + print('label mean',self.dh.data['s_mean'],'label std',self.dh.data['s_std']) + + return dataframe['s'] + + + def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: + """ + Filter the training data and train a model to it. Train makes heavy use of the datahandler + for storing, saving, loading, and managed. + :params: + :unfiltered_dataframe: Full dataframe for the current training period + :metadata: pair metadata from strategy. + :returns: + :model: Trained model which can be used to inference (self.predict) + """ + print("--------------------Starting training--------------------") + + # create the full feature list based on user config info + self.dh.training_features_list = self.dh.build_feature_list(self.config) + unfiltered_labels = self.make_labels(unfiltered_dataframe) + + # filter the features requested by user in the configuration file and elegantly handle NaNs + features_filtered, labels_filtered = self.dh.filter_features(unfiltered_dataframe, + self.dh.training_features_list, unfiltered_labels, training_filter=True) + + # split data into train/test data. + data_dictionary = self.dh.make_train_test_datasets(features_filtered, labels_filtered) + # standardize all data based on train_dataset only + data_dictionary = self.dh.standardize_data(data_dictionary) + + # optional additional data cleaning + if self.feature_parameters['principal_component_analysis']: + self.dh.principal_component_analysis() + if self.feature_parameters["remove_outliers"]: + self.dh.remove_outliers(predict=False) + if self.feature_parameters['DI_threshold']: + self.dh.data['avg_mean_dist'] = self.dh.compute_distances() + + print("length of train data", len(data_dictionary['train_features'])) + + model = self.fit(data_dictionary) + + print('Finished training') + print(f'--------------------done training {metadata["pair"]}--------------------') + + return model + + def fit(self, data_dictionary: Dict) -> Any: + """ + Most regressors use the same function names and arguments e.g. user + can drop in LGBMRegressor in place of CatBoostRegressor and all data + management will be properly handled by Freqai. + :params: + :data_dictionary: the dictionary constructed by DataHandler to hold + all the training and test data/labels. + """ + + train_data = Pool( + data=data_dictionary['train_features'], + label=data_dictionary['train_labels'], + weight=data_dictionary['train_weights'] + ) + + test_data = Pool( + data=data_dictionary['test_features'], + label=data_dictionary['test_labels'], + weight=data_dictionary['test_weights'] + ) + + model = CatBoostRegressor(verbose=100, early_stopping_rounds=400, + **self.model_training_parameters) + model.fit(X=train_data, eval_set=test_data) + + return model + + def predict(self, unfiltered_dataframe: DataFrame) -> Tuple[DataFrame, DataFrame]: + """ + Filter the prediction features data and predict with it. + :param: unfiltered_dataframe: Full dataframe for the current backtest period. + :return: + :predictions: np.array of predictions + :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove + data (NaNs) or felt uncertain about data (PCA and DI index) + """ + + print("--------------------Starting prediction--------------------") + + original_feature_list = self.dh.build_feature_list(self.config) + filtered_dataframe, _ = self.dh.filter_features(unfiltered_dataframe, original_feature_list, training_filter=False) + filtered_dataframe = self.dh.standardize_data_from_metadata(filtered_dataframe) + self.dh.data_dictionary['prediction_features'] = filtered_dataframe + + # optional additional data cleaning + if self.feature_parameters['principal_component_analysis']: + pca_components = self.dh.pca.transform(filtered_dataframe) + self.dh.data_dictionary['prediction_features'] = pd.DataFrame(data=pca_components, + columns = ['PC'+str(i) for i in range(0,self.dh.data['n_kept_components'])], + index = filtered_dataframe.index) + + if self.feature_parameters["remove_outliers"]: + self.dh.remove_outliers(predict=True) # creates dropped index + + if self.feature_parameters['DI_threshold']: + self.dh.check_if_pred_in_training_spaces() # sets do_predict + + predictions = self.model.predict(self.dh.data_dictionary['prediction_features']) + + # compute the non-standardized predictions + predictions = predictions * self.dh.data['labels_std'] + self.dh.data['labels_mean'] + + print("--------------------Finished prediction--------------------") + + return (predictions, self.dh.do_predict) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py new file mode 100644 index 000000000..d6b1295ec --- /dev/null +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -0,0 +1,179 @@ +import logging +import talib.abstract as ta +from pandas import DataFrame +import pandas as pd +from technical import qtpylib +import numpy as np +from freqtrade.strategy import (merge_informative_pair) +from freqtrade.strategy.interface import IStrategy +from freqtrade.freqai.strategy_bridge import CustomModel +from functools import reduce +logger = logging.getLogger(__name__) + +class FreqaiExampleStrategy(IStrategy): + """ + Example strategy showing how the user connects their own + IFreqaiModel to the strategy. Namely, the user uses: + self.model = CustomModel(self.config) + self.model.bridge.start(dataframe, metadata) + + to make predictions on their data. populate_any_indicators() automatically + generates the variety of features indicated by the user in the + canonical freqtrade configuration file under config['freqai']. + """ + + minimal_roi = { + "0": 0.01, + "240": -1 + } + + plot_config = { + 'main_plot': { + }, + 'subplots': { + "prediction":{ + 'prediction':{'color':'blue'} + }, + "target_roi":{ + 'target_roi':{'color':'brown'}, + }, + "do_predict":{ + 'do_predict':{'color':'brown'}, + }, + } + } + + stoploss = -0.05 + use_sell_signal = True + startup_candle_count: int = 1000 + + + def informative_pairs(self): + pairs = self.freqai_info['corr_pairlist'] + informative_pairs = [] + for tf in self.timeframes: + informative_pairs.append([(pair, tf) for pair in pairs]) + return informative_pairs + + def populate_any_indicators(self, pair, df, tf, informative=None,coin=''): + """ + Function designed to automatically generate, name and merge features + from user indicated timeframes in the configuration file. User can add + additional features here, but must follow the naming convention. + :params: + :pair: pair to be used as informative + :df: strategy dataframe which will receive merges from informatives + :tf: timeframe of the dataframe which will modify the feature names + :informative: the dataframe associated with the informative pair + :coin: the name of the coin which will modify the feature names. + """ + if informative is None: + informative = self.dp.get_pair_dataframe(pair, tf) + + informative[coin+'rsi'] = ta.RSI(informative, timeperiod=14) + informative[coin+'mfi'] = ta.MFI(informative, timeperiod=25) + informative[coin+'adx'] = ta.ADX(informative, window=20) + + informative[coin+'20sma'] = ta.SMA(informative,timeperiod=20) + informative[coin+'21ema'] = ta.EMA(informative,timeperiod=21) + informative[coin+'bmsb'] = np.where(informative[coin+'20sma'].lt(informative[coin+'21ema']),1,0) + informative[coin+'close_over_20sma'] = informative['close']/informative[coin+'20sma'] + + informative[coin+'mfi'] = ta.MFI(informative, timeperiod=25) + + informative[coin+'ema21'] = ta.EMA(informative, timeperiod=21) + informative[coin+'sma20'] = ta.SMA(informative, timeperiod=20) + stoch = ta.STOCHRSI(informative, 15, 20, 2, 2) + informative[coin+'srsi-fk'] = stoch['fastk'] + informative[coin+'srsi-fd'] = stoch['fastd'] + + bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(informative), window=14, stds=2.2) + informative[coin+'bb_lowerband'] = bollinger['lower'] + informative[coin+'bb_middleband'] = bollinger['mid'] + informative[coin+'bb_upperband'] = bollinger['upper'] + informative[coin+'bb_width'] = ((informative[coin+"bb_upperband"] - informative[coin+"bb_lowerband"]) / informative[coin+"bb_middleband"]) + informative[coin+'close-bb_lower'] = informative['close'] / informative[coin+'bb_lowerband'] + + informative[coin+'roc'] = ta.ROC(informative, timeperiod=3) + informative[coin+'adx'] = ta.ADX(informative, window=14) + + macd = ta.MACD(informative) + informative[coin+'macd'] = macd['macd'] + informative[coin+'pct-change'] = informative['close'].pct_change() + informative[coin+'relative_volume'] = informative['volume'] / informative['volume'].rolling(10).mean() + + informative[coin+'pct-change'] = informative['close'].pct_change() + + indicators = [col for col in informative if col.startswith(coin)] + + for n in range(self.freqai_info['feature_parameters']['shift']+1): + if n==0: continue + informative_shift = informative[indicators].shift(n) + informative_shift = informative_shift.add_suffix('_shift-'+str(n)) + informative = pd.concat((informative,informative_shift),axis=1) + + df = merge_informative_pair(df, informative, self.config['timeframe'], tf, ffill=True) + skip_columns = [(s + '_'+tf) for s in + ['date', 'open', 'high', 'low', 'close', 'volume']] + df = df.drop(columns=skip_columns) + + return df + + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + + # the configuration file parameters are stored here + self.freqai_info = self.config['freqai'] + + # the model is instantiated here + self.model = CustomModel(self.config) + + print('Populating indicators...') + + # the following loops are necessary for building the features + # indicated by the user in the configuration file. + for tf in self.freqai_info['timeframes']: + dataframe = self.populate_any_indicators(metadata['pair'], + dataframe.copy(), tf) + for i in self.freqai_info['corr_pairlist']: + dataframe = self.populate_any_indicators(i, + dataframe.copy(), tf, coin=i.split("/")[0]+'-') + + # the model will return 4 values, its prediction, an indication of whether or not the prediction + # should be accepted, the target mean/std values from the labels used during each training period. + (dataframe['prediction'], dataframe['do_predict'], + dataframe['target_mean'], dataframe['target_std']) = self.model.bridge.start(dataframe, metadata) + + dataframe['target_roi'] = dataframe['target_mean']+dataframe['target_std']*0.5 + dataframe['sell_roi'] = dataframe['target_mean']-dataframe['target_std']*1.5 + return dataframe + + + def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + + buy_conditions = [ + (dataframe['prediction'] > dataframe['target_roi']) + & + (dataframe['do_predict'] == 1) + ] + + if buy_conditions: + dataframe.loc[reduce(lambda x, y: x | y, buy_conditions), 'buy'] = 1 + + return dataframe + + + def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + # sell_goal = eval('self.'+metadata['pair'].split("/")[0]+'_sell_goal.value') + sell_conditions = [ + (dataframe['prediction'] < dataframe['sell_roi']) + & + (dataframe['do_predict'] == 1) + ] + if sell_conditions: + dataframe.loc[reduce(lambda x, y: x | y, sell_conditions), 'sell'] = 1 + + return dataframe + + def get_ticker_indicator(self): + return int(self.config['timeframe'][:-1]) diff --git a/mkdocs.yml b/mkdocs.yml index a43322f78..64d78363d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,6 +36,7 @@ nav: - Advanced Strategy: strategy-advanced.md - Advanced Hyperopt: advanced-hyperopt.md - Sandbox Testing: sandbox-testing.md + - Freqai: freqai.md - FAQ: faq.md - SQL Cheat-sheet: sql_cheatsheet.md - Strategy migration: strategy_migration.md diff --git a/requirements-freqai.txt b/requirements-freqai.txt new file mode 100644 index 000000000..f84d3df07 --- /dev/null +++ b/requirements-freqai.txt @@ -0,0 +1,8 @@ +# Include all requirements to run the bot. +-r requirements.txt + +# Required for freqai +scikit-learn==1.0.2 +scikit-optimize==0.9.0 +joblib==1.1.0 +catboost==1.0.4 From b40f8f88acb40591e338fc728aa5d17ea7374026 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 3 May 2022 10:28:13 +0200 Subject: [PATCH 002/130] cleaning and bug fixing --- freqtrade/freqai/data_handler.py | 5 +++++ freqtrade/freqai/freqai_interface.py | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/freqtrade/freqai/data_handler.py b/freqtrade/freqai/data_handler.py index d399cd12b..373063e42 100644 --- a/freqtrade/freqai/data_handler.py +++ b/freqtrade/freqai/data_handler.py @@ -19,6 +19,7 @@ class DataHandler: """ Class designed to handle all the data for the IFreqaiModel class model. Functionalities include holding, saving, loading, and analyzing the data. + author: Robert Caulk, rob.caulk@gmail.com """ def __init__(self, config: Dict[str, Any], dataframe: DataFrame, data: List): @@ -32,6 +33,10 @@ class DataHandler: self.data_dictionary = {} self.config = config self.freq_config = config['freqai'] + self.predictions = np.array([]) + self.do_predict = np.array([]) + self.target_mean = np.array([]) + self.target_std = np.array([]) def save_data(self, model: Any) -> None: """ diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index eb15e7e49..0f83793f1 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -17,6 +17,7 @@ class IFreqaiModel(ABC): User models should inherit from this class as shown in templates/ExamplePredictionModel.py where the user overrides train(), predict(), fit(), and make_labels(). + Author: Robert Caulk, rob.caulk@gmail.com """ def __init__(self, config: Dict[str, Any]) -> None: @@ -104,10 +105,10 @@ class IFreqaiModel(ABC): return dataframe - def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: + def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Any: """ Filter the training data and train a model to it. Train makes heavy use of the datahandler - for storing, saving, loading, and managed. + for storing, saving, loading, and analyzing the data. :params: :unfiltered_dataframe: Full dataframe for the current training period :metadata: pair metadata from strategy. @@ -115,7 +116,7 @@ class IFreqaiModel(ABC): :model: Trained model which can be used to inference (self.predict) """ - return unfiltered_dataframe, unfiltered_dataframe + return Any def fit(self) -> Any: """ From 630d201546892ad1565f871c594948770f8e44f0 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 3 May 2022 10:36:44 +0200 Subject: [PATCH 003/130] remove trained_stake --- freqtrade/freqai/freqai_interface.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 0f83793f1..2e840127c 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -62,10 +62,6 @@ class IFreqaiModel(ABC): print('going to train',len(self.dh.training_timeranges), 'timeranges:',self.dh.training_timeranges) - predictions = np.array([]) - do_predict = np.array([]) - target_mean = np.array([]) - target_std = np.array([]) # Loop enforcing the sliding window training/backtesting paragigm # tr_train is the training time range e.g. 1 historical month @@ -149,7 +145,7 @@ class IFreqaiModel(ABC): :param path: path to model """ coin,_ = pair.split('/') - self.dh.model_filename = f"cb_"+coin.lower()+"_"+self.freqai_info['trained_stake']+"_"+training_timerange + self.dh.model_filename = f"cb_"+coin.lower()+"_"+training_timerange file_exists = os.path.isfile(self.dh.model_path+ self.dh.model_filename+"_model.joblib") if file_exists: From 2600ba4e746a169f262c0c96447ea67df0dc72a7 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 3 May 2022 11:44:54 +0200 Subject: [PATCH 004/130] remove unused remnants --- freqtrade/commands/__init__.py | 1 - freqtrade/commands/arguments.py | 3 +-- freqtrade/commands/freqai_commands.py | 24 ------------------------ 3 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 freqtrade/commands/freqai_commands.py diff --git a/freqtrade/commands/__init__.py b/freqtrade/commands/__init__.py index d5aea62be..0e637c487 100644 --- a/freqtrade/commands/__init__.py +++ b/freqtrade/commands/__init__.py @@ -19,7 +19,6 @@ from freqtrade.commands.list_commands import (start_list_exchanges, start_list_m start_show_trades) from freqtrade.commands.optimize_commands import (start_backtesting, start_backtesting_show, start_edge, start_hyperopt) -from freqtrade.commands.freqai_commands import (start_training) from freqtrade.commands.pairlist_commands import start_test_pairlist from freqtrade.commands.plot_commands import start_plot_dataframe, start_plot_profit from freqtrade.commands.trade_commands import start_trading diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index 4388e84e4..f47748502 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -190,8 +190,7 @@ class Arguments: start_list_markets, start_list_strategies, start_list_timeframes, start_new_config, start_new_strategy, start_plot_dataframe, start_plot_profit, start_show_trades, - start_test_pairlist, start_trading, start_webserver, - start_training) + start_test_pairlist, start_trading, start_webserver) subparsers = self.parser.add_subparsers(dest='command', # Use custom message when no subhandler is added diff --git a/freqtrade/commands/freqai_commands.py b/freqtrade/commands/freqai_commands.py deleted file mode 100644 index 2733c851a..000000000 --- a/freqtrade/commands/freqai_commands.py +++ /dev/null @@ -1,24 +0,0 @@ -import logging -from typing import Any, Dict - -from freqtrade import constants -from freqtrade.configuration import setup_utils_configuration -from freqtrade.enums import RunMode -from freqtrade.exceptions import OperationalException -from freqtrade.misc import round_coin_value - - -logger = logging.getLogger(__name__) - -def start_training(args: Dict[str, Any]) -> None: - """ - Train a model for predicting signals - :param args: Cli args from Arguments() - :return: None - """ - from freqtrade.freqai.training import Training - - config = setup_utils_configuration(args, RunMode.FREQAI) - - training = Training(config) - training.start() From 99f7e44c30441567615ac01b89bd4920f7b83c06 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 4 May 2022 17:42:34 +0200 Subject: [PATCH 005/130] flake8 passing, use pathlib in lieu of os.path to accommodate windows/mac OS --- config_examples/config_freqai.example.json | 104 ++-- freqtrade/freqai/data_handler.py | 513 +++++++++++------- freqtrade/freqai/freqai_interface.py | 121 +++-- freqtrade/freqai/strategy_bridge.py | 4 +- freqtrade/resolvers/freqaimodel_resolver.py | 16 +- freqtrade/templates/ExamplePredictionModel.py | 100 ++-- freqtrade/templates/FreqaiExampleStrategy.py | 174 +++--- 7 files changed, 593 insertions(+), 439 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 0092a8c51..47109ff31 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -6,17 +6,19 @@ "fiat_display_currency": "USD", "dry_run": true, "timeframe": "5m", - "dry_run_wallet":1000, + "dry_run_wallet": 1000, "cancel_open_orders_on_exit": true, "unfilledtimeout": { "entry": 10, "exit": 30 - }, + }, "exchange": { "name": "ftx", "key": "", "secret": "", - "ccxt_config": {"enableRateLimit": true}, + "ccxt_config": { + "enableRateLimit": true + }, "ccxt_async_config": { "enableRateLimit": true, "rateLimit": 200 @@ -24,8 +26,7 @@ "pair_whitelist": [ "BTC/USDT" ], - "pair_blacklist": [ - ] + "pair_blacklist": [] }, "entry_pricing": { "price_side": "same", @@ -43,54 +44,57 @@ "order_book_top": 1 }, "pairlists": [ - {"method": "StaticPairList"} + { + "method": "StaticPairList" + } ], - "freqai": { - "btc_pair" : "BTC/USDT", - "timeframes" : ["5m","15m","1h"], - "full_timerange" : "20210601-20220101", - "train_period" : 30, - "backtest_period" : 7, - "identifier" : "example", - "base_features": [ - "rsi", - "close_over_20sma", - "relative_volume", - "bb_width", - "mfi", - "roc", - "pct-change", - "adx", - "macd" - ], - "corr_pairlist": [ - "ETH/USDT", - "LINK/USDT", - "DOT/USDT" - ], - "training_timerange" : "20211220-20220117", - - "feature_parameters" : { - "period": 12, - "shift": 2, - "drop_features": false, - "DI_threshold": 1, - "weight_factor": 0, - "principal_component_analysis": false, - "remove_outliers": false - }, - "data_split_parameters" : { - "test_size": 0.25, - "random_state": 1 - }, - "model_training_parameters" : { - "n_estimators": 2000, - "random_state": 1, - "learning_rate": 0.02, - "task_type": "CPU" - } + "btc_pair": "BTC/USDT", + "timeframes": [ + "5m", + "15m" + ], + "full_timerange": "20210601-20210901", + "train_period": 30, + "backtest_period": 7, + "identifier": "example", + "base_features": [ + "rsi", + "close_over_20sma", + "relative_volume", + "bb_width", + "mfi", + "roc", + "pct-change", + "adx", + "macd" + ], + "corr_pairlist": [ + "ETH/USDT", + "LINK/USDT", + "DOT/USDT" + ], + "training_timerange": "20211220-20220117", + "feature_parameters": { + "period": 12, + "shift": 1, + "drop_features": false, + "DI_threshold": 1, + "weight_factor": 0, + "principal_component_analysis": false, + "remove_outliers": false }, + "data_split_parameters": { + "test_size": 0.25, + "random_state": 1 + }, + "model_training_parameters": { + "n_estimators": 2000, + "random_state": 1, + "learning_rate": 0.02, + "task_type": "CPU" + } + }, "bot_name": "", "initial_state": "running", "forcebuy_enable": false, diff --git a/freqtrade/freqai/data_handler.py b/freqtrade/freqai/data_handler.py index 373063e42..7264c6fab 100644 --- a/freqtrade/freqai/data_handler.py +++ b/freqtrade/freqai/data_handler.py @@ -1,64 +1,77 @@ -import json -import os import copy +import datetime +import json +import pickle as pk +from pathlib import Path +from typing import Any, Dict, List, Tuple + import numpy as np import pandas as pd +from joblib import dump, load from pandas import DataFrame -from joblib import dump -from joblib import load -from sklearn.model_selection import train_test_split from sklearn.metrics.pairwise import pairwise_distances -import datetime -from typing import Any, Dict, List, Tuple -import pickle as pk +from sklearn.model_selection import train_test_split + from freqtrade.configuration import TimeRange + SECONDS_IN_DAY = 86400 + class DataHandler: """ - Class designed to handle all the data for the IFreqaiModel class model. + Class designed to handle all the data for the IFreqaiModel class model. Functionalities include holding, saving, loading, and analyzing the data. author: Robert Caulk, rob.caulk@gmail.com """ - def __init__(self, config: Dict[str, Any], dataframe: DataFrame, data: List): + def __init__(self, config: Dict[str, Any], dataframe: DataFrame): self.full_dataframe = dataframe - (self.training_timeranges, - self.backtesting_timeranges) = self.split_timerange( - config['freqai']['full_timerange'], - config['freqai']['train_period'], - config['freqai']['backtest_period']) - self.data = data - self.data_dictionary = {} + (self.training_timeranges, self.backtesting_timeranges) = self.split_timerange( + config["freqai"]["full_timerange"], + config["freqai"]["train_period"], + config["freqai"]["backtest_period"], + ) + self.data: Dict[Any, Any] = {} self.config = config - self.freq_config = config['freqai'] + self.freq_config = config["freqai"] self.predictions = np.array([]) self.do_predict = np.array([]) self.target_mean = np.array([]) self.target_std = np.array([]) + self.model_path = Path() + self.model_filename = "" def save_data(self, model: Any) -> None: """ Saves all data associated with a model for a single sub-train time range :params: - :model: User trained model which can be reused for inferencing to generate + :model: User trained model which can be reused for inferencing to generate predictions """ - if not os.path.exists(self.model_path): os.mkdir(self.model_path) - save_path = self.model_path + self.model_filename + if not self.model_path.is_dir(): + self.model_path.mkdir(parents=True, exist_ok=True) + + save_path = Path(self.model_path) + + # if not os.path.exists(self.model_path): + # os.mkdir(self.model_path) + # save_path = self.model_path + self.model_filename + # Save the trained model - dump(model, save_path+"_model.joblib") - self.data['model_path'] = self.model_path - self.data['model_filename'] = self.model_filename - self.data['training_features_list'] = list(self.data_dictionary['train_features'].columns) + dump(model, save_path / str(self.model_filename + "_model.joblib")) + self.data["model_path"] = self.model_path + self.data["model_filename"] = self.model_filename + self.data["training_features_list"] = list(self.data_dictionary["train_features"].columns) # store the metadata - with open(save_path+"_metadata.json", 'w') as fp: - json.dump(self.data, fp, default=self.np_encoder) + with open(save_path / str(self.model_filename + "_metadata.json"), "w") as fp: + json.dump(self.data, fp, default=self.np_encoder) # save the train data to file so we can check preds for area of applicability later - self.data_dictionary['train_features'].to_pickle(save_path+"_trained_df.pkl") + self.data_dictionary["train_features"].to_pickle( + save_path / str(self.model_filename + "_trained_df.pkl") + ) return @@ -68,156 +81,210 @@ class DataHandler: :returns: :model: User trained model which can be inferenced for new predictions """ - model = load(self.model_path+self.model_filename+"_model.joblib") + model = load(self.model_path / str(self.model_filename + "_model.joblib")) - with open(self.model_path+self.model_filename+"_metadata.json", 'r') as fp: + with open(self.model_path / str(self.model_filename + "_metadata.json"), "r") as fp: self.data = json.load(fp) - if self.data.get('training_features_list'): - self.training_features_list = [*self.data.get('training_features_list')] + self.training_features_list = self.data["training_features_list"] + # if self.data.get("training_features_list"): + # self.training_features_list = [*self.data.get("training_features_list")] - self.data_dictionary['train_features'] = pd.read_pickle(self.model_path+ - self.model_filename+"_trained_df.pkl") + self.data_dictionary["train_features"] = pd.read_pickle( + self.model_path / str(self.model_filename + "_trained_df.pkl") + ) - self.model_path = self.data['model_path'] - self.model_filename = self.data['model_filename'] - if self.config['freqai']['feature_parameters']['principal_component_analysis']: - self.pca = pk.load(open(self.model_path+self.model_filename+"_pca_object.pkl","rb")) + self.model_path = self.data["model_path"] + self.model_filename = self.data["model_filename"] + if self.config["freqai"]["feature_parameters"]["principal_component_analysis"]: + self.pca = pk.load( + open(self.model_path / str(self.model_filename + "_pca_object.pkl"), "rb") + ) return model - def make_train_test_datasets(self, filtered_dataframe: DataFrame, labels: DataFrame) -> None: - ''' - Given the dataframe for the full history for training, split the data into - training and test data according to user specified parameters in configuration - file. + def make_train_test_datasets( + self, filtered_dataframe: DataFrame, labels: DataFrame + ) -> Dict[Any, Any]: + """ + Given the dataframe for the full history for training, split the data into + training and test data according to user specified parameters in configuration + file. :filtered_dataframe: cleaned dataframe ready to be split. :labels: cleaned labels ready to be split. - ''' + """ - if self.config['freqai']['feature_parameters']['weight_factor'] > 0: + if self.config["freqai"]["feature_parameters"]["weight_factor"] > 0: weights = self.set_weights_higher_recent(len(filtered_dataframe)) - else: weights = np.ones(len(filtered_dataframe)) + else: + weights = np.ones(len(filtered_dataframe)) - (train_features, test_features, train_labels, - test_labels, train_weights, test_weights) = train_test_split( - filtered_dataframe[:filtered_dataframe.shape[0]], + ( + train_features, + test_features, + train_labels, + test_labels, + train_weights, + test_weights, + ) = train_test_split( + filtered_dataframe[: filtered_dataframe.shape[0]], labels, weights, - **self.config['freqai']['data_split_parameters'] + **self.config["freqai"]["data_split_parameters"] ) return self.build_data_dictionary( - train_features,test_features, - train_labels,test_labels, - train_weights,test_weights) + train_features, test_features, train_labels, test_labels, train_weights, test_weights + ) - - - def filter_features(self, unfiltered_dataframe: DataFrame, training_feature_list: List, - labels: DataFrame = None, training_filter: bool=True) -> Tuple[DataFrame, DataFrame]: - ''' - Filter the unfiltered dataframe to extract the user requested features and properly - remove all NaNs. Any row with a NaN is removed from training dataset or replaced with - 0s in the prediction dataset. However, prediction dataset do_predict will reflect any + def filter_features( + self, + unfiltered_dataframe: DataFrame, + training_feature_list: List, + labels: DataFrame = pd.DataFrame(), + training_filter: bool = True, + ) -> Tuple[DataFrame, DataFrame]: + """ + Filter the unfiltered dataframe to extract the user requested features and properly + remove all NaNs. Any row with a NaN is removed from training dataset or replaced with + 0s in the prediction dataset. However, prediction dataset do_predict will reflect any row that had a NaN and will shield user from that prediction. :params: :unfiltered_dataframe: the full dataframe for the present training period - :training_feature_list: list, the training feature list constructed by self.build_feature_list() - according to user specified parameters in the configuration file. + :training_feature_list: list, the training feature list constructed by + self.build_feature_list() according to user specified parameters in the configuration file. :labels: the labels for the dataset - :training_filter: boolean which lets the function know if it is training data or - prediction data to be filtered. + :training_filter: boolean which lets the function know if it is training data or + prediction data to be filtered. :returns: :filtered_dataframe: dataframe cleaned of NaNs and only containing the user requested feature set. :labels: labels cleaned of NaNs. - ''' + """ filtered_dataframe = unfiltered_dataframe.filter(training_feature_list, axis=1) - drop_index = pd.isnull(filtered_dataframe).any(1) # get the rows that have NaNs, - - if training_filter: # we don't care about total row number (total no. datapoints) in training, we only care about removing any row with NaNs + drop_index = pd.isnull(filtered_dataframe).any(1) # get the rows that have NaNs, + drop_index = drop_index.replace(True, 1).replace(False, 0) # pep8 requirement. + if ( + training_filter + ): # we don't care about total row number (total no. datapoints) in training, we only care + # about removing any row with NaNs drop_index_labels = pd.isnull(labels) - filtered_dataframe = filtered_dataframe[(drop_index==False) & (drop_index_labels==False)] # dropping values - labels = labels[(drop_index==False) & (drop_index_labels==False)] # assuming the labels depend entirely on the dataframe here. - print('dropped',len(unfiltered_dataframe)-len(filtered_dataframe), - 'training data points due to NaNs, ensure you have downloaded all historical training data') - self.data['filter_drop_index_training'] = drop_index + drop_index_labels = drop_index_labels.replace(True, 1).replace(False, 0) + filtered_dataframe = filtered_dataframe[ + (drop_index == 0) & (drop_index_labels == 0) + ] # dropping values + labels = labels[ + (drop_index == 0) & (drop_index_labels == 0) + ] # assuming the labels depend entirely on the dataframe here. + print( + "dropped", + len(unfiltered_dataframe) - len(filtered_dataframe), + "training data points due to NaNs, ensure you have downloaded", + "all historical training data", + ) + self.data["filter_drop_index_training"] = drop_index - else: # we are backtesting so we need to preserve row number to send back to strategy, so now we use do_predict to avoid any prediction based on a NaN + else: + # we are backtesting so we need to preserve row number to send back to strategy, + # so now we use do_predict to avoid any prediction based on a NaN drop_index = pd.isnull(filtered_dataframe).any(1) - self.data['filter_drop_index_prediction'] = drop_index - filtered_dataframe.fillna(0, inplace=True) # replacing all NaNs with zeros to avoid issues in 'prediction', but any prediction that was based on a single NaN is ultimately protected from buys with do_predict + self.data["filter_drop_index_prediction"] = drop_index + filtered_dataframe.fillna(0, inplace=True) + # replacing all NaNs with zeros to avoid issues in 'prediction', but any prediction + # that was based on a single NaN is ultimately protected from buys with do_predict drop_index = ~drop_index - self.do_predict = np.array(drop_index.replace(True,1).replace(False,0)) - print('dropped',len(self.do_predict) - self.do_predict.sum(),'of',len(filtered_dataframe), - 'prediction data points due to NaNs. These are protected from prediction with do_predict vector returned to strategy.') - + self.do_predict = np.array(drop_index.replace(True, 1).replace(False, 0)) + print( + "dropped", + len(self.do_predict) - self.do_predict.sum(), + "of", + len(filtered_dataframe), + "prediction data points due to NaNs. These are protected from prediction", + "with do_predict vector returned to strategy.", + ) return filtered_dataframe, labels - def build_data_dictionary(self, train_df: DataFrame, test_df: DataFrame, - train_labels: DataFrame, test_labels: DataFrame, - train_weights: Any, test_weights: Any) -> Dict: + def build_data_dictionary( + self, + train_df: DataFrame, + test_df: DataFrame, + train_labels: DataFrame, + test_labels: DataFrame, + train_weights: Any, + test_weights: Any, + ) -> Dict: - self.data_dictionary = {'train_features': train_df, - 'test_features': test_df, - 'train_labels': train_labels, - 'test_labels': test_labels, - 'train_weights': train_weights, - 'test_weights': test_weights} + self.data_dictionary = { + "train_features": train_df, + "test_features": test_df, + "train_labels": train_labels, + "test_labels": test_labels, + "train_weights": train_weights, + "test_weights": test_weights, + } return self.data_dictionary - def standardize_data(self, data_dictionary: Dict) -> None: - ''' + def standardize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: + """ Standardize all data in the data_dictionary according to the training dataset :params: :data_dictionary: dictionary containing the cleaned and split training/test data/labels :returns: :data_dictionary: updated dictionary with standardized values. - ''' + """ # standardize the data by training stats - train_mean = data_dictionary['train_features'].mean() - train_std = data_dictionary['train_features'].std() - data_dictionary['train_features'] = (data_dictionary['train_features'] - train_mean) / train_std - data_dictionary['test_features'] = (data_dictionary['test_features'] - train_mean) / train_std + train_mean = data_dictionary["train_features"].mean() + train_std = data_dictionary["train_features"].std() + data_dictionary["train_features"] = ( + data_dictionary["train_features"] - train_mean + ) / train_std + data_dictionary["test_features"] = ( + data_dictionary["test_features"] - train_mean + ) / train_std - train_labels_std = data_dictionary['train_labels'].std() - train_labels_mean = data_dictionary['train_labels'].mean() - data_dictionary['train_labels'] = (data_dictionary['train_labels'] - train_labels_mean) / train_labels_std - data_dictionary['test_labels'] = (data_dictionary['test_labels'] - train_labels_mean) / train_labels_std + train_labels_std = data_dictionary["train_labels"].std() + train_labels_mean = data_dictionary["train_labels"].mean() + data_dictionary["train_labels"] = ( + data_dictionary["train_labels"] - train_labels_mean + ) / train_labels_std + data_dictionary["test_labels"] = ( + data_dictionary["test_labels"] - train_labels_mean + ) / train_labels_std for item in train_std.keys(): - self.data[item+'_std'] = train_std[item] - self.data[item+'_mean'] = train_mean[item] + self.data[item + "_std"] = train_std[item] + self.data[item + "_mean"] = train_mean[item] - self.data['labels_std'] = train_labels_std - self.data['labels_mean'] = train_labels_mean + self.data["labels_std"] = train_labels_std + self.data["labels_mean"] = train_labels_mean return data_dictionary def standardize_data_from_metadata(self, df: DataFrame) -> DataFrame: - ''' - Standardizes a set of data using the mean and standard deviation from + """ + Standardizes a set of data using the mean and standard deviation from the associated training data. :params: :df: Dataframe to be standardized - ''' + """ for item in df.keys(): - df[item] = (df[item] - self.data[item+'_mean']) / self.data[item+'_std'] + df[item] = (df[item] - self.data[item + "_mean"]) / self.data[item + "_std"] return df - def split_timerange(self, tr: Dict, train_split: int=28, bt_split: int=7) -> list: - ''' + def split_timerange( + self, tr: str, train_split: int = 28, bt_split: int = 7 + ) -> Tuple[list, list]: + """ Function which takes a single time range (tr) and splits it into sub timeranges to train and backtest on based on user input tr: str, full timerange to train on train_split: the period length for the each training (days). Specified in user configuration file bt_split: the backtesting length (dats). Specified in user configuration file - ''' + """ train_period = train_split * SECONDS_IN_DAY bt_period = bt_split * SECONDS_IN_DAY @@ -230,22 +297,24 @@ class DataHandler: tr_backtesting_list = [] first = True while True: - if not first: timerange_train.startts = timerange_train.startts + bt_period + if not first: + timerange_train.startts = timerange_train.startts + bt_period timerange_train.stopts = timerange_train.startts + train_period # if a full training period doesnt fit, we stop - if timerange_train.stopts > full_timerange.stopts: break + if timerange_train.stopts > full_timerange.stopts: + break first = False start = datetime.datetime.utcfromtimestamp(timerange_train.startts) stop = datetime.datetime.utcfromtimestamp(timerange_train.stopts) - tr_training_list.append(start.strftime("%Y%m%d")+'-'+stop.strftime("%Y%m%d")) + tr_training_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")) - ## associated backtest period - timerange_backtest.startts = timerange_train.stopts - timerange_backtest.stopts = timerange_backtest.startts + bt_period + # associated backtest period + timerange_backtest.startts = timerange_train.stopts + timerange_backtest.stopts = timerange_backtest.startts + bt_period start = datetime.datetime.utcfromtimestamp(timerange_backtest.startts) stop = datetime.datetime.utcfromtimestamp(timerange_backtest.stopts) - tr_backtesting_list.append(start.strftime("%Y%m%d")+'-'+stop.strftime("%Y%m%d")) + tr_backtesting_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")) return tr_training_list, tr_backtesting_list @@ -260,8 +329,8 @@ class DataHandler: timerange = TimeRange.parse_timerange(tr) start = datetime.datetime.fromtimestamp(timerange.startts, tz=datetime.timezone.utc) stop = datetime.datetime.fromtimestamp(timerange.stopts, tz=datetime.timezone.utc) - df = df.loc[df['date'] >= start, :] - df = df.loc[df['date'] <= stop, :] + df = df.loc[df["date"] >= start, :] + df = df.loc[df["date"] <= stop, :] return df @@ -272,128 +341,171 @@ class DataHandler: No parameters or returns, it acts on the data_dictionary held by the DataHandler. """ - from sklearn.decomposition import PCA # avoid importing if we dont need it + from sklearn.decomposition import PCA # avoid importing if we dont need it - n_components = self.data_dictionary['train_features'].shape[1] + n_components = self.data_dictionary["train_features"].shape[1] pca = PCA(n_components=n_components) - pca = pca.fit(self.data_dictionary['train_features']) + pca = pca.fit(self.data_dictionary["train_features"]) n_keep_components = np.argmin(pca.explained_variance_ratio_.cumsum() < 0.999) pca2 = PCA(n_components=n_keep_components) - self.data['n_kept_components'] = n_keep_components - pca2 = pca2.fit(self.data_dictionary['train_features']) - print('reduced feature dimension by',n_components-n_keep_components) - print("explained variance",np.sum(pca2.explained_variance_ratio_)) - train_components = pca2.transform(self.data_dictionary['train_features']) - test_components = pca2.transform(self.data_dictionary['test_features']) + self.data["n_kept_components"] = n_keep_components + pca2 = pca2.fit(self.data_dictionary["train_features"]) + print("reduced feature dimension by", n_components - n_keep_components) + print("explained variance", np.sum(pca2.explained_variance_ratio_)) + train_components = pca2.transform(self.data_dictionary["train_features"]) + test_components = pca2.transform(self.data_dictionary["test_features"]) - self.data_dictionary['train_features'] = pd.DataFrame(data=train_components, - columns = ['PC'+str(i) for i in range(0,n_keep_components)], - index = self.data_dictionary['train_features'].index) + self.data_dictionary["train_features"] = pd.DataFrame( + data=train_components, + columns=["PC" + str(i) for i in range(0, n_keep_components)], + index=self.data_dictionary["train_features"].index, + ) - self.data_dictionary['test_features'] = pd.DataFrame(data=test_components, - columns = ['PC'+str(i) for i in range(0,n_keep_components)], - index = self.data_dictionary['test_features'].index) + self.data_dictionary["test_features"] = pd.DataFrame( + data=test_components, + columns=["PC" + str(i) for i in range(0, n_keep_components)], + index=self.data_dictionary["test_features"].index, + ) - self.data['n_kept_components'] = n_keep_components + self.data["n_kept_components"] = n_keep_components self.pca = pca2 - if not os.path.exists(self.model_path): os.mkdir(self.model_path) - pk.dump(pca2, open(self.model_path + self.model_filename+"_pca_object.pkl","wb")) + + if not self.model_path.is_dir(): + self.model_path.mkdir(parents=True, exist_ok=True) + pk.dump(pca2, open(self.model_path / str(self.model_filename + "_pca_object.pkl"), "wb")) return None def compute_distances(self) -> float: - print('computing average mean distance for all training points') - pairwise = pairwise_distances(self.data_dictionary['train_features'],n_jobs=-1) + print("computing average mean distance for all training points") + pairwise = pairwise_distances(self.data_dictionary["train_features"], n_jobs=-1) avg_mean_dist = pairwise.mean(axis=1).mean() - print('avg_mean_dist',avg_mean_dist) + print("avg_mean_dist", avg_mean_dist) return avg_mean_dist - def remove_outliers(self,predict: bool) -> None: + def remove_outliers(self, predict: bool) -> None: """ - Remove data that looks like an outlier based on the distribution of each - variable. + Remove data that looks like an outlier based on the distribution of each + variable. :params: - :predict: boolean which tells the function if this is prediction data or - training data coming in. + :predict: boolean which tells the function if this is prediction data or + training data coming in. """ - lower_quantile = self.data_dictionary['train_features'].quantile(0.001) - upper_quantile = self.data_dictionary['train_features'].quantile(0.999) + lower_quantile = self.data_dictionary["train_features"].quantile(0.001) + upper_quantile = self.data_dictionary["train_features"].quantile(0.999) if predict: - df = self.data_dictionary['prediction_features'][(self.data_dictionary['prediction_features']lower_quantile)] + df = self.data_dictionary["prediction_features"][ + (self.data_dictionary["prediction_features"] < upper_quantile) + & (self.data_dictionary["prediction_features"] > lower_quantile) + ] drop_index = pd.isnull(df).any(1) - self.data_dictionary['prediction_features'].fillna(0,inplace=True) + self.data_dictionary["prediction_features"].fillna(0, inplace=True) drop_index = ~drop_index - do_predict = np.array(drop_index.replace(True,1).replace(False,0)) - - print('remove_outliers() tossed',len(do_predict)-do_predict.sum(),'predictions because they were beyond 3 std deviations from training data.') + do_predict = np.array(drop_index.replace(True, 1).replace(False, 0)) + + print( + "remove_outliers() tossed", + len(do_predict) - do_predict.sum(), + "predictions because they were beyond 3 std deviations from training data.", + ) self.do_predict += do_predict self.do_predict -= 1 else: - filter_train_df = self.data_dictionary['train_features'][(self.data_dictionary['train_features']lower_quantile)] + filter_train_df = self.data_dictionary["train_features"][ + (self.data_dictionary["train_features"] < upper_quantile) + & (self.data_dictionary["train_features"] > lower_quantile) + ] drop_index = pd.isnull(filter_train_df).any(1) - self.data_dictionary['train_features'] = self.data_dictionary['train_features'][(drop_index==False)] - self.data_dictionary['train_labels'] = self.data_dictionary['train_labels'][(drop_index==False)] - self.data_dictionary['train_weights'] = self.data_dictionary['train_weights'][(drop_index==False)] + drop_index = drop_index.replace(True, 1).replace(False, 0) + self.data_dictionary["train_features"] = self.data_dictionary["train_features"][ + (drop_index == 0) + ] + self.data_dictionary["train_labels"] = self.data_dictionary["train_labels"][ + (drop_index == 0) + ] + self.data_dictionary["train_weights"] = self.data_dictionary["train_weights"][ + (drop_index == 0) + ] # do the same for the test data - filter_test_df = self.data_dictionary['test_features'][(self.data_dictionary['test_features']lower_quantile)] + filter_test_df = self.data_dictionary["test_features"][ + (self.data_dictionary["test_features"] < upper_quantile) + & (self.data_dictionary["test_features"] > lower_quantile) + ] drop_index = pd.isnull(filter_test_df).any(1) - #pdb.set_trace() - self.data_dictionary['test_labels'] = self.data_dictionary['test_labels'][(drop_index==False)] - self.data_dictionary['test_features'] = self.data_dictionary['test_features'][(drop_index==False)] - self.data_dictionary['test_weights'] = self.data_dictionary['test_weights'][(drop_index==False)] + drop_index = drop_index.replace(True, 1).replace(False, 0) + self.data_dictionary["test_labels"] = self.data_dictionary["test_labels"][ + (drop_index == 0) + ] + self.data_dictionary["test_features"] = self.data_dictionary["test_features"][ + (drop_index == 0) + ] + self.data_dictionary["test_weights"] = self.data_dictionary["test_weights"][ + (drop_index == 0) + ] return - def build_feature_list(self, config: dict) -> int: + def build_feature_list(self, config: dict) -> list: """ - Build the list of features that will be used to filter - the full dataframe. Feature list is construced from the + Build the list of features that will be used to filter + the full dataframe. Feature list is construced from the user configuration file. :params: :config: Canonical freqtrade config file containing all user defined input in config['freqai] dictionary. """ features = [] - for tf in config['freqai']['timeframes']: - for ft in config['freqai']['base_features']: - for n in range(config['freqai']['feature_parameters']['shift']+1): - shift='' - if n>0: shift = '_shift-'+str(n) - features.append(ft+shift+'_'+tf) - for p in config['freqai']['corr_pairlist']: - features.append(p.split("/")[0]+'-'+ft+shift+'_'+tf) + for tf in config["freqai"]["timeframes"]: + for ft in config["freqai"]["base_features"]: + for n in range(config["freqai"]["feature_parameters"]["shift"] + 1): + shift = "" + if n > 0: + shift = "_shift-" + str(n) + features.append(ft + shift + "_" + tf) + for p in config["freqai"]["corr_pairlist"]: + features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) - print('number of features',len(features)) + print("number of features", len(features)) return features def check_if_pred_in_training_spaces(self) -> None: """ - Compares the distance from each prediction point to each training data + Compares the distance from each prediction point to each training data point. It uses this information to estimate a Dissimilarity Index (DI) - and avoid making predictions on any points that are too far away - from the training data set. + and avoid making predictions on any points that are too far away + from the training data set. """ - print('checking if prediction features are in AOA') - distance = pairwise_distances(self.data_dictionary['train_features'], - self.data_dictionary['prediction_features'],n_jobs=-1) + print("checking if prediction features are in AOA") + distance = pairwise_distances( + self.data_dictionary["train_features"], + self.data_dictionary["prediction_features"], + n_jobs=-1, + ) - do_predict = np.where(distance.min(axis=0) / - self.data['avg_mean_dist'] < self.config['freqai']['feature_parameters']['DI_threshold'],1,0) + do_predict = np.where( + distance.min(axis=0) / self.data["avg_mean_dist"] + < self.config["freqai"]["feature_parameters"]["DI_threshold"], + 1, + 0, + ) - print('Distance checker tossed',len(do_predict)-do_predict.sum(), - 'predictions for being too far from training data') + print( + "Distance checker tossed", + len(do_predict) - do_predict.sum(), + "predictions for being too far from training data", + ) - self.do_predict += do_predict + self.do_predict += do_predict self.do_predict -= 1 - + def set_weights_higher_recent(self, num_weights: int) -> int: """ Set weights so that recent data is more heavily weighted during @@ -401,8 +513,9 @@ class DataHandler: """ weights = np.zeros(num_weights) for i in range(1, len(weights)): - weights[len(weights) - i] = np.exp(-i/ - (self.config['freqai']['feature_parameters']['weight_factor']*num_weights)) + weights[len(weights) - i] = np.exp( + -i / (self.config["freqai"]["feature_parameters"]["weight_factor"] * num_weights) + ) return weights def append_predictions(self, predictions, do_predict, len_dataframe): @@ -411,12 +524,12 @@ class DataHandler: """ ones = np.ones(len_dataframe) - s_mean, s_std = ones*self.data['s_mean'], ones*self.data['s_std'] + s_mean, s_std = ones * self.data["s_mean"], ones * self.data["s_std"] - self.predictions = np.append(self.predictions,predictions) - self.do_predict = np.append(self.do_predict,do_predict) - self.target_mean = np.append(self.target_mean,s_mean) - self.target_std = np.append(self.target_std,s_std) + self.predictions = np.append(self.predictions, predictions) + self.do_predict = np.append(self.do_predict, do_predict) + self.target_mean = np.append(self.target_mean, s_mean) + self.target_std = np.append(self.target_std, s_std) return @@ -426,14 +539,14 @@ class DataHandler: when it goes back to the strategy. These rows are not included in the backtest. """ - filler = np.zeros(len_dataframe -len(self.predictions)) # startup_candle_count - self.predictions = np.append(filler,self.predictions) - self.do_predict = np.append(filler,self.do_predict) - self.target_mean = np.append(filler,self.target_mean) - self.target_std = np.append(filler,self.target_std) + filler = np.zeros(len_dataframe - len(self.predictions)) # startup_candle_count + self.predictions = np.append(filler, self.predictions) + self.do_predict = np.append(filler, self.do_predict) + self.target_mean = np.append(filler, self.target_mean) + self.target_std = np.append(filler, self.target_std) return - + def np_encoder(self, object): if isinstance(object, np.generic): return object.item() diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 2e840127c..9f04b09cd 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -1,20 +1,23 @@ +import gc +import shutil +from abc import ABC +from pathlib import Path +from typing import Any, Dict, Tuple -import os import numpy as np import pandas as pd from pandas import DataFrame -import shutil -import gc -from typing import Any, Dict, Optional, Tuple -from abc import ABC + from freqtrade.freqai.data_handler import DataHandler + pd.options.mode.chained_assignment = None + class IFreqaiModel(ABC): """ Class containing all tools for training and prediction in the strategy. - User models should inherit from this class as shown in + User models should inherit from this class as shown in templates/ExamplePredictionModel.py where the user overrides train(), predict(), fit(), and make_labels(). Author: Robert Caulk, rob.caulk@gmail.com @@ -23,61 +26,71 @@ class IFreqaiModel(ABC): def __init__(self, config: Dict[str, Any]) -> None: self.config = config - self.freqai_info = config['freqai'] - self.data_split_parameters = config['freqai']['data_split_parameters'] - self.model_training_parameters = config['freqai']['model_training_parameters'] - self.feature_parameters = config['freqai']['feature_parameters'] - self.full_path = (str(config['user_data_dir'])+ - "/models/"+self.freqai_info['full_timerange']+ - '-'+self.freqai_info['identifier']) - self.metadata = {} - self.data = {} + self.freqai_info = config["freqai"] + self.data_split_parameters = config["freqai"]["data_split_parameters"] + self.model_training_parameters = config["freqai"]["model_training_parameters"] + self.feature_parameters = config["freqai"]["feature_parameters"] + self.full_path = Path( + config["user_data_dir"] + / "models" + / str(self.freqai_info["full_timerange"] + self.freqai_info["identifier"]) + ) + self.time_last_trained = None self.current_time = None self.model = None self.predictions = None - if not os.path.exists(self.full_path): - os.mkdir(self.full_path) - shutil.copy(self.config['config_files'][0],self.full_path+"/"+self.config['config_files'][0]) + if not self.full_path.is_dir(): + self.full_path.mkdir(parents=True, exist_ok=True) + shutil.copy( + self.config["config_files"][0], + Path(self.full_path / self.config["config_files"][0]), + ) def start(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ - Entry point to the FreqaiModel, it will train a new model if + Entry point to the FreqaiModel, it will train a new model if necesssary before making the prediction. The backtesting and training paradigm is a sliding training window with a following backtest window. Both windows slide according to the - length of the backtest window. This function is not intended to be - overridden by children of IFreqaiModel, but technically, it can be + length of the backtest window. This function is not intended to be + overridden by children of IFreqaiModel, but technically, it can be if the user wishes to make deeper changes to the sliding window logic. :params: :dataframe: Full dataframe coming from strategy - it contains entire - backtesting timerange + additional historical data necessary to train + backtesting timerange + additional historical data necessary to train the model. - :metadata: pair metadataa coming from strategy. + :metadata: pair metadataa coming from strategy. """ - self.pair = metadata['pair'] - self.dh = DataHandler(self.config, dataframe, self.data) + self.pair = metadata["pair"] + self.dh = DataHandler(self.config, dataframe) - print('going to train',len(self.dh.training_timeranges), - 'timeranges:',self.dh.training_timeranges) + print( + "going to train", + len(self.dh.training_timeranges), + "timeranges:", + self.dh.training_timeranges, + ) # Loop enforcing the sliding window training/backtesting paragigm # tr_train is the training time range e.g. 1 historical month - # tr_backtest is the backtesting time range e.g. the week directly - # following tr_train. Both of these windows slide through the + # tr_backtest is the backtesting time range e.g. the week directly + # following tr_train. Both of these windows slide through the # entire backtest - for tr_train, tr_backtest in zip(self.dh.training_timeranges, - self.dh.backtesting_timeranges): + for tr_train, tr_backtest in zip( + self.dh.training_timeranges, self.dh.backtesting_timeranges + ): gc.collect() - #self.config['timerange'] = tr_train - self.dh.data = {} # clean the pair specific data between models - self.freqai_info['training_timerange'] = tr_train + # self.config['timerange'] = tr_train + self.dh.data = {} # clean the pair specific data between models + self.freqai_info["training_timerange"] = tr_train dataframe_train = self.dh.slice_dataframe(tr_train, dataframe) dataframe_backtest = self.dh.slice_dataframe(tr_backtest, dataframe) - print("training",self.pair,"for",tr_train) - self.dh.model_path = self.full_path+"/"+ 'sub-train'+'-'+str(tr_train)+'/' + print("training", self.pair, "for", tr_train) + # self.dh.model_path = self.full_path + "/" + "sub-train" + "-" + str(tr_train) + "/" + self.dh.model_path = Path(self.full_path / str("sub-train" + "-" + str(tr_train))) if not self.model_exists(self.pair, training_timerange=tr_train): self.model = self.train(dataframe_train, metadata) self.dh.save_data(self.model) @@ -86,8 +99,8 @@ class IFreqaiModel(ABC): preds, do_preds = self.predict(dataframe_backtest) - self.dh.append_predictions(preds,do_preds,len(dataframe_backtest)) - + self.dh.append_predictions(preds, do_preds, len(dataframe_backtest)) + self.dh.fill_predictions(len(dataframe)) return self.dh.predictions, self.dh.do_predict, self.dh.target_mean, self.dh.target_std @@ -107,7 +120,7 @@ class IFreqaiModel(ABC): for storing, saving, loading, and analyzing the data. :params: :unfiltered_dataframe: Full dataframe for the current training period - :metadata: pair metadata from strategy. + :metadata: pair metadata from strategy. :returns: :model: Trained model which can be used to inference (self.predict) """ @@ -116,40 +129,40 @@ class IFreqaiModel(ABC): def fit(self) -> Any: """ - Most regressors use the same function names and arguments e.g. user + Most regressors use the same function names and arguments e.g. user can drop in LGBMRegressor in place of CatBoostRegressor and all data management will be properly handled by Freqai. :params: - :data_dictionary: the dictionary constructed by DataHandler to hold + :data_dictionary: the dictionary constructed by DataHandler to hold all the training and test data/labels. """ - return None - - def predict(self) -> Optional[Tuple[DataFrame, DataFrame]]: + return Any + + def predict(self, dataframe: DataFrame) -> Tuple[np.array, np.array]: """ Filter the prediction features data and predict with it. :param: unfiltered_dataframe: Full dataframe for the current backtest period. - :return: + :return: :predictions: np.array of predictions :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove data (NaNs) or felt uncertain about data (PCA and DI index) """ - return None + return np.array([]), np.array([]) - def model_exists(self, pair: str, training_timerange: str = None) -> bool: + def model_exists(self, pair: str, training_timerange: str) -> bool: """ Given a pair and path, check if a model already exists :param pair: pair e.g. BTC/USD :param path: path to model """ - coin,_ = pair.split('/') - self.dh.model_filename = f"cb_"+coin.lower()+"_"+training_timerange - file_exists = os.path.isfile(self.dh.model_path+ - self.dh.model_filename+"_model.joblib") + coin, _ = pair.split("/") + self.dh.model_filename = "cb_" + coin.lower() + "_" + training_timerange + path_to_modelfile = Path(self.dh.model_path / str(self.dh.model_filename + "_model.joblib")) + file_exists = path_to_modelfile.is_file() if file_exists: - print("Found model at", self.dh.model_path+self.dh.model_filename) - else: print("Could not find model at", - self.dh.model_path+self.dh.model_filename) + print("Found model at", self.dh.model_path / self.dh.model_filename) + else: + print("Could not find model at", self.dh.model_path / self.dh.model_filename) return file_exists diff --git a/freqtrade/freqai/strategy_bridge.py b/freqtrade/freqai/strategy_bridge.py index c336e3c84..bb43084a0 100644 --- a/freqtrade/freqai/strategy_bridge.py +++ b/freqtrade/freqai/strategy_bridge.py @@ -3,10 +3,10 @@ from freqtrade.resolvers.freqaimodel_resolver import FreqaiModelResolver class CustomModel: """ - A bridge between the user defined IFreqaiModel class + A bridge between the user defined IFreqaiModel class and the strategy. """ - def __init__(self,config): + def __init__(self, config): self.bridge = FreqaiModelResolver.load_freqaimodel(config) diff --git a/freqtrade/resolvers/freqaimodel_resolver.py b/freqtrade/resolvers/freqaimodel_resolver.py index 9545afd24..2ba6b3e8a 100644 --- a/freqtrade/resolvers/freqaimodel_resolver.py +++ b/freqtrade/resolvers/freqaimodel_resolver.py @@ -12,6 +12,7 @@ from freqtrade.exceptions import OperationalException from freqtrade.freqai.freqai_interface import IFreqaiModel from freqtrade.resolvers import IResolver + logger = logging.getLogger(__name__) @@ -19,10 +20,11 @@ class FreqaiModelResolver(IResolver): """ This class contains all the logic to load custom hyperopt loss class """ + object_type = IFreqaiModel object_type_str = "FreqaiModel" user_subdir = USERPATH_FREQAIMODELS - initial_search_path = Path(__file__).parent.parent.joinpath('optimize').resolve() + initial_search_path = Path(__file__).parent.parent.joinpath("optimize").resolve() @staticmethod def load_freqaimodel(config: Dict) -> IFreqaiModel: @@ -31,15 +33,17 @@ class FreqaiModelResolver(IResolver): :param config: configuration dictionary """ - freqaimodel_name = config.get('freqaimodel') + freqaimodel_name = config.get("freqaimodel") if not freqaimodel_name: raise OperationalException( "No freqaimodel set. Please use `--freqaimodel` to " "specify the FreqaiModel class to use.\n" ) - freqaimodel = FreqaiModelResolver.load_object(freqaimodel_name, - config, kwargs={'config': config}, - extra_dir=config.get('freqaimodel_path')) - + freqaimodel = FreqaiModelResolver.load_object( + freqaimodel_name, + config, + kwargs={"config": config}, + extra_dir=config.get("freqaimodel_path"), + ) return freqaimodel diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py index a5370b5ac..feeed11a9 100644 --- a/freqtrade/templates/ExamplePredictionModel.py +++ b/freqtrade/templates/ExamplePredictionModel.py @@ -1,15 +1,17 @@ -import numpy as np +from typing import Any, Dict, Tuple + import pandas as pd from catboost import CatBoostRegressor, Pool from pandas import DataFrame -from typing import Any, Dict, Tuple + from freqtrade.freqai.freqai_interface import IFreqaiModel + class ExamplePredictionModel(IFreqaiModel): """ User created prediction model. The class needs to override three necessary functions, predict(), train(), fit(). The class inherits ModelHandler which - has its own DataHandler where data is held, saved, loaded, and managed. + has its own DataHandler where data is held, saved, loaded, and managed. """ def make_labels(self, dataframe: DataFrame) -> DataFrame: @@ -19,15 +21,20 @@ class ExamplePredictionModel(IFreqaiModel): :dataframe: the full dataframe for the present training period """ - dataframe['s'] = (dataframe['close'].shift(-self.feature_parameters['period']).rolling( - self.feature_parameters['period']).max() / dataframe['close'] - 1) - self.dh.data['s_mean'] = dataframe['s'].mean() - self.dh.data['s_std'] = dataframe['s'].std() + dataframe["s"] = ( + dataframe["close"] + .shift(-self.feature_parameters["period"]) + .rolling(self.feature_parameters["period"]) + .max() + / dataframe["close"] + - 1 + ) + self.dh.data["s_mean"] = dataframe["s"].mean() + self.dh.data["s_std"] = dataframe["s"].std() - print('label mean',self.dh.data['s_mean'],'label std',self.dh.data['s_std']) - - return dataframe['s'] + print("label mean", self.dh.data["s_mean"], "label std", self.dh.data["s_std"]) + return dataframe["s"] def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: """ @@ -35,7 +42,7 @@ class ExamplePredictionModel(IFreqaiModel): for storing, saving, loading, and managed. :params: :unfiltered_dataframe: Full dataframe for the current training period - :metadata: pair metadata from strategy. + :metadata: pair metadata from strategy. :returns: :model: Trained model which can be used to inference (self.predict) """ @@ -46,8 +53,12 @@ class ExamplePredictionModel(IFreqaiModel): unfiltered_labels = self.make_labels(unfiltered_dataframe) # filter the features requested by user in the configuration file and elegantly handle NaNs - features_filtered, labels_filtered = self.dh.filter_features(unfiltered_dataframe, - self.dh.training_features_list, unfiltered_labels, training_filter=True) + features_filtered, labels_filtered = self.dh.filter_features( + unfiltered_dataframe, + self.dh.training_features_list, + unfiltered_labels, + training_filter=True, + ) # split data into train/test data. data_dictionary = self.dh.make_train_test_datasets(features_filtered, labels_filtered) @@ -55,46 +66,47 @@ class ExamplePredictionModel(IFreqaiModel): data_dictionary = self.dh.standardize_data(data_dictionary) # optional additional data cleaning - if self.feature_parameters['principal_component_analysis']: + if self.feature_parameters["principal_component_analysis"]: self.dh.principal_component_analysis() if self.feature_parameters["remove_outliers"]: self.dh.remove_outliers(predict=False) - if self.feature_parameters['DI_threshold']: - self.dh.data['avg_mean_dist'] = self.dh.compute_distances() + if self.feature_parameters["DI_threshold"]: + self.dh.data["avg_mean_dist"] = self.dh.compute_distances() - print("length of train data", len(data_dictionary['train_features'])) + print("length of train data", len(data_dictionary["train_features"])) model = self.fit(data_dictionary) - print('Finished training') + print("Finished training") print(f'--------------------done training {metadata["pair"]}--------------------') return model def fit(self, data_dictionary: Dict) -> Any: """ - Most regressors use the same function names and arguments e.g. user + Most regressors use the same function names and arguments e.g. user can drop in LGBMRegressor in place of CatBoostRegressor and all data management will be properly handled by Freqai. :params: - :data_dictionary: the dictionary constructed by DataHandler to hold + :data_dictionary: the dictionary constructed by DataHandler to hold all the training and test data/labels. """ train_data = Pool( - data=data_dictionary['train_features'], - label=data_dictionary['train_labels'], - weight=data_dictionary['train_weights'] + data=data_dictionary["train_features"], + label=data_dictionary["train_labels"], + weight=data_dictionary["train_weights"], ) test_data = Pool( - data=data_dictionary['test_features'], - label=data_dictionary['test_labels'], - weight=data_dictionary['test_weights'] + data=data_dictionary["test_features"], + label=data_dictionary["test_labels"], + weight=data_dictionary["test_weights"], ) - model = CatBoostRegressor(verbose=100, early_stopping_rounds=400, - **self.model_training_parameters) + model = CatBoostRegressor( + verbose=100, early_stopping_rounds=400, **self.model_training_parameters + ) model.fit(X=train_data, eval_set=test_data) return model @@ -103,7 +115,7 @@ class ExamplePredictionModel(IFreqaiModel): """ Filter the prediction features data and predict with it. :param: unfiltered_dataframe: Full dataframe for the current backtest period. - :return: + :return: :predictions: np.array of predictions :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove data (NaNs) or felt uncertain about data (PCA and DI index) @@ -112,27 +124,31 @@ class ExamplePredictionModel(IFreqaiModel): print("--------------------Starting prediction--------------------") original_feature_list = self.dh.build_feature_list(self.config) - filtered_dataframe, _ = self.dh.filter_features(unfiltered_dataframe, original_feature_list, training_filter=False) + filtered_dataframe, _ = self.dh.filter_features( + unfiltered_dataframe, original_feature_list, training_filter=False + ) filtered_dataframe = self.dh.standardize_data_from_metadata(filtered_dataframe) - self.dh.data_dictionary['prediction_features'] = filtered_dataframe + self.dh.data_dictionary["prediction_features"] = filtered_dataframe - # optional additional data cleaning - if self.feature_parameters['principal_component_analysis']: + # optional additional data cleaning + if self.feature_parameters["principal_component_analysis"]: pca_components = self.dh.pca.transform(filtered_dataframe) - self.dh.data_dictionary['prediction_features'] = pd.DataFrame(data=pca_components, - columns = ['PC'+str(i) for i in range(0,self.dh.data['n_kept_components'])], - index = filtered_dataframe.index) - + self.dh.data_dictionary["prediction_features"] = pd.DataFrame( + data=pca_components, + columns=["PC" + str(i) for i in range(0, self.dh.data["n_kept_components"])], + index=filtered_dataframe.index, + ) + if self.feature_parameters["remove_outliers"]: - self.dh.remove_outliers(predict=True) # creates dropped index + self.dh.remove_outliers(predict=True) # creates dropped index - if self.feature_parameters['DI_threshold']: - self.dh.check_if_pred_in_training_spaces() # sets do_predict + if self.feature_parameters["DI_threshold"]: + self.dh.check_if_pred_in_training_spaces() # sets do_predict - predictions = self.model.predict(self.dh.data_dictionary['prediction_features']) + predictions = self.model.predict(self.dh.data_dictionary["prediction_features"]) # compute the non-standardized predictions - predictions = predictions * self.dh.data['labels_std'] + self.dh.data['labels_mean'] + predictions = predictions * self.dh.data["labels_std"] + self.dh.data["labels_mean"] print("--------------------Finished prediction--------------------") diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index d6b1295ec..873b31115 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -1,61 +1,59 @@ import logging +from functools import reduce + +import numpy as np +import pandas as pd import talib.abstract as ta from pandas import DataFrame -import pandas as pd from technical import qtpylib -import numpy as np -from freqtrade.strategy import (merge_informative_pair) -from freqtrade.strategy.interface import IStrategy + from freqtrade.freqai.strategy_bridge import CustomModel -from functools import reduce +from freqtrade.strategy import merge_informative_pair +from freqtrade.strategy.interface import IStrategy + + logger = logging.getLogger(__name__) + class FreqaiExampleStrategy(IStrategy): """ - Example strategy showing how the user connects their own + Example strategy showing how the user connects their own IFreqaiModel to the strategy. Namely, the user uses: self.model = CustomModel(self.config) self.model.bridge.start(dataframe, metadata) - to make predictions on their data. populate_any_indicators() automatically + to make predictions on their data. populate_any_indicators() automatically generates the variety of features indicated by the user in the canonical freqtrade configuration file under config['freqai']. """ - minimal_roi = { - "0": 0.01, - "240": -1 - } + minimal_roi = {"0": 0.01, "240": -1} plot_config = { - 'main_plot': { + "main_plot": {}, + "subplots": { + "prediction": {"prediction": {"color": "blue"}}, + "target_roi": { + "target_roi": {"color": "brown"}, + }, + "do_predict": { + "do_predict": {"color": "brown"}, + }, }, - 'subplots': { - "prediction":{ - 'prediction':{'color':'blue'} - }, - "target_roi":{ - 'target_roi':{'color':'brown'}, - }, - "do_predict":{ - 'do_predict':{'color':'brown'}, - }, - } } stoploss = -0.05 use_sell_signal = True - startup_candle_count: int = 1000 - + startup_candle_count: int = 1000 def informative_pairs(self): - pairs = self.freqai_info['corr_pairlist'] + pairs = self.freqai_info["corr_pairlist"] informative_pairs = [] for tf in self.timeframes: informative_pairs.append([(pair, tf) for pair in pairs]) return informative_pairs - def populate_any_indicators(self, pair, df, tf, informative=None,coin=''): + def populate_any_indicators(self, pair, df, tf, informative=None, coin=""): """ Function designed to automatically generate, name and merge features from user indicated timeframes in the configuration file. User can add @@ -70,110 +68,116 @@ class FreqaiExampleStrategy(IStrategy): if informative is None: informative = self.dp.get_pair_dataframe(pair, tf) - informative[coin+'rsi'] = ta.RSI(informative, timeperiod=14) - informative[coin+'mfi'] = ta.MFI(informative, timeperiod=25) - informative[coin+'adx'] = ta.ADX(informative, window=20) + informative[coin + "rsi"] = ta.RSI(informative, timeperiod=14) + informative[coin + "mfi"] = ta.MFI(informative, timeperiod=25) + informative[coin + "adx"] = ta.ADX(informative, window=20) - informative[coin+'20sma'] = ta.SMA(informative,timeperiod=20) - informative[coin+'21ema'] = ta.EMA(informative,timeperiod=21) - informative[coin+'bmsb'] = np.where(informative[coin+'20sma'].lt(informative[coin+'21ema']),1,0) - informative[coin+'close_over_20sma'] = informative['close']/informative[coin+'20sma'] + informative[coin + "20sma"] = ta.SMA(informative, timeperiod=20) + informative[coin + "21ema"] = ta.EMA(informative, timeperiod=21) + informative[coin + "bmsb"] = np.where( + informative[coin + "20sma"].lt(informative[coin + "21ema"]), 1, 0 + ) + informative[coin + "close_over_20sma"] = informative["close"] / informative[coin + "20sma"] - informative[coin+'mfi'] = ta.MFI(informative, timeperiod=25) + informative[coin + "mfi"] = ta.MFI(informative, timeperiod=25) - informative[coin+'ema21'] = ta.EMA(informative, timeperiod=21) - informative[coin+'sma20'] = ta.SMA(informative, timeperiod=20) + informative[coin + "ema21"] = ta.EMA(informative, timeperiod=21) + informative[coin + "sma20"] = ta.SMA(informative, timeperiod=20) stoch = ta.STOCHRSI(informative, 15, 20, 2, 2) - informative[coin+'srsi-fk'] = stoch['fastk'] - informative[coin+'srsi-fd'] = stoch['fastd'] + informative[coin + "srsi-fk"] = stoch["fastk"] + informative[coin + "srsi-fd"] = stoch["fastd"] bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(informative), window=14, stds=2.2) - informative[coin+'bb_lowerband'] = bollinger['lower'] - informative[coin+'bb_middleband'] = bollinger['mid'] - informative[coin+'bb_upperband'] = bollinger['upper'] - informative[coin+'bb_width'] = ((informative[coin+"bb_upperband"] - informative[coin+"bb_lowerband"]) / informative[coin+"bb_middleband"]) - informative[coin+'close-bb_lower'] = informative['close'] / informative[coin+'bb_lowerband'] + informative[coin + "bb_lowerband"] = bollinger["lower"] + informative[coin + "bb_middleband"] = bollinger["mid"] + informative[coin + "bb_upperband"] = bollinger["upper"] + informative[coin + "bb_width"] = ( + informative[coin + "bb_upperband"] - informative[coin + "bb_lowerband"] + ) / informative[coin + "bb_middleband"] + informative[coin + "close-bb_lower"] = ( + informative["close"] / informative[coin + "bb_lowerband"] + ) - informative[coin+'roc'] = ta.ROC(informative, timeperiod=3) - informative[coin+'adx'] = ta.ADX(informative, window=14) + informative[coin + "roc"] = ta.ROC(informative, timeperiod=3) + informative[coin + "adx"] = ta.ADX(informative, window=14) macd = ta.MACD(informative) - informative[coin+'macd'] = macd['macd'] - informative[coin+'pct-change'] = informative['close'].pct_change() - informative[coin+'relative_volume'] = informative['volume'] / informative['volume'].rolling(10).mean() + informative[coin + "macd"] = macd["macd"] + informative[coin + "pct-change"] = informative["close"].pct_change() + informative[coin + "relative_volume"] = ( + informative["volume"] / informative["volume"].rolling(10).mean() + ) - informative[coin+'pct-change'] = informative['close'].pct_change() + informative[coin + "pct-change"] = informative["close"].pct_change() indicators = [col for col in informative if col.startswith(coin)] - for n in range(self.freqai_info['feature_parameters']['shift']+1): - if n==0: continue + for n in range(self.freqai_info["feature_parameters"]["shift"] + 1): + if n == 0: + continue informative_shift = informative[indicators].shift(n) - informative_shift = informative_shift.add_suffix('_shift-'+str(n)) - informative = pd.concat((informative,informative_shift),axis=1) + informative_shift = informative_shift.add_suffix("_shift-" + str(n)) + informative = pd.concat((informative, informative_shift), axis=1) - df = merge_informative_pair(df, informative, self.config['timeframe'], tf, ffill=True) - skip_columns = [(s + '_'+tf) for s in - ['date', 'open', 'high', 'low', 'close', 'volume']] + df = merge_informative_pair(df, informative, self.config["timeframe"], tf, ffill=True) + skip_columns = [(s + "_" + tf) for s in ["date", "open", "high", "low", "close", "volume"]] df = df.drop(columns=skip_columns) return df - def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # the configuration file parameters are stored here - self.freqai_info = self.config['freqai'] + self.freqai_info = self.config["freqai"] # the model is instantiated here self.model = CustomModel(self.config) - print('Populating indicators...') + print("Populating indicators...") - # the following loops are necessary for building the features + # the following loops are necessary for building the features # indicated by the user in the configuration file. - for tf in self.freqai_info['timeframes']: - dataframe = self.populate_any_indicators(metadata['pair'], - dataframe.copy(), tf) - for i in self.freqai_info['corr_pairlist']: - dataframe = self.populate_any_indicators(i, - dataframe.copy(), tf, coin=i.split("/")[0]+'-') + for tf in self.freqai_info["timeframes"]: + dataframe = self.populate_any_indicators(metadata["pair"], dataframe.copy(), tf) + for i in self.freqai_info["corr_pairlist"]: + dataframe = self.populate_any_indicators( + i, dataframe.copy(), tf, coin=i.split("/")[0] + "-" + ) - # the model will return 4 values, its prediction, an indication of whether or not the prediction - # should be accepted, the target mean/std values from the labels used during each training period. - (dataframe['prediction'], dataframe['do_predict'], - dataframe['target_mean'], dataframe['target_std']) = self.model.bridge.start(dataframe, metadata) + # the model will return 4 values, its prediction, an indication of whether or not the + # prediction should be accepted, the target mean/std values from the labels used during + # each training period. + ( + dataframe["prediction"], + dataframe["do_predict"], + dataframe["target_mean"], + dataframe["target_std"], + ) = self.model.bridge.start(dataframe, metadata) - dataframe['target_roi'] = dataframe['target_mean']+dataframe['target_std']*0.5 - dataframe['sell_roi'] = dataframe['target_mean']-dataframe['target_std']*1.5 + dataframe["target_roi"] = dataframe["target_mean"] + dataframe["target_std"] * 0.5 + dataframe["sell_roi"] = dataframe["target_mean"] - dataframe["target_std"] * 1.5 return dataframe - def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: buy_conditions = [ - (dataframe['prediction'] > dataframe['target_roi']) - & - (dataframe['do_predict'] == 1) + (dataframe["prediction"] > dataframe["target_roi"]) & (dataframe["do_predict"] == 1) ] if buy_conditions: - dataframe.loc[reduce(lambda x, y: x | y, buy_conditions), 'buy'] = 1 + dataframe.loc[reduce(lambda x, y: x | y, buy_conditions), "buy"] = 1 return dataframe - def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - # sell_goal = eval('self.'+metadata['pair'].split("/")[0]+'_sell_goal.value') + # sell_goal = eval('self.'+metadata['pair'].split("/")[0]+'_sell_goal.value') sell_conditions = [ - (dataframe['prediction'] < dataframe['sell_roi']) - & - (dataframe['do_predict'] == 1) + (dataframe["prediction"] < dataframe["sell_roi"]) & (dataframe["do_predict"] == 1) ] if sell_conditions: - dataframe.loc[reduce(lambda x, y: x | y, sell_conditions), 'sell'] = 1 + dataframe.loc[reduce(lambda x, y: x | y, sell_conditions), "sell"] = 1 return dataframe def get_ticker_indicator(self): - return int(self.config['timeframe'][:-1]) + return int(self.config["timeframe"][:-1]) From 29c2d1d1891f7e804a133908702f435ff4fd8f32 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 4 May 2022 17:53:40 +0200 Subject: [PATCH 006/130] use logger in favor of print --- freqtrade/freqai/data_handler.py | 23 +++++++++++-------- freqtrade/freqai/freqai_interface.py | 15 ++++++++---- freqtrade/templates/ExamplePredictionModel.py | 18 +++++++++------ 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/freqtrade/freqai/data_handler.py b/freqtrade/freqai/data_handler.py index 7264c6fab..9ab47d223 100644 --- a/freqtrade/freqai/data_handler.py +++ b/freqtrade/freqai/data_handler.py @@ -1,6 +1,7 @@ import copy import datetime import json +import logging import pickle as pk from pathlib import Path from typing import Any, Dict, List, Tuple @@ -17,6 +18,8 @@ from freqtrade.configuration import TimeRange SECONDS_IN_DAY = 86400 +logger = logging.getLogger(__name__) + class DataHandler: """ @@ -175,7 +178,7 @@ class DataHandler: labels = labels[ (drop_index == 0) & (drop_index_labels == 0) ] # assuming the labels depend entirely on the dataframe here. - print( + logger.info( "dropped", len(unfiltered_dataframe) - len(filtered_dataframe), "training data points due to NaNs, ensure you have downloaded", @@ -193,7 +196,7 @@ class DataHandler: # that was based on a single NaN is ultimately protected from buys with do_predict drop_index = ~drop_index self.do_predict = np.array(drop_index.replace(True, 1).replace(False, 0)) - print( + logger.info( "dropped", len(self.do_predict) - self.do_predict.sum(), "of", @@ -350,8 +353,8 @@ class DataHandler: pca2 = PCA(n_components=n_keep_components) self.data["n_kept_components"] = n_keep_components pca2 = pca2.fit(self.data_dictionary["train_features"]) - print("reduced feature dimension by", n_components - n_keep_components) - print("explained variance", np.sum(pca2.explained_variance_ratio_)) + logger.info("reduced feature dimension by", n_components - n_keep_components) + logger.info("explained variance", np.sum(pca2.explained_variance_ratio_)) train_components = pca2.transform(self.data_dictionary["train_features"]) test_components = pca2.transform(self.data_dictionary["test_features"]) @@ -377,10 +380,10 @@ class DataHandler: return None def compute_distances(self) -> float: - print("computing average mean distance for all training points") + logger.info("computing average mean distance for all training points") pairwise = pairwise_distances(self.data_dictionary["train_features"], n_jobs=-1) avg_mean_dist = pairwise.mean(axis=1).mean() - print("avg_mean_dist", avg_mean_dist) + logger.info("avg_mean_dist", avg_mean_dist) return avg_mean_dist @@ -407,7 +410,7 @@ class DataHandler: drop_index = ~drop_index do_predict = np.array(drop_index.replace(True, 1).replace(False, 0)) - print( + logger.info( "remove_outliers() tossed", len(do_predict) - do_predict.sum(), "predictions because they were beyond 3 std deviations from training data.", @@ -472,7 +475,7 @@ class DataHandler: for p in config["freqai"]["corr_pairlist"]: features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) - print("number of features", len(features)) + logger.info("number of features", len(features)) return features def check_if_pred_in_training_spaces(self) -> None: @@ -483,7 +486,7 @@ class DataHandler: from the training data set. """ - print("checking if prediction features are in AOA") + logger.info("checking if prediction features are in AOA") distance = pairwise_distances( self.data_dictionary["train_features"], self.data_dictionary["prediction_features"], @@ -497,7 +500,7 @@ class DataHandler: 0, ) - print( + logger.info( "Distance checker tossed", len(do_predict) - do_predict.sum(), "predictions for being too far from training data", diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 9f04b09cd..05a0594f3 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -1,6 +1,7 @@ import gc +import logging import shutil -from abc import ABC +from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Dict, Tuple @@ -12,6 +13,7 @@ from freqtrade.freqai.data_handler import DataHandler pd.options.mode.chained_assignment = None +logger = logging.getLogger(__name__) class IFreqaiModel(ABC): @@ -67,7 +69,7 @@ class IFreqaiModel(ABC): self.pair = metadata["pair"] self.dh = DataHandler(self.config, dataframe) - print( + logger.info( "going to train", len(self.dh.training_timeranges), "timeranges:", @@ -88,7 +90,7 @@ class IFreqaiModel(ABC): self.freqai_info["training_timerange"] = tr_train dataframe_train = self.dh.slice_dataframe(tr_train, dataframe) dataframe_backtest = self.dh.slice_dataframe(tr_backtest, dataframe) - print("training", self.pair, "for", tr_train) + logger.info("training", self.pair, "for", tr_train) # self.dh.model_path = self.full_path + "/" + "sub-train" + "-" + str(tr_train) + "/" self.dh.model_path = Path(self.full_path / str("sub-train" + "-" + str(tr_train))) if not self.model_exists(self.pair, training_timerange=tr_train): @@ -114,6 +116,7 @@ class IFreqaiModel(ABC): return dataframe + @abstractmethod def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Any: """ Filter the training data and train a model to it. Train makes heavy use of the datahandler @@ -127,6 +130,7 @@ class IFreqaiModel(ABC): return Any + @abstractmethod def fit(self) -> Any: """ Most regressors use the same function names and arguments e.g. user @@ -139,6 +143,7 @@ class IFreqaiModel(ABC): return Any + @abstractmethod def predict(self, dataframe: DataFrame) -> Tuple[np.array, np.array]: """ Filter the prediction features data and predict with it. @@ -162,7 +167,7 @@ class IFreqaiModel(ABC): path_to_modelfile = Path(self.dh.model_path / str(self.dh.model_filename + "_model.joblib")) file_exists = path_to_modelfile.is_file() if file_exists: - print("Found model at", self.dh.model_path / self.dh.model_filename) + logger.info("Found model at", self.dh.model_path / self.dh.model_filename) else: - print("Could not find model at", self.dh.model_path / self.dh.model_filename) + logger.info("Could not find model at", self.dh.model_path / self.dh.model_filename) return file_exists diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py index feeed11a9..4906b8c04 100644 --- a/freqtrade/templates/ExamplePredictionModel.py +++ b/freqtrade/templates/ExamplePredictionModel.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Dict, Tuple import pandas as pd @@ -7,6 +8,9 @@ from pandas import DataFrame from freqtrade.freqai.freqai_interface import IFreqaiModel +logger = logging.getLogger(__name__) + + class ExamplePredictionModel(IFreqaiModel): """ User created prediction model. The class needs to override three necessary @@ -32,7 +36,7 @@ class ExamplePredictionModel(IFreqaiModel): self.dh.data["s_mean"] = dataframe["s"].mean() self.dh.data["s_std"] = dataframe["s"].std() - print("label mean", self.dh.data["s_mean"], "label std", self.dh.data["s_std"]) + logger.info("label mean", self.dh.data["s_mean"], "label std", self.dh.data["s_std"]) return dataframe["s"] @@ -46,7 +50,7 @@ class ExamplePredictionModel(IFreqaiModel): :returns: :model: Trained model which can be used to inference (self.predict) """ - print("--------------------Starting training--------------------") + logger.info("--------------------Starting training--------------------") # create the full feature list based on user config info self.dh.training_features_list = self.dh.build_feature_list(self.config) @@ -73,12 +77,12 @@ class ExamplePredictionModel(IFreqaiModel): if self.feature_parameters["DI_threshold"]: self.dh.data["avg_mean_dist"] = self.dh.compute_distances() - print("length of train data", len(data_dictionary["train_features"])) + logger.info("length of train data", len(data_dictionary["train_features"])) model = self.fit(data_dictionary) - print("Finished training") - print(f'--------------------done training {metadata["pair"]}--------------------') + logger.info("Finished training") + logger.info(f'--------------------done training {metadata["pair"]}--------------------') return model @@ -121,7 +125,7 @@ class ExamplePredictionModel(IFreqaiModel): data (NaNs) or felt uncertain about data (PCA and DI index) """ - print("--------------------Starting prediction--------------------") + logger.info("--------------------Starting prediction--------------------") original_feature_list = self.dh.build_feature_list(self.config) filtered_dataframe, _ = self.dh.filter_features( @@ -150,6 +154,6 @@ class ExamplePredictionModel(IFreqaiModel): # compute the non-standardized predictions predictions = predictions * self.dh.data["labels_std"] + self.dh.data["labels_mean"] - print("--------------------Finished prediction--------------------") + logger.info("--------------------Finished prediction--------------------") return (predictions, self.dh.do_predict) From 764f9449b428be8b744ebea989ee81b81330aae9 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Thu, 5 May 2022 14:37:37 +0200 Subject: [PATCH 007/130] fix logger, debug some flake8 appeasements --- freqtrade/constants.py | 837 ++++++++++-------- freqtrade/freqai/data_handler.py | 29 +- freqtrade/freqai/freqai_interface.py | 15 +- freqtrade/optimize/backtesting.py | 8 +- freqtrade/templates/ExamplePredictionModel.py | 5 +- 5 files changed, 479 insertions(+), 415 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index f8a9dc06d..d9664cff8 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -8,88 +8,133 @@ from typing import List, Literal, Tuple from freqtrade.enums import CandleType -DEFAULT_CONFIG = 'config.json' -DEFAULT_EXCHANGE = 'bittrex' +DEFAULT_CONFIG = "config.json" +DEFAULT_EXCHANGE = "bittrex" PROCESS_THROTTLE_SECS = 5 # sec HYPEROPT_EPOCH = 100 # epochs RETRY_TIMEOUT = 30 # sec -TIMEOUT_UNITS = ['minutes', 'seconds'] -EXPORT_OPTIONS = ['none', 'trades', 'signals'] -DEFAULT_DB_PROD_URL = 'sqlite:///tradesv3.sqlite' -DEFAULT_DB_DRYRUN_URL = 'sqlite:///tradesv3.dryrun.sqlite' -UNLIMITED_STAKE_AMOUNT = 'unlimited' +TIMEOUT_UNITS = ["minutes", "seconds"] +EXPORT_OPTIONS = ["none", "trades", "signals"] +DEFAULT_DB_PROD_URL = "sqlite:///tradesv3.sqlite" +DEFAULT_DB_DRYRUN_URL = "sqlite:///tradesv3.dryrun.sqlite" +UNLIMITED_STAKE_AMOUNT = "unlimited" DEFAULT_AMOUNT_RESERVE_PERCENT = 0.05 -REQUIRED_ORDERTIF = ['entry', 'exit'] -REQUIRED_ORDERTYPES = ['entry', 'exit', 'stoploss', 'stoploss_on_exchange'] -PRICING_SIDES = ['ask', 'bid', 'same', 'other'] -ORDERTYPE_POSSIBILITIES = ['limit', 'market'] -ORDERTIF_POSSIBILITIES = ['gtc', 'fok', 'ioc'] -HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', - 'SharpeHyperOptLoss', 'SharpeHyperOptLossDaily', - 'SortinoHyperOptLoss', 'SortinoHyperOptLossDaily', - 'CalmarHyperOptLoss', - 'MaxDrawDownHyperOptLoss', 'MaxDrawDownRelativeHyperOptLoss', - 'ProfitDrawDownHyperOptLoss'] -AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', - 'AgeFilter', 'OffsetFilter', 'PerformanceFilter', - 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', - 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] -AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] -AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] -BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] -BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month'] -BACKTEST_CACHE_DEFAULT = 'day' +REQUIRED_ORDERTIF = ["entry", "exit"] +REQUIRED_ORDERTYPES = ["entry", "exit", "stoploss", "stoploss_on_exchange"] +PRICING_SIDES = ["ask", "bid", "same", "other"] +ORDERTYPE_POSSIBILITIES = ["limit", "market"] +ORDERTIF_POSSIBILITIES = ["gtc", "fok", "ioc"] +HYPEROPT_LOSS_BUILTIN = [ + "ShortTradeDurHyperOptLoss", + "OnlyProfitHyperOptLoss", + "SharpeHyperOptLoss", + "SharpeHyperOptLossDaily", + "SortinoHyperOptLoss", + "SortinoHyperOptLossDaily", + "CalmarHyperOptLoss", + "MaxDrawDownHyperOptLoss", + "MaxDrawDownRelativeHyperOptLoss", + "ProfitDrawDownHyperOptLoss", +] +AVAILABLE_PAIRLISTS = [ + "StaticPairList", + "VolumePairList", + "AgeFilter", + "OffsetFilter", + "PerformanceFilter", + "PrecisionFilter", + "PriceFilter", + "RangeStabilityFilter", + "ShuffleFilter", + "SpreadFilter", + "VolatilityFilter", +] +AVAILABLE_PROTECTIONS = ["CooldownPeriod", "LowProfitPairs", "MaxDrawdown", "StoplossGuard"] +AVAILABLE_DATAHANDLERS = ["json", "jsongz", "hdf5"] +BACKTEST_BREAKDOWNS = ["day", "week", "month"] +BACKTEST_CACHE_AGE = ["none", "day", "week", "month"] +BACKTEST_CACHE_DEFAULT = "day" DRY_RUN_WALLET = 1000 -DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S' +DATETIME_PRINT_FORMAT = "%Y-%m-%d %H:%M:%S" MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons -DEFAULT_DATAFRAME_COLUMNS = ['date', 'open', 'high', 'low', 'close', 'volume'] +DEFAULT_DATAFRAME_COLUMNS = ["date", "open", "high", "low", "close", "volume"] # Don't modify sequence of DEFAULT_TRADES_COLUMNS # it has wide consequences for stored trades files -DEFAULT_TRADES_COLUMNS = ['timestamp', 'id', 'type', 'side', 'price', 'amount', 'cost'] -TRADING_MODES = ['spot', 'margin', 'futures'] -MARGIN_MODES = ['cross', 'isolated', ''] +DEFAULT_TRADES_COLUMNS = ["timestamp", "id", "type", "side", "price", "amount", "cost"] +TRADING_MODES = ["spot", "margin", "futures"] +MARGIN_MODES = ["cross", "isolated", ""] -LAST_BT_RESULT_FN = '.last_result.json' -FTHYPT_FILEVERSION = 'fthypt_fileversion' +LAST_BT_RESULT_FN = ".last_result.json" +FTHYPT_FILEVERSION = "fthypt_fileversion" -USERPATH_HYPEROPTS = 'hyperopts' -USERPATH_STRATEGIES = 'strategies' -USERPATH_NOTEBOOKS = 'notebooks' -USERPATH_FREQAIMODELS = 'freqaimodels' +USERPATH_HYPEROPTS = "hyperopts" +USERPATH_STRATEGIES = "strategies" +USERPATH_NOTEBOOKS = "notebooks" +USERPATH_FREQAIMODELS = "freqaimodels" -TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] -WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] +TELEGRAM_SETTING_OPTIONS = ["on", "off", "silent"] +WEBHOOK_FORMAT_OPTIONS = ["form", "json", "raw"] -ENV_VAR_PREFIX = 'FREQTRADE__' +ENV_VAR_PREFIX = "FREQTRADE__" -NON_OPEN_EXCHANGE_STATES = ('cancelled', 'canceled', 'closed', 'expired') +NON_OPEN_EXCHANGE_STATES = ("cancelled", "canceled", "closed", "expired") # Define decimals per coin for outputs # Only used for outputs. DECIMAL_PER_COIN_FALLBACK = 3 # Should be low to avoid listing all possible FIAT's DECIMALS_PER_COIN = { - 'BTC': 8, - 'ETH': 5, + "BTC": 8, + "ETH": 5, } -DUST_PER_COIN = { - 'BTC': 0.0001, - 'ETH': 0.01 -} +DUST_PER_COIN = {"BTC": 0.0001, "ETH": 0.01} # Source files with destination directories within user-directory USER_DATA_FILES = { - 'sample_strategy.py': USERPATH_STRATEGIES, - 'sample_hyperopt_loss.py': USERPATH_HYPEROPTS, - 'strategy_analysis_example.ipynb': USERPATH_NOTEBOOKS, + "sample_strategy.py": USERPATH_STRATEGIES, + "sample_hyperopt_loss.py": USERPATH_HYPEROPTS, + "strategy_analysis_example.ipynb": USERPATH_NOTEBOOKS, } SUPPORTED_FIAT = [ - "AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "CZK", "DKK", - "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", - "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PKR", "PLN", - "RUB", "UAH", "SEK", "SGD", "THB", "TRY", "TWD", "ZAR", - "USD", "BTC", "ETH", "XRP", "LTC", "BCH" + "AUD", + "BRL", + "CAD", + "CHF", + "CLP", + "CNY", + "CZK", + "DKK", + "EUR", + "GBP", + "HKD", + "HUF", + "IDR", + "ILS", + "INR", + "JPY", + "KRW", + "MXN", + "MYR", + "NOK", + "NZD", + "PHP", + "PKR", + "PLN", + "RUB", + "UAH", + "SEK", + "SGD", + "THB", + "TRY", + "TWD", + "ZAR", + "USD", + "BTC", + "ETH", + "XRP", + "LTC", + "BCH", ] MINIMAL_CONFIG = { @@ -100,380 +145,416 @@ MINIMAL_CONFIG = { "key": "", "secret": "", "pair_whitelist": [], - "ccxt_async_config": { - } - } + "ccxt_async_config": {}, + }, } # Required json-schema for user specified config CONF_SCHEMA = { - 'type': 'object', - 'properties': { - 'max_open_trades': {'type': ['integer', 'number'], 'minimum': -1}, - 'new_pairs_days': {'type': 'integer', 'default': 30}, - 'timeframe': {'type': 'string'}, - 'stake_currency': {'type': 'string'}, - 'stake_amount': { - 'type': ['number', 'string'], - 'minimum': 0.0001, - 'pattern': UNLIMITED_STAKE_AMOUNT + "type": "object", + "properties": { + "max_open_trades": {"type": ["integer", "number"], "minimum": -1}, + "new_pairs_days": {"type": "integer", "default": 30}, + "timeframe": {"type": "string"}, + "stake_currency": {"type": "string"}, + "stake_amount": { + "type": ["number", "string"], + "minimum": 0.0001, + "pattern": UNLIMITED_STAKE_AMOUNT, }, - 'tradable_balance_ratio': { - 'type': 'number', - 'minimum': 0.0, - 'maximum': 1, - 'default': 0.99 + "tradable_balance_ratio": {"type": "number", "minimum": 0.0, "maximum": 1, "default": 0.99}, + "available_capital": { + "type": "number", + "minimum": 0, }, - 'available_capital': { - 'type': 'number', - 'minimum': 0, + "amend_last_stake_amount": {"type": "boolean", "default": False}, + "last_stake_amount_min_ratio": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "default": 0.5, }, - 'amend_last_stake_amount': {'type': 'boolean', 'default': False}, - 'last_stake_amount_min_ratio': { - 'type': 'number', 'minimum': 0.0, 'maximum': 1.0, 'default': 0.5 + "fiat_display_currency": {"type": "string", "enum": SUPPORTED_FIAT}, + "dry_run": {"type": "boolean"}, + "dry_run_wallet": {"type": "number", "default": DRY_RUN_WALLET}, + "cancel_open_orders_on_exit": {"type": "boolean", "default": False}, + "process_only_new_candles": {"type": "boolean"}, + "minimal_roi": { + "type": "object", + "patternProperties": {"^[0-9.]+$": {"type": "number"}}, + "minProperties": 1, }, - 'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT}, - 'dry_run': {'type': 'boolean'}, - 'dry_run_wallet': {'type': 'number', 'default': DRY_RUN_WALLET}, - 'cancel_open_orders_on_exit': {'type': 'boolean', 'default': False}, - 'process_only_new_candles': {'type': 'boolean'}, - 'minimal_roi': { - 'type': 'object', - 'patternProperties': { - '^[0-9.]+$': {'type': 'number'} + "amount_reserve_percent": {"type": "number", "minimum": 0.0, "maximum": 0.5}, + "stoploss": {"type": "number", "maximum": 0, "exclusiveMaximum": True, "minimum": -1}, + "trailing_stop": {"type": "boolean"}, + "trailing_stop_positive": {"type": "number", "minimum": 0, "maximum": 1}, + "trailing_stop_positive_offset": {"type": "number", "minimum": 0, "maximum": 1}, + "trailing_only_offset_is_reached": {"type": "boolean"}, + "use_exit_signal": {"type": "boolean"}, + "exit_profit_only": {"type": "boolean"}, + "exit_profit_offset": {"type": "number"}, + "ignore_roi_if_entry_signal": {"type": "boolean"}, + "ignore_buying_expired_candle_after": {"type": "number"}, + "trading_mode": {"type": "string", "enum": TRADING_MODES}, + "margin_mode": {"type": "string", "enum": MARGIN_MODES}, + "liquidation_buffer": {"type": "number", "minimum": 0.0, "maximum": 0.99}, + "backtest_breakdown": { + "type": "array", + "items": {"type": "string", "enum": BACKTEST_BREAKDOWNS}, + }, + "bot_name": {"type": "string"}, + "unfilledtimeout": { + "type": "object", + "properties": { + "entry": {"type": "number", "minimum": 1}, + "exit": {"type": "number", "minimum": 1}, + "exit_timeout_count": {"type": "number", "minimum": 0, "default": 0}, + "unit": {"type": "string", "enum": TIMEOUT_UNITS, "default": "minutes"}, }, - 'minProperties': 1 }, - 'amount_reserve_percent': {'type': 'number', 'minimum': 0.0, 'maximum': 0.5}, - 'stoploss': {'type': 'number', 'maximum': 0, 'exclusiveMaximum': True, 'minimum': -1}, - 'trailing_stop': {'type': 'boolean'}, - 'trailing_stop_positive': {'type': 'number', 'minimum': 0, 'maximum': 1}, - 'trailing_stop_positive_offset': {'type': 'number', 'minimum': 0, 'maximum': 1}, - 'trailing_only_offset_is_reached': {'type': 'boolean'}, - 'use_exit_signal': {'type': 'boolean'}, - 'exit_profit_only': {'type': 'boolean'}, - 'exit_profit_offset': {'type': 'number'}, - 'ignore_roi_if_entry_signal': {'type': 'boolean'}, - 'ignore_buying_expired_candle_after': {'type': 'number'}, - 'trading_mode': {'type': 'string', 'enum': TRADING_MODES}, - 'margin_mode': {'type': 'string', 'enum': MARGIN_MODES}, - 'liquidation_buffer': {'type': 'number', 'minimum': 0.0, 'maximum': 0.99}, - 'backtest_breakdown': { - 'type': 'array', - 'items': {'type': 'string', 'enum': BACKTEST_BREAKDOWNS} - }, - 'bot_name': {'type': 'string'}, - 'unfilledtimeout': { - 'type': 'object', - 'properties': { - 'entry': {'type': 'number', 'minimum': 1}, - 'exit': {'type': 'number', 'minimum': 1}, - 'exit_timeout_count': {'type': 'number', 'minimum': 0, 'default': 0}, - 'unit': {'type': 'string', 'enum': TIMEOUT_UNITS, 'default': 'minutes'} - } - }, - 'entry_pricing': { - 'type': 'object', - 'properties': { - 'price_last_balance': { - 'type': 'number', - 'minimum': 0, - 'maximum': 1, - 'exclusiveMaximum': False, + "entry_pricing": { + "type": "object", + "properties": { + "price_last_balance": { + "type": "number", + "minimum": 0, + "maximum": 1, + "exclusiveMaximum": False, }, - 'price_side': {'type': 'string', 'enum': PRICING_SIDES, 'default': 'same'}, - 'use_order_book': {'type': 'boolean'}, - 'order_book_top': {'type': 'integer', 'minimum': 1, 'maximum': 50, }, - 'check_depth_of_market': { - 'type': 'object', - 'properties': { - 'enabled': {'type': 'boolean'}, - 'bids_to_ask_delta': {'type': 'number', 'minimum': 0}, - } + "price_side": {"type": "string", "enum": PRICING_SIDES, "default": "same"}, + "use_order_book": {"type": "boolean"}, + "order_book_top": { + "type": "integer", + "minimum": 1, + "maximum": 50, + }, + "check_depth_of_market": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "bids_to_ask_delta": {"type": "number", "minimum": 0}, + }, }, }, - 'required': ['price_side'] + "required": ["price_side"], }, - 'exit_pricing': { - 'type': 'object', - 'properties': { - 'price_side': {'type': 'string', 'enum': PRICING_SIDES, 'default': 'same'}, - 'price_last_balance': { - 'type': 'number', - 'minimum': 0, - 'maximum': 1, - 'exclusiveMaximum': False, + "exit_pricing": { + "type": "object", + "properties": { + "price_side": {"type": "string", "enum": PRICING_SIDES, "default": "same"}, + "price_last_balance": { + "type": "number", + "minimum": 0, + "maximum": 1, + "exclusiveMaximum": False, }, - 'use_order_book': {'type': 'boolean'}, - 'order_book_top': {'type': 'integer', 'minimum': 1, 'maximum': 50, }, - }, - 'required': ['price_side'] - }, - 'custom_price_max_distance_ratio': { - 'type': 'number', 'minimum': 0.0 - }, - 'order_types': { - 'type': 'object', - 'properties': { - 'entry': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, - 'exit': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, - 'force_exit': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, - 'force_entry': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, - 'emergency_exit': { - 'type': 'string', - 'enum': ORDERTYPE_POSSIBILITIES, - 'default': 'market'}, - 'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, - 'stoploss_on_exchange': {'type': 'boolean'}, - 'stoploss_on_exchange_interval': {'type': 'number'}, - 'stoploss_on_exchange_limit_ratio': {'type': 'number', 'minimum': 0.0, - 'maximum': 1.0} - }, - 'required': ['entry', 'exit', 'stoploss', 'stoploss_on_exchange'] - }, - 'order_time_in_force': { - 'type': 'object', - 'properties': { - 'entry': {'type': 'string', 'enum': ORDERTIF_POSSIBILITIES}, - 'exit': {'type': 'string', 'enum': ORDERTIF_POSSIBILITIES} - }, - 'required': REQUIRED_ORDERTIF - }, - 'exchange': {'$ref': '#/definitions/exchange'}, - 'edge': {'$ref': '#/definitions/edge'}, - 'experimental': { - 'type': 'object', - 'properties': { - 'block_bad_exchanges': {'type': 'boolean'} - } - }, - 'pairlists': { - 'type': 'array', - 'items': { - 'type': 'object', - 'properties': { - 'method': {'type': 'string', 'enum': AVAILABLE_PAIRLISTS}, + "use_order_book": {"type": "boolean"}, + "order_book_top": { + "type": "integer", + "minimum": 1, + "maximum": 50, }, - 'required': ['method'], - } + }, + "required": ["price_side"], }, - 'protections': { - 'type': 'array', - 'items': { - 'type': 'object', - 'properties': { - 'method': {'type': 'string', 'enum': AVAILABLE_PROTECTIONS}, - 'stop_duration': {'type': 'number', 'minimum': 0.0}, - 'stop_duration_candles': {'type': 'number', 'minimum': 0}, - 'trade_limit': {'type': 'number', 'minimum': 1}, - 'lookback_period': {'type': 'number', 'minimum': 1}, - 'lookback_period_candles': {'type': 'number', 'minimum': 1}, + "custom_price_max_distance_ratio": {"type": "number", "minimum": 0.0}, + "order_types": { + "type": "object", + "properties": { + "entry": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, + "exit": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, + "force_exit": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, + "force_entry": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, + "emergency_exit": { + "type": "string", + "enum": ORDERTYPE_POSSIBILITIES, + "default": "market", }, - 'required': ['method'], - } + "stoploss": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, + "stoploss_on_exchange": {"type": "boolean"}, + "stoploss_on_exchange_interval": {"type": "number"}, + "stoploss_on_exchange_limit_ratio": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + }, + }, + "required": ["entry", "exit", "stoploss", "stoploss_on_exchange"], }, - 'telegram': { - 'type': 'object', - 'properties': { - 'enabled': {'type': 'boolean'}, - 'token': {'type': 'string'}, - 'chat_id': {'type': 'string'}, - 'balance_dust_level': {'type': 'number', 'minimum': 0.0}, - 'notification_settings': { - 'type': 'object', - 'default': {}, - 'properties': { - 'status': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, - 'warning': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, - 'startup': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, - 'entry': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, - 'entry_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, - 'entry_fill': {'type': 'string', - 'enum': TELEGRAM_SETTING_OPTIONS, - 'default': 'off' - }, - 'exit': { - 'type': ['string', 'object'], - 'additionalProperties': { - 'type': 'string', - 'enum': TELEGRAM_SETTING_OPTIONS - } + "order_time_in_force": { + "type": "object", + "properties": { + "entry": {"type": "string", "enum": ORDERTIF_POSSIBILITIES}, + "exit": {"type": "string", "enum": ORDERTIF_POSSIBILITIES}, + }, + "required": REQUIRED_ORDERTIF, + }, + "exchange": {"$ref": "#/definitions/exchange"}, + "edge": {"$ref": "#/definitions/edge"}, + "experimental": { + "type": "object", + "properties": {"block_bad_exchanges": {"type": "boolean"}}, + }, + "pairlists": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": {"type": "string", "enum": AVAILABLE_PAIRLISTS}, + }, + "required": ["method"], + }, + }, + "protections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "method": {"type": "string", "enum": AVAILABLE_PROTECTIONS}, + "stop_duration": {"type": "number", "minimum": 0.0}, + "stop_duration_candles": {"type": "number", "minimum": 0}, + "trade_limit": {"type": "number", "minimum": 1}, + "lookback_period": {"type": "number", "minimum": 1}, + "lookback_period_candles": {"type": "number", "minimum": 1}, + }, + "required": ["method"], + }, + }, + "telegram": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "token": {"type": "string"}, + "chat_id": {"type": "string"}, + "balance_dust_level": {"type": "number", "minimum": 0.0}, + "notification_settings": { + "type": "object", + "default": {}, + "properties": { + "status": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, + "warning": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, + "startup": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, + "entry": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, + "entry_cancel": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, + "entry_fill": { + "type": "string", + "enum": TELEGRAM_SETTING_OPTIONS, + "default": "off", }, - 'exit_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, - 'exit_fill': { - 'type': 'string', - 'enum': TELEGRAM_SETTING_OPTIONS, - 'default': 'off' + "exit": { + "type": ["string", "object"], + "additionalProperties": { + "type": "string", + "enum": TELEGRAM_SETTING_OPTIONS, + }, }, - 'protection_trigger': { - 'type': 'string', - 'enum': TELEGRAM_SETTING_OPTIONS, - 'default': 'off' + "exit_cancel": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, + "exit_fill": { + "type": "string", + "enum": TELEGRAM_SETTING_OPTIONS, + "default": "off", }, - 'protection_trigger_global': { - 'type': 'string', - 'enum': TELEGRAM_SETTING_OPTIONS, + "protection_trigger": { + "type": "string", + "enum": TELEGRAM_SETTING_OPTIONS, + "default": "off", }, - } + "protection_trigger_global": { + "type": "string", + "enum": TELEGRAM_SETTING_OPTIONS, + }, + }, }, - 'reload': {'type': 'boolean'}, + "reload": {"type": "boolean"}, }, - 'required': ['enabled', 'token', 'chat_id'], + "required": ["enabled", "token", "chat_id"], }, - 'webhook': { - 'type': 'object', - 'properties': { - 'enabled': {'type': 'boolean'}, - 'url': {'type': 'string'}, - 'format': {'type': 'string', 'enum': WEBHOOK_FORMAT_OPTIONS, 'default': 'form'}, - 'retries': {'type': 'integer', 'minimum': 0}, - 'retry_delay': {'type': 'number', 'minimum': 0}, - 'webhookentry': {'type': 'object'}, - 'webhookentrycancel': {'type': 'object'}, - 'webhookentryfill': {'type': 'object'}, - 'webhookexit': {'type': 'object'}, - 'webhookexitcancel': {'type': 'object'}, - 'webhookexitfill': {'type': 'object'}, - 'webhookstatus': {'type': 'object'}, + "webhook": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "url": {"type": "string"}, + "format": {"type": "string", "enum": WEBHOOK_FORMAT_OPTIONS, "default": "form"}, + "retries": {"type": "integer", "minimum": 0}, + "retry_delay": {"type": "number", "minimum": 0}, + "webhookentry": {"type": "object"}, + "webhookentrycancel": {"type": "object"}, + "webhookentryfill": {"type": "object"}, + "webhookexit": {"type": "object"}, + "webhookexitcancel": {"type": "object"}, + "webhookexitfill": {"type": "object"}, + "webhookstatus": {"type": "object"}, }, }, - 'api_server': { - 'type': 'object', - 'properties': { - 'enabled': {'type': 'boolean'}, - 'listen_ip_address': {'format': 'ipv4'}, - 'listen_port': { - 'type': 'integer', - 'minimum': 1024, - 'maximum': 65535 - }, - 'username': {'type': 'string'}, - 'password': {'type': 'string'}, - 'jwt_secret_key': {'type': 'string'}, - 'CORS_origins': {'type': 'array', 'items': {'type': 'string'}}, - 'verbosity': {'type': 'string', 'enum': ['error', 'info']}, + "api_server": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "listen_ip_address": {"format": "ipv4"}, + "listen_port": {"type": "integer", "minimum": 1024, "maximum": 65535}, + "username": {"type": "string"}, + "password": {"type": "string"}, + "jwt_secret_key": {"type": "string"}, + "CORS_origins": {"type": "array", "items": {"type": "string"}}, + "verbosity": {"type": "string", "enum": ["error", "info"]}, }, - 'required': ['enabled', 'listen_ip_address', 'listen_port', 'username', 'password'] + "required": ["enabled", "listen_ip_address", "listen_port", "username", "password"], }, - 'db_url': {'type': 'string'}, - 'export': {'type': 'string', 'enum': EXPORT_OPTIONS, 'default': 'trades'}, - 'disableparamexport': {'type': 'boolean'}, - 'initial_state': {'type': 'string', 'enum': ['running', 'stopped']}, - 'force_entry_enable': {'type': 'boolean'}, - 'disable_dataframe_checks': {'type': 'boolean'}, - 'internals': { - 'type': 'object', - 'default': {}, - 'properties': { - 'process_throttle_secs': {'type': 'integer'}, - 'interval': {'type': 'integer'}, - 'sd_notify': {'type': 'boolean'}, - } + "db_url": {"type": "string"}, + "export": {"type": "string", "enum": EXPORT_OPTIONS, "default": "trades"}, + "disableparamexport": {"type": "boolean"}, + "initial_state": {"type": "string", "enum": ["running", "stopped"]}, + "force_entry_enable": {"type": "boolean"}, + "disable_dataframe_checks": {"type": "boolean"}, + "internals": { + "type": "object", + "default": {}, + "properties": { + "process_throttle_secs": {"type": "integer"}, + "interval": {"type": "integer"}, + "sd_notify": {"type": "boolean"}, + }, }, - 'dataformat_ohlcv': { - 'type': 'string', - 'enum': AVAILABLE_DATAHANDLERS, - 'default': 'json' + "dataformat_ohlcv": {"type": "string", "enum": AVAILABLE_DATAHANDLERS, "default": "json"}, + "dataformat_trades": { + "type": "string", + "enum": AVAILABLE_DATAHANDLERS, + "default": "jsongz", }, - 'dataformat_trades': { - 'type': 'string', - 'enum': AVAILABLE_DATAHANDLERS, - 'default': 'jsongz' - }, - 'position_adjustment_enable': {'type': 'boolean'}, - 'max_entry_position_adjustment': {'type': ['integer', 'number'], 'minimum': -1}, + "position_adjustment_enable": {"type": "boolean"}, + "max_entry_position_adjustment": {"type": ["integer", "number"], "minimum": -1}, }, - 'definitions': { - 'exchange': { - 'type': 'object', - 'properties': { - 'name': {'type': 'string'}, - 'sandbox': {'type': 'boolean', 'default': False}, - 'key': {'type': 'string', 'default': ''}, - 'secret': {'type': 'string', 'default': ''}, - 'password': {'type': 'string', 'default': ''}, - 'uid': {'type': 'string'}, - 'pair_whitelist': { - 'type': 'array', - 'items': { - 'type': 'string', + "definitions": { + "exchange": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "sandbox": {"type": "boolean", "default": False}, + "key": {"type": "string", "default": ""}, + "secret": {"type": "string", "default": ""}, + "password": {"type": "string", "default": ""}, + "uid": {"type": "string"}, + "pair_whitelist": { + "type": "array", + "items": { + "type": "string", }, - 'uniqueItems': True + "uniqueItems": True, }, - 'pair_blacklist': { - 'type': 'array', - 'items': { - 'type': 'string', + "pair_blacklist": { + "type": "array", + "items": { + "type": "string", }, - 'uniqueItems': True + "uniqueItems": True, }, - 'unknown_fee_rate': {'type': 'number'}, - 'outdated_offset': {'type': 'integer', 'minimum': 1}, - 'markets_refresh_interval': {'type': 'integer'}, - 'ccxt_config': {'type': 'object'}, - 'ccxt_async_config': {'type': 'object'} + "unknown_fee_rate": {"type": "number"}, + "outdated_offset": {"type": "integer", "minimum": 1}, + "markets_refresh_interval": {"type": "integer"}, + "ccxt_config": {"type": "object"}, + "ccxt_async_config": {"type": "object"}, }, - 'required': ['name'] + "required": ["name"], }, - 'edge': { - 'type': 'object', - 'properties': { - 'enabled': {'type': 'boolean'}, - 'process_throttle_secs': {'type': 'integer', 'minimum': 600}, - 'calculate_since_number_of_days': {'type': 'integer'}, - 'allowed_risk': {'type': 'number'}, - 'stoploss_range_min': {'type': 'number'}, - 'stoploss_range_max': {'type': 'number'}, - 'stoploss_range_step': {'type': 'number'}, - 'minimum_winrate': {'type': 'number'}, - 'minimum_expectancy': {'type': 'number'}, - 'min_trade_number': {'type': 'number'}, - 'max_trade_duration_minute': {'type': 'integer'}, - 'remove_pumps': {'type': 'boolean'} + "edge": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "process_throttle_secs": {"type": "integer", "minimum": 600}, + "calculate_since_number_of_days": {"type": "integer"}, + "allowed_risk": {"type": "number"}, + "stoploss_range_min": {"type": "number"}, + "stoploss_range_max": {"type": "number"}, + "stoploss_range_step": {"type": "number"}, + "minimum_winrate": {"type": "number"}, + "minimum_expectancy": {"type": "number"}, + "min_trade_number": {"type": "number"}, + "max_trade_duration_minute": {"type": "integer"}, + "remove_pumps": {"type": "boolean"}, }, - 'required': ['process_throttle_secs', 'allowed_risk'] - } + "required": ["process_throttle_secs", "allowed_risk"], + }, + "freqai": { + "type": "object", + "properties": { + "timeframes": {"type": "list"}, + "full_timerange": {"type": "str"}, + "train_period": {"type": "integer", "default": 0}, + "backtest_period": {"type": "integer", "default": 7}, + "identifier": {"type": "str", "default": "example"}, + "base_features": {"type": "list"}, + "corr_pairlist": {"type": "list"}, + "training_timerange": {"type": "string", "default": None}, + "feature_parameters": { + "type": "object", + "properties": { + "period": {"type": "integer"}, + "shift": {"type": "integer", "default": 0}, + "DI_threshold": {"type": "integer", "default": 0}, + "weight_factor": {"type": "number", "default": 0}, + "principal_component_analysis": {"type": "boolean", "default": False}, + "remove_outliers": {"type": "boolean", "default": False}, + }, + }, + "data_split_parameters": { + "type": "object", + "properties": { + "test_size": {"type": "number"}, + "random_state": {"type": "integer"}, + }, + }, + "model_training_parameters": { + "type": "object", + "properties": { + "n_estimators": {"type": "integer", "default": 2000}, + "random_state": {"type": "integer", "default": 1}, + "learning_rate": {"type": "number", "default": 0.02}, + "task_type": {"type": "string", "default": "CPU"}, + }, + }, + }, + }, }, } SCHEMA_TRADE_REQUIRED = [ - 'exchange', - 'timeframe', - 'max_open_trades', - 'stake_currency', - 'stake_amount', - 'tradable_balance_ratio', - 'last_stake_amount_min_ratio', - 'dry_run', - 'dry_run_wallet', - 'exit_pricing', - 'entry_pricing', - 'stoploss', - 'minimal_roi', - 'internals', - 'dataformat_ohlcv', - 'dataformat_trades', + "exchange", + "timeframe", + "max_open_trades", + "stake_currency", + "stake_amount", + "tradable_balance_ratio", + "last_stake_amount_min_ratio", + "dry_run", + "dry_run_wallet", + "exit_pricing", + "entry_pricing", + "stoploss", + "minimal_roi", + "internals", + "dataformat_ohlcv", + "dataformat_trades", ] SCHEMA_BACKTEST_REQUIRED = [ - 'exchange', - 'max_open_trades', - 'stake_currency', - 'stake_amount', - 'dry_run_wallet', - 'dataformat_ohlcv', - 'dataformat_trades', + "exchange", + "max_open_trades", + "stake_currency", + "stake_amount", + "dry_run_wallet", + "dataformat_ohlcv", + "dataformat_trades", ] SCHEMA_BACKTEST_REQUIRED_FINAL = SCHEMA_BACKTEST_REQUIRED + [ - 'stoploss', - 'minimal_roi', + "stoploss", + "minimal_roi", ] SCHEMA_MINIMAL_REQUIRED = [ - 'exchange', - 'dry_run', - 'dataformat_ohlcv', - 'dataformat_trades', + "exchange", + "dry_run", + "dataformat_ohlcv", + "dataformat_trades", ] CANCEL_REASON = { diff --git a/freqtrade/freqai/data_handler.py b/freqtrade/freqai/data_handler.py index 9ab47d223..94df869a1 100644 --- a/freqtrade/freqai/data_handler.py +++ b/freqtrade/freqai/data_handler.py @@ -36,6 +36,7 @@ class DataHandler: config["freqai"]["backtest_period"], ) self.data: Dict[Any, Any] = {} + self.data_dictionary: Dict[Any, Any] = {} self.config = config self.freq_config = config["freqai"] self.predictions = np.array([]) @@ -58,10 +59,6 @@ class DataHandler: save_path = Path(self.model_path) - # if not os.path.exists(self.model_path): - # os.mkdir(self.model_path) - # save_path = self.model_path + self.model_filename - # Save the trained model dump(model, save_path / str(self.model_filename + "_model.joblib")) self.data["model_path"] = self.model_path @@ -179,10 +176,8 @@ class DataHandler: (drop_index == 0) & (drop_index_labels == 0) ] # assuming the labels depend entirely on the dataframe here. logger.info( - "dropped", + "dropped %s training points due to NaNs, ensure all historical data downloaded", len(unfiltered_dataframe) - len(filtered_dataframe), - "training data points due to NaNs, ensure you have downloaded", - "all historical training data", ) self.data["filter_drop_index_training"] = drop_index @@ -197,12 +192,9 @@ class DataHandler: drop_index = ~drop_index self.do_predict = np.array(drop_index.replace(True, 1).replace(False, 0)) logger.info( - "dropped", + "dropped %s of %s prediction data points due to NaNs.", len(self.do_predict) - self.do_predict.sum(), - "of", len(filtered_dataframe), - "prediction data points due to NaNs. These are protected from prediction", - "with do_predict vector returned to strategy.", ) return filtered_dataframe, labels @@ -353,8 +345,8 @@ class DataHandler: pca2 = PCA(n_components=n_keep_components) self.data["n_kept_components"] = n_keep_components pca2 = pca2.fit(self.data_dictionary["train_features"]) - logger.info("reduced feature dimension by", n_components - n_keep_components) - logger.info("explained variance", np.sum(pca2.explained_variance_ratio_)) + logger.info("reduced feature dimension by %s", n_components - n_keep_components) + logger.info("explained variance %f", np.sum(pca2.explained_variance_ratio_)) train_components = pca2.transform(self.data_dictionary["train_features"]) test_components = pca2.transform(self.data_dictionary["test_features"]) @@ -383,7 +375,7 @@ class DataHandler: logger.info("computing average mean distance for all training points") pairwise = pairwise_distances(self.data_dictionary["train_features"], n_jobs=-1) avg_mean_dist = pairwise.mean(axis=1).mean() - logger.info("avg_mean_dist", avg_mean_dist) + logger.info("avg_mean_dist %s", avg_mean_dist) return avg_mean_dist @@ -411,9 +403,8 @@ class DataHandler: do_predict = np.array(drop_index.replace(True, 1).replace(False, 0)) logger.info( - "remove_outliers() tossed", + "remove_outliers() tossed %s predictions", len(do_predict) - do_predict.sum(), - "predictions because they were beyond 3 std deviations from training data.", ) self.do_predict += do_predict self.do_predict -= 1 @@ -475,7 +466,7 @@ class DataHandler: for p in config["freqai"]["corr_pairlist"]: features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) - logger.info("number of features", len(features)) + logger.info("number of features %s", len(features)) return features def check_if_pred_in_training_spaces(self) -> None: @@ -486,7 +477,6 @@ class DataHandler: from the training data set. """ - logger.info("checking if prediction features are in AOA") distance = pairwise_distances( self.data_dictionary["train_features"], self.data_dictionary["prediction_features"], @@ -501,9 +491,8 @@ class DataHandler: ) logger.info( - "Distance checker tossed", + "Distance checker tossed %s predictions for being too far from training data", len(do_predict) - do_predict.sum(), - "predictions for being too far from training data", ) self.do_predict += do_predict diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 05a0594f3..368ed1635 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -69,12 +69,7 @@ class IFreqaiModel(ABC): self.pair = metadata["pair"] self.dh = DataHandler(self.config, dataframe) - logger.info( - "going to train", - len(self.dh.training_timeranges), - "timeranges:", - self.dh.training_timeranges, - ) + logger.info("going to train %s timeranges", len(self.dh.training_timeranges)) # Loop enforcing the sliding window training/backtesting paragigm # tr_train is the training time range e.g. 1 historical month @@ -90,14 +85,14 @@ class IFreqaiModel(ABC): self.freqai_info["training_timerange"] = tr_train dataframe_train = self.dh.slice_dataframe(tr_train, dataframe) dataframe_backtest = self.dh.slice_dataframe(tr_backtest, dataframe) - logger.info("training", self.pair, "for", tr_train) + logger.info("training %s for %s", self.pair, tr_train) # self.dh.model_path = self.full_path + "/" + "sub-train" + "-" + str(tr_train) + "/" self.dh.model_path = Path(self.full_path / str("sub-train" + "-" + str(tr_train))) if not self.model_exists(self.pair, training_timerange=tr_train): self.model = self.train(dataframe_train, metadata) self.dh.save_data(self.model) else: - self.model = self.dh.load_data(self.dh.model_path) + self.model = self.dh.load_data() preds, do_preds = self.predict(dataframe_backtest) @@ -167,7 +162,7 @@ class IFreqaiModel(ABC): path_to_modelfile = Path(self.dh.model_path / str(self.dh.model_filename + "_model.joblib")) file_exists = path_to_modelfile.is_file() if file_exists: - logger.info("Found model at", self.dh.model_path / self.dh.model_filename) + logger.info("Found model at %s", self.dh.model_path / self.dh.model_filename) else: - logger.info("Could not find model at", self.dh.model_path / self.dh.model_filename) + logger.info("Could not find model at %s", self.dh.model_path / self.dh.model_filename) return file_exists diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 5051a8db0..f976b1238 100755 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -204,12 +204,12 @@ class Backtesting: """ self.progress.init_step(BacktestState.DATALOAD, 1) - if self.config['freqaimodel']: - self.required_startup += int((self.config['freqai']['train_period']*86400) / - timeframe_to_seconds(self.config['timeframe'])) + if self.config['freqai']['train_period'] > 0: + self.required_startup += int((self.config['freqai']['train_period'] * 86400) / + timeframe_to_seconds(self.config['timeframe'])) + logger.info("Increasing startup_candle_count for freqai to %s", self.required_startup) self.config['startup_candle_count'] = self.required_startup - data = history.load_data( datadir=self.config['datadir'], pairs=self.pairlists.whitelist, diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py index 4906b8c04..35f25775a 100644 --- a/freqtrade/templates/ExamplePredictionModel.py +++ b/freqtrade/templates/ExamplePredictionModel.py @@ -36,7 +36,7 @@ class ExamplePredictionModel(IFreqaiModel): self.dh.data["s_mean"] = dataframe["s"].mean() self.dh.data["s_std"] = dataframe["s"].std() - logger.info("label mean", self.dh.data["s_mean"], "label std", self.dh.data["s_std"]) + # logger.info("label mean", self.dh.data["s_mean"], "label std", self.dh.data["s_std"]) return dataframe["s"] @@ -77,11 +77,10 @@ class ExamplePredictionModel(IFreqaiModel): if self.feature_parameters["DI_threshold"]: self.dh.data["avg_mean_dist"] = self.dh.compute_distances() - logger.info("length of train data", len(data_dictionary["train_features"])) + logger.info("length of train data %s", len(data_dictionary["train_features"])) model = self.fit(data_dictionary) - logger.info("Finished training") logger.info(f'--------------------done training {metadata["pair"]}--------------------') return model From def71a0afedf18378bebed478decc3bb785efad5 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Thu, 5 May 2022 15:35:51 +0200 Subject: [PATCH 008/130] auto build full_timerange and self manage training_timerange --- config_examples/config_freqai.example.json | 3 -- freqtrade/constants.py | 2 -- freqtrade/freqai/data_handler.py | 41 ++++++++++++++++++---- freqtrade/freqai/freqai_interface.py | 19 ++-------- 4 files changed, 38 insertions(+), 27 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 47109ff31..5bd4de6c4 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -49,12 +49,10 @@ } ], "freqai": { - "btc_pair": "BTC/USDT", "timeframes": [ "5m", "15m" ], - "full_timerange": "20210601-20210901", "train_period": 30, "backtest_period": 7, "identifier": "example", @@ -74,7 +72,6 @@ "LINK/USDT", "DOT/USDT" ], - "training_timerange": "20211220-20220117", "feature_parameters": { "period": 12, "shift": 1, diff --git a/freqtrade/constants.py b/freqtrade/constants.py index d9664cff8..d988a164e 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -478,13 +478,11 @@ CONF_SCHEMA = { "type": "object", "properties": { "timeframes": {"type": "list"}, - "full_timerange": {"type": "str"}, "train_period": {"type": "integer", "default": 0}, "backtest_period": {"type": "integer", "default": 7}, "identifier": {"type": "str", "default": "example"}, "base_features": {"type": "list"}, "corr_pairlist": {"type": "list"}, - "training_timerange": {"type": "string", "default": None}, "feature_parameters": { "type": "object", "properties": { diff --git a/freqtrade/freqai/data_handler.py b/freqtrade/freqai/data_handler.py index 94df869a1..e58575970 100644 --- a/freqtrade/freqai/data_handler.py +++ b/freqtrade/freqai/data_handler.py @@ -3,6 +3,7 @@ import datetime import json import logging import pickle as pk +import shutil from pathlib import Path from typing import Any, Dict, List, Tuple @@ -30,15 +31,10 @@ class DataHandler: def __init__(self, config: Dict[str, Any], dataframe: DataFrame): self.full_dataframe = dataframe - (self.training_timeranges, self.backtesting_timeranges) = self.split_timerange( - config["freqai"]["full_timerange"], - config["freqai"]["train_period"], - config["freqai"]["backtest_period"], - ) self.data: Dict[Any, Any] = {} self.data_dictionary: Dict[Any, Any] = {} self.config = config - self.freq_config = config["freqai"] + self.freqai_config = config["freqai"] self.predictions = np.array([]) self.do_predict = np.array([]) self.target_mean = np.array([]) @@ -46,6 +42,16 @@ class DataHandler: self.model_path = Path() self.model_filename = "" + self.full_timerange = self.create_fulltimerange( + self.config["timerange"], self.freqai_config["train_period"] + ) + + (self.training_timeranges, self.backtesting_timeranges) = self.split_timerange( + self.full_timerange, + config["freqai"]["train_period"], + config["freqai"]["backtest_period"], + ) + def save_data(self, model: Any) -> None: """ Saves all data associated with a model for a single sub-train time range @@ -539,6 +545,29 @@ class DataHandler: return + def create_fulltimerange(self, backtest_tr: str, backtest_period: int) -> str: + backtest_timerange = TimeRange.parse_timerange(backtest_tr) + + backtest_timerange.startts = backtest_timerange.startts - backtest_period * SECONDS_IN_DAY + start = datetime.datetime.utcfromtimestamp(backtest_timerange.startts) + stop = datetime.datetime.utcfromtimestamp(backtest_timerange.stopts) + full_timerange = start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d") + + self.full_path = Path( + self.config["user_data_dir"] + / "models" + / str(full_timerange + self.freqai_config["identifier"]) + ) + + if not self.full_path.is_dir(): + self.full_path.mkdir(parents=True, exist_ok=True) + shutil.copy( + Path(self.config["config_files"][0]).name, + Path(self.full_path / self.config["config_files"][0]), + ) + + return full_timerange + def np_encoder(self, object): if isinstance(object, np.generic): return object.item() diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 368ed1635..62779a4e1 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -1,6 +1,5 @@ import gc import logging -import shutil from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Dict, Tuple @@ -32,24 +31,13 @@ class IFreqaiModel(ABC): self.data_split_parameters = config["freqai"]["data_split_parameters"] self.model_training_parameters = config["freqai"]["model_training_parameters"] self.feature_parameters = config["freqai"]["feature_parameters"] - self.full_path = Path( - config["user_data_dir"] - / "models" - / str(self.freqai_info["full_timerange"] + self.freqai_info["identifier"]) - ) + self.backtest_timerange = config["timerange"] self.time_last_trained = None self.current_time = None self.model = None self.predictions = None - if not self.full_path.is_dir(): - self.full_path.mkdir(parents=True, exist_ok=True) - shutil.copy( - self.config["config_files"][0], - Path(self.full_path / self.config["config_files"][0]), - ) - def start(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Entry point to the FreqaiModel, it will train a new model if @@ -82,12 +70,11 @@ class IFreqaiModel(ABC): gc.collect() # self.config['timerange'] = tr_train self.dh.data = {} # clean the pair specific data between models - self.freqai_info["training_timerange"] = tr_train + self.training_timerange = tr_train dataframe_train = self.dh.slice_dataframe(tr_train, dataframe) dataframe_backtest = self.dh.slice_dataframe(tr_backtest, dataframe) logger.info("training %s for %s", self.pair, tr_train) - # self.dh.model_path = self.full_path + "/" + "sub-train" + "-" + str(tr_train) + "/" - self.dh.model_path = Path(self.full_path / str("sub-train" + "-" + str(tr_train))) + self.dh.model_path = Path(self.dh.full_path / str("sub-train" + "-" + str(tr_train))) if not self.model_exists(self.pair, training_timerange=tr_train): self.model = self.train(dataframe_train, metadata) self.dh.save_data(self.model) From 66715c5ba4316be461632777e7f4a68569b52857 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Thu, 5 May 2022 15:49:19 +0200 Subject: [PATCH 009/130] update doc --- docs/freqai.md | 43 ++++++++++++------------------------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 6bc1e9365..844881613 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -49,9 +49,8 @@ config setup includes: ```json "freqai": { "timeframes" : ["5m","15m","4h"], - "full_timerange" : "20211220-20220220", - "train_period" : "month", - "backtest_period" : "week", + "train_period" : 30, + "backtest_period" : 7, "identifier" : "unique-id", "base_features": [ "rsi", @@ -63,18 +62,18 @@ config setup includes: "LINK/USD", "BNB/USD" ], - "train_params" : { + "feature_parameters" : { "period": 24, "shift": 2, "drop_features": false, "DI_threshold": 1, "weight_factor": 0, }, - "SPLIT_PARAMS" : { + "data_split_parameters" : { "test_size": 0.25, "random_state": 42 }, - "CLASSIFIER_PARAMS" : { + "model_training_parameters" : { "n_estimators": 100, "random_state": 42, "learning_rate": 0.02, @@ -110,11 +109,11 @@ no. `timeframes` * no. `base_features` * no. `corr_pairlist` * no. `shift`_ ### Deciding the sliding training window and backtesting duration -`full_timerange` lets the user set the full backtesting range to train and -backtest through. Meanwhile `train_period` is the sliding training window and -`backtest_period` is the sliding backtesting window. In the present example, -the user is asking Freqai to train and backtest the range of `20211220-20220220` (`month`). -The user wishes to backtest each `week` with a newly trained model. This means that +Users define the backtesting timerange with the typical `--timerange` parameter in the user +configuration file. `train_period` is the duration of the sliding training window, while +`backtest_period` is the sliding backtesting window, both in number of days. In the present example, +the user is asking Freqai to use a training period of 30 days and backtest the subsequent 7 days. +This means that if the user sets `--timerange 20210501-20210701`, Freqai will train 8 separate models (because the full range comprises 8 weeks), and then backtest the subsequent week associated with each of the 8 training data set timerange months. Users can think of this as a "sliding window" which @@ -128,7 +127,7 @@ month of data. The freqai training/backtesting module can be executed with the following command: ```bash -freqtrade backtesting --strategy FreqaiExampleStrategy --config config_freqai.example.json --freqaimodel ExamplePredictionModel +freqtrade backtesting --strategy FreqaiExampleStrategy --config config_freqai.example.json --freqaimodel ExamplePredictionModel --timerange 20210501-20210701 ``` where the user needs to have a FreqaiExampleStrategy that fits to the requirements outlined @@ -178,19 +177,7 @@ and `make_labels()` to let them customize various aspects of their training proc ### Running the model live -After the user has designed a desirable featureset, Freqai can be run in dry/live -using the typical trade command: - -```bash -freqtrade trade --strategy FreqaiExampleStrategy --config config_freqai.example.json --training_timerange '20211220-20220120' -``` - -Where the user has now specified exactly which of the models from the sliding window -that they wish to run live using `--training_timerange` (typically this would be the most -recent model trained). As of right now, freqai will -not automatically retain itself, so the user needs to manually retrain and then -reload the config file with a new `--training_timerange` in order to update the -model. +TODO: Freqai is not automated for live yet. ## Data anylsis techniques @@ -208,12 +195,6 @@ $$ W_i = \exp(\frac{-i}{\alpha*n}) $$ where $W_i$ is the weight of data point $i$ in a total set of $n$ data points._ -`drop_features` tells Freqai to train the model on the user defined features, -followed by a feature importance evaluation where it drops the top and bottom -performing features (there is evidence to suggest the top features may not be -helpful in equity/crypto trading since the ultimate objective is to predict low -frequency patterns, source: numerai)._ - Finally, `period` defines the offset used for the `labels`. In the present example, the user is asking for `labels` that are 24 candles in the future. From 00ff0c9b917a28f55d676976736cd4e0e25a7bc9 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 12:54:49 +0200 Subject: [PATCH 010/130] ensure user defined timerange truncates final backtest so that we arent mismatching data lengths upon return to strategy. Rename DataHandler class to FreqaiDataKitchen --- .../{data_handler.py => data_kitchen.py} | 24 +++++++++++++++---- freqtrade/freqai/freqai_interface.py | 4 ++-- 2 files changed, 22 insertions(+), 6 deletions(-) rename freqtrade/freqai/{data_handler.py => data_kitchen.py} (96%) diff --git a/freqtrade/freqai/data_handler.py b/freqtrade/freqai/data_kitchen.py similarity index 96% rename from freqtrade/freqai/data_handler.py rename to freqtrade/freqai/data_kitchen.py index e58575970..1bea97697 100644 --- a/freqtrade/freqai/data_handler.py +++ b/freqtrade/freqai/data_kitchen.py @@ -22,7 +22,7 @@ SECONDS_IN_DAY = 86400 logger = logging.getLogger(__name__) -class DataHandler: +class FreqaiDataKitchen: """ Class designed to handle all the data for the IFreqaiModel class model. Functionalities include holding, saving, loading, and analyzing the data. @@ -291,32 +291,48 @@ class DataHandler: bt_period = bt_split * SECONDS_IN_DAY full_timerange = TimeRange.parse_timerange(tr) + config_timerange = TimeRange.parse_timerange(self.config["timerange"]) timerange_train = copy.deepcopy(full_timerange) timerange_backtest = copy.deepcopy(full_timerange) tr_training_list = [] tr_backtesting_list = [] first = True + # within_config_timerange = True while True: if not first: timerange_train.startts = timerange_train.startts + bt_period timerange_train.stopts = timerange_train.startts + train_period - # if a full training period doesnt fit, we stop - if timerange_train.stopts > full_timerange.stopts: - break + # make sure we finish with a full backtest + # if timerange_train.stopts > config_timerange.stopts - bt_period: + # within_config_timerange = False + # timerange_train.stopts = config_timerange.stopts - bt_period + # # if a full training period doesnt fit, we stop + # if timerange_train.stopts > full_timerange.stopts: + # break first = False start = datetime.datetime.utcfromtimestamp(timerange_train.startts) stop = datetime.datetime.utcfromtimestamp(timerange_train.stopts) tr_training_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")) # associated backtest period + if timerange_backtest.stopts > config_timerange.stopts: + timerange_backtest.stopts = config_timerange.stopts + timerange_backtest.startts = timerange_train.stopts timerange_backtest.stopts = timerange_backtest.startts + bt_period + start = datetime.datetime.utcfromtimestamp(timerange_backtest.startts) stop = datetime.datetime.utcfromtimestamp(timerange_backtest.stopts) tr_backtesting_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")) + # ensure we are predicting on exactly same amount of data as requested by user defined + # --timerange + if timerange_backtest.stopts == config_timerange.stopts: + break + + print(tr_training_list, tr_backtesting_list) return tr_training_list, tr_backtesting_list def slice_dataframe(self, tr: str, df: DataFrame) -> DataFrame: diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 62779a4e1..63e94383c 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -8,7 +8,7 @@ import numpy as np import pandas as pd from pandas import DataFrame -from freqtrade.freqai.data_handler import DataHandler +from freqtrade.freqai.data_kitchen import FreqaiDataKitchen pd.options.mode.chained_assignment = None @@ -55,7 +55,7 @@ class IFreqaiModel(ABC): :metadata: pair metadataa coming from strategy. """ self.pair = metadata["pair"] - self.dh = DataHandler(self.config, dataframe) + self.dh = FreqaiDataKitchen(self.config, dataframe) logger.info("going to train %s timeranges", len(self.dh.training_timeranges)) From 3020218096593105e38ce8993068431bda4e3dac Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 13:06:54 +0200 Subject: [PATCH 011/130] fix bug on backtest timerange --- freqtrade/freqai/data_kitchen.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 1bea97697..38d518e23 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -317,11 +317,14 @@ class FreqaiDataKitchen: tr_training_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")) # associated backtest period + + timerange_backtest.startts = timerange_train.stopts + if timerange_backtest.stopts > config_timerange.stopts: timerange_backtest.stopts = config_timerange.stopts - timerange_backtest.startts = timerange_train.stopts - timerange_backtest.stopts = timerange_backtest.startts + bt_period + else: + timerange_backtest.stopts = timerange_backtest.startts + bt_period start = datetime.datetime.utcfromtimestamp(timerange_backtest.startts) stop = datetime.datetime.utcfromtimestamp(timerange_backtest.stopts) From e9a7b68bc121a13131e6f08aed4477b993cb48e0 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 13:10:47 +0200 Subject: [PATCH 012/130] revert constants.py and add changes --- freqtrade/constants.py | 782 +++++++++++++++++++---------------------- 1 file changed, 370 insertions(+), 412 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index d988a164e..653fa8c67 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -8,133 +8,87 @@ from typing import List, Literal, Tuple from freqtrade.enums import CandleType -DEFAULT_CONFIG = "config.json" -DEFAULT_EXCHANGE = "bittrex" +DEFAULT_CONFIG = 'config.json' +DEFAULT_EXCHANGE = 'bittrex' PROCESS_THROTTLE_SECS = 5 # sec HYPEROPT_EPOCH = 100 # epochs RETRY_TIMEOUT = 30 # sec -TIMEOUT_UNITS = ["minutes", "seconds"] -EXPORT_OPTIONS = ["none", "trades", "signals"] -DEFAULT_DB_PROD_URL = "sqlite:///tradesv3.sqlite" -DEFAULT_DB_DRYRUN_URL = "sqlite:///tradesv3.dryrun.sqlite" -UNLIMITED_STAKE_AMOUNT = "unlimited" +TIMEOUT_UNITS = ['minutes', 'seconds'] +EXPORT_OPTIONS = ['none', 'trades', 'signals'] +DEFAULT_DB_PROD_URL = 'sqlite:///tradesv3.sqlite' +DEFAULT_DB_DRYRUN_URL = 'sqlite:///tradesv3.dryrun.sqlite' +UNLIMITED_STAKE_AMOUNT = 'unlimited' DEFAULT_AMOUNT_RESERVE_PERCENT = 0.05 -REQUIRED_ORDERTIF = ["entry", "exit"] -REQUIRED_ORDERTYPES = ["entry", "exit", "stoploss", "stoploss_on_exchange"] -PRICING_SIDES = ["ask", "bid", "same", "other"] -ORDERTYPE_POSSIBILITIES = ["limit", "market"] -ORDERTIF_POSSIBILITIES = ["gtc", "fok", "ioc"] -HYPEROPT_LOSS_BUILTIN = [ - "ShortTradeDurHyperOptLoss", - "OnlyProfitHyperOptLoss", - "SharpeHyperOptLoss", - "SharpeHyperOptLossDaily", - "SortinoHyperOptLoss", - "SortinoHyperOptLossDaily", - "CalmarHyperOptLoss", - "MaxDrawDownHyperOptLoss", - "MaxDrawDownRelativeHyperOptLoss", - "ProfitDrawDownHyperOptLoss", -] -AVAILABLE_PAIRLISTS = [ - "StaticPairList", - "VolumePairList", - "AgeFilter", - "OffsetFilter", - "PerformanceFilter", - "PrecisionFilter", - "PriceFilter", - "RangeStabilityFilter", - "ShuffleFilter", - "SpreadFilter", - "VolatilityFilter", -] -AVAILABLE_PROTECTIONS = ["CooldownPeriod", "LowProfitPairs", "MaxDrawdown", "StoplossGuard"] -AVAILABLE_DATAHANDLERS = ["json", "jsongz", "hdf5"] -BACKTEST_BREAKDOWNS = ["day", "week", "month"] -BACKTEST_CACHE_AGE = ["none", "day", "week", "month"] -BACKTEST_CACHE_DEFAULT = "day" +REQUIRED_ORDERTIF = ['entry', 'exit'] +REQUIRED_ORDERTYPES = ['entry', 'exit', 'stoploss', 'stoploss_on_exchange'] +PRICING_SIDES = ['ask', 'bid', 'same', 'other'] +ORDERTYPE_POSSIBILITIES = ['limit', 'market'] +ORDERTIF_POSSIBILITIES = ['gtc', 'fok', 'ioc'] +HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', + 'SharpeHyperOptLoss', 'SharpeHyperOptLossDaily', + 'SortinoHyperOptLoss', 'SortinoHyperOptLossDaily', + 'CalmarHyperOptLoss', + 'MaxDrawDownHyperOptLoss', 'MaxDrawDownRelativeHyperOptLoss', + 'ProfitDrawDownHyperOptLoss'] +AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', + 'AgeFilter', 'OffsetFilter', 'PerformanceFilter', + 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', + 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] +AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] +AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] +BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] +BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month'] +BACKTEST_CACHE_DEFAULT = 'day' DRY_RUN_WALLET = 1000 -DATETIME_PRINT_FORMAT = "%Y-%m-%d %H:%M:%S" +DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S' MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons -DEFAULT_DATAFRAME_COLUMNS = ["date", "open", "high", "low", "close", "volume"] +DEFAULT_DATAFRAME_COLUMNS = ['date', 'open', 'high', 'low', 'close', 'volume'] # Don't modify sequence of DEFAULT_TRADES_COLUMNS # it has wide consequences for stored trades files -DEFAULT_TRADES_COLUMNS = ["timestamp", "id", "type", "side", "price", "amount", "cost"] -TRADING_MODES = ["spot", "margin", "futures"] -MARGIN_MODES = ["cross", "isolated", ""] +DEFAULT_TRADES_COLUMNS = ['timestamp', 'id', 'type', 'side', 'price', 'amount', 'cost'] +TRADING_MODES = ['spot', 'margin', 'futures'] +MARGIN_MODES = ['cross', 'isolated', ''] -LAST_BT_RESULT_FN = ".last_result.json" -FTHYPT_FILEVERSION = "fthypt_fileversion" +LAST_BT_RESULT_FN = '.last_result.json' +FTHYPT_FILEVERSION = 'fthypt_fileversion' -USERPATH_HYPEROPTS = "hyperopts" -USERPATH_STRATEGIES = "strategies" -USERPATH_NOTEBOOKS = "notebooks" -USERPATH_FREQAIMODELS = "freqaimodels" +USERPATH_HYPEROPTS = 'hyperopts' +USERPATH_STRATEGIES = 'strategies' +USERPATH_NOTEBOOKS = 'notebooks' -TELEGRAM_SETTING_OPTIONS = ["on", "off", "silent"] -WEBHOOK_FORMAT_OPTIONS = ["form", "json", "raw"] +TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] +WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] -ENV_VAR_PREFIX = "FREQTRADE__" +ENV_VAR_PREFIX = 'FREQTRADE__' -NON_OPEN_EXCHANGE_STATES = ("cancelled", "canceled", "closed", "expired") +NON_OPEN_EXCHANGE_STATES = ('cancelled', 'canceled', 'closed', 'expired') # Define decimals per coin for outputs # Only used for outputs. DECIMAL_PER_COIN_FALLBACK = 3 # Should be low to avoid listing all possible FIAT's DECIMALS_PER_COIN = { - "BTC": 8, - "ETH": 5, + 'BTC': 8, + 'ETH': 5, } -DUST_PER_COIN = {"BTC": 0.0001, "ETH": 0.01} +DUST_PER_COIN = { + 'BTC': 0.0001, + 'ETH': 0.01 +} # Source files with destination directories within user-directory USER_DATA_FILES = { - "sample_strategy.py": USERPATH_STRATEGIES, - "sample_hyperopt_loss.py": USERPATH_HYPEROPTS, - "strategy_analysis_example.ipynb": USERPATH_NOTEBOOKS, + 'sample_strategy.py': USERPATH_STRATEGIES, + 'sample_hyperopt_loss.py': USERPATH_HYPEROPTS, + 'strategy_analysis_example.ipynb': USERPATH_NOTEBOOKS, } SUPPORTED_FIAT = [ - "AUD", - "BRL", - "CAD", - "CHF", - "CLP", - "CNY", - "CZK", - "DKK", - "EUR", - "GBP", - "HKD", - "HUF", - "IDR", - "ILS", - "INR", - "JPY", - "KRW", - "MXN", - "MYR", - "NOK", - "NZD", - "PHP", - "PKR", - "PLN", - "RUB", - "UAH", - "SEK", - "SGD", - "THB", - "TRY", - "TWD", - "ZAR", - "USD", - "BTC", - "ETH", - "XRP", - "LTC", - "BCH", + "AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "CZK", "DKK", + "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", + "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PKR", "PLN", + "RUB", "UAH", "SEK", "SGD", "THB", "TRY", "TWD", "ZAR", + "USD", "BTC", "ETH", "XRP", "LTC", "BCH" ] MINIMAL_CONFIG = { @@ -145,334 +99,338 @@ MINIMAL_CONFIG = { "key": "", "secret": "", "pair_whitelist": [], - "ccxt_async_config": {}, - }, + "ccxt_async_config": { + } + } } # Required json-schema for user specified config CONF_SCHEMA = { - "type": "object", - "properties": { - "max_open_trades": {"type": ["integer", "number"], "minimum": -1}, - "new_pairs_days": {"type": "integer", "default": 30}, - "timeframe": {"type": "string"}, - "stake_currency": {"type": "string"}, - "stake_amount": { - "type": ["number", "string"], - "minimum": 0.0001, - "pattern": UNLIMITED_STAKE_AMOUNT, + 'type': 'object', + 'properties': { + 'max_open_trades': {'type': ['integer', 'number'], 'minimum': -1}, + 'new_pairs_days': {'type': 'integer', 'default': 30}, + 'timeframe': {'type': 'string'}, + 'stake_currency': {'type': 'string'}, + 'stake_amount': { + 'type': ['number', 'string'], + 'minimum': 0.0001, + 'pattern': UNLIMITED_STAKE_AMOUNT }, - "tradable_balance_ratio": {"type": "number", "minimum": 0.0, "maximum": 1, "default": 0.99}, - "available_capital": { - "type": "number", - "minimum": 0, + 'tradable_balance_ratio': { + 'type': 'number', + 'minimum': 0.0, + 'maximum': 1, + 'default': 0.99 }, - "amend_last_stake_amount": {"type": "boolean", "default": False}, - "last_stake_amount_min_ratio": { - "type": "number", - "minimum": 0.0, - "maximum": 1.0, - "default": 0.5, + 'available_capital': { + 'type': 'number', + 'minimum': 0, }, - "fiat_display_currency": {"type": "string", "enum": SUPPORTED_FIAT}, - "dry_run": {"type": "boolean"}, - "dry_run_wallet": {"type": "number", "default": DRY_RUN_WALLET}, - "cancel_open_orders_on_exit": {"type": "boolean", "default": False}, - "process_only_new_candles": {"type": "boolean"}, - "minimal_roi": { - "type": "object", - "patternProperties": {"^[0-9.]+$": {"type": "number"}}, - "minProperties": 1, + 'amend_last_stake_amount': {'type': 'boolean', 'default': False}, + 'last_stake_amount_min_ratio': { + 'type': 'number', 'minimum': 0.0, 'maximum': 1.0, 'default': 0.5 }, - "amount_reserve_percent": {"type": "number", "minimum": 0.0, "maximum": 0.5}, - "stoploss": {"type": "number", "maximum": 0, "exclusiveMaximum": True, "minimum": -1}, - "trailing_stop": {"type": "boolean"}, - "trailing_stop_positive": {"type": "number", "minimum": 0, "maximum": 1}, - "trailing_stop_positive_offset": {"type": "number", "minimum": 0, "maximum": 1}, - "trailing_only_offset_is_reached": {"type": "boolean"}, - "use_exit_signal": {"type": "boolean"}, - "exit_profit_only": {"type": "boolean"}, - "exit_profit_offset": {"type": "number"}, - "ignore_roi_if_entry_signal": {"type": "boolean"}, - "ignore_buying_expired_candle_after": {"type": "number"}, - "trading_mode": {"type": "string", "enum": TRADING_MODES}, - "margin_mode": {"type": "string", "enum": MARGIN_MODES}, - "liquidation_buffer": {"type": "number", "minimum": 0.0, "maximum": 0.99}, - "backtest_breakdown": { - "type": "array", - "items": {"type": "string", "enum": BACKTEST_BREAKDOWNS}, - }, - "bot_name": {"type": "string"}, - "unfilledtimeout": { - "type": "object", - "properties": { - "entry": {"type": "number", "minimum": 1}, - "exit": {"type": "number", "minimum": 1}, - "exit_timeout_count": {"type": "number", "minimum": 0, "default": 0}, - "unit": {"type": "string", "enum": TIMEOUT_UNITS, "default": "minutes"}, + 'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT}, + 'dry_run': {'type': 'boolean'}, + 'dry_run_wallet': {'type': 'number', 'default': DRY_RUN_WALLET}, + 'cancel_open_orders_on_exit': {'type': 'boolean', 'default': False}, + 'process_only_new_candles': {'type': 'boolean'}, + 'minimal_roi': { + 'type': 'object', + 'patternProperties': { + '^[0-9.]+$': {'type': 'number'} }, + 'minProperties': 1 }, - "entry_pricing": { - "type": "object", - "properties": { - "price_last_balance": { - "type": "number", - "minimum": 0, - "maximum": 1, - "exclusiveMaximum": False, + 'amount_reserve_percent': {'type': 'number', 'minimum': 0.0, 'maximum': 0.5}, + 'stoploss': {'type': 'number', 'maximum': 0, 'exclusiveMaximum': True, 'minimum': -1}, + 'trailing_stop': {'type': 'boolean'}, + 'trailing_stop_positive': {'type': 'number', 'minimum': 0, 'maximum': 1}, + 'trailing_stop_positive_offset': {'type': 'number', 'minimum': 0, 'maximum': 1}, + 'trailing_only_offset_is_reached': {'type': 'boolean'}, + 'use_exit_signal': {'type': 'boolean'}, + 'exit_profit_only': {'type': 'boolean'}, + 'exit_profit_offset': {'type': 'number'}, + 'ignore_roi_if_entry_signal': {'type': 'boolean'}, + 'ignore_buying_expired_candle_after': {'type': 'number'}, + 'trading_mode': {'type': 'string', 'enum': TRADING_MODES}, + 'margin_mode': {'type': 'string', 'enum': MARGIN_MODES}, + 'liquidation_buffer': {'type': 'number', 'minimum': 0.0, 'maximum': 0.99}, + 'backtest_breakdown': { + 'type': 'array', + 'items': {'type': 'string', 'enum': BACKTEST_BREAKDOWNS} + }, + 'bot_name': {'type': 'string'}, + 'unfilledtimeout': { + 'type': 'object', + 'properties': { + 'entry': {'type': 'number', 'minimum': 1}, + 'exit': {'type': 'number', 'minimum': 1}, + 'exit_timeout_count': {'type': 'number', 'minimum': 0, 'default': 0}, + 'unit': {'type': 'string', 'enum': TIMEOUT_UNITS, 'default': 'minutes'} + } + }, + 'entry_pricing': { + 'type': 'object', + 'properties': { + 'price_last_balance': { + 'type': 'number', + 'minimum': 0, + 'maximum': 1, + 'exclusiveMaximum': False, }, - "price_side": {"type": "string", "enum": PRICING_SIDES, "default": "same"}, - "use_order_book": {"type": "boolean"}, - "order_book_top": { - "type": "integer", - "minimum": 1, - "maximum": 50, - }, - "check_depth_of_market": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "bids_to_ask_delta": {"type": "number", "minimum": 0}, - }, + 'price_side': {'type': 'string', 'enum': PRICING_SIDES, 'default': 'same'}, + 'use_order_book': {'type': 'boolean'}, + 'order_book_top': {'type': 'integer', 'minimum': 1, 'maximum': 50, }, + 'check_depth_of_market': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'bids_to_ask_delta': {'type': 'number', 'minimum': 0}, + } }, }, - "required": ["price_side"], + 'required': ['price_side'] }, - "exit_pricing": { - "type": "object", - "properties": { - "price_side": {"type": "string", "enum": PRICING_SIDES, "default": "same"}, - "price_last_balance": { - "type": "number", - "minimum": 0, - "maximum": 1, - "exclusiveMaximum": False, - }, - "use_order_book": {"type": "boolean"}, - "order_book_top": { - "type": "integer", - "minimum": 1, - "maximum": 50, + 'exit_pricing': { + 'type': 'object', + 'properties': { + 'price_side': {'type': 'string', 'enum': PRICING_SIDES, 'default': 'same'}, + 'price_last_balance': { + 'type': 'number', + 'minimum': 0, + 'maximum': 1, + 'exclusiveMaximum': False, }, + 'use_order_book': {'type': 'boolean'}, + 'order_book_top': {'type': 'integer', 'minimum': 1, 'maximum': 50, }, }, - "required": ["price_side"], + 'required': ['price_side'] }, - "custom_price_max_distance_ratio": {"type": "number", "minimum": 0.0}, - "order_types": { - "type": "object", - "properties": { - "entry": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, - "exit": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, - "force_exit": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, - "force_entry": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, - "emergency_exit": { - "type": "string", - "enum": ORDERTYPE_POSSIBILITIES, - "default": "market", - }, - "stoploss": {"type": "string", "enum": ORDERTYPE_POSSIBILITIES}, - "stoploss_on_exchange": {"type": "boolean"}, - "stoploss_on_exchange_interval": {"type": "number"}, - "stoploss_on_exchange_limit_ratio": { - "type": "number", - "minimum": 0.0, - "maximum": 1.0, - }, + 'custom_price_max_distance_ratio': { + 'type': 'number', 'minimum': 0.0 + }, + 'order_types': { + 'type': 'object', + 'properties': { + 'entry': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, + 'exit': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, + 'force_exit': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, + 'force_entry': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, + 'emergency_exit': { + 'type': 'string', + 'enum': ORDERTYPE_POSSIBILITIES, + 'default': 'market'}, + 'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, + 'stoploss_on_exchange': {'type': 'boolean'}, + 'stoploss_on_exchange_interval': {'type': 'number'}, + 'stoploss_on_exchange_limit_ratio': {'type': 'number', 'minimum': 0.0, + 'maximum': 1.0} }, - "required": ["entry", "exit", "stoploss", "stoploss_on_exchange"], + 'required': ['entry', 'exit', 'stoploss', 'stoploss_on_exchange'] }, - "order_time_in_force": { - "type": "object", - "properties": { - "entry": {"type": "string", "enum": ORDERTIF_POSSIBILITIES}, - "exit": {"type": "string", "enum": ORDERTIF_POSSIBILITIES}, + 'order_time_in_force': { + 'type': 'object', + 'properties': { + 'entry': {'type': 'string', 'enum': ORDERTIF_POSSIBILITIES}, + 'exit': {'type': 'string', 'enum': ORDERTIF_POSSIBILITIES} }, - "required": REQUIRED_ORDERTIF, + 'required': REQUIRED_ORDERTIF }, - "exchange": {"$ref": "#/definitions/exchange"}, - "edge": {"$ref": "#/definitions/edge"}, - "experimental": { - "type": "object", - "properties": {"block_bad_exchanges": {"type": "boolean"}}, + 'exchange': {'$ref': '#/definitions/exchange'}, + 'edge': {'$ref': '#/definitions/edge'}, + 'experimental': { + 'type': 'object', + 'properties': { + 'block_bad_exchanges': {'type': 'boolean'} + } }, - "pairlists": { - "type": "array", - "items": { - "type": "object", - "properties": { - "method": {"type": "string", "enum": AVAILABLE_PAIRLISTS}, + 'pairlists': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'method': {'type': 'string', 'enum': AVAILABLE_PAIRLISTS}, }, - "required": ["method"], - }, + 'required': ['method'], + } }, - "protections": { - "type": "array", - "items": { - "type": "object", - "properties": { - "method": {"type": "string", "enum": AVAILABLE_PROTECTIONS}, - "stop_duration": {"type": "number", "minimum": 0.0}, - "stop_duration_candles": {"type": "number", "minimum": 0}, - "trade_limit": {"type": "number", "minimum": 1}, - "lookback_period": {"type": "number", "minimum": 1}, - "lookback_period_candles": {"type": "number", "minimum": 1}, + 'protections': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'method': {'type': 'string', 'enum': AVAILABLE_PROTECTIONS}, + 'stop_duration': {'type': 'number', 'minimum': 0.0}, + 'stop_duration_candles': {'type': 'number', 'minimum': 0}, + 'trade_limit': {'type': 'number', 'minimum': 1}, + 'lookback_period': {'type': 'number', 'minimum': 1}, + 'lookback_period_candles': {'type': 'number', 'minimum': 1}, }, - "required": ["method"], - }, + 'required': ['method'], + } }, - "telegram": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "token": {"type": "string"}, - "chat_id": {"type": "string"}, - "balance_dust_level": {"type": "number", "minimum": 0.0}, - "notification_settings": { - "type": "object", - "default": {}, - "properties": { - "status": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, - "warning": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, - "startup": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, - "entry": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, - "entry_cancel": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, - "entry_fill": { - "type": "string", - "enum": TELEGRAM_SETTING_OPTIONS, - "default": "off", + 'telegram': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'token': {'type': 'string'}, + 'chat_id': {'type': 'string'}, + 'balance_dust_level': {'type': 'number', 'minimum': 0.0}, + 'notification_settings': { + 'type': 'object', + 'default': {}, + 'properties': { + 'status': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, + 'warning': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, + 'startup': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, + 'entry': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, + 'entry_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, + 'entry_fill': {'type': 'string', + 'enum': TELEGRAM_SETTING_OPTIONS, + 'default': 'off' + }, + 'exit': { + 'type': ['string', 'object'], + 'additionalProperties': { + 'type': 'string', + 'enum': TELEGRAM_SETTING_OPTIONS + } }, - "exit": { - "type": ["string", "object"], - "additionalProperties": { - "type": "string", - "enum": TELEGRAM_SETTING_OPTIONS, - }, + 'exit_cancel': {'type': 'string', 'enum': TELEGRAM_SETTING_OPTIONS}, + 'exit_fill': { + 'type': 'string', + 'enum': TELEGRAM_SETTING_OPTIONS, + 'default': 'off' }, - "exit_cancel": {"type": "string", "enum": TELEGRAM_SETTING_OPTIONS}, - "exit_fill": { - "type": "string", - "enum": TELEGRAM_SETTING_OPTIONS, - "default": "off", + 'protection_trigger': { + 'type': 'string', + 'enum': TELEGRAM_SETTING_OPTIONS, + 'default': 'off' }, - "protection_trigger": { - "type": "string", - "enum": TELEGRAM_SETTING_OPTIONS, - "default": "off", + 'protection_trigger_global': { + 'type': 'string', + 'enum': TELEGRAM_SETTING_OPTIONS, }, - "protection_trigger_global": { - "type": "string", - "enum": TELEGRAM_SETTING_OPTIONS, - }, - }, + } }, - "reload": {"type": "boolean"}, + 'reload': {'type': 'boolean'}, }, - "required": ["enabled", "token", "chat_id"], + 'required': ['enabled', 'token', 'chat_id'], }, - "webhook": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "url": {"type": "string"}, - "format": {"type": "string", "enum": WEBHOOK_FORMAT_OPTIONS, "default": "form"}, - "retries": {"type": "integer", "minimum": 0}, - "retry_delay": {"type": "number", "minimum": 0}, - "webhookentry": {"type": "object"}, - "webhookentrycancel": {"type": "object"}, - "webhookentryfill": {"type": "object"}, - "webhookexit": {"type": "object"}, - "webhookexitcancel": {"type": "object"}, - "webhookexitfill": {"type": "object"}, - "webhookstatus": {"type": "object"}, + 'webhook': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'url': {'type': 'string'}, + 'format': {'type': 'string', 'enum': WEBHOOK_FORMAT_OPTIONS, 'default': 'form'}, + 'retries': {'type': 'integer', 'minimum': 0}, + 'retry_delay': {'type': 'number', 'minimum': 0}, + 'webhookentry': {'type': 'object'}, + 'webhookentrycancel': {'type': 'object'}, + 'webhookentryfill': {'type': 'object'}, + 'webhookexit': {'type': 'object'}, + 'webhookexitcancel': {'type': 'object'}, + 'webhookexitfill': {'type': 'object'}, + 'webhookstatus': {'type': 'object'}, }, }, - "api_server": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "listen_ip_address": {"format": "ipv4"}, - "listen_port": {"type": "integer", "minimum": 1024, "maximum": 65535}, - "username": {"type": "string"}, - "password": {"type": "string"}, - "jwt_secret_key": {"type": "string"}, - "CORS_origins": {"type": "array", "items": {"type": "string"}}, - "verbosity": {"type": "string", "enum": ["error", "info"]}, + 'api_server': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'listen_ip_address': {'format': 'ipv4'}, + 'listen_port': { + 'type': 'integer', + 'minimum': 1024, + 'maximum': 65535 + }, + 'username': {'type': 'string'}, + 'password': {'type': 'string'}, + 'jwt_secret_key': {'type': 'string'}, + 'CORS_origins': {'type': 'array', 'items': {'type': 'string'}}, + 'verbosity': {'type': 'string', 'enum': ['error', 'info']}, }, - "required": ["enabled", "listen_ip_address", "listen_port", "username", "password"], + 'required': ['enabled', 'listen_ip_address', 'listen_port', 'username', 'password'] }, - "db_url": {"type": "string"}, - "export": {"type": "string", "enum": EXPORT_OPTIONS, "default": "trades"}, - "disableparamexport": {"type": "boolean"}, - "initial_state": {"type": "string", "enum": ["running", "stopped"]}, - "force_entry_enable": {"type": "boolean"}, - "disable_dataframe_checks": {"type": "boolean"}, - "internals": { - "type": "object", - "default": {}, - "properties": { - "process_throttle_secs": {"type": "integer"}, - "interval": {"type": "integer"}, - "sd_notify": {"type": "boolean"}, - }, + 'db_url': {'type': 'string'}, + 'export': {'type': 'string', 'enum': EXPORT_OPTIONS, 'default': 'trades'}, + 'disableparamexport': {'type': 'boolean'}, + 'initial_state': {'type': 'string', 'enum': ['running', 'stopped']}, + 'force_entry_enable': {'type': 'boolean'}, + 'disable_dataframe_checks': {'type': 'boolean'}, + 'internals': { + 'type': 'object', + 'default': {}, + 'properties': { + 'process_throttle_secs': {'type': 'integer'}, + 'interval': {'type': 'integer'}, + 'sd_notify': {'type': 'boolean'}, + } }, - "dataformat_ohlcv": {"type": "string", "enum": AVAILABLE_DATAHANDLERS, "default": "json"}, - "dataformat_trades": { - "type": "string", - "enum": AVAILABLE_DATAHANDLERS, - "default": "jsongz", + 'dataformat_ohlcv': { + 'type': 'string', + 'enum': AVAILABLE_DATAHANDLERS, + 'default': 'json' }, - "position_adjustment_enable": {"type": "boolean"}, - "max_entry_position_adjustment": {"type": ["integer", "number"], "minimum": -1}, + 'dataformat_trades': { + 'type': 'string', + 'enum': AVAILABLE_DATAHANDLERS, + 'default': 'jsongz' + }, + 'position_adjustment_enable': {'type': 'boolean'}, + 'max_entry_position_adjustment': {'type': ['integer', 'number'], 'minimum': -1}, }, - "definitions": { - "exchange": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "sandbox": {"type": "boolean", "default": False}, - "key": {"type": "string", "default": ""}, - "secret": {"type": "string", "default": ""}, - "password": {"type": "string", "default": ""}, - "uid": {"type": "string"}, - "pair_whitelist": { - "type": "array", - "items": { - "type": "string", + 'definitions': { + 'exchange': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'sandbox': {'type': 'boolean', 'default': False}, + 'key': {'type': 'string', 'default': ''}, + 'secret': {'type': 'string', 'default': ''}, + 'password': {'type': 'string', 'default': ''}, + 'uid': {'type': 'string'}, + 'pair_whitelist': { + 'type': 'array', + 'items': { + 'type': 'string', }, - "uniqueItems": True, + 'uniqueItems': True }, - "pair_blacklist": { - "type": "array", - "items": { - "type": "string", + 'pair_blacklist': { + 'type': 'array', + 'items': { + 'type': 'string', }, - "uniqueItems": True, + 'uniqueItems': True }, - "unknown_fee_rate": {"type": "number"}, - "outdated_offset": {"type": "integer", "minimum": 1}, - "markets_refresh_interval": {"type": "integer"}, - "ccxt_config": {"type": "object"}, - "ccxt_async_config": {"type": "object"}, + 'unknown_fee_rate': {'type': 'number'}, + 'outdated_offset': {'type': 'integer', 'minimum': 1}, + 'markets_refresh_interval': {'type': 'integer'}, + 'ccxt_config': {'type': 'object'}, + 'ccxt_async_config': {'type': 'object'} }, - "required": ["name"], + 'required': ['name'] }, - "edge": { - "type": "object", - "properties": { - "enabled": {"type": "boolean"}, - "process_throttle_secs": {"type": "integer", "minimum": 600}, - "calculate_since_number_of_days": {"type": "integer"}, - "allowed_risk": {"type": "number"}, - "stoploss_range_min": {"type": "number"}, - "stoploss_range_max": {"type": "number"}, - "stoploss_range_step": {"type": "number"}, - "minimum_winrate": {"type": "number"}, - "minimum_expectancy": {"type": "number"}, - "min_trade_number": {"type": "number"}, - "max_trade_duration_minute": {"type": "integer"}, - "remove_pumps": {"type": "boolean"}, + 'edge': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'process_throttle_secs': {'type': 'integer', 'minimum': 600}, + 'calculate_since_number_of_days': {'type': 'integer'}, + 'allowed_risk': {'type': 'number'}, + 'stoploss_range_min': {'type': 'number'}, + 'stoploss_range_max': {'type': 'number'}, + 'stoploss_range_step': {'type': 'number'}, + 'minimum_winrate': {'type': 'number'}, + 'minimum_expectancy': {'type': 'number'}, + 'min_trade_number': {'type': 'number'}, + 'max_trade_duration_minute': {'type': 'integer'}, + 'remove_pumps': {'type': 'boolean'} }, - "required": ["process_throttle_secs", "allowed_risk"], + 'required': ['process_throttle_secs', 'allowed_risk'] }, "freqai": { "type": "object", @@ -516,43 +474,43 @@ CONF_SCHEMA = { } SCHEMA_TRADE_REQUIRED = [ - "exchange", - "timeframe", - "max_open_trades", - "stake_currency", - "stake_amount", - "tradable_balance_ratio", - "last_stake_amount_min_ratio", - "dry_run", - "dry_run_wallet", - "exit_pricing", - "entry_pricing", - "stoploss", - "minimal_roi", - "internals", - "dataformat_ohlcv", - "dataformat_trades", + 'exchange', + 'timeframe', + 'max_open_trades', + 'stake_currency', + 'stake_amount', + 'tradable_balance_ratio', + 'last_stake_amount_min_ratio', + 'dry_run', + 'dry_run_wallet', + 'exit_pricing', + 'entry_pricing', + 'stoploss', + 'minimal_roi', + 'internals', + 'dataformat_ohlcv', + 'dataformat_trades', ] SCHEMA_BACKTEST_REQUIRED = [ - "exchange", - "max_open_trades", - "stake_currency", - "stake_amount", - "dry_run_wallet", - "dataformat_ohlcv", - "dataformat_trades", + 'exchange', + 'max_open_trades', + 'stake_currency', + 'stake_amount', + 'dry_run_wallet', + 'dataformat_ohlcv', + 'dataformat_trades', ] SCHEMA_BACKTEST_REQUIRED_FINAL = SCHEMA_BACKTEST_REQUIRED + [ - "stoploss", - "minimal_roi", + 'stoploss', + 'minimal_roi', ] SCHEMA_MINIMAL_REQUIRED = [ - "exchange", - "dry_run", - "dataformat_ohlcv", - "dataformat_trades", + 'exchange', + 'dry_run', + 'dataformat_ohlcv', + 'dataformat_trades', ] CANCEL_REASON = { @@ -576,4 +534,4 @@ TradeList = List[List] LongShort = Literal['long', 'short'] EntryExit = Literal['entry', 'exit'] -BuySell = Literal['buy', 'sell'] +BuySell = Literal['buy', 'sell'] \ No newline at end of file From b03c7b514ddca28f4b0f49807d0ac42d717d1f0c Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 13:15:06 +0200 Subject: [PATCH 013/130] optional style for interfacing freqai with backtesting --- freqtrade/freqai/data_kitchen.py | 9 +-------- freqtrade/optimize/backtesting.py | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 38d518e23..600f82e21 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -304,13 +304,6 @@ class FreqaiDataKitchen: timerange_train.startts = timerange_train.startts + bt_period timerange_train.stopts = timerange_train.startts + train_period - # make sure we finish with a full backtest - # if timerange_train.stopts > config_timerange.stopts - bt_period: - # within_config_timerange = False - # timerange_train.stopts = config_timerange.stopts - bt_period - # # if a full training period doesnt fit, we stop - # if timerange_train.stopts > full_timerange.stopts: - # break first = False start = datetime.datetime.utcfromtimestamp(timerange_train.startts) stop = datetime.datetime.utcfromtimestamp(timerange_train.stopts) @@ -335,7 +328,7 @@ class FreqaiDataKitchen: if timerange_backtest.stopts == config_timerange.stopts: break - print(tr_training_list, tr_backtesting_list) + # print(tr_training_list, tr_backtesting_list) return tr_training_list, tr_backtesting_list def slice_dataframe(self, tr: str, df: DataFrame) -> DataFrame: diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index f976b1238..a753a3b07 100755 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -204,7 +204,7 @@ class Backtesting: """ self.progress.init_step(BacktestState.DATALOAD, 1) - if self.config['freqai']['train_period'] > 0: + if self.config.get('freqai', {}).get('train_period') > 0: self.required_startup += int((self.config['freqai']['train_period'] * 86400) / timeframe_to_seconds(self.config['timeframe'])) logger.info("Increasing startup_candle_count for freqai to %s", self.required_startup) From b08c0888bbfde75171057004dffd1c871930a536 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 13:35:01 +0200 Subject: [PATCH 014/130] add USERPATH_FREQAIMODELS, remove return values from @abstract methods --- freqtrade/constants.py | 1 + freqtrade/freqai/freqai_interface.py | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 653fa8c67..c19a71c61 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -55,6 +55,7 @@ FTHYPT_FILEVERSION = 'fthypt_fileversion' USERPATH_HYPEROPTS = 'hyperopts' USERPATH_STRATEGIES = 'strategies' USERPATH_NOTEBOOKS = 'notebooks' +USERPATH_FREQAIMODELS = 'freqaimodels' TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 63e94383c..081e69de4 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -110,8 +110,6 @@ class IFreqaiModel(ABC): :model: Trained model which can be used to inference (self.predict) """ - return Any - @abstractmethod def fit(self) -> Any: """ @@ -123,8 +121,6 @@ class IFreqaiModel(ABC): all the training and test data/labels. """ - return Any - @abstractmethod def predict(self, dataframe: DataFrame) -> Tuple[np.array, np.array]: """ @@ -136,8 +132,6 @@ class IFreqaiModel(ABC): data (NaNs) or felt uncertain about data (PCA and DI index) """ - return np.array([]), np.array([]) - def model_exists(self, pair: str, training_timerange: str) -> bool: """ Given a pair and path, check if a model already exists From f653ace24bda2d0b900fdfad3a810242cd98cc48 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 15:10:11 +0200 Subject: [PATCH 015/130] another attempt at fixing datalength bug --- freqtrade/freqai/data_kitchen.py | 29 +++++++++++++++------------- freqtrade/freqai/freqai_interface.py | 5 ++++- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 600f82e21..eac5eac30 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -39,6 +39,10 @@ class FreqaiDataKitchen: self.do_predict = np.array([]) self.target_mean = np.array([]) self.target_std = np.array([]) + self.full_predictions = np.array([]) + self.full_do_predict = np.array([]) + self.full_target_mean = np.array([]) + self.full_target_std = np.array([]) self.model_path = Path() self.model_filename = "" @@ -313,12 +317,11 @@ class FreqaiDataKitchen: timerange_backtest.startts = timerange_train.stopts + timerange_backtest.stopts = timerange_backtest.startts + bt_period + if timerange_backtest.stopts > config_timerange.stopts: timerange_backtest.stopts = config_timerange.stopts - else: - timerange_backtest.stopts = timerange_backtest.startts + bt_period - start = datetime.datetime.utcfromtimestamp(timerange_backtest.startts) stop = datetime.datetime.utcfromtimestamp(timerange_backtest.stopts) tr_backtesting_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")) @@ -328,7 +331,7 @@ class FreqaiDataKitchen: if timerange_backtest.stopts == config_timerange.stopts: break - # print(tr_training_list, tr_backtesting_list) + print(tr_training_list, tr_backtesting_list) return tr_training_list, tr_backtesting_list def slice_dataframe(self, tr: str, df: DataFrame) -> DataFrame: @@ -536,10 +539,10 @@ class FreqaiDataKitchen: ones = np.ones(len_dataframe) s_mean, s_std = ones * self.data["s_mean"], ones * self.data["s_std"] - self.predictions = np.append(self.predictions, predictions) - self.do_predict = np.append(self.do_predict, do_predict) - self.target_mean = np.append(self.target_mean, s_mean) - self.target_std = np.append(self.target_std, s_std) + self.full_predictions = np.append(self.full_predictions, predictions) + self.full_do_predict = np.append(self.full_do_predict, do_predict) + self.full_target_mean = np.append(self.full_target_mean, s_mean) + self.full_target_std = np.append(self.full_target_std, s_std) return @@ -549,11 +552,11 @@ class FreqaiDataKitchen: when it goes back to the strategy. These rows are not included in the backtest. """ - filler = np.zeros(len_dataframe - len(self.predictions)) # startup_candle_count - self.predictions = np.append(filler, self.predictions) - self.do_predict = np.append(filler, self.do_predict) - self.target_mean = np.append(filler, self.target_mean) - self.target_std = np.append(filler, self.target_std) + filler = np.zeros(len_dataframe - len(self.full_predictions)) # startup_candle_count + self.full_predictions = np.append(filler, self.full_predictions) + self.full_do_predict = np.append(filler, self.full_do_predict) + self.full_target_mean = np.append(filler, self.full_target_mean) + self.full_target_std = np.append(filler, self.full_target_std) return diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 081e69de4..002596fee 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -84,10 +84,13 @@ class IFreqaiModel(ABC): preds, do_preds = self.predict(dataframe_backtest) self.dh.append_predictions(preds, do_preds, len(dataframe_backtest)) + print('predictions', len(self.dh.full_predictions), + 'do_predict', len(self.dh.full_do_predict)) self.dh.fill_predictions(len(dataframe)) - return self.dh.predictions, self.dh.do_predict, self.dh.target_mean, self.dh.target_std + return (self.dh.full_predictions, self.dh.full_do_predict, + self.dh.full_target_mean, self.dh.full_target_std) def make_labels(self, dataframe: DataFrame) -> DataFrame: """ From aae233bd6c19e80f8c23a930e3913b2781c455f3 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 15:24:45 +0200 Subject: [PATCH 016/130] try passing the check tests --- freqtrade/optimize/backtesting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index a753a3b07..add864a67 100755 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -204,9 +204,9 @@ class Backtesting: """ self.progress.init_step(BacktestState.DATALOAD, 1) - if self.config.get('freqai', {}).get('train_period') > 0: - self.required_startup += int((self.config['freqai']['train_period'] * 86400) / - timeframe_to_seconds(self.config['timeframe'])) + if self.config.get('freqai') is not None: + self.required_startup += int((self.config.get('freqai', {}).get('train_period') * + 86400) / timeframe_to_seconds(self.config['timeframe'])) logger.info("Increasing startup_candle_count for freqai to %s", self.required_startup) self.config['startup_candle_count'] = self.required_startup From a4f5811a5b1cdb030af85ac63135e331d4ac83fe Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 15:36:15 +0200 Subject: [PATCH 017/130] fix flake8 issue in arguments.py --- freqtrade/commands/arguments.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index f47748502..bdd6e4fb4 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -12,7 +12,8 @@ from freqtrade.constants import DEFAULT_CONFIG ARGS_COMMON = ["verbosity", "logfile", "version", "config", "datadir", "user_data_dir"] -ARGS_STRATEGY = ["strategy", "strategy_path", "recursive_strategy_search", "freqaimodel", "freqaimodel_path"] +ARGS_STRATEGY = ["strategy", "strategy_path", "recursive_strategy_search", "freqaimodel", + "freqaimodel_path"] ARGS_TRADE = ["db_url", "sd_notify", "dry_run", "dry_run_wallet", "fee"] From 178c2014b044632c87c9295b37c7ef861fb0cf37 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 6 May 2022 16:20:52 +0200 Subject: [PATCH 018/130] appease mypy --- freqtrade/freqai/data_kitchen.py | 23 +++++++++++++---------- freqtrade/freqai/freqai_interface.py | 16 ++++++++++++---- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index eac5eac30..b2ea71984 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, Dict, List, Tuple import numpy as np +import numpy.typing as npt import pandas as pd from joblib import dump, load from pandas import DataFrame @@ -35,14 +36,14 @@ class FreqaiDataKitchen: self.data_dictionary: Dict[Any, Any] = {} self.config = config self.freqai_config = config["freqai"] - self.predictions = np.array([]) - self.do_predict = np.array([]) - self.target_mean = np.array([]) - self.target_std = np.array([]) - self.full_predictions = np.array([]) - self.full_do_predict = np.array([]) - self.full_target_mean = np.array([]) - self.full_target_std = np.array([]) + self.predictions: npt.ArrayLike = np.array([]) + self.do_predict: npt.ArrayLike = np.array([]) + self.target_mean: npt.ArrayLike = np.array([]) + self.target_std: npt.ArrayLike = np.array([]) + self.full_predictions: npt.ArrayLike = np.array([]) + self.full_do_predict: npt.ArrayLike = np.array([]) + self.full_target_mean: npt.ArrayLike = np.array([]) + self.full_target_std: npt.ArrayLike = np.array([]) self.model_path = Path() self.model_filename = "" @@ -123,6 +124,7 @@ class FreqaiDataKitchen: :labels: cleaned labels ready to be split. """ + weights: npt.ArrayLike if self.config["freqai"]["feature_parameters"]["weight_factor"] > 0: weights = self.set_weights_higher_recent(len(filtered_dataframe)) else: @@ -519,12 +521,13 @@ class FreqaiDataKitchen: self.do_predict += do_predict self.do_predict -= 1 - def set_weights_higher_recent(self, num_weights: int) -> int: + def set_weights_higher_recent(self, num_weights: int) -> npt.ArrayLike: """ Set weights so that recent data is more heavily weighted during training than older data. """ - weights = np.zeros(num_weights) + + weights = np.zeros_like(num_weights) for i in range(1, len(weights)): weights[len(weights) - i] = np.exp( -i / (self.config["freqai"]["feature_parameters"]["weight_factor"] * num_weights) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 002596fee..16b6fd9f9 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -4,10 +4,12 @@ from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Dict, Tuple -import numpy as np +import numpy.typing as npt import pandas as pd from pandas import DataFrame +from freqtrade.data.dataprovider import DataProvider +from freqtrade.enums import RunMode from freqtrade.freqai.data_kitchen import FreqaiDataKitchen @@ -37,8 +39,9 @@ class IFreqaiModel(ABC): self.current_time = None self.model = None self.predictions = None + self.live_trained_timerange = None - def start(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def start(self, dataframe: DataFrame, metadata: dict, dp: DataProvider) -> DataFrame: """ Entry point to the FreqaiModel, it will train a new model if necesssary before making the prediction. @@ -57,6 +60,9 @@ class IFreqaiModel(ABC): self.pair = metadata["pair"] self.dh = FreqaiDataKitchen(self.config, dataframe) + if dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE): + logger.info('testing live') + logger.info("going to train %s timeranges", len(self.dh.training_timeranges)) # Loop enforcing the sliding window training/backtesting paragigm @@ -99,7 +105,7 @@ class IFreqaiModel(ABC): :dataframe: the full dataframe for the present training period """ - return dataframe + return @abstractmethod def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Any: @@ -124,8 +130,10 @@ class IFreqaiModel(ABC): all the training and test data/labels. """ + return + @abstractmethod - def predict(self, dataframe: DataFrame) -> Tuple[np.array, np.array]: + def predict(self, dataframe: DataFrame) -> Tuple[npt.ArrayLike, npt.ArrayLike]: """ Filter the prediction features data and predict with it. :param: unfiltered_dataframe: Full dataframe for the current backtest period. From 22bd5556ed21c6483e2b5ebb542e4ea0efa9df0f Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 9 May 2022 15:25:00 +0200 Subject: [PATCH 019/130] add self-retraining functionality for live/dry --- config_examples/config_freqai.example.json | 7 +- freqtrade/constants.py | 2 + freqtrade/freqai/data_kitchen.py | 150 +++++++++++++++--- freqtrade/freqai/freqai_interface.py | 58 ++++++- freqtrade/strategy/interface.py | 16 ++ freqtrade/templates/ExamplePredictionModel.py | 8 +- freqtrade/templates/FreqaiExampleStrategy.py | 21 ++- 7 files changed, 218 insertions(+), 44 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 5bd4de6c4..351585d17 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -13,7 +13,7 @@ "exit": 30 }, "exchange": { - "name": "ftx", + "name": "binance", "key": "", "secret": "", "ccxt_config": { @@ -55,7 +55,9 @@ ], "train_period": 30, "backtest_period": 7, - "identifier": "example", + "identifier": "livetest5", + "live_trained_timerange": "20220330-20220429", + "live_full_backtestrange": "20220302-20220501", "base_features": [ "rsi", "close_over_20sma", @@ -68,6 +70,7 @@ "macd" ], "corr_pairlist": [ + "BTC/USDT", "ETH/USDT", "LINK/USDT", "DOT/USDT" diff --git a/freqtrade/constants.py b/freqtrade/constants.py index c19a71c61..0dc355914 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -440,6 +440,8 @@ CONF_SCHEMA = { "train_period": {"type": "integer", "default": 0}, "backtest_period": {"type": "integer", "default": 7}, "identifier": {"type": "str", "default": "example"}, + "live_trained_timerange": {"type": "str"}, + "live_full_backtestrange": {"type": "str"}, "base_features": {"type": "list"}, "corr_pairlist": {"type": "list"}, "feature_parameters": { diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index b2ea71984..7b6a65a59 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -16,6 +16,10 @@ from sklearn.metrics.pairwise import pairwise_distances from sklearn.model_selection import train_test_split from freqtrade.configuration import TimeRange +from freqtrade.data.history import load_pair_history +from freqtrade.data.history.history_utils import refresh_backtest_ohlcv_data +from freqtrade.resolvers import ExchangeResolver +from freqtrade.strategy.interface import IStrategy SECONDS_IN_DAY = 86400 @@ -30,7 +34,7 @@ class FreqaiDataKitchen: author: Robert Caulk, rob.caulk@gmail.com """ - def __init__(self, config: Dict[str, Any], dataframe: DataFrame): + def __init__(self, config: Dict[str, Any], dataframe: DataFrame, live: bool = False): self.full_dataframe = dataframe self.data: Dict[Any, Any] = {} self.data_dictionary: Dict[Any, Any] = {} @@ -45,17 +49,29 @@ class FreqaiDataKitchen: self.full_target_mean: npt.ArrayLike = np.array([]) self.full_target_std: npt.ArrayLike = np.array([]) self.model_path = Path() - self.model_filename = "" + self.model_filename: str = "" - self.full_timerange = self.create_fulltimerange( - self.config["timerange"], self.freqai_config["train_period"] - ) + if not live: + self.full_timerange = self.create_fulltimerange(self.config["timerange"], + self.freqai_config["train_period"] + ) - (self.training_timeranges, self.backtesting_timeranges) = self.split_timerange( - self.full_timerange, - config["freqai"]["train_period"], - config["freqai"]["backtest_period"], - ) + (self.training_timeranges, self.backtesting_timeranges) = self.split_timerange( + self.full_timerange, + config["freqai"]["train_period"], + config["freqai"]["backtest_period"], + ) + + def set_paths(self) -> None: + self.full_path = Path(self.config['user_data_dir'] / + "models" / + str(self.freqai_config['live_full_backtestrange'] + + self.freqai_config['identifier'])) + + self.model_path = Path(self.full_path / str("sub-train" + "-" + + str(self.freqai_config['live_trained_timerange']))) + + return def save_data(self, model: Any) -> None: """ @@ -187,10 +203,10 @@ class FreqaiDataKitchen: labels = labels[ (drop_index == 0) & (drop_index_labels == 0) ] # assuming the labels depend entirely on the dataframe here. - logger.info( - "dropped %s training points due to NaNs, ensure all historical data downloaded", - len(unfiltered_dataframe) - len(filtered_dataframe), - ) + # logger.info( + # "dropped %s training points due to NaNs, ensure all historical data downloaded", + # len(unfiltered_dataframe) - len(filtered_dataframe), + # ) self.data["filter_drop_index_training"] = drop_index else: @@ -485,11 +501,11 @@ class FreqaiDataKitchen: shift = "" if n > 0: shift = "_shift-" + str(n) - features.append(ft + shift + "_" + tf) + # features.append(ft + shift + "_" + tf) for p in config["freqai"]["corr_pairlist"]: features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) - logger.info("number of features %s", len(features)) + # logger.info("number of features %s", len(features)) return features def check_if_pred_in_training_spaces(self) -> None: @@ -513,10 +529,10 @@ class FreqaiDataKitchen: 0, ) - logger.info( - "Distance checker tossed %s predictions for being too far from training data", - len(do_predict) - do_predict.sum(), - ) + # logger.info( + # "Distance checker tossed %s predictions for being too far from training data", + # len(do_predict) - do_predict.sum(), + # ) self.do_predict += do_predict self.do_predict -= 1 @@ -577,15 +593,105 @@ class FreqaiDataKitchen: / str(full_timerange + self.freqai_config["identifier"]) ) + config_path = Path(self.config["config_files"][0]) + if not self.full_path.is_dir(): self.full_path.mkdir(parents=True, exist_ok=True) shutil.copy( - Path(self.config["config_files"][0]).name, - Path(self.full_path / self.config["config_files"][0]), + config_path.name, + Path(self.full_path / config_path.parts[-1]), ) return full_timerange + def check_if_new_training_required(self, training_timerange: str, + metadata: dict) -> Tuple[bool, str]: + + time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() + + trained_timerange = TimeRange.parse_timerange(training_timerange) + + elapsed_time = (time - trained_timerange.stopts) / SECONDS_IN_DAY + + trained_timerange.startts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY + trained_timerange.stopts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY + start = datetime.datetime.utcfromtimestamp(trained_timerange.startts) + stop = datetime.datetime.utcfromtimestamp(trained_timerange.stopts) + + new_trained_timerange = start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d") + + retrain = elapsed_time > self.freqai_config['backtest_period'] + + if retrain: + coin, _ = metadata['pair'].split("/") + # set the new model_path + self.model_path = Path(self.full_path / str("sub-train" + "-" + + str(new_trained_timerange))) + + self.model_filename = "cb_" + coin.lower() + "_" + new_trained_timerange + # this is not persistent at the moment TODO + self.freqai_config['live_trained_timerange'] = new_trained_timerange + # enables persistence, but not fully implemented into save/load data yer + self.data['live_trained_timerange'] = new_trained_timerange + + return retrain, new_trained_timerange + + def download_new_data_for_retraining(self, new_timerange: str, metadata: dict) -> None: + + exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], + self.config, validate=False) + pairs = self.freqai_config['corr_pairlist'] + [metadata['pair']] + timerange = TimeRange.parse_timerange(new_timerange) + # data_handler = get_datahandler(datadir, data_format) + + refresh_backtest_ohlcv_data( + exchange, pairs=pairs, timeframes=self.freqai_config['timeframes'], + datadir=self.config['datadir'], timerange=timerange, + new_pairs_days=self.config['new_pairs_days'], + erase=False, data_format=self.config['dataformat_ohlcv'], + trading_mode=self.config.get('trading_mode', 'spot'), + prepend=self.config.get('prepend_data', False) + ) + + def load_pairs_histories(self, new_timerange: str, metadata: dict) -> Tuple[Dict[Any, Any], + DataFrame]: + corr_dataframes: Dict[Any, Any] = {} + # pair_dataframes: Dict[Any, Any] = {} + pairs = self.freqai_config['corr_pairlist'] # + [metadata['pair']] + timerange = TimeRange.parse_timerange(new_timerange) + + for p in pairs: + corr_dataframes[p] = {} + for tf in self.freqai_config['timeframes']: + corr_dataframes[p][tf] = load_pair_history(datadir=self.config['datadir'], + timeframe=tf, + pair=p, timerange=timerange) + + base_dataframe = [dataframe for key, dataframe in corr_dataframes.items() + if metadata['pair'] in key] + + # [0] indexes the lowest tf for the basepair + return corr_dataframes, base_dataframe[0][self.config['timeframe']] + + def use_strategy_to_populate_indicators(self, strategy: IStrategy, metadata: dict, + corr_dataframes: dict, + dataframe: DataFrame) -> DataFrame: + + # dataframe = pair_dataframes[0] # this is the base tf pair df + + for tf in self.freqai_config["timeframes"]: + # dataframe = strategy.populate_any_indicators(metadata["pair"], dataframe.copy, + # tf, pair_dataframes[tf]) + for i in self.freqai_config["corr_pairlist"]: + dataframe = strategy.populate_any_indicators(i, + dataframe.copy(), + tf, + corr_dataframes[i][tf], + coin=i.split("/")[0] + "-" + ) + + return dataframe + def np_encoder(self, object): if isinstance(object, np.generic): return object.item() diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 16b6fd9f9..222061e2a 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -8,9 +8,9 @@ import numpy.typing as npt import pandas as pd from pandas import DataFrame -from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import RunMode from freqtrade.freqai.data_kitchen import FreqaiDataKitchen +from freqtrade.strategy.interface import IStrategy pd.options.mode.chained_assignment = None @@ -33,15 +33,14 @@ class IFreqaiModel(ABC): self.data_split_parameters = config["freqai"]["data_split_parameters"] self.model_training_parameters = config["freqai"]["model_training_parameters"] self.feature_parameters = config["freqai"]["feature_parameters"] - self.backtest_timerange = config["timerange"] + # self.backtest_timerange = config["timerange"] self.time_last_trained = None self.current_time = None self.model = None self.predictions = None - self.live_trained_timerange = None - def start(self, dataframe: DataFrame, metadata: dict, dp: DataProvider) -> DataFrame: + def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ Entry point to the FreqaiModel, it will train a new model if necesssary before making the prediction. @@ -57,11 +56,18 @@ class IFreqaiModel(ABC): the model. :metadata: pair metadataa coming from strategy. """ - self.pair = metadata["pair"] - self.dh = FreqaiDataKitchen(self.config, dataframe) - if dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE): - logger.info('testing live') + live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE) + + self.pair = metadata["pair"] + self.dh = FreqaiDataKitchen(self.config, dataframe, live) + + if live: + # logger.info('testing live') + self.start_live(dataframe, metadata, strategy) + + return (self.dh.full_predictions, self.dh.full_do_predict, + self.dh.full_target_mean, self.dh.full_target_std) logger.info("going to train %s timeranges", len(self.dh.training_timeranges)) @@ -98,6 +104,42 @@ class IFreqaiModel(ABC): return (self.dh.full_predictions, self.dh.full_do_predict, self.dh.full_target_mean, self.dh.full_target_std) + def start_live(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> None: + + self.dh.set_paths() + + file_exists = self.model_exists(metadata['pair'], + training_timerange=self.freqai_info[ + 'live_trained_timerange']) + + (retrain, + new_trained_timerange) = self.dh.check_if_new_training_required(self.freqai_info[ + 'live_trained_timerange'], + metadata) + + if retrain or not file_exists: + self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) + # dataframe = download-data + corr_dataframes, pair_dataframes = self.dh.load_pairs_histories(new_trained_timerange, + metadata) + + unfiltered_dataframe = self.dh.use_strategy_to_populate_indicators(strategy, + metadata, + corr_dataframes, + pair_dataframes) + + self.model = self.train(unfiltered_dataframe, metadata) + self.dh.save_data(self.model) + + self.freqai_info + + self.model = self.dh.load_data() + preds, do_preds = self.predict(dataframe) + self.dh.append_predictions(preds, do_preds, len(dataframe)) + # dataframe should have len 1 here + + return + def make_labels(self, dataframe: DataFrame) -> DataFrame: """ User defines the labels here (target values). diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 57afbf32a..e681d70bd 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -532,6 +532,22 @@ class IStrategy(ABC, HyperStrategyMixin): """ return None + def populate_any_indicators(self, pair: str, df: DataFrame, tf: str, + informative: DataFrame = None, coin: str = "") -> DataFrame: + """ + Function designed to automatically generate, name and merge features + from user indicated timeframes in the configuration file. User can add + additional features here, but must follow the naming convention. + Defined in IStrategy because Freqai needs to know it exists. + :params: + :pair: pair to be used as informative + :df: strategy dataframe which will receive merges from informatives + :tf: timeframe of the dataframe which will modify the feature names + :informative: the dataframe associated with the informative pair + :coin: the name of the coin which will modify the feature names. + """ + return df + ### # END - Intended to be overridden by strategy ### diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py index 35f25775a..08f9d2ba9 100644 --- a/freqtrade/templates/ExamplePredictionModel.py +++ b/freqtrade/templates/ExamplePredictionModel.py @@ -124,7 +124,7 @@ class ExamplePredictionModel(IFreqaiModel): data (NaNs) or felt uncertain about data (PCA and DI index) """ - logger.info("--------------------Starting prediction--------------------") + # logger.info("--------------------Starting prediction--------------------") original_feature_list = self.dh.build_feature_list(self.config) filtered_dataframe, _ = self.dh.filter_features( @@ -151,8 +151,8 @@ class ExamplePredictionModel(IFreqaiModel): predictions = self.model.predict(self.dh.data_dictionary["prediction_features"]) # compute the non-standardized predictions - predictions = predictions * self.dh.data["labels_std"] + self.dh.data["labels_mean"] + self.dh.predictions = predictions * self.dh.data["labels_std"] + self.dh.data["labels_mean"] - logger.info("--------------------Finished prediction--------------------") + # logger.info("--------------------Finished prediction--------------------") - return (predictions, self.dh.do_predict) + return (self.dh.predictions, self.dh.do_predict) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 873b31115..13309d8c3 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -44,13 +44,16 @@ class FreqaiExampleStrategy(IStrategy): stoploss = -0.05 use_sell_signal = True - startup_candle_count: int = 1000 + startup_candle_count: int = 300 def informative_pairs(self): - pairs = self.freqai_info["corr_pairlist"] + pairs = self.config["freqai"]["corr_pairlist"] informative_pairs = [] - for tf in self.timeframes: - informative_pairs.append([(pair, tf) for pair in pairs]) + for tf in self.config["freqai"]["timeframes"]: + # informative_pairs.append((self.pair, tf)) + # informative_pairs.append([(pair, tf) for pair in pairs]) + for pair in pairs: + informative_pairs.append((pair, tf)) return informative_pairs def populate_any_indicators(self, pair, df, tf, informative=None, coin=""): @@ -129,6 +132,7 @@ class FreqaiExampleStrategy(IStrategy): # the configuration file parameters are stored here self.freqai_info = self.config["freqai"] + self.pair = metadata['pair'] # the model is instantiated here self.model = CustomModel(self.config) @@ -138,12 +142,13 @@ class FreqaiExampleStrategy(IStrategy): # the following loops are necessary for building the features # indicated by the user in the configuration file. for tf in self.freqai_info["timeframes"]: - dataframe = self.populate_any_indicators(metadata["pair"], dataframe.copy(), tf) - for i in self.freqai_info["corr_pairlist"]: + # dataframe = self.populate_any_indicators(metadata["pair"], dataframe.copy(), tf) + for pair in self.freqai_info["corr_pairlist"]: dataframe = self.populate_any_indicators( - i, dataframe.copy(), tf, coin=i.split("/")[0] + "-" + pair, dataframe.copy(), tf, coin=pair.split("/")[0] + "-" ) + print('dataframe_built') # the model will return 4 values, its prediction, an indication of whether or not the # prediction should be accepted, the target mean/std values from the labels used during # each training period. @@ -152,7 +157,7 @@ class FreqaiExampleStrategy(IStrategy): dataframe["do_predict"], dataframe["target_mean"], dataframe["target_std"], - ) = self.model.bridge.start(dataframe, metadata) + ) = self.model.bridge.start(dataframe, metadata, self) dataframe["target_roi"] = dataframe["target_mean"] + dataframe["target_std"] * 0.5 dataframe["sell_roi"] = dataframe["target_mean"] - dataframe["target_std"] * 1.5 From 9b3e5faebeb2235358ccc9ca003014fb89d64567 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 9 May 2022 17:01:49 +0200 Subject: [PATCH 020/130] create more flexible whitelist, avoid duplicating whitelist features into corr_pairlist, update docs --- config_examples/config_freqai.example.json | 5 +- docs/freqai.md | 82 ++++++++++++++++--- freqtrade/freqai/data_kitchen.py | 47 +++++++---- freqtrade/freqai/freqai_interface.py | 12 +-- freqtrade/templates/ExamplePredictionModel.py | 7 +- freqtrade/templates/FreqaiExampleStrategy.py | 5 +- 6 files changed, 119 insertions(+), 39 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 351585d17..d89c835b1 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -24,7 +24,8 @@ "rateLimit": 200 }, "pair_whitelist": [ - "BTC/USDT" + "BTC/USDT", + "ETH/USDT" ], "pair_blacklist": [] }, @@ -55,7 +56,7 @@ ], "train_period": 30, "backtest_period": 7, - "identifier": "livetest5", + "identifier": "new_corrlist", "live_trained_timerange": "20220330-20220429", "live_full_backtestrange": "20220302-20220501", "base_features": [ diff --git a/docs/freqai.md b/docs/freqai.md index 844881613..431705dd9 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -65,8 +65,6 @@ config setup includes: "feature_parameters" : { "period": 24, "shift": 2, - "drop_features": false, - "DI_threshold": 1, "weight_factor": 0, }, "data_split_parameters" : { @@ -79,8 +77,7 @@ config setup includes: "learning_rate": 0.02, "task_type": "CPU", }, - }, - + } ``` ### Building the feature set @@ -153,8 +150,6 @@ The Freqai strategy requires the user to include the following lines of code in # the following loops are necessary for building the features # indicated by the user in the configuration file. for tf in self.freqai_info['timeframes']: - dataframe = self.populate_any_indicators(metadata['pair'], - dataframe.copy(), tf) for i in self.freqai_info['corr_pairlist']: dataframe = self.populate_any_indicators(i, dataframe.copy(), tf, coin=i.split("/")[0]+'-') @@ -177,8 +172,36 @@ and `make_labels()` to let them customize various aspects of their training proc ### Running the model live -TODO: Freqai is not automated for live yet. +Freqai can be run dry/live using the following command +```bash +freqtrade trade --strategy FreqaiExampleStrategy --config config_freqai.example.json --freqaimodel ExamplePredictionModel +``` + +By default, Freqai will not find find any existing models and will start by training a new one +given the user configuration settings. Following training, it will use that model to predict for the +duration of `backtest_period`. After a full `backtest_period` has elapsed, Freqai will auto retrain +a new model, and begin making predictions with the updated model. + +If the user wishes to start dry/live from a saved model, the following configuration +parameters need to be set: + +```json + "freqai": { + "identifier": "example", + "live_trained_timerange": "20220330-20220429", + "live_full_backtestrange": "20220302-20220501" + } +``` + +Where the `identifier` is the same identifier which was set during the backtesting/training. Meanwhile, +the `live_trained_timerange` is the sub-trained timerange (the training window) which was set +during backtesting/training. These are available to the user inside `user_data/models/*/sub-train-*`. +`live_full_backtestrange` was the full data range assocaited with the backtest/training (the full time +window that the training window and backtesting windows slide through). These values can be located +inside the `user_data/models/` directory. In this case, although Freqai will initiate with a +pretrained model, if a full `backtest_period` has elapsed since the end of the user set +`live_trained_timerange`, it will self retrain. ## Data anylsis techniques ### Controlling the model learning process @@ -226,12 +249,49 @@ $$ DI_k = d_k/\overline{d} $$ Equity and crypto markets suffer from a high level of non-patterned noise in the form of outlier data points. The dissimilarity index allows predictions which are outliers and not existent in the model feature space, to be thrown out due -to low levels of certainty. The user can tweak the DI with `DI_threshold` to increase -or decrease the extrapolation of the trained model. +to low levels of certainty. Activating the Dissimilarity Index can be achieved with: + +```json + "freqai": { + "feature_parameters" : { + "DI_threshold": 1 + } + } +``` + +The user can tweak the DI with `DI_threshold` to increase or decrease the extrapolation of the +trained model. ### Reducing data dimensionality with Principal Component Analysis -TO BE WRITTEN +Users can reduce the dimensionality of their features by activating the `principal_component_analysis`: + +```json + "freqai": { + "feature_parameters" : { + "principal_component_analysis": true + } + } +``` + +Which will perform PCA on the features and reduce the dimensionality of the data so that the explained +variance of the data set is >= 0.999. + +### Removing outliers based on feature statistical distributions + +The user can tell Freqai to remove outlier data points from the trainig/test data sets by setting: + +```json + "freqai": { + "feature_parameters" : { + "remove_outliers": true + } + } +``` + +Freqai will check the statistical distributions of each feature (or component if the user activated +`principal_component_analysis`) and remove any data point that sits more than 3 standard deviations away +from the mean. ## Additional information ### Feature standardization @@ -242,5 +302,5 @@ data only. This includes all test data and unseen prediction data (dry/live/back ### File structure `user_data_dir/models/` contains all the data associated with the trainings and -backtestings. This file structure is heavily controlled and read by the `DataHandler()` +backtestings. This file structure is heavily controlled and read by the `FreqaiDataKitchen()` and should thus not be modified. diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 7b6a65a59..961f26e57 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -485,7 +485,7 @@ class FreqaiDataKitchen: return - def build_feature_list(self, config: dict) -> list: + def build_feature_list(self, config: dict, metadata: dict) -> list: """ Build the list of features that will be used to filter the full dataframe. Feature list is construced from the @@ -501,8 +501,10 @@ class FreqaiDataKitchen: shift = "" if n > 0: shift = "_shift-" + str(n) - # features.append(ft + shift + "_" + tf) + features.append(metadata['pair'].split("/")[0] + "-" + ft + shift + "_" + tf) for p in config["freqai"]["corr_pairlist"]: + if metadata['pair'] in p: + continue # avoid duplicate features features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) # logger.info("number of features %s", len(features)) @@ -640,9 +642,10 @@ class FreqaiDataKitchen: exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config, validate=False) - pairs = self.freqai_config['corr_pairlist'] + [metadata['pair']] + pairs = self.freqai_config['corr_pairlist'] + if metadata['pair'] not in pairs: + pairs += metadata['pair'] # dont include pair twice timerange = TimeRange.parse_timerange(new_timerange) - # data_handler = get_datahandler(datadir, data_format) refresh_backtest_ohlcv_data( exchange, pairs=pairs, timeframes=self.freqai_config['timeframes'], @@ -656,33 +659,45 @@ class FreqaiDataKitchen: def load_pairs_histories(self, new_timerange: str, metadata: dict) -> Tuple[Dict[Any, Any], DataFrame]: corr_dataframes: Dict[Any, Any] = {} - # pair_dataframes: Dict[Any, Any] = {} + base_dataframes: Dict[Any, Any] = {} pairs = self.freqai_config['corr_pairlist'] # + [metadata['pair']] timerange = TimeRange.parse_timerange(new_timerange) - for p in pairs: - corr_dataframes[p] = {} - for tf in self.freqai_config['timeframes']: + for tf in self.freqai_config['timeframes']: + base_dataframes[tf] = load_pair_history(datadir=self.config['datadir'], + timeframe=tf, + pair=metadata['pair'], timerange=timerange) + for p in pairs: + if metadata['pair'] in p: + continue # dont repeat anything from whitelist + corr_dataframes[p] = {} corr_dataframes[p][tf] = load_pair_history(datadir=self.config['datadir'], timeframe=tf, pair=p, timerange=timerange) - base_dataframe = [dataframe for key, dataframe in corr_dataframes.items() - if metadata['pair'] in key] + # base_dataframe = [dataframe for key, dataframe in corr_dataframes.items() + # if metadata['pair'] in key] # [0] indexes the lowest tf for the basepair - return corr_dataframes, base_dataframe[0][self.config['timeframe']] + return corr_dataframes, base_dataframes - def use_strategy_to_populate_indicators(self, strategy: IStrategy, metadata: dict, + def use_strategy_to_populate_indicators(self, strategy: IStrategy, corr_dataframes: dict, - dataframe: DataFrame) -> DataFrame: + base_dataframes: dict, + metadata: dict) -> DataFrame: - # dataframe = pair_dataframes[0] # this is the base tf pair df + dataframe = base_dataframes[self.config['timeframe']] for tf in self.freqai_config["timeframes"]: - # dataframe = strategy.populate_any_indicators(metadata["pair"], dataframe.copy, - # tf, pair_dataframes[tf]) + dataframe = strategy.populate_any_indicators(metadata['pair'], + dataframe.copy(), + tf, + base_dataframes[tf], + coin=metadata['pair'].split("/")[0] + "-" + ) for i in self.freqai_config["corr_pairlist"]: + if metadata['pair'] in i: + continue # dont repeat anything from whitelist dataframe = strategy.populate_any_indicators(i, dataframe.copy(), tf, diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 222061e2a..e019eb842 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -93,7 +93,7 @@ class IFreqaiModel(ABC): else: self.model = self.dh.load_data() - preds, do_preds = self.predict(dataframe_backtest) + preds, do_preds = self.predict(dataframe_backtest, metadata) self.dh.append_predictions(preds, do_preds, len(dataframe_backtest)) print('predictions', len(self.dh.full_predictions), @@ -120,13 +120,13 @@ class IFreqaiModel(ABC): if retrain or not file_exists: self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) # dataframe = download-data - corr_dataframes, pair_dataframes = self.dh.load_pairs_histories(new_trained_timerange, + corr_dataframes, base_dataframes = self.dh.load_pairs_histories(new_trained_timerange, metadata) unfiltered_dataframe = self.dh.use_strategy_to_populate_indicators(strategy, - metadata, corr_dataframes, - pair_dataframes) + base_dataframes, + metadata) self.model = self.train(unfiltered_dataframe, metadata) self.dh.save_data(self.model) @@ -134,7 +134,7 @@ class IFreqaiModel(ABC): self.freqai_info self.model = self.dh.load_data() - preds, do_preds = self.predict(dataframe) + preds, do_preds = self.predict(dataframe, metadata) self.dh.append_predictions(preds, do_preds, len(dataframe)) # dataframe should have len 1 here @@ -175,7 +175,7 @@ class IFreqaiModel(ABC): return @abstractmethod - def predict(self, dataframe: DataFrame) -> Tuple[npt.ArrayLike, npt.ArrayLike]: + def predict(self, dataframe: DataFrame, metadata: dict) -> Tuple[npt.ArrayLike, npt.ArrayLike]: """ Filter the prediction features data and predict with it. :param: unfiltered_dataframe: Full dataframe for the current backtest period. diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py index 08f9d2ba9..3db8d3aeb 100644 --- a/freqtrade/templates/ExamplePredictionModel.py +++ b/freqtrade/templates/ExamplePredictionModel.py @@ -53,7 +53,7 @@ class ExamplePredictionModel(IFreqaiModel): logger.info("--------------------Starting training--------------------") # create the full feature list based on user config info - self.dh.training_features_list = self.dh.build_feature_list(self.config) + self.dh.training_features_list = self.dh.build_feature_list(self.config, metadata) unfiltered_labels = self.make_labels(unfiltered_dataframe) # filter the features requested by user in the configuration file and elegantly handle NaNs @@ -114,7 +114,8 @@ class ExamplePredictionModel(IFreqaiModel): return model - def predict(self, unfiltered_dataframe: DataFrame) -> Tuple[DataFrame, DataFrame]: + def predict(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, + DataFrame]: """ Filter the prediction features data and predict with it. :param: unfiltered_dataframe: Full dataframe for the current backtest period. @@ -126,7 +127,7 @@ class ExamplePredictionModel(IFreqaiModel): # logger.info("--------------------Starting prediction--------------------") - original_feature_list = self.dh.build_feature_list(self.config) + original_feature_list = self.dh.build_feature_list(self.config, metadata) filtered_dataframe, _ = self.dh.filter_features( unfiltered_dataframe, original_feature_list, training_filter=False ) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 13309d8c3..45526e2ac 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -142,8 +142,11 @@ class FreqaiExampleStrategy(IStrategy): # the following loops are necessary for building the features # indicated by the user in the configuration file. for tf in self.freqai_info["timeframes"]: - # dataframe = self.populate_any_indicators(metadata["pair"], dataframe.copy(), tf) + dataframe = self.populate_any_indicators(self.pair, dataframe.copy(), tf, + coin=self.pair.split("/")[0] + "-") for pair in self.freqai_info["corr_pairlist"]: + if metadata['pair'] in pair: + continue # do not include whitelisted pair twice if it is in corr_pairlist dataframe = self.populate_any_indicators( pair, dataframe.copy(), tf, coin=pair.split("/")[0] + "-" ) From a7029e35b51404424f872e1663d9602f29695607 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 10 May 2022 11:39:01 +0200 Subject: [PATCH 021/130] ensure informative pairs includes any combination of whitelist - corr_pairlist --- freqtrade/templates/FreqaiExampleStrategy.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 45526e2ac..e2bb6e041 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -47,12 +47,15 @@ class FreqaiExampleStrategy(IStrategy): startup_candle_count: int = 300 def informative_pairs(self): - pairs = self.config["freqai"]["corr_pairlist"] + whitelist_pairs = self.dp.current_whitelist() + corr_pairs = self.config["freqai"]["corr_pairlist"] informative_pairs = [] for tf in self.config["freqai"]["timeframes"]: - # informative_pairs.append((self.pair, tf)) - # informative_pairs.append([(pair, tf) for pair in pairs]) - for pair in pairs: + for pair in whitelist_pairs: + informative_pairs.append((pair, tf)) + for pair in corr_pairs: + if pair in whitelist_pairs: + continue # avoid duplication informative_pairs.append((pair, tf)) return informative_pairs From a8022c104a23f0a25e2fc4f6b65c15746be83824 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 15 May 2022 14:01:53 +0200 Subject: [PATCH 022/130] give beta testers more information in the doc --- docs/freqai.md | 17 +++++++++++++++++ freqtrade/freqai/data_kitchen.py | 2 -- freqtrade/templates/ExamplePredictionModel.py | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 431705dd9..0fb1fa4c6 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -41,6 +41,23 @@ in the model. intermediate performance of the model during training. This data does not directly influence nodal weights within the model. +## Install prerequisites + +Use `pip` to install the prerequisities with: + +`pip install -r requirements-freqai.txt` + +## Running from the example files + +An example strategy, example prediction model, and example config can all be found in +`freqtrade/templates/ExampleFreqaiStrategy.py`, `freqtrade/templates/ExamplePredictionModel.py`, +`config_examples/config_freqai.example.json`, respectively. Assuming the user has downloaded +the necessary data, Freqai can be executed from these templates with: + +`freqtrade backtesting --config config_examples/config_freqai.example.json--strategy +ExampleFreqaiStrategy --freqaimodel ExamplePredictionModel +--freqaimodel-path freqtrade/templates --strategy-path freqtrade/templates` + ## Configuring the bot ### Example config file The user interface is isolated to the typical config file. A typical Freqai diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 961f26e57..364b503e9 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -113,8 +113,6 @@ class FreqaiDataKitchen: with open(self.model_path / str(self.model_filename + "_metadata.json"), "r") as fp: self.data = json.load(fp) self.training_features_list = self.data["training_features_list"] - # if self.data.get("training_features_list"): - # self.training_features_list = [*self.data.get("training_features_list")] self.data_dictionary["train_features"] = pd.read_pickle( self.model_path / str(self.model_filename + "_trained_df.pkl") diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py index 3db8d3aeb..796fb23ed 100644 --- a/freqtrade/templates/ExamplePredictionModel.py +++ b/freqtrade/templates/ExamplePredictionModel.py @@ -42,8 +42,8 @@ class ExamplePredictionModel(IFreqaiModel): def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: """ - Filter the training data and train a model to it. Train makes heavy use of the datahandler - for storing, saving, loading, and managed. + Filter the training data and train a model to it. Train makes heavy use of the datahkitchen + for storing, saving, loading, and analyzing the data. :params: :unfiltered_dataframe: Full dataframe for the current training period :metadata: pair metadata from strategy. From 717df891b1ecc7ce6c148149c32d51609af47147 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 15 May 2022 14:05:19 +0200 Subject: [PATCH 023/130] use bash visual in doc --- docs/freqai.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 0fb1fa4c6..fd84dffc6 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -54,9 +54,11 @@ An example strategy, example prediction model, and example config can all be fou `config_examples/config_freqai.example.json`, respectively. Assuming the user has downloaded the necessary data, Freqai can be executed from these templates with: -`freqtrade backtesting --config config_examples/config_freqai.example.json--strategy +```bash +freqtrade backtesting --config config_examples/config_freqai.example.json--strategy ExampleFreqaiStrategy --freqaimodel ExamplePredictionModel ---freqaimodel-path freqtrade/templates --strategy-path freqtrade/templates` +--freqaimodel-path freqtrade/templates --strategy-path freqtrade/templates +``` ## Configuring the bot ### Example config file From f4296173e938fe5f32421f1750fa15274f1a1fd9 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 15 May 2022 14:31:26 +0200 Subject: [PATCH 024/130] use bash visual in doc --- docs/freqai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/freqai.md b/docs/freqai.md index fd84dffc6..11ef972c7 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -56,7 +56,7 @@ the necessary data, Freqai can be executed from these templates with: ```bash freqtrade backtesting --config config_examples/config_freqai.example.json--strategy -ExampleFreqaiStrategy --freqaimodel ExamplePredictionModel +FreqaiExampleStrategy --freqaimodel ExamplePredictionModel --freqaimodel-path freqtrade/templates --strategy-path freqtrade/templates ``` From e5759d950b111b60c3650e5a69285718db566645 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 15 May 2022 14:32:17 +0200 Subject: [PATCH 025/130] fix typo --- docs/freqai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/freqai.md b/docs/freqai.md index 11ef972c7..9e9155826 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -55,7 +55,7 @@ An example strategy, example prediction model, and example config can all be fou the necessary data, Freqai can be executed from these templates with: ```bash -freqtrade backtesting --config config_examples/config_freqai.example.json--strategy +freqtrade backtesting --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel ExamplePredictionModel --freqaimodel-path freqtrade/templates --strategy-path freqtrade/templates ``` From 9e94d28860515018e472e8079d9d6e1f9b39886b Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 15 May 2022 14:33:39 +0200 Subject: [PATCH 026/130] add timerange to backtest commnad --- docs/freqai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/freqai.md b/docs/freqai.md index 9e9155826..6b7a4d5c8 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -57,7 +57,7 @@ the necessary data, Freqai can be executed from these templates with: ```bash freqtrade backtesting --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel ExamplePredictionModel ---freqaimodel-path freqtrade/templates --strategy-path freqtrade/templates +--freqaimodel-path freqtrade/templates --strategy-path freqtrade/templates --timerange 20220101-220201 ``` ## Configuring the bot From 80dcd88abf33f42510f97d75de6998ce397a2f50 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 15 May 2022 15:26:09 +0200 Subject: [PATCH 027/130] allow user to run config from anywhere on their system --- freqtrade/freqai/data_kitchen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 364b503e9..c9d518418 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -598,7 +598,7 @@ class FreqaiDataKitchen: if not self.full_path.is_dir(): self.full_path.mkdir(parents=True, exist_ok=True) shutil.copy( - config_path.name, + config_path.resolve(), Path(self.full_path / config_path.parts[-1]), ) From 8664e8f9a3f57a1ec19a48c74fdd6ac07236bfe2 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 17 May 2022 17:13:38 +0200 Subject: [PATCH 028/130] create a prediction_models folder where basic prediction models can live (similar to optimize/hyperopt-loss. Update resolver/docs/and gitignore to accommodate change --- .gitignore | 2 + docs/freqai.md | 11 +- freqtrade/freqai/freqai_interface.py | 9 +- .../CatboostPredictionModel.py | 159 ++++++++++++++++++ freqtrade/resolvers/freqaimodel_resolver.py | 3 +- user_data/freqaimodels/.gitkeep | 0 6 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 freqtrade/freqai/prediction_models/CatboostPredictionModel.py create mode 100644 user_data/freqaimodels/.gitkeep diff --git a/.gitignore b/.gitignore index 17823f642..dc87402b4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ user_data/* !user_data/strategy/sample_strategy.py !user_data/notebooks !user_data/models +!user_data/freqaimodels +user_data/freqaimodels/* user_data/models/* user_data/notebooks/* freqtrade-plot.html diff --git a/docs/freqai.md b/docs/freqai.md index 6b7a4d5c8..b5aa587bf 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -49,15 +49,16 @@ Use `pip` to install the prerequisities with: ## Running from the example files -An example strategy, example prediction model, and example config can all be found in -`freqtrade/templates/ExampleFreqaiStrategy.py`, `freqtrade/templates/ExamplePredictionModel.py`, +An example strategy, an example prediction model, and example config can all be found in +`freqtrade/templates/ExampleFreqaiStrategy.py`, +`freqtrade/freqai/prediction_models/CatboostPredictionModel.py`, `config_examples/config_freqai.example.json`, respectively. Assuming the user has downloaded the necessary data, Freqai can be executed from these templates with: ```bash freqtrade backtesting --config config_examples/config_freqai.example.json --strategy -FreqaiExampleStrategy --freqaimodel ExamplePredictionModel ---freqaimodel-path freqtrade/templates --strategy-path freqtrade/templates --timerange 20220101-220201 +FreqaiExampleStrategy --freqaimodel CatboostPredictionModel --strategy-path freqtrade/templates +--timerange 20220101-220201 ``` ## Configuring the bot @@ -185,7 +186,7 @@ the feature set with a proper naming convention for the IFreqaiModel to use late ### Building an IFreqaiModel -Freqai has a base example model in `templates/ExamplePredictionModel.py`, but users can customize and create +Freqai has an example prediction model based on the popular `Catboost` regression (`freqai/prediction_models/CatboostPredictionModel.py`). However, users can customize and create their own prediction models using the `IFreqaiModel` class. Users are encouraged to inherit `train()`, `predict()`, and `make_labels()` to let them customize various aspects of their training procedures. diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index e019eb842..ae05ae33a 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -105,6 +105,11 @@ class IFreqaiModel(ABC): self.dh.full_target_mean, self.dh.full_target_std) def start_live(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> None: + """ + The main broad execution for dry/live. This function will check if a retraining should be + performed, and if so, retrain and reset the model. + + """ self.dh.set_paths() @@ -119,7 +124,6 @@ class IFreqaiModel(ABC): if retrain or not file_exists: self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) - # dataframe = download-data corr_dataframes, base_dataframes = self.dh.load_pairs_histories(new_trained_timerange, metadata) @@ -131,12 +135,9 @@ class IFreqaiModel(ABC): self.model = self.train(unfiltered_dataframe, metadata) self.dh.save_data(self.model) - self.freqai_info - self.model = self.dh.load_data() preds, do_preds = self.predict(dataframe, metadata) self.dh.append_predictions(preds, do_preds, len(dataframe)) - # dataframe should have len 1 here return diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py new file mode 100644 index 000000000..fecfc2220 --- /dev/null +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -0,0 +1,159 @@ +import logging +from typing import Any, Dict, Tuple + +import pandas as pd +from catboost import CatBoostRegressor, Pool +from pandas import DataFrame + +from freqtrade.freqai.freqai_interface import IFreqaiModel + + +logger = logging.getLogger(__name__) + + +class CatboostPredictionModel(IFreqaiModel): + """ + User created prediction model. The class needs to override three necessary + functions, predict(), train(), fit(). The class inherits ModelHandler which + has its own DataHandler where data is held, saved, loaded, and managed. + """ + + def make_labels(self, dataframe: DataFrame) -> DataFrame: + """ + User defines the labels here (target values). + :params: + :dataframe: the full dataframe for the present training period + """ + + dataframe["s"] = ( + dataframe["close"] + .shift(-self.feature_parameters["period"]) + .rolling(self.feature_parameters["period"]) + .max() + / dataframe["close"] + - 1 + ) + self.dh.data["s_mean"] = dataframe["s"].mean() + self.dh.data["s_std"] = dataframe["s"].std() + + # logger.info("label mean", self.dh.data["s_mean"], "label std", self.dh.data["s_std"]) + + return dataframe["s"] + + def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: + """ + Filter the training data and train a model to it. Train makes heavy use of the datahkitchen + for storing, saving, loading, and analyzing the data. + :params: + :unfiltered_dataframe: Full dataframe for the current training period + :metadata: pair metadata from strategy. + :returns: + :model: Trained model which can be used to inference (self.predict) + """ + logger.info("--------------------Starting training--------------------") + + # create the full feature list based on user config info + self.dh.training_features_list = self.dh.build_feature_list(self.config, metadata) + unfiltered_labels = self.make_labels(unfiltered_dataframe) + + # filter the features requested by user in the configuration file and elegantly handle NaNs + features_filtered, labels_filtered = self.dh.filter_features( + unfiltered_dataframe, + self.dh.training_features_list, + unfiltered_labels, + training_filter=True, + ) + + # split data into train/test data. + data_dictionary = self.dh.make_train_test_datasets(features_filtered, labels_filtered) + # standardize all data based on train_dataset only + data_dictionary = self.dh.standardize_data(data_dictionary) + + # optional additional data cleaning + if self.feature_parameters["principal_component_analysis"]: + self.dh.principal_component_analysis() + if self.feature_parameters["remove_outliers"]: + self.dh.remove_outliers(predict=False) + if self.feature_parameters["DI_threshold"]: + self.dh.data["avg_mean_dist"] = self.dh.compute_distances() + + logger.info("length of train data %s", len(data_dictionary["train_features"])) + + model = self.fit(data_dictionary) + + logger.info(f'--------------------done training {metadata["pair"]}--------------------') + + return model + + def fit(self, data_dictionary: Dict) -> Any: + """ + Most regressors use the same function names and arguments e.g. user + can drop in LGBMRegressor in place of CatBoostRegressor and all data + management will be properly handled by Freqai. + :params: + :data_dictionary: the dictionary constructed by DataHandler to hold + all the training and test data/labels. + """ + + train_data = Pool( + data=data_dictionary["train_features"], + label=data_dictionary["train_labels"], + weight=data_dictionary["train_weights"], + ) + + test_data = Pool( + data=data_dictionary["test_features"], + label=data_dictionary["test_labels"], + weight=data_dictionary["test_weights"], + ) + + model = CatBoostRegressor( + verbose=100, early_stopping_rounds=400, **self.model_training_parameters + ) + model.fit(X=train_data, eval_set=test_data) + + return model + + def predict(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, + DataFrame]: + """ + Filter the prediction features data and predict with it. + :param: unfiltered_dataframe: Full dataframe for the current backtest period. + :return: + :predictions: np.array of predictions + :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove + data (NaNs) or felt uncertain about data (PCA and DI index) + """ + + # logger.info("--------------------Starting prediction--------------------") + + original_feature_list = self.dh.build_feature_list(self.config, metadata) + filtered_dataframe, _ = self.dh.filter_features( + unfiltered_dataframe, original_feature_list, training_filter=False + ) + filtered_dataframe = self.dh.standardize_data_from_metadata(filtered_dataframe) + self.dh.data_dictionary["prediction_features"] = filtered_dataframe + + # optional additional data cleaning + if self.feature_parameters["principal_component_analysis"]: + pca_components = self.dh.pca.transform(filtered_dataframe) + self.dh.data_dictionary["prediction_features"] = pd.DataFrame( + data=pca_components, + columns=["PC" + str(i) for i in range(0, self.dh.data["n_kept_components"])], + index=filtered_dataframe.index, + ) + + if self.feature_parameters["remove_outliers"]: + self.dh.remove_outliers(predict=True) # creates dropped index + + if self.feature_parameters["DI_threshold"]: + self.dh.check_if_pred_in_training_spaces() # sets do_predict + + predictions = self.model.predict(self.dh.data_dictionary["prediction_features"]) + + # compute the non-standardized predictions + self.dh.predictions = predictions * self.dh.data["labels_std"] + self.dh.data["labels_mean"] + + # logger.info("--------------------Finished prediction--------------------") + + return (self.dh.predictions, self.dh.do_predict) diff --git a/freqtrade/resolvers/freqaimodel_resolver.py b/freqtrade/resolvers/freqaimodel_resolver.py index 2ba6b3e8a..e666b462c 100644 --- a/freqtrade/resolvers/freqaimodel_resolver.py +++ b/freqtrade/resolvers/freqaimodel_resolver.py @@ -24,7 +24,8 @@ class FreqaiModelResolver(IResolver): object_type = IFreqaiModel object_type_str = "FreqaiModel" user_subdir = USERPATH_FREQAIMODELS - initial_search_path = Path(__file__).parent.parent.joinpath("optimize").resolve() + initial_search_path = Path(__file__).parent.parent.joinpath( + "freqai/prediction_models").resolve() @staticmethod def load_freqaimodel(config: Dict) -> IFreqaiModel: diff --git a/user_data/freqaimodels/.gitkeep b/user_data/freqaimodels/.gitkeep new file mode 100644 index 000000000..e69de29bb From d1d451c27e496bf2546993d9060efa1abb6e96b9 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 17 May 2022 18:15:03 +0200 Subject: [PATCH 029/130] auto populate features based on a prepended % in the strategy (remove feature assignment from config). Update doc/constants/example strategy to reflect change --- config_examples/config_freqai.example.json | 13 +---- docs/freqai.md | 35 ++++++++---- freqtrade/constants.py | 3 +- freqtrade/freqai/data_kitchen.py | 53 +++++++++++-------- .../CatboostPredictionModel.py | 5 +- freqtrade/templates/FreqaiExampleStrategy.py | 40 +++++++------- 6 files changed, 80 insertions(+), 69 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index d89c835b1..648f36917 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -56,20 +56,9 @@ ], "train_period": 30, "backtest_period": 7, - "identifier": "new_corrlist", + "identifier": "example", "live_trained_timerange": "20220330-20220429", "live_full_backtestrange": "20220302-20220501", - "base_features": [ - "rsi", - "close_over_20sma", - "relative_volume", - "bb_width", - "mfi", - "roc", - "pct-change", - "adx", - "macd" - ], "corr_pairlist": [ "BTC/USDT", "ETH/USDT", diff --git a/docs/freqai.md b/docs/freqai.md index b5aa587bf..29a45d042 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -72,11 +72,6 @@ config setup includes: "train_period" : 30, "backtest_period" : 7, "identifier" : "unique-id", - "base_features": [ - "rsi", - "mfi", - "roc", - ], "corr_pairlist": [ "ETH/USD", "LINK/USD", @@ -102,11 +97,31 @@ config setup includes: ### Building the feature set -Most of these parameters are controlling the feature data set. The `base_features` -indicates the basic indicators the user wishes to include in the feature set. -The `timeframes` are the timeframes of each base_feature that the user wishes to -include in the feature set. In the present case, the user is asking for the -`5m`, `15m`, and `4h` timeframes of the `rsi`, `mfi`, `roc`, etc. to be included +Most of these parameters are controlling the feature data set. Features are added by the user +inside the `populate_any_indicators()` method of the strategy by prepending indicators with `%`: + +```python + def populate_any_indicators(self, pair, df, tf, informative=None, coin=""): + informative['%-''%-' + coin + "rsi"] = ta.RSI(informative, timeperiod=14) + informative['%-' + coin + "mfi"] = ta.MFI(informative, timeperiod=25) + informative['%-' + coin + "adx"] = ta.ADX(informative, window=20) + bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(informative), window=14, stds=2.2) + informative[coin + "bb_lowerband"] = bollinger["lower"] + informative[coin + "bb_middleband"] = bollinger["mid"] + informative[coin + "bb_upperband"] = bollinger["upper"] + informative['%-' + coin + "bb_width"] = ( + informative[coin + "bb_upperband"] - informative[coin + "bb_lowerband"] + ) / informative[coin + "bb_middleband"] +``` +The user of the present example does not want to pass the `bb_lowerband` as a feature to the model, +and has therefore not prepended it with `%`. The user does, however, wish to pass `bb_width` to the +model for training/prediction and has therfore prepended it with `%`._ + +(Please see the example script located in `freqtrade/templates/FreqaiExampleStrategy.py` for a full example of `populate_any_indicators()`) + +The `timeframes` from the example config above are the timeframes of each `populate_any_indicator()` + included metric for inclusion in the feature set. In the present case, the user is asking for the +`5m`, `15m`, and `4h` timeframes of the `rsi`, `mfi`, `roc`, and `bb_width` to be included in the feature set. In addition, the user can ask for each of these features to be included from diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 0dc355914..686991e2c 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -442,7 +442,6 @@ CONF_SCHEMA = { "identifier": {"type": "str", "default": "example"}, "live_trained_timerange": {"type": "str"}, "live_full_backtestrange": {"type": "str"}, - "base_features": {"type": "list"}, "corr_pairlist": {"type": "list"}, "feature_parameters": { "type": "object", @@ -537,4 +536,4 @@ TradeList = List[List] LongShort = Literal['long', 'short'] EntryExit = Literal['entry', 'exit'] -BuySell = Literal['buy', 'sell'] \ No newline at end of file +BuySell = Literal['buy', 'sell'] diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index c9d518418..cfdbac5f5 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -483,31 +483,38 @@ class FreqaiDataKitchen: return - def build_feature_list(self, config: dict, metadata: dict) -> list: - """ - Build the list of features that will be used to filter - the full dataframe. Feature list is construced from the - user configuration file. - :params: - :config: Canonical freqtrade config file containing all - user defined input in config['freqai] dictionary. - """ - features = [] - for tf in config["freqai"]["timeframes"]: - for ft in config["freqai"]["base_features"]: - for n in range(config["freqai"]["feature_parameters"]["shift"] + 1): - shift = "" - if n > 0: - shift = "_shift-" + str(n) - features.append(metadata['pair'].split("/")[0] + "-" + ft + shift + "_" + tf) - for p in config["freqai"]["corr_pairlist"]: - if metadata['pair'] in p: - continue # avoid duplicate features - features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) - - # logger.info("number of features %s", len(features)) + def find_features(self, dataframe: DataFrame) -> list: + column_names = dataframe.columns + features = [c for c in column_names if '%' in c] + assert features, ("Could not find any features!") return features + # def build_feature_list(self, config: dict, metadata: dict) -> list: + # """ + # SUPERCEDED BY self.find_features() + # Build the list of features that will be used to filter + # the full dataframe. Feature list is construced from the + # user configuration file. + # :params: + # :config: Canonical freqtrade config file containing all + # user defined input in config['freqai] dictionary. + # """ + # features = [] + # for tf in config["freqai"]["timeframes"]: + # for ft in config["freqai"]["base_features"]: + # for n in range(config["freqai"]["feature_parameters"]["shift"] + 1): + # shift = "" + # if n > 0: + # shift = "_shift-" + str(n) + # features.append(metadata['pair'].split("/")[0] + "-" + ft + shift + "_" + tf) + # for p in config["freqai"]["corr_pairlist"]: + # if metadata['pair'] in p: + # continue # avoid duplicate features + # features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) + + # # logger.info("number of features %s", len(features)) + # return features + def check_if_pred_in_training_spaces(self) -> None: """ Compares the distance from each prediction point to each training data diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index fecfc2220..e2ba6bd29 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -53,9 +53,8 @@ class CatboostPredictionModel(IFreqaiModel): logger.info("--------------------Starting training--------------------") # create the full feature list based on user config info - self.dh.training_features_list = self.dh.build_feature_list(self.config, metadata) + self.dh.training_features_list = self.dh.find_features(unfiltered_dataframe) unfiltered_labels = self.make_labels(unfiltered_dataframe) - # filter the features requested by user in the configuration file and elegantly handle NaNs features_filtered, labels_filtered = self.dh.filter_features( unfiltered_dataframe, @@ -127,7 +126,7 @@ class CatboostPredictionModel(IFreqaiModel): # logger.info("--------------------Starting prediction--------------------") - original_feature_list = self.dh.build_feature_list(self.config, metadata) + original_feature_list = self.dh.find_features(unfiltered_dataframe) filtered_dataframe, _ = self.dh.filter_features( unfiltered_dataframe, original_feature_list, training_filter=False ) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index e2bb6e041..f478dd332 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -62,8 +62,11 @@ class FreqaiExampleStrategy(IStrategy): def populate_any_indicators(self, pair, df, tf, informative=None, coin=""): """ Function designed to automatically generate, name and merge features - from user indicated timeframes in the configuration file. User can add - additional features here, but must follow the naming convention. + from user indicated timeframes in the configuration file. User controls the indicators + passed to the training/prediction by prepending indicators with `'%-' + coin ` + (see convention below). I.e. user should not prepend any supporting metrics + (e.g. bb_lowerband below) with % unless they explicitly want to pass that metric to the + model. :params: :pair: pair to be used as informative :df: strategy dataframe which will receive merges from informatives @@ -74,49 +77,50 @@ class FreqaiExampleStrategy(IStrategy): if informative is None: informative = self.dp.get_pair_dataframe(pair, tf) - informative[coin + "rsi"] = ta.RSI(informative, timeperiod=14) - informative[coin + "mfi"] = ta.MFI(informative, timeperiod=25) - informative[coin + "adx"] = ta.ADX(informative, window=20) + informative['%-' + coin + "rsi"] = ta.RSI(informative, timeperiod=14) + informative['%-' + coin + "mfi"] = ta.MFI(informative, timeperiod=25) + informative['%-' + coin + "adx"] = ta.ADX(informative, window=20) informative[coin + "20sma"] = ta.SMA(informative, timeperiod=20) informative[coin + "21ema"] = ta.EMA(informative, timeperiod=21) - informative[coin + "bmsb"] = np.where( + informative['%-' + coin + "bmsb"] = np.where( informative[coin + "20sma"].lt(informative[coin + "21ema"]), 1, 0 ) - informative[coin + "close_over_20sma"] = informative["close"] / informative[coin + "20sma"] + informative['%-' + coin + "close_over_20sma"] = informative["close"] / informative[ + coin + "20sma"] - informative[coin + "mfi"] = ta.MFI(informative, timeperiod=25) + informative['%-' + coin + "mfi"] = ta.MFI(informative, timeperiod=25) informative[coin + "ema21"] = ta.EMA(informative, timeperiod=21) informative[coin + "sma20"] = ta.SMA(informative, timeperiod=20) stoch = ta.STOCHRSI(informative, 15, 20, 2, 2) - informative[coin + "srsi-fk"] = stoch["fastk"] - informative[coin + "srsi-fd"] = stoch["fastd"] + informative['%-' + coin + "srsi-fk"] = stoch["fastk"] + informative['%-' + coin + "srsi-fd"] = stoch["fastd"] bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(informative), window=14, stds=2.2) informative[coin + "bb_lowerband"] = bollinger["lower"] informative[coin + "bb_middleband"] = bollinger["mid"] informative[coin + "bb_upperband"] = bollinger["upper"] - informative[coin + "bb_width"] = ( + informative['%-' + coin + "bb_width"] = ( informative[coin + "bb_upperband"] - informative[coin + "bb_lowerband"] ) / informative[coin + "bb_middleband"] - informative[coin + "close-bb_lower"] = ( + informative['%-' + coin + "close-bb_lower"] = ( informative["close"] / informative[coin + "bb_lowerband"] ) - informative[coin + "roc"] = ta.ROC(informative, timeperiod=3) - informative[coin + "adx"] = ta.ADX(informative, window=14) + informative['%-' + coin + "roc"] = ta.ROC(informative, timeperiod=3) + informative['%-' + coin + "adx"] = ta.ADX(informative, window=14) macd = ta.MACD(informative) - informative[coin + "macd"] = macd["macd"] + informative['%-' + coin + "macd"] = macd["macd"] informative[coin + "pct-change"] = informative["close"].pct_change() - informative[coin + "relative_volume"] = ( + informative['%-' + coin + "relative_volume"] = ( informative["volume"] / informative["volume"].rolling(10).mean() ) informative[coin + "pct-change"] = informative["close"].pct_change() - indicators = [col for col in informative if col.startswith(coin)] + indicators = [col for col in informative if col.startswith('%')] for n in range(self.freqai_info["feature_parameters"]["shift"] + 1): if n == 0: @@ -154,7 +158,6 @@ class FreqaiExampleStrategy(IStrategy): pair, dataframe.copy(), tf, coin=pair.split("/")[0] + "-" ) - print('dataframe_built') # the model will return 4 values, its prediction, an indication of whether or not the # prediction should be accepted, the target mean/std values from the labels used during # each training period. @@ -181,7 +184,6 @@ class FreqaiExampleStrategy(IStrategy): return dataframe def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - # sell_goal = eval('self.'+metadata['pair'].split("/")[0]+'_sell_goal.value') sell_conditions = [ (dataframe["prediction"] < dataframe["sell_roi"]) & (dataframe["do_predict"] == 1) ] From db66b82f6fc432bc16cdb07b4269deb3163a06e5 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 17 May 2022 19:50:06 +0200 Subject: [PATCH 030/130] accept open-ended timeranges from user --- freqtrade/freqai/data_kitchen.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index cfdbac5f5..8ccb95dbe 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -312,6 +312,9 @@ class FreqaiDataKitchen: full_timerange = TimeRange.parse_timerange(tr) config_timerange = TimeRange.parse_timerange(self.config["timerange"]) + if config_timerange.stopts == 0: + config_timerange.stopts = int(datetime.datetime.now( + tz=datetime.timezone.utc).timestamp()) timerange_train = copy.deepcopy(full_timerange) timerange_backtest = copy.deepcopy(full_timerange) @@ -589,6 +592,10 @@ class FreqaiDataKitchen: def create_fulltimerange(self, backtest_tr: str, backtest_period: int) -> str: backtest_timerange = TimeRange.parse_timerange(backtest_tr) + if backtest_timerange.stopts == 0: + backtest_timerange.stopts = int(datetime.datetime.now( + tz=datetime.timezone.utc).timestamp()) + backtest_timerange.startts = backtest_timerange.startts - backtest_period * SECONDS_IN_DAY start = datetime.datetime.utcfromtimestamp(backtest_timerange.startts) stop = datetime.datetime.utcfromtimestamp(backtest_timerange.stopts) From c81b9607915bbf3fc916a5f0e0a7fbbd0e0834f2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 15 May 2022 16:25:08 +0200 Subject: [PATCH 031/130] Fix some typos --- docs/freqai.md | 35 +++++++++++-------- freqtrade/freqai/freqai_interface.py | 6 ++-- freqtrade/templates/ExamplePredictionModel.py | 2 +- mkdocs.yml | 2 +- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 29a45d042..730f353c8 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -4,8 +4,9 @@ Freqai is still experimental, and should be used at the user's own discretion. Freqai is a module designed to automate a variety of tasks associated with -training a regressor to predict signals based on input features. Among the -the features includes: +training a regressor to predict signals based on input features. + +Among the the features included: * Easy large feature set construction based on simple user input * Sweep model training and backtesting to simulate consistent model retraining through time @@ -16,6 +17,7 @@ the features includes: * Cleaning of NaNs from the data set before training and prediction. TODO: + * live is not automated, still some architectural work to be done ## Background and vocabulary @@ -43,7 +45,7 @@ directly influence nodal weights within the model. ## Install prerequisites -Use `pip` to install the prerequisities with: +Use `pip` to install the prerequisites with: `pip install -r requirements-freqai.txt` @@ -62,7 +64,9 @@ FreqaiExampleStrategy --freqaimodel CatboostPredictionModel --strategy-path freq ``` ## Configuring the bot + ### Example config file + The user interface is isolated to the typical config file. A typical Freqai config setup includes: @@ -152,8 +156,8 @@ data set timerange months. Users can think of this as a "sliding window" which emulates Freqai retraining itself once per week in live using the previous month of data. - ## Running Freqai + ### Training and backtesting The freqai training/backtesting module can be executed with the following command: @@ -196,6 +200,7 @@ The Freqai strategy requires the user to include the following lines of code in return dataframe ``` + The user should also include `populate_any_indicators()` from `templates/FreqaiExampleStrategy.py` which builds the feature set with a proper naming convention for the IFreqaiModel to use later. @@ -216,7 +221,7 @@ freqtrade trade --strategy FreqaiExampleStrategy --config config_freqai.example. By default, Freqai will not find find any existing models and will start by training a new one given the user configuration settings. Following training, it will use that model to predict for the duration of `backtest_period`. After a full `backtest_period` has elapsed, Freqai will auto retrain -a new model, and begin making predictions with the updated model. +a new model, and begin making predictions with the updated model. If the user wishes to start dry/live from a saved model, the following configuration parameters need to be set: @@ -232,13 +237,14 @@ parameters need to be set: Where the `identifier` is the same identifier which was set during the backtesting/training. Meanwhile, the `live_trained_timerange` is the sub-trained timerange (the training window) which was set during backtesting/training. These are available to the user inside `user_data/models/*/sub-train-*`. -`live_full_backtestrange` was the full data range assocaited with the backtest/training (the full time +`live_full_backtestrange` was the full data range associated with the backtest/training (the full time window that the training window and backtesting windows slide through). These values can be located inside the `user_data/models/` directory. In this case, although Freqai will initiate with a -pretrained model, if a full `backtest_period` has elapsed since the end of the user set -`live_trained_timerange`, it will self retrain. +pre-trained model, if a full `backtest_period` has elapsed since the end of the user set +`live_trained_timerange`, it will self retrain. ## Data anylsis techniques + ### Controlling the model learning process The user can define model settings for the data split `data_split_parameters` and learning parameters @@ -258,7 +264,7 @@ the user is asking for `labels` that are 24 candles in the future. ### Removing outliers with the Dissimilarity Index -The Dissimilarity Index (DI) aims to quantiy the uncertainty associated with each +The Dissimilarity Index (DI) aims to quantity the uncertainty associated with each prediction by the model. To do so, Freqai measures the distance between each training data point and all other training data points: @@ -310,11 +316,11 @@ Users can reduce the dimensionality of their features by activating the `princip ``` Which will perform PCA on the features and reduce the dimensionality of the data so that the explained -variance of the data set is >= 0.999. +variance of the data set is >= 0.999. ### Removing outliers based on feature statistical distributions -The user can tell Freqai to remove outlier data points from the trainig/test data sets by setting: +The user can tell Freqai to remove outlier data points from the training/test data sets by setting: ```json "freqai": { @@ -326,9 +332,10 @@ The user can tell Freqai to remove outlier data points from the trainig/test dat Freqai will check the statistical distributions of each feature (or component if the user activated `principal_component_analysis`) and remove any data point that sits more than 3 standard deviations away -from the mean. +from the mean. ## Additional information + ### Feature standardization The feature set created by the user is automatically standardized to the training @@ -337,5 +344,5 @@ data only. This includes all test data and unseen prediction data (dry/live/back ### File structure `user_data_dir/models/` contains all the data associated with the trainings and -backtestings. This file structure is heavily controlled and read by the `FreqaiDataKitchen()` -and should thus not be modified. +backtests. This file structure is heavily controlled and read by the `FreqaiDataKitchen()` +and should thus not be modified. diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index ae05ae33a..b7c879ff0 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -43,7 +43,7 @@ class IFreqaiModel(ABC): def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ Entry point to the FreqaiModel, it will train a new model if - necesssary before making the prediction. + necessary before making the prediction. The backtesting and training paradigm is a sliding training window with a following backtest window. Both windows slide according to the length of the backtest window. This function is not intended to be @@ -54,7 +54,7 @@ class IFreqaiModel(ABC): :dataframe: Full dataframe coming from strategy - it contains entire backtesting timerange + additional historical data necessary to train the model. - :metadata: pair metadataa coming from strategy. + :metadata: pair metadata coming from strategy. """ live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE) @@ -71,7 +71,7 @@ class IFreqaiModel(ABC): logger.info("going to train %s timeranges", len(self.dh.training_timeranges)) - # Loop enforcing the sliding window training/backtesting paragigm + # Loop enforcing the sliding window training/backtesting paradigm # tr_train is the training time range e.g. 1 historical month # tr_backtest is the backtesting time range e.g. the week directly # following tr_train. Both of these windows slide through the diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py index 796fb23ed..3d2b7a808 100644 --- a/freqtrade/templates/ExamplePredictionModel.py +++ b/freqtrade/templates/ExamplePredictionModel.py @@ -42,7 +42,7 @@ class ExamplePredictionModel(IFreqaiModel): def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: """ - Filter the training data and train a model to it. Train makes heavy use of the datahkitchen + Filter the training data and train a model to it. Train makes heavy use of the datakitchen for storing, saving, loading, and analyzing the data. :params: :unfiltered_dataframe: Full dataframe for the current training period diff --git a/mkdocs.yml b/mkdocs.yml index 64d78363d..18744e0d5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,8 +35,8 @@ nav: - Edge Positioning: edge.md - Advanced Strategy: strategy-advanced.md - Advanced Hyperopt: advanced-hyperopt.md - - Sandbox Testing: sandbox-testing.md - Freqai: freqai.md + - Sandbox Testing: sandbox-testing.md - FAQ: faq.md - SQL Cheat-sheet: sql_cheatsheet.md - Strategy migration: strategy_migration.md From c708dd3186a577fdab5ad5afd7a6c9b6b42e718b Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 17 May 2022 20:41:42 +0200 Subject: [PATCH 032/130] doc update thanks matthias --- docs/freqai.md | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 730f353c8..df41846a4 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -4,7 +4,7 @@ Freqai is still experimental, and should be used at the user's own discretion. Freqai is a module designed to automate a variety of tasks associated with -training a regressor to predict signals based on input features. +training a predictive model to provide signals based on input features. Among the the features included: @@ -15,10 +15,16 @@ Among the the features included: * Automatic file management for storage of models to be reused during live * Smart and safe data standardization * Cleaning of NaNs from the data set before training and prediction. +* Automated live retraining (still VERY experimental. Proceed with caution.) -TODO: +## General approach -* live is not automated, still some architectural work to be done +The user provides FreqAI with a set of custom indicators (created inside the strategy the same way +a typical Freqtrade strategy is created) as well as a target value (typically some price change into +the future). FreqAI trains a model to predict the target value based on the input of custom indicators. +FreqAI will train and save a new model for each pair in the config whitelist. +Users employ FreqAI to backtest a strategy (emulate reality with retraining a model as new data is +introduced) and run the model live to generate buy and sell signals. ## Background and vocabulary @@ -58,9 +64,7 @@ An example strategy, an example prediction model, and example config can all be the necessary data, Freqai can be executed from these templates with: ```bash -freqtrade backtesting --config config_examples/config_freqai.example.json --strategy -FreqaiExampleStrategy --freqaimodel CatboostPredictionModel --strategy-path freqtrade/templates ---timerange 20220101-220201 +freqtrade backtesting --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel CatboostPredictionModel --strategy-path freqtrade/templates --timerange 20220101-20220201 ``` ## Configuring the bot @@ -163,12 +167,21 @@ month of data. The freqai training/backtesting module can be executed with the following command: ```bash -freqtrade backtesting --strategy FreqaiExampleStrategy --config config_freqai.example.json --freqaimodel ExamplePredictionModel --timerange 20210501-20210701 +freqtrade backtesting --strategy FreqaiExampleStrategy --config config_freqai.example.json --freqaimodel CatboostPredictionModel --timerange 20210501-20210701 ``` -where the user needs to have a FreqaiExampleStrategy that fits to the requirements outlined -below. The ExamplePredictionModel is a user built class which lets users design their -own training procedures and data analysis. +If this command has never been executed with the existing config file, then it will train a new model +for each pair, for each backtesting window within the bigger `--timerange`._ + +--- +**NOTE** +Once the training is completed, the user can execute this again with the same config file and +FreqAI will find the trained models and load them instead of spending time training. This is useful +if the user wants to tweak (or even hyperopt) buy and sell criteria inside the strategy. IF the user +*wants* to retrain a new model with the same config file, then he/she should simply change the `identifier`. +This way, the user can return to using any model they wish by simply changing the `identifier`. + +--- ### Building a freqai strategy @@ -264,7 +277,7 @@ the user is asking for `labels` that are 24 candles in the future. ### Removing outliers with the Dissimilarity Index -The Dissimilarity Index (DI) aims to quantity the uncertainty associated with each +The Dissimilarity Index (DI) aims to quantify the uncertainty associated with each prediction by the model. To do so, Freqai measures the distance between each training data point and all other training data points: From 89eacf2f47fd02526027400022db7b5c487be23d Mon Sep 17 00:00:00 2001 From: robcaulk Date: Thu, 19 May 2022 17:15:50 +0200 Subject: [PATCH 033/130] Retrain model if FreqAI found a pretrained model but user strategy is not passing the expected features (user has changed the features in the strategy but has passed a the same config[freqai][identifier]). Logger warning output to user. --- freqtrade/freqai/freqai_interface.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index b7c879ff0..6a1d97470 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -92,6 +92,11 @@ class IFreqaiModel(ABC): self.dh.save_data(self.model) else: self.model = self.dh.load_data() + strategy_provided_features = self.dh.find_features(dataframe_train) + if strategy_provided_features != self.dh.training_features_list: + logger.info("User changed input features, retraining model.") + self.model = self.train(dataframe_train, metadata) + self.dh.save_data(self.model) preds, do_preds = self.predict(dataframe_backtest, metadata) From 67eb94c69d88f7b53a1bf9d980faf5dcd4f4b037 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Thu, 19 May 2022 17:55:00 +0200 Subject: [PATCH 034/130] download-data will now check if freqai is active in config, and if so will also download data for corr_pairlist --- freqtrade/commands/data_commands.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index a2e2a100a..4588bf67b 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -50,7 +50,13 @@ def start_download_data(args: Dict[str, Any]) -> None: exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) markets = [p for p, m in exchange.markets.items() if market_is_active(m) or config.get('include_inactive')] - expanded_pairs = expand_pairlist(config['pairs'], markets) + if config.get('freqai') is not None: + assert config['freqai'].get('corr_pairlist'), "No corr_pairlist found in config." + full_pairs = config['pairs'] + [pair for pair in config['freqai']['corr_pairlist'] + if pair not in config['pairs']] + expanded_pairs = expand_pairlist(full_pairs, markets) + else: + expanded_pairs = expand_pairlist(config['pairs'], markets) # Manual validations of relevant settings if not config['exchange'].get('skip_pair_validation', False): From 1fae6c9ef794a014c3e8f1a692bda8b66b46b960 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Thu, 19 May 2022 19:27:38 +0200 Subject: [PATCH 035/130] keep model accessible in memory to avoid loading objects from disk during live/dry --- freqtrade/freqai/data_kitchen.py | 30 +++++++++++++++----- freqtrade/templates/FreqaiExampleStrategy.py | 6 ++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 8ccb95dbe..e35243f6a 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -50,8 +50,9 @@ class FreqaiDataKitchen: self.full_target_std: npt.ArrayLike = np.array([]) self.model_path = Path() self.model_filename: str = "" - - if not live: + self.model_dictionary: Dict[Any, Any] = {} + self.live = live + if not self.live: self.full_timerange = self.create_fulltimerange(self.config["timerange"], self.freqai_config["train_period"] ) @@ -88,8 +89,8 @@ class FreqaiDataKitchen: # Save the trained model dump(model, save_path / str(self.model_filename + "_model.joblib")) - self.data["model_path"] = self.model_path - self.data["model_filename"] = self.model_filename + self.data["model_path"] = str(self.model_path) + self.data["model_filename"] = str(self.model_filename) self.data["training_features_list"] = list(self.data_dictionary["train_features"].columns) # store the metadata with open(save_path / str(self.model_filename + "_metadata.json"), "w") as fp: @@ -100,6 +101,9 @@ class FreqaiDataKitchen: save_path / str(self.model_filename + "_trained_df.pkl") ) + if self.live: + self.model_dictionary[self.model_filename] = model + return def load_data(self) -> Any: @@ -108,7 +112,6 @@ class FreqaiDataKitchen: :returns: :model: User trained model which can be inferenced for new predictions """ - model = load(self.model_path / str(self.model_filename + "_model.joblib")) with open(self.model_path / str(self.model_filename + "_metadata.json"), "r") as fp: self.data = json.load(fp) @@ -118,8 +121,20 @@ class FreqaiDataKitchen: self.model_path / str(self.model_filename + "_trained_df.pkl") ) - self.model_path = self.data["model_path"] + self.model_path = Path(self.data["model_path"]) self.model_filename = self.data["model_filename"] + + # try to access model in memory instead of loading object from disk to save time + if self.live and self.model_filename in self.model_dictionary: + model = self.model_dictionary[self.model_filename] + else: + model = load(self.model_path / str(self.model_filename + "_model.joblib")) + + assert model, ( + f"Unable to load model, ensure model exists at " + f"{self.model_path} " + ) + if self.config["freqai"]["feature_parameters"]["principal_component_analysis"]: self.pca = pk.load( open(self.model_path / str(self.model_filename + "_pca_object.pkl"), "rb") @@ -682,7 +697,8 @@ class FreqaiDataKitchen: for p in pairs: if metadata['pair'] in p: continue # dont repeat anything from whitelist - corr_dataframes[p] = {} + if p not in corr_dataframes: + corr_dataframes[p] = {} corr_dataframes[p][tf] = load_pair_history(datadir=self.config['datadir'], timeframe=tf, pair=p, timerange=timerange) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index f478dd332..6478ca167 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -59,6 +59,9 @@ class FreqaiExampleStrategy(IStrategy): informative_pairs.append((pair, tf)) return informative_pairs + def bot_start(self): + self.model = CustomModel(self.config) + def populate_any_indicators(self, pair, df, tf, informative=None, coin=""): """ Function designed to automatically generate, name and merge features @@ -141,9 +144,6 @@ class FreqaiExampleStrategy(IStrategy): self.freqai_info = self.config["freqai"] self.pair = metadata['pair'] - # the model is instantiated here - self.model = CustomModel(self.config) - print("Populating indicators...") # the following loops are necessary for building the features From c5ecf941770727908774ba928a96a7a3c1ec32b6 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Thu, 19 May 2022 21:15:58 +0200 Subject: [PATCH 036/130] move live retraining to separate thread. --- freqtrade/freqai/freqai_interface.py | 89 ++++++++++++++++---- freqtrade/templates/FreqaiExampleStrategy.py | 2 - 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 6a1d97470..2523cd561 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -1,5 +1,8 @@ +# import contextlib import gc import logging +# import sys +import threading from abc import ABC, abstractmethod from pathlib import Path from typing import Any, Dict, Tuple @@ -16,6 +19,24 @@ from freqtrade.strategy.interface import IStrategy pd.options.mode.chained_assignment = None logger = logging.getLogger(__name__) +# FIXME: suppress stdout for background training +# class DummyFile(object): +# def write(self, x): pass + + +# @contextlib.contextmanager +# def nostdout(): +# save_stdout = sys.stdout +# sys.stdout = DummyFile() +# yield +# sys.stdout = save_stdout + + +def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs).start() + return wrapper + class IFreqaiModel(ABC): """ @@ -39,6 +60,8 @@ class IFreqaiModel(ABC): self.current_time = None self.model = None self.predictions = None + self.training_on_separate_thread = False + self.retrain = False def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ @@ -122,25 +145,26 @@ class IFreqaiModel(ABC): training_timerange=self.freqai_info[ 'live_trained_timerange']) - (retrain, - new_trained_timerange) = self.dh.check_if_new_training_required(self.freqai_info[ + if not self.training_on_separate_thread: + # this will also prevent other pairs from trying to train simultaneously. + (self.retrain, + new_trained_timerange) = self.dh.check_if_new_training_required(self.freqai_info[ 'live_trained_timerange'], - metadata) + metadata) + else: + logger.info("FreqAI training a new model on background thread.") + self.retrain = False - if retrain or not file_exists: - self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) - corr_dataframes, base_dataframes = self.dh.load_pairs_histories(new_trained_timerange, - metadata) - - unfiltered_dataframe = self.dh.use_strategy_to_populate_indicators(strategy, - corr_dataframes, - base_dataframes, - metadata) - - self.model = self.train(unfiltered_dataframe, metadata) - self.dh.save_data(self.model) + if self.retrain or not file_exists: + self.training_on_separate_thread = True # acts like a lock + self.retrain_model_on_separate_thread(new_trained_timerange, metadata, strategy) self.model = self.dh.load_data() + + strategy_provided_features = self.dh.find_features(dataframe) + if strategy_provided_features != self.dh.training_features_list: + self.train_model_in_series(new_trained_timerange, metadata, strategy) + preds, do_preds = self.predict(dataframe, metadata) self.dh.append_predictions(preds, do_preds, len(dataframe)) @@ -206,3 +230,38 @@ class IFreqaiModel(ABC): else: logger.info("Could not find model at %s", self.dh.model_path / self.dh.model_filename) return file_exists + + @threaded + def retrain_model_on_separate_thread(self, new_trained_timerange: str, metadata: dict, + strategy: IStrategy): + + # with nostdout(): + self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) + corr_dataframes, base_dataframes = self.dh.load_pairs_histories(new_trained_timerange, + metadata) + + unfiltered_dataframe = self.dh.use_strategy_to_populate_indicators(strategy, + corr_dataframes, + base_dataframes, + metadata) + + self.model = self.train(unfiltered_dataframe, metadata) + self.dh.save_data(self.model) + + self.training_on_separate_thread = False + self.retrain = False + + def train_model_in_series(self, new_trained_timerange: str, metadata: dict, + strategy: IStrategy): + + self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) + corr_dataframes, base_dataframes = self.dh.load_pairs_histories(new_trained_timerange, + metadata) + + unfiltered_dataframe = self.dh.use_strategy_to_populate_indicators(strategy, + corr_dataframes, + base_dataframes, + metadata) + + self.model = self.train(unfiltered_dataframe, metadata) + self.dh.save_data(self.model) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 6478ca167..c8befebcf 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -144,8 +144,6 @@ class FreqaiExampleStrategy(IStrategy): self.freqai_info = self.config["freqai"] self.pair = metadata['pair'] - print("Populating indicators...") - # the following loops are necessary for building the features # indicated by the user in the configuration file. for tf in self.freqai_info["timeframes"]: From 42d95af829738dbb4efc1f69ba9606c01e811230 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 22 May 2022 17:51:49 +0200 Subject: [PATCH 037/130] Aggregated commit. Adding support vector machine for outlier detection, improve user interface to dry/live, better standardization, fix various other bugs --- config_examples/config_freqai.example.json | 11 +- docs/freqai.md | 10 +- freqtrade/freqai/data_kitchen.py | 380 +++++++++++++----- freqtrade/freqai/freqai_interface.py | 56 ++- .../CatboostPredictionModel.py | 82 ++-- freqtrade/templates/ExamplePredictionModel.py | 159 -------- freqtrade/templates/FreqaiExampleStrategy.py | 6 +- 7 files changed, 404 insertions(+), 300 deletions(-) delete mode 100644 freqtrade/templates/ExamplePredictionModel.py diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 648f36917..a895a7341 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -57,8 +57,8 @@ "train_period": 30, "backtest_period": 7, "identifier": "example", - "live_trained_timerange": "20220330-20220429", - "live_full_backtestrange": "20220302-20220501", + "live_trained_timerange": "", + "live_full_backtestrange": "", "corr_pairlist": [ "BTC/USDT", "ETH/USDT", @@ -68,20 +68,19 @@ "feature_parameters": { "period": 12, "shift": 1, - "drop_features": false, "DI_threshold": 1, "weight_factor": 0, "principal_component_analysis": false, - "remove_outliers": false + "use_SVM_to_remove_outliers": false }, "data_split_parameters": { "test_size": 0.25, "random_state": 1 }, "model_training_parameters": { - "n_estimators": 2000, + "n_estimators": 1000, "random_state": 1, - "learning_rate": 0.02, + "learning_rate": 0.1, "task_type": "CPU" } }, diff --git a/docs/freqai.md b/docs/freqai.md index df41846a4..8a37e7d66 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -331,21 +331,21 @@ Users can reduce the dimensionality of their features by activating the `princip Which will perform PCA on the features and reduce the dimensionality of the data so that the explained variance of the data set is >= 0.999. -### Removing outliers based on feature statistical distributions +### Removing outliers using a Support Vector Machine (SVM) The user can tell Freqai to remove outlier data points from the training/test data sets by setting: ```json "freqai": { "feature_parameters" : { - "remove_outliers": true + "use_SVM_to_remove_outliers: true } } ``` -Freqai will check the statistical distributions of each feature (or component if the user activated -`principal_component_analysis`) and remove any data point that sits more than 3 standard deviations away -from the mean. +Freqai will train an SVM on the training data (or components if the user activated +`principal_component_analysis`) and remove any data point that it deems to be sit beyond the +feature space. ## Additional information diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index e35243f6a..f589a1c89 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -10,8 +10,9 @@ from typing import Any, Dict, List, Tuple import numpy as np import numpy.typing as npt import pandas as pd -from joblib import dump, load +from joblib import dump, load # , Parallel, delayed # used for auto distribution assignment from pandas import DataFrame +from sklearn import linear_model from sklearn.metrics.pairwise import pairwise_distances from sklearn.model_selection import train_test_split @@ -22,6 +23,9 @@ from freqtrade.resolvers import ExchangeResolver from freqtrade.strategy.interface import IStrategy +# import scipy as spy # used for auto distribution assignment + + SECONDS_IN_DAY = 86400 logger = logging.getLogger(__name__) @@ -52,6 +56,7 @@ class FreqaiDataKitchen: self.model_filename: str = "" self.model_dictionary: Dict[Any, Any] = {} self.live = live + self.svm_model: linear_model.SGDOneClassSVM = None if not self.live: self.full_timerange = self.create_fulltimerange(self.config["timerange"], self.freqai_config["train_period"] @@ -89,6 +94,10 @@ class FreqaiDataKitchen: # Save the trained model dump(model, save_path / str(self.model_filename + "_model.joblib")) + + if self.svm_model is not None: + dump(self.svm_model, save_path / str(self.model_filename + "_svm_model.joblib")) + self.data["model_path"] = str(self.model_path) self.data["model_filename"] = str(self.model_filename) self.data["training_features_list"] = list(self.data_dictionary["train_features"].columns) @@ -104,6 +113,19 @@ class FreqaiDataKitchen: if self.live: self.model_dictionary[self.model_filename] = model + # TODO add a helper function to let user save/load any data they are custom adding. We + # do not want them having to edit the default save/load methods here. Below is an example + # of what we do NOT want. + + # if self.freqai_config['feature_parameters']['determine_statistical_distributions']: + # self.data_dictionary["upper_quantiles"].to_pickle( + # save_path / str(self.model_filename + "_upper_quantiles.pkl") + # ) + + # self.data_dictionary["lower_quantiles"].to_pickle( + # save_path / str(self.model_filename + "_lower_quantiles.pkl") + # ) + return def load_data(self) -> Any: @@ -121,6 +143,19 @@ class FreqaiDataKitchen: self.model_path / str(self.model_filename + "_trained_df.pkl") ) + # TODO add a helper function to let user save/load any data they are custom adding. We + # do not want them having to edit the default save/load methods here. Below is an example + # of what we do NOT want. + + # if self.freqai_config['feature_parameters']['determine_statistical_distributions']: + # self.data_dictionary["upper_quantiles"] = pd.read_pickle( + # self.model_path / str(self.model_filename + "_upper_quantiles.pkl") + # ) + + # self.data_dictionary["lower_quantiles"] = pd.read_pickle( + # self.model_path / str(self.model_filename + "_lower_quantiles.pkl") + # ) + self.model_path = Path(self.data["model_path"]) self.model_filename = self.data["model_filename"] @@ -130,6 +165,10 @@ class FreqaiDataKitchen: else: model = load(self.model_path / str(self.model_filename + "_model.joblib")) + if Path(self.model_path / str(self.model_filename + + "_svm_model.joblib")).resolve().exists(): + self.svm_model = load(self.model_path / str(self.model_filename + "_svm_model.joblib")) + assert model, ( f"Unable to load model, ensure model exists at " f"{self.model_path} " @@ -159,6 +198,12 @@ class FreqaiDataKitchen: else: weights = np.ones(len(filtered_dataframe)) + if self.config["freqai"]["feature_parameters"]["stratify"] > 0: + stratification = np.zeros(len(filtered_dataframe)) + for i in range(1, len(stratification)): + if i % self.config["freqai"]["feature_parameters"]["stratify"] == 0: + stratification[i] = 1 + ( train_features, test_features, @@ -170,6 +215,8 @@ class FreqaiDataKitchen: filtered_dataframe[: filtered_dataframe.shape[0]], labels, weights, + stratify=stratification, + # shuffle=False, **self.config["freqai"]["data_split_parameters"] ) @@ -261,9 +308,9 @@ class FreqaiDataKitchen: return self.data_dictionary - def standardize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: + def normalize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: """ - Standardize all data in the data_dictionary according to the training dataset + Normalize all data in the data_dictionary according to the training dataset :params: :data_dictionary: dictionary containing the cleaned and split training/test data/labels :returns: @@ -297,6 +344,42 @@ class FreqaiDataKitchen: return data_dictionary + def standardize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: + """ + Standardize all data in the data_dictionary according to the training dataset + :params: + :data_dictionary: dictionary containing the cleaned and split training/test data/labels + :returns: + :data_dictionary: updated dictionary with standardized values. + """ + # standardize the data by training stats + train_max = data_dictionary["train_features"].max() + train_min = data_dictionary["train_features"].min() + data_dictionary["train_features"] = 2 * ( + data_dictionary["train_features"] - train_min + ) / (train_max - train_min) - 1 + data_dictionary["test_features"] = 2 * ( + data_dictionary["test_features"] - train_min + ) / (train_max - train_min) - 1 + + train_labels_max = data_dictionary["train_labels"].max() + train_labels_min = data_dictionary["train_labels"].min() + data_dictionary["train_labels"] = 2 * ( + data_dictionary["train_labels"] - train_labels_min + ) / (train_labels_max - train_labels_min) - 1 + data_dictionary["test_labels"] = 2 * ( + data_dictionary["test_labels"] - train_labels_min + ) / (train_labels_max - train_labels_min) - 1 + + for item in train_max.keys(): + self.data[item + "_max"] = train_max[item] + self.data[item + "_min"] = train_min[item] + + self.data["labels_max"] = train_labels_max + self.data["labels_min"] = train_labels_min + + return data_dictionary + def standardize_data_from_metadata(self, df: DataFrame) -> DataFrame: """ Standardizes a set of data using the mean and standard deviation from @@ -305,6 +388,20 @@ class FreqaiDataKitchen: :df: Dataframe to be standardized """ + for item in df.keys(): + df[item] = 2 * (df[item] - self.data[item + "_min"]) / (self.data[item + "_max"] - + self.data[item + '_min']) - 1 + + return df + + def normalize_data_from_metadata(self, df: DataFrame) -> DataFrame: + """ + Normalizes a set of data using the mean and standard deviation from + the associated training data. + :params: + :df: Dataframe to be standardized + """ + for item in df.keys(): df[item] = (df[item] - self.data[item + "_mean"]) / self.data[item + "_std"] @@ -420,6 +517,8 @@ class FreqaiDataKitchen: self.data["n_kept_components"] = n_keep_components self.pca = pca2 + logger.info(f'PCA reduced total features from {n_components} to {n_keep_components}') + if not self.model_path.is_dir(): self.model_path.mkdir(parents=True, exist_ok=True) pk.dump(pca2, open(self.model_path / str(self.model_filename + "_pca_object.pkl"), "wb")) @@ -434,70 +533,53 @@ class FreqaiDataKitchen: return avg_mean_dist - def remove_outliers(self, predict: bool) -> None: - """ - Remove data that looks like an outlier based on the distribution of each - variable. - :params: - :predict: boolean which tells the function if this is prediction data or - training data coming in. - """ - - lower_quantile = self.data_dictionary["train_features"].quantile(0.001) - upper_quantile = self.data_dictionary["train_features"].quantile(0.999) + def use_SVM_to_remove_outliers(self, predict: bool) -> None: if predict: - - df = self.data_dictionary["prediction_features"][ - (self.data_dictionary["prediction_features"] < upper_quantile) - & (self.data_dictionary["prediction_features"] > lower_quantile) - ] - drop_index = pd.isnull(df).any(1) - self.data_dictionary["prediction_features"].fillna(0, inplace=True) - drop_index = ~drop_index - do_predict = np.array(drop_index.replace(True, 1).replace(False, 0)) + assert self.svm_model, "No svm model available for outlier removal" + y_pred = self.svm_model.predict(self.data_dictionary["prediction_features"]) + do_predict = np.where(y_pred == -1, 0, y_pred) logger.info( - "remove_outliers() tossed %s predictions", - len(do_predict) - do_predict.sum(), + f'svm_remove_outliers() tossed {len(do_predict) - do_predict.sum()} predictions' ) self.do_predict += do_predict self.do_predict -= 1 else: + # use SGDOneClassSVM to increase speed? + self.svm_model = linear_model.SGDOneClassSVM(nu=0.1).fit( + self.data_dictionary["train_features"] + ) + y_pred = self.svm_model.predict(self.data_dictionary["train_features"]) + dropped_points = np.where(y_pred == -1, 0, y_pred) + # keep_index = np.where(y_pred == 1) + self.data_dictionary["train_features"] = self.data_dictionary[ + "train_features"][(y_pred == 1)] + self.data_dictionary["train_labels"] = self.data_dictionary[ + "train_labels"][(y_pred == 1)] + self.data_dictionary["train_weights"] = self.data_dictionary[ + "train_weights"][(y_pred == 1)] - filter_train_df = self.data_dictionary["train_features"][ - (self.data_dictionary["train_features"] < upper_quantile) - & (self.data_dictionary["train_features"] > lower_quantile) - ] - drop_index = pd.isnull(filter_train_df).any(1) - drop_index = drop_index.replace(True, 1).replace(False, 0) - self.data_dictionary["train_features"] = self.data_dictionary["train_features"][ - (drop_index == 0) - ] - self.data_dictionary["train_labels"] = self.data_dictionary["train_labels"][ - (drop_index == 0) - ] - self.data_dictionary["train_weights"] = self.data_dictionary["train_weights"][ - (drop_index == 0) - ] + logger.info( + f'svm_remove_outliers() tossed {len(y_pred) - dropped_points.sum()}' + f' train points from {len(y_pred)}' + ) - # do the same for the test data - filter_test_df = self.data_dictionary["test_features"][ - (self.data_dictionary["test_features"] < upper_quantile) - & (self.data_dictionary["test_features"] > lower_quantile) - ] - drop_index = pd.isnull(filter_test_df).any(1) - drop_index = drop_index.replace(True, 1).replace(False, 0) - self.data_dictionary["test_labels"] = self.data_dictionary["test_labels"][ - (drop_index == 0) - ] - self.data_dictionary["test_features"] = self.data_dictionary["test_features"][ - (drop_index == 0) - ] - self.data_dictionary["test_weights"] = self.data_dictionary["test_weights"][ - (drop_index == 0) - ] + # same for test data + y_pred = self.svm_model.predict(self.data_dictionary["test_features"]) + dropped_points = np.where(y_pred == -1, 0, y_pred) + self.data_dictionary["test_features"] = self.data_dictionary[ + "test_features"][(y_pred == 1)] + self.data_dictionary["test_labels"] = self.data_dictionary[ + "test_labels"][(y_pred == 1)] + self.data_dictionary["test_weights"] = self.data_dictionary[ + "test_weights"][(y_pred == 1)] + + logger.info( + f'svm_remove_outliers() tossed {len(y_pred) - dropped_points.sum()}' + f' test points from {len(y_pred)}' + ) return @@ -507,32 +589,6 @@ class FreqaiDataKitchen: assert features, ("Could not find any features!") return features - # def build_feature_list(self, config: dict, metadata: dict) -> list: - # """ - # SUPERCEDED BY self.find_features() - # Build the list of features that will be used to filter - # the full dataframe. Feature list is construced from the - # user configuration file. - # :params: - # :config: Canonical freqtrade config file containing all - # user defined input in config['freqai] dictionary. - # """ - # features = [] - # for tf in config["freqai"]["timeframes"]: - # for ft in config["freqai"]["base_features"]: - # for n in range(config["freqai"]["feature_parameters"]["shift"] + 1): - # shift = "" - # if n > 0: - # shift = "_shift-" + str(n) - # features.append(metadata['pair'].split("/")[0] + "-" + ft + shift + "_" + tf) - # for p in config["freqai"]["corr_pairlist"]: - # if metadata['pair'] in p: - # continue # avoid duplicate features - # features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) - - # # logger.info("number of features %s", len(features)) - # return features - def check_if_pred_in_training_spaces(self) -> None: """ Compares the distance from each prediction point to each training data @@ -568,7 +624,7 @@ class FreqaiDataKitchen: training than older data. """ - weights = np.zeros_like(num_weights) + weights = np.zeros(num_weights) for i in range(1, len(weights)): weights[len(weights) - i] = np.exp( -i / (self.config["freqai"]["feature_parameters"]["weight_factor"] * num_weights) @@ -638,19 +694,23 @@ class FreqaiDataKitchen: time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() - trained_timerange = TimeRange.parse_timerange(training_timerange) + if training_timerange: # user passed no live_trained_timerange in config + trained_timerange = TimeRange.parse_timerange(training_timerange) + elapsed_time = (time - trained_timerange.stopts) / SECONDS_IN_DAY + trained_timerange.startts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY + trained_timerange.stopts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY + retrain = elapsed_time > self.freqai_config['backtest_period'] + else: + trained_timerange = TimeRange.parse_timerange("20000101-20000201") + trained_timerange.startts = int(time - self.freqai_config['train_period'] * + SECONDS_IN_DAY) + trained_timerange.stopts = int(time) + retrain = True - elapsed_time = (time - trained_timerange.stopts) / SECONDS_IN_DAY - - trained_timerange.startts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY - trained_timerange.stopts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY start = datetime.datetime.utcfromtimestamp(trained_timerange.startts) stop = datetime.datetime.utcfromtimestamp(trained_timerange.stopts) - new_trained_timerange = start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d") - retrain = elapsed_time > self.freqai_config['backtest_period'] - if retrain: coin, _ = metadata['pair'].split("/") # set the new model_path @@ -738,3 +798,141 @@ class FreqaiDataKitchen: def np_encoder(self, object): if isinstance(object, np.generic): return object.item() + + # Functions containing useful data manpulation examples. but not actively in use. + + # def build_feature_list(self, config: dict, metadata: dict) -> list: + # """ + # SUPERCEDED BY self.find_features() + # Build the list of features that will be used to filter + # the full dataframe. Feature list is construced from the + # user configuration file. + # :params: + # :config: Canonical freqtrade config file containing all + # user defined input in config['freqai] dictionary. + # """ + # features = [] + # for tf in config["freqai"]["timeframes"]: + # for ft in config["freqai"]["base_features"]: + # for n in range(config["freqai"]["feature_parameters"]["shift"] + 1): + # shift = "" + # if n > 0: + # shift = "_shift-" + str(n) + # features.append(metadata['pair'].split("/")[0] + "-" + ft + shift + "_" + tf) + # for p in config["freqai"]["corr_pairlist"]: + # if metadata['pair'] in p: + # continue # avoid duplicate features + # features.append(p.split("/")[0] + "-" + ft + shift + "_" + tf) + + # # logger.info("number of features %s", len(features)) + # return features + + # Possibly phasing these outlier removal methods below out in favor of + # use_SVM_to_remove_outliers (computationally more efficient and apparently higher performance). + # But these have good data manipulation examples, so keep them commented here for now. + + # def determine_statistical_distributions(self) -> None: + # from fitter import Fitter + + # logger.info('Determining best model for all features, may take some time') + + # def compute_quantiles(ft): + # f = Fitter(self.data_dictionary["train_features"][ft], + # distributions=['gamma', 'cauchy', 'laplace', + # 'beta', 'uniform', 'lognorm']) + # f.fit() + # # f.summary() + # dist = list(f.get_best().items())[0][0] + # params = f.get_best()[dist] + # upper_q = getattr(spy.stats, list(f.get_best().items())[0][0]).ppf(0.999, **params) + # lower_q = getattr(spy.stats, list(f.get_best().items())[0][0]).ppf(0.001, **params) + + # return ft, upper_q, lower_q, dist + + # quantiles_tuple = Parallel(n_jobs=-1)( + # delayed(compute_quantiles)(ft) for ft in self.data_dictionary[ + # 'train_features'].columns) + + # df = pd.DataFrame(quantiles_tuple, columns=['features', 'upper_quantiles', + # 'lower_quantiles', 'dist']) + # self.data_dictionary['upper_quantiles'] = df['upper_quantiles'] + # self.data_dictionary['lower_quantiles'] = df['lower_quantiles'] + + # return + + # def remove_outliers(self, predict: bool) -> None: + # """ + # Remove data that looks like an outlier based on the distribution of each + # variable. + # :params: + # :predict: boolean which tells the function if this is prediction data or + # training data coming in. + # """ + + # lower_quantile = self.data_dictionary["lower_quantiles"].to_numpy() + # upper_quantile = self.data_dictionary["upper_quantiles"].to_numpy() + + # if predict: + + # df = self.data_dictionary["prediction_features"][ + # (self.data_dictionary["prediction_features"] < upper_quantile) + # & (self.data_dictionary["prediction_features"] > lower_quantile) + # ] + # drop_index = pd.isnull(df).any(1) + # self.data_dictionary["prediction_features"].fillna(0, inplace=True) + # drop_index = ~drop_index + # do_predict = np.array(drop_index.replace(True, 1).replace(False, 0)) + + # logger.info( + # "remove_outliers() tossed %s predictions", + # len(do_predict) - do_predict.sum(), + # ) + # self.do_predict += do_predict + # self.do_predict -= 1 + + # else: + + # filter_train_df = self.data_dictionary["train_features"][ + # (self.data_dictionary["train_features"] < upper_quantile) + # & (self.data_dictionary["train_features"] > lower_quantile) + # ] + # drop_index = pd.isnull(filter_train_df).any(1) + # drop_index = drop_index.replace(True, 1).replace(False, 0) + # self.data_dictionary["train_features"] = self.data_dictionary["train_features"][ + # (drop_index == 0) + # ] + # self.data_dictionary["train_labels"] = self.data_dictionary["train_labels"][ + # (drop_index == 0) + # ] + # self.data_dictionary["train_weights"] = self.data_dictionary["train_weights"][ + # (drop_index == 0) + # ] + + # logger.info( + # f'remove_outliers() tossed {drop_index.sum()}' + # f' training points from {len(filter_train_df)}' + # ) + + # # do the same for the test data + # filter_test_df = self.data_dictionary["test_features"][ + # (self.data_dictionary["test_features"] < upper_quantile) + # & (self.data_dictionary["test_features"] > lower_quantile) + # ] + # drop_index = pd.isnull(filter_test_df).any(1) + # drop_index = drop_index.replace(True, 1).replace(False, 0) + # self.data_dictionary["test_labels"] = self.data_dictionary["test_labels"][ + # (drop_index == 0) + # ] + # self.data_dictionary["test_features"] = self.data_dictionary["test_features"][ + # (drop_index == 0) + # ] + # self.data_dictionary["test_weights"] = self.data_dictionary["test_weights"][ + # (drop_index == 0) + # ] + + # logger.info( + # f'remove_outliers() tossed {drop_index.sum()}' + # f' test points from {len(filter_test_df)}' + # ) + + # return diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 2523cd561..f1dd5550a 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -62,6 +62,7 @@ class IFreqaiModel(ABC): self.predictions = None self.training_on_separate_thread = False self.retrain = False + self.first = True def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ @@ -80,12 +81,12 @@ class IFreqaiModel(ABC): :metadata: pair metadata coming from strategy. """ - live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE) + self.live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE) self.pair = metadata["pair"] - self.dh = FreqaiDataKitchen(self.config, dataframe, live) + self.dh = FreqaiDataKitchen(self.config, dataframe, self.live) - if live: + if self.live: # logger.info('testing live') self.start_live(dataframe, metadata, strategy) @@ -115,11 +116,12 @@ class IFreqaiModel(ABC): self.dh.save_data(self.model) else: self.model = self.dh.load_data() - strategy_provided_features = self.dh.find_features(dataframe_train) - if strategy_provided_features != self.dh.training_features_list: - logger.info("User changed input features, retraining model.") - self.model = self.train(dataframe_train, metadata) - self.dh.save_data(self.model) + # strategy_provided_features = self.dh.find_features(dataframe_train) + # # TOFIX doesnt work with PCA + # if strategy_provided_features != self.dh.training_features_list: + # logger.info("User changed input features, retraining model.") + # self.model = self.train(dataframe_train, metadata) + # self.dh.save_data(self.model) preds, do_preds = self.predict(dataframe_backtest, metadata) @@ -148,7 +150,7 @@ class IFreqaiModel(ABC): if not self.training_on_separate_thread: # this will also prevent other pairs from trying to train simultaneously. (self.retrain, - new_trained_timerange) = self.dh.check_if_new_training_required(self.freqai_info[ + self.new_trained_timerange) = self.dh.check_if_new_training_required(self.freqai_info[ 'live_trained_timerange'], metadata) else: @@ -156,14 +158,19 @@ class IFreqaiModel(ABC): self.retrain = False if self.retrain or not file_exists: - self.training_on_separate_thread = True # acts like a lock - self.retrain_model_on_separate_thread(new_trained_timerange, metadata, strategy) + if self.first: + self.train_model_in_series(self.new_trained_timerange, metadata, strategy) + self.first = False + else: + self.training_on_separate_thread = True # acts like a lock + self.retrain_model_on_separate_thread(self.new_trained_timerange, + metadata, strategy) self.model = self.dh.load_data() strategy_provided_features = self.dh.find_features(dataframe) if strategy_provided_features != self.dh.training_features_list: - self.train_model_in_series(new_trained_timerange, metadata, strategy) + self.train_model_in_series(self.new_trained_timerange, metadata, strategy) preds, do_preds = self.predict(dataframe, metadata) self.dh.append_predictions(preds, do_preds, len(dataframe)) @@ -215,12 +222,36 @@ class IFreqaiModel(ABC): data (NaNs) or felt uncertain about data (PCA and DI index) """ + @abstractmethod + def data_cleaning_train(self) -> None: + """ + User can add data analysis and cleaning here. + Any function inside this method should drop training data points from the filtered_dataframe + based on user decided logic. See FreqaiDataKitchen::remove_outliers() for an example + of how outlier data points are dropped from the dataframe used for training. + """ + + @abstractmethod + def data_cleaning_predict(self) -> None: + """ + User can add data analysis and cleaning here. + These functions each modify self.dh.do_predict, which is a dataframe with equal length + to the number of candles coming from and returning to the strategy. Inside do_predict, + 1 allows prediction and < 0 signals to the strategy that the model is not confident in + the prediction. + See FreqaiDataKitchen::remove_outliers() for an example + of how the do_predict vector is modified. do_predict is ultimately passed back to strategy + for buy signals. + """ + def model_exists(self, pair: str, training_timerange: str) -> bool: """ Given a pair and path, check if a model already exists :param pair: pair e.g. BTC/USD :param path: path to model """ + if self.live and training_timerange is None: + return False coin, _ = pair.split("/") self.dh.model_filename = "cb_" + coin.lower() + "_" + training_timerange path_to_modelfile = Path(self.dh.model_path / str(self.dh.model_filename + "_model.joblib")) @@ -265,3 +296,4 @@ class IFreqaiModel(ABC): self.model = self.train(unfiltered_dataframe, metadata) self.dh.save_data(self.model) + self.retrain = False diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index e2ba6bd29..8550f3f15 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -29,7 +29,7 @@ class CatboostPredictionModel(IFreqaiModel): dataframe["close"] .shift(-self.feature_parameters["period"]) .rolling(self.feature_parameters["period"]) - .max() + .mean() / dataframe["close"] - 1 ) @@ -68,15 +68,11 @@ class CatboostPredictionModel(IFreqaiModel): # standardize all data based on train_dataset only data_dictionary = self.dh.standardize_data(data_dictionary) - # optional additional data cleaning - if self.feature_parameters["principal_component_analysis"]: - self.dh.principal_component_analysis() - if self.feature_parameters["remove_outliers"]: - self.dh.remove_outliers(predict=False) - if self.feature_parameters["DI_threshold"]: - self.dh.data["avg_mean_dist"] = self.dh.compute_distances() + # optional additional data cleaning/analysis + self.data_cleaning_train() - logger.info("length of train data %s", len(data_dictionary["train_features"])) + logger.info(f'Training model on {len(self.dh.training_features_list)} features') + logger.info(f'Training model on {len(data_dictionary["train_features"])} data points') model = self.fit(data_dictionary) @@ -86,9 +82,7 @@ class CatboostPredictionModel(IFreqaiModel): def fit(self, data_dictionary: Dict) -> Any: """ - Most regressors use the same function names and arguments e.g. user - can drop in LGBMRegressor in place of CatBoostRegressor and all data - management will be properly handled by Freqai. + User sets up the training and test data to fit their desired model here :params: :data_dictionary: the dictionary constructed by DataHandler to hold all the training and test data/labels. @@ -133,7 +127,51 @@ class CatboostPredictionModel(IFreqaiModel): filtered_dataframe = self.dh.standardize_data_from_metadata(filtered_dataframe) self.dh.data_dictionary["prediction_features"] = filtered_dataframe - # optional additional data cleaning + # optional additional data cleaning/analysis + self.data_cleaning_predict(filtered_dataframe) + + predictions = self.model.predict(self.dh.data_dictionary["prediction_features"]) + + # compute the non-standardized predictions + self.dh.predictions = (predictions + 1) * (self.dh.data["labels_max"] - + self.dh.data["labels_min"]) / 2 + self.dh.data[ + "labels_min"] + + # logger.info("--------------------Finished prediction--------------------") + + return (self.dh.predictions, self.dh.do_predict) + + def data_cleaning_train(self) -> None: + """ + User can add data analysis and cleaning here. + Any function inside this method should drop training data points from the filtered_dataframe + based on user decided logic. See FreqaiDataKitchen::remove_outliers() for an example + of how outlier data points are dropped from the dataframe used for training. + """ + if self.feature_parameters["principal_component_analysis"]: + self.dh.principal_component_analysis() + + # if self.feature_parameters["determine_statistical_distributions"]: + # self.dh.determine_statistical_distributions() + # if self.feature_parameters["remove_outliers"]: + # self.dh.remove_outliers(predict=False) + + if self.feature_parameters["use_SVM_to_remove_outliers"]: + self.dh.use_SVM_to_remove_outliers(predict=False) + if self.feature_parameters["DI_threshold"]: + self.dh.data["avg_mean_dist"] = self.dh.compute_distances() + + def data_cleaning_predict(self, filtered_dataframe: DataFrame) -> None: + """ + User can add data analysis and cleaning here. + These functions each modify self.dh.do_predict, which is a dataframe with equal length + to the number of candles coming from and returning to the strategy. Inside do_predict, + 1 allows prediction and < 0 signals to the strategy that the model is not confident in + the prediction. + See FreqaiDataKitchen::remove_outliers() for an example + of how the do_predict vector is modified. do_predict is ultimately passed back to strategy + for buy signals. + """ if self.feature_parameters["principal_component_analysis"]: pca_components = self.dh.pca.transform(filtered_dataframe) self.dh.data_dictionary["prediction_features"] = pd.DataFrame( @@ -142,17 +180,13 @@ class CatboostPredictionModel(IFreqaiModel): index=filtered_dataframe.index, ) - if self.feature_parameters["remove_outliers"]: - self.dh.remove_outliers(predict=True) # creates dropped index + # if self.feature_parameters["determine_statistical_distributions"]: + # self.dh.determine_statistical_distributions() + # if self.feature_parameters["remove_outliers"]: + # self.dh.remove_outliers(predict=True) # creates dropped index + + if self.feature_parameters["use_SVM_to_remove_outliers"]: + self.dh.use_SVM_to_remove_outliers(predict=True) if self.feature_parameters["DI_threshold"]: self.dh.check_if_pred_in_training_spaces() # sets do_predict - - predictions = self.model.predict(self.dh.data_dictionary["prediction_features"]) - - # compute the non-standardized predictions - self.dh.predictions = predictions * self.dh.data["labels_std"] + self.dh.data["labels_mean"] - - # logger.info("--------------------Finished prediction--------------------") - - return (self.dh.predictions, self.dh.do_predict) diff --git a/freqtrade/templates/ExamplePredictionModel.py b/freqtrade/templates/ExamplePredictionModel.py deleted file mode 100644 index 3d2b7a808..000000000 --- a/freqtrade/templates/ExamplePredictionModel.py +++ /dev/null @@ -1,159 +0,0 @@ -import logging -from typing import Any, Dict, Tuple - -import pandas as pd -from catboost import CatBoostRegressor, Pool -from pandas import DataFrame - -from freqtrade.freqai.freqai_interface import IFreqaiModel - - -logger = logging.getLogger(__name__) - - -class ExamplePredictionModel(IFreqaiModel): - """ - User created prediction model. The class needs to override three necessary - functions, predict(), train(), fit(). The class inherits ModelHandler which - has its own DataHandler where data is held, saved, loaded, and managed. - """ - - def make_labels(self, dataframe: DataFrame) -> DataFrame: - """ - User defines the labels here (target values). - :params: - :dataframe: the full dataframe for the present training period - """ - - dataframe["s"] = ( - dataframe["close"] - .shift(-self.feature_parameters["period"]) - .rolling(self.feature_parameters["period"]) - .max() - / dataframe["close"] - - 1 - ) - self.dh.data["s_mean"] = dataframe["s"].mean() - self.dh.data["s_std"] = dataframe["s"].std() - - # logger.info("label mean", self.dh.data["s_mean"], "label std", self.dh.data["s_std"]) - - return dataframe["s"] - - def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: - """ - Filter the training data and train a model to it. Train makes heavy use of the datakitchen - for storing, saving, loading, and analyzing the data. - :params: - :unfiltered_dataframe: Full dataframe for the current training period - :metadata: pair metadata from strategy. - :returns: - :model: Trained model which can be used to inference (self.predict) - """ - logger.info("--------------------Starting training--------------------") - - # create the full feature list based on user config info - self.dh.training_features_list = self.dh.build_feature_list(self.config, metadata) - unfiltered_labels = self.make_labels(unfiltered_dataframe) - - # filter the features requested by user in the configuration file and elegantly handle NaNs - features_filtered, labels_filtered = self.dh.filter_features( - unfiltered_dataframe, - self.dh.training_features_list, - unfiltered_labels, - training_filter=True, - ) - - # split data into train/test data. - data_dictionary = self.dh.make_train_test_datasets(features_filtered, labels_filtered) - # standardize all data based on train_dataset only - data_dictionary = self.dh.standardize_data(data_dictionary) - - # optional additional data cleaning - if self.feature_parameters["principal_component_analysis"]: - self.dh.principal_component_analysis() - if self.feature_parameters["remove_outliers"]: - self.dh.remove_outliers(predict=False) - if self.feature_parameters["DI_threshold"]: - self.dh.data["avg_mean_dist"] = self.dh.compute_distances() - - logger.info("length of train data %s", len(data_dictionary["train_features"])) - - model = self.fit(data_dictionary) - - logger.info(f'--------------------done training {metadata["pair"]}--------------------') - - return model - - def fit(self, data_dictionary: Dict) -> Any: - """ - Most regressors use the same function names and arguments e.g. user - can drop in LGBMRegressor in place of CatBoostRegressor and all data - management will be properly handled by Freqai. - :params: - :data_dictionary: the dictionary constructed by DataHandler to hold - all the training and test data/labels. - """ - - train_data = Pool( - data=data_dictionary["train_features"], - label=data_dictionary["train_labels"], - weight=data_dictionary["train_weights"], - ) - - test_data = Pool( - data=data_dictionary["test_features"], - label=data_dictionary["test_labels"], - weight=data_dictionary["test_weights"], - ) - - model = CatBoostRegressor( - verbose=100, early_stopping_rounds=400, **self.model_training_parameters - ) - model.fit(X=train_data, eval_set=test_data) - - return model - - def predict(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, - DataFrame]: - """ - Filter the prediction features data and predict with it. - :param: unfiltered_dataframe: Full dataframe for the current backtest period. - :return: - :predictions: np.array of predictions - :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove - data (NaNs) or felt uncertain about data (PCA and DI index) - """ - - # logger.info("--------------------Starting prediction--------------------") - - original_feature_list = self.dh.build_feature_list(self.config, metadata) - filtered_dataframe, _ = self.dh.filter_features( - unfiltered_dataframe, original_feature_list, training_filter=False - ) - filtered_dataframe = self.dh.standardize_data_from_metadata(filtered_dataframe) - self.dh.data_dictionary["prediction_features"] = filtered_dataframe - - # optional additional data cleaning - if self.feature_parameters["principal_component_analysis"]: - pca_components = self.dh.pca.transform(filtered_dataframe) - self.dh.data_dictionary["prediction_features"] = pd.DataFrame( - data=pca_components, - columns=["PC" + str(i) for i in range(0, self.dh.data["n_kept_components"])], - index=filtered_dataframe.index, - ) - - if self.feature_parameters["remove_outliers"]: - self.dh.remove_outliers(predict=True) # creates dropped index - - if self.feature_parameters["DI_threshold"]: - self.dh.check_if_pred_in_training_spaces() # sets do_predict - - predictions = self.model.predict(self.dh.data_dictionary["prediction_features"]) - - # compute the non-standardized predictions - self.dh.predictions = predictions * self.dh.data["labels_std"] + self.dh.data["labels_mean"] - - # logger.info("--------------------Finished prediction--------------------") - - return (self.dh.predictions, self.dh.do_predict) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index c8befebcf..a76ea2303 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -166,8 +166,8 @@ class FreqaiExampleStrategy(IStrategy): dataframe["target_std"], ) = self.model.bridge.start(dataframe, metadata, self) - dataframe["target_roi"] = dataframe["target_mean"] + dataframe["target_std"] * 0.5 - dataframe["sell_roi"] = dataframe["target_mean"] - dataframe["target_std"] * 1.5 + dataframe["target_roi"] = dataframe["target_mean"] + dataframe["target_std"] * 1.5 + dataframe["sell_roi"] = dataframe["target_mean"] - dataframe["target_std"] * 1 return dataframe def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: @@ -183,7 +183,7 @@ class FreqaiExampleStrategy(IStrategy): def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: sell_conditions = [ - (dataframe["prediction"] < dataframe["sell_roi"]) & (dataframe["do_predict"] == 1) + (dataframe["do_predict"] <= 0) ] if sell_conditions: dataframe.loc[reduce(lambda x, y: x | y, sell_conditions), "sell"] = 1 From af0cc21af919a503ce1a7fd2854ca2ce50935fca Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 23 May 2022 00:06:26 +0200 Subject: [PATCH 038/130] Enable hourly/minute retraining in live/dry. Suppress catboost folder output. Update config + constants + docs to reflect updates. --- config_examples/config_freqai.example.json | 3 +- docs/freqai.md | 19 ++++++- freqtrade/constants.py | 4 +- freqtrade/freqai/data_kitchen.py | 54 ++++++++++--------- freqtrade/freqai/freqai_interface.py | 20 ++++--- .../CatboostPredictionModel.py | 1 + 6 files changed, 66 insertions(+), 35 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index a895a7341..ed3782775 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -71,7 +71,8 @@ "DI_threshold": 1, "weight_factor": 0, "principal_component_analysis": false, - "use_SVM_to_remove_outliers": false + "use_SVM_to_remove_outliers": false, + "stratify": 0 }, "data_split_parameters": { "test_size": 0.25, diff --git a/docs/freqai.md b/docs/freqai.md index 8a37e7d66..606b88912 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -151,7 +151,8 @@ no. `timeframes` * no. `base_features` * no. `corr_pairlist` * no. `shift`_ Users define the backtesting timerange with the typical `--timerange` parameter in the user configuration file. `train_period` is the duration of the sliding training window, while -`backtest_period` is the sliding backtesting window, both in number of days. In the present example, +`backtest_period` is the sliding backtesting window, both in number of days (backtest_period can be +a float to indicate sub daily retraining in live/dry mode). In the present example, the user is asking Freqai to use a training period of 30 days and backtest the subsequent 7 days. This means that if the user sets `--timerange 20210501-20210701`, Freqai will train 8 separate models (because the full range comprises 8 weeks), @@ -347,6 +348,22 @@ Freqai will train an SVM on the training data (or components if the user activat `principal_component_analysis`) and remove any data point that it deems to be sit beyond the feature space. +## Stratifying the data + +The user can stratify the training/testing data using: + +```json + "freqai": { + "feature_parameters" : { + "stratify": 3 + } + } +``` + +which will split the data chronolocially so that every X data points is a testing data point. In the +present example, the user is asking for every third data point in the dataframe to be used for +testing, the other points are used for training. + ## Additional information ### Feature standardization diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 686991e2c..05581cc3a 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -438,7 +438,7 @@ CONF_SCHEMA = { "properties": { "timeframes": {"type": "list"}, "train_period": {"type": "integer", "default": 0}, - "backtest_period": {"type": "integer", "default": 7}, + "backtest_period": {"type": "float", "default": 7}, "identifier": {"type": "str", "default": "example"}, "live_trained_timerange": {"type": "str"}, "live_full_backtestrange": {"type": "str"}, @@ -451,7 +451,7 @@ CONF_SCHEMA = { "DI_threshold": {"type": "integer", "default": 0}, "weight_factor": {"type": "number", "default": 0}, "principal_component_analysis": {"type": "boolean", "default": False}, - "remove_outliers": {"type": "boolean", "default": False}, + "use_SVM_to_remove_outliers": {"type": "boolean", "default": False}, }, }, "data_split_parameters": { diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index f589a1c89..e09a2d0d5 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -689,50 +689,58 @@ class FreqaiDataKitchen: return full_timerange - def check_if_new_training_required(self, training_timerange: str, - metadata: dict) -> Tuple[bool, str]: + def check_if_new_training_required(self, trained_timerange: TimeRange, + metadata: dict, + timestamp: int = 0) -> Tuple[bool, TimeRange, int]: time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() - if training_timerange: # user passed no live_trained_timerange in config - trained_timerange = TimeRange.parse_timerange(training_timerange) + if trained_timerange.startts != 0: + # trained_timerange = TimeRange.parse_timerange(training_timerange) + # keep hour available incase user wants to train multiple times per day + # training_timerange is a str for day range only, so we add the extra hours + # original_stop_seconds = trained_timerange.stopts + # trained_timerange.stopts += int(timestamp - original_stop_seconds) + # trained_timerange.startts += int(timestamp - original_stop_seconds) elapsed_time = (time - trained_timerange.stopts) / SECONDS_IN_DAY - trained_timerange.startts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY - trained_timerange.stopts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY retrain = elapsed_time > self.freqai_config['backtest_period'] - else: - trained_timerange = TimeRange.parse_timerange("20000101-20000201") + if retrain: + trained_timerange.startts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY + trained_timerange.stopts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY + else: # user passed no live_trained_timerange in config + trained_timerange = TimeRange.parse_timerange("20000101-20000201") # arbitrary date trained_timerange.startts = int(time - self.freqai_config['train_period'] * SECONDS_IN_DAY) trained_timerange.stopts = int(time) retrain = True - start = datetime.datetime.utcfromtimestamp(trained_timerange.startts) - stop = datetime.datetime.utcfromtimestamp(trained_timerange.stopts) - new_trained_timerange = start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d") + timestamp = trained_timerange.stopts + # start = datetime.datetime.utcfromtimestamp(trained_timerange.startts) + # stop = datetime.datetime.utcfromtimestamp(trained_timerange.stopts) + # new_trained_timerange_str = start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d") if retrain: coin, _ = metadata['pair'].split("/") # set the new model_path self.model_path = Path(self.full_path / str("sub-train" + "-" + - str(new_trained_timerange))) + str(timestamp))) - self.model_filename = "cb_" + coin.lower() + "_" + new_trained_timerange + self.model_filename = "cb_" + coin.lower() + "_" + str(timestamp) # this is not persistent at the moment TODO - self.freqai_config['live_trained_timerange'] = new_trained_timerange + self.freqai_config['live_trained_timerange'] = str(timestamp) # enables persistence, but not fully implemented into save/load data yer - self.data['live_trained_timerange'] = new_trained_timerange + self.data['live_trained_timerange'] = str(timestamp) - return retrain, new_trained_timerange + return retrain, trained_timerange, timestamp - def download_new_data_for_retraining(self, new_timerange: str, metadata: dict) -> None: + def download_new_data_for_retraining(self, timerange: TimeRange, metadata: dict) -> None: exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config, validate=False) pairs = self.freqai_config['corr_pairlist'] if metadata['pair'] not in pairs: pairs += metadata['pair'] # dont include pair twice - timerange = TimeRange.parse_timerange(new_timerange) + # timerange = TimeRange.parse_timerange(new_timerange) refresh_backtest_ohlcv_data( exchange, pairs=pairs, timeframes=self.freqai_config['timeframes'], @@ -743,12 +751,12 @@ class FreqaiDataKitchen: prepend=self.config.get('prepend_data', False) ) - def load_pairs_histories(self, new_timerange: str, metadata: dict) -> Tuple[Dict[Any, Any], - DataFrame]: + def load_pairs_histories(self, timerange: TimeRange, metadata: dict) -> Tuple[Dict[Any, Any], + DataFrame]: corr_dataframes: Dict[Any, Any] = {} base_dataframes: Dict[Any, Any] = {} pairs = self.freqai_config['corr_pairlist'] # + [metadata['pair']] - timerange = TimeRange.parse_timerange(new_timerange) + # timerange = TimeRange.parse_timerange(new_timerange) for tf in self.freqai_config['timeframes']: base_dataframes[tf] = load_pair_history(datadir=self.config['datadir'], @@ -763,10 +771,6 @@ class FreqaiDataKitchen: timeframe=tf, pair=p, timerange=timerange) - # base_dataframe = [dataframe for key, dataframe in corr_dataframes.items() - # if metadata['pair'] in key] - - # [0] indexes the lowest tf for the basepair return corr_dataframes, base_dataframes def use_strategy_to_populate_indicators(self, strategy: IStrategy, diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index f1dd5550a..6e597531b 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -11,6 +11,7 @@ import numpy.typing as npt import pandas as pd from pandas import DataFrame +from freqtrade.configuration import TimeRange from freqtrade.enums import RunMode from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.strategy.interface import IStrategy @@ -63,6 +64,12 @@ class IFreqaiModel(ABC): self.training_on_separate_thread = False self.retrain = False self.first = True + self.timestamp = 0 + if self.freqai_info['live_trained_timerange']: + self.new_trained_timerange = TimeRange.parse_timerange( + self.freqai_info['live_trained_timerange']) + else: + self.new_trained_timerange = TimeRange() def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ @@ -150,9 +157,10 @@ class IFreqaiModel(ABC): if not self.training_on_separate_thread: # this will also prevent other pairs from trying to train simultaneously. (self.retrain, - self.new_trained_timerange) = self.dh.check_if_new_training_required(self.freqai_info[ - 'live_trained_timerange'], - metadata) + self.new_trained_timerange, + self.timestamp) = self.dh.check_if_new_training_required(self.new_trained_timerange, + metadata, + timestamp=self.timestamp) else: logger.info("FreqAI training a new model on background thread.") self.retrain = False @@ -250,7 +258,7 @@ class IFreqaiModel(ABC): :param pair: pair e.g. BTC/USD :param path: path to model """ - if self.live and training_timerange is None: + if self.live and training_timerange == "": return False coin, _ = pair.split("/") self.dh.model_filename = "cb_" + coin.lower() + "_" + training_timerange @@ -263,7 +271,7 @@ class IFreqaiModel(ABC): return file_exists @threaded - def retrain_model_on_separate_thread(self, new_trained_timerange: str, metadata: dict, + def retrain_model_on_separate_thread(self, new_trained_timerange: TimeRange, metadata: dict, strategy: IStrategy): # with nostdout(): @@ -282,7 +290,7 @@ class IFreqaiModel(ABC): self.training_on_separate_thread = False self.retrain = False - def train_model_in_series(self, new_trained_timerange: str, metadata: dict, + def train_model_in_series(self, new_trained_timerange: TimeRange, metadata: dict, strategy: IStrategy): self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index 8550f3f15..3dad6add6 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -101,6 +101,7 @@ class CatboostPredictionModel(IFreqaiModel): ) model = CatBoostRegressor( + allow_writing_files=False, verbose=100, early_stopping_rounds=400, **self.model_training_parameters ) model.fit(X=train_data, eval_set=test_data) From 3587bd82e16b2781e1f12f926114dfde88927cee Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 23 May 2022 00:10:36 +0200 Subject: [PATCH 039/130] cleanup superceded code --- freqtrade/freqai/data_kitchen.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index e09a2d0d5..da0d7e4df 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -696,28 +696,19 @@ class FreqaiDataKitchen: time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() if trained_timerange.startts != 0: - # trained_timerange = TimeRange.parse_timerange(training_timerange) - # keep hour available incase user wants to train multiple times per day - # training_timerange is a str for day range only, so we add the extra hours - # original_stop_seconds = trained_timerange.stopts - # trained_timerange.stopts += int(timestamp - original_stop_seconds) - # trained_timerange.startts += int(timestamp - original_stop_seconds) elapsed_time = (time - trained_timerange.stopts) / SECONDS_IN_DAY retrain = elapsed_time > self.freqai_config['backtest_period'] if retrain: trained_timerange.startts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY trained_timerange.stopts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY else: # user passed no live_trained_timerange in config - trained_timerange = TimeRange.parse_timerange("20000101-20000201") # arbitrary date + trained_timerange = TimeRange() trained_timerange.startts = int(time - self.freqai_config['train_period'] * SECONDS_IN_DAY) trained_timerange.stopts = int(time) retrain = True timestamp = trained_timerange.stopts - # start = datetime.datetime.utcfromtimestamp(trained_timerange.startts) - # stop = datetime.datetime.utcfromtimestamp(trained_timerange.stopts) - # new_trained_timerange_str = start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d") if retrain: coin, _ = metadata['pair'].split("/") From ee3cdd0ffec87bc89ec0700196609998ae2a1d21 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 23 May 2022 09:55:58 +0200 Subject: [PATCH 040/130] more cleanup --- freqtrade/freqai/data_kitchen.py | 15 ++++++--------- freqtrade/freqai/freqai_interface.py | 8 +++----- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index da0d7e4df..3347bbe97 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -690,8 +690,7 @@ class FreqaiDataKitchen: return full_timerange def check_if_new_training_required(self, trained_timerange: TimeRange, - metadata: dict, - timestamp: int = 0) -> Tuple[bool, TimeRange, int]: + metadata: dict) -> Tuple[bool, TimeRange]: time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() @@ -708,21 +707,19 @@ class FreqaiDataKitchen: trained_timerange.stopts = int(time) retrain = True - timestamp = trained_timerange.stopts - if retrain: coin, _ = metadata['pair'].split("/") # set the new model_path self.model_path = Path(self.full_path / str("sub-train" + "-" + - str(timestamp))) + str(int(trained_timerange.stopts)))) - self.model_filename = "cb_" + coin.lower() + "_" + str(timestamp) + self.model_filename = "cb_" + coin.lower() + "_" + str(int(trained_timerange.stopts)) # this is not persistent at the moment TODO - self.freqai_config['live_trained_timerange'] = str(timestamp) + self.freqai_config['live_trained_timerange'] = str(int(trained_timerange.stopts)) # enables persistence, but not fully implemented into save/load data yer - self.data['live_trained_timerange'] = str(timestamp) + self.data['live_trained_timerange'] = str(int(trained_timerange.stopts)) - return retrain, trained_timerange, timestamp + return retrain, trained_timerange def download_new_data_for_retraining(self, timerange: TimeRange, metadata: dict) -> None: diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 6e597531b..3ff98b8ee 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -64,7 +64,6 @@ class IFreqaiModel(ABC): self.training_on_separate_thread = False self.retrain = False self.first = True - self.timestamp = 0 if self.freqai_info['live_trained_timerange']: self.new_trained_timerange = TimeRange.parse_timerange( self.freqai_info['live_trained_timerange']) @@ -157,10 +156,9 @@ class IFreqaiModel(ABC): if not self.training_on_separate_thread: # this will also prevent other pairs from trying to train simultaneously. (self.retrain, - self.new_trained_timerange, - self.timestamp) = self.dh.check_if_new_training_required(self.new_trained_timerange, - metadata, - timestamp=self.timestamp) + self.new_trained_timerange) = self.dh.check_if_new_training_required( + self.new_trained_timerange, + metadata) else: logger.info("FreqAI training a new model on background thread.") self.retrain = False From dede12864899094dc9f487fc41edbf96d069a7b0 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 23 May 2022 10:15:59 +0200 Subject: [PATCH 041/130] set process_only_new_candles to true in example strat --- freqtrade/templates/FreqaiExampleStrategy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index a76ea2303..690532c10 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -42,6 +42,7 @@ class FreqaiExampleStrategy(IStrategy): }, } + process_only_new_candles = False stoploss = -0.05 use_sell_signal = True startup_candle_count: int = 300 From e1c068ca662ce7f35160659edbf8502f27083a6d Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 23 May 2022 12:07:09 +0200 Subject: [PATCH 042/130] add config asserts, use .get method with default values for optional functionality, move data_cleaning_* to freqai_interface (away from user custom pred model) since it is controlled by config params. --- freqtrade/freqai/data_kitchen.py | 105 ++++++++++----- freqtrade/freqai/freqai_interface.py | 127 ++++++++++++------ .../CatboostPredictionModel.py | 21 ++- freqtrade/optimize/backtesting.py | 2 +- 4 files changed, 162 insertions(+), 93 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 3347bbe97..148efd5dd 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -43,6 +43,7 @@ class FreqaiDataKitchen: self.data: Dict[Any, Any] = {} self.data_dictionary: Dict[Any, Any] = {} self.config = config + self.assert_config(self.config, live) self.freqai_config = config["freqai"] self.predictions: npt.ArrayLike = np.array([]) self.do_predict: npt.ArrayLike = np.array([]) @@ -59,7 +60,7 @@ class FreqaiDataKitchen: self.svm_model: linear_model.SGDOneClassSVM = None if not self.live: self.full_timerange = self.create_fulltimerange(self.config["timerange"], - self.freqai_config["train_period"] + self.freqai_config.get("train_period") ) (self.training_timeranges, self.backtesting_timeranges) = self.split_timerange( @@ -68,14 +69,33 @@ class FreqaiDataKitchen: config["freqai"]["backtest_period"], ) + def assert_config(self, config: Dict[str, Any], live: bool) -> None: + assert config.get('freqai'), "No Freqai parameters found in config file." + assert config.get('freqai', {}).get('train_period'), ("No Freqai train_period found in" + "config file.") + assert type(config.get('freqai', {}) + .get('train_period')) is int, ('Can only train on full day period.' + 'No fractional days permitted.') + assert config.get('freqai', {}).get('backtest_period'), ("No Freqai backtest_period found" + "in config file.") + if not live: + assert type(config.get('freqai', {}) + .get('backtest_period')) is int, ('Can only backtest on full day' + 'backtest_period. Only live/dry mode' + 'allows fractions of days') + assert config.get('freqai', {}).get('identifier'), ("No Freqai identifier found in config" + "file.") + assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai feature_parameters" + "found in config file.") + def set_paths(self) -> None: self.full_path = Path(self.config['user_data_dir'] / "models" / - str(self.freqai_config['live_full_backtestrange'] + - self.freqai_config['identifier'])) + str(self.freqai_config.get('live_full_backtestrange') + + self.freqai_config.get('identifier'))) self.model_path = Path(self.full_path / str("sub-train" + "-" + - str(self.freqai_config['live_trained_timerange']))) + str(self.freqai_config.get('live_trained_timerange')))) return @@ -117,7 +137,7 @@ class FreqaiDataKitchen: # do not want them having to edit the default save/load methods here. Below is an example # of what we do NOT want. - # if self.freqai_config['feature_parameters']['determine_statistical_distributions']: + # if self.freqai_config.get('feature_parameters','determine_statistical_distributions'): # self.data_dictionary["upper_quantiles"].to_pickle( # save_path / str(self.model_filename + "_upper_quantiles.pkl") # ) @@ -147,7 +167,7 @@ class FreqaiDataKitchen: # do not want them having to edit the default save/load methods here. Below is an example # of what we do NOT want. - # if self.freqai_config['feature_parameters']['determine_statistical_distributions']: + # if self.freqai_config.get('feature_parameters','determine_statistical_distributions'): # self.data_dictionary["upper_quantiles"] = pd.read_pickle( # self.model_path / str(self.model_filename + "_upper_quantiles.pkl") # ) @@ -193,15 +213,15 @@ class FreqaiDataKitchen: """ weights: npt.ArrayLike - if self.config["freqai"]["feature_parameters"]["weight_factor"] > 0: + if self.freqai_config["feature_parameters"].get("weight_factor", 0) > 0: weights = self.set_weights_higher_recent(len(filtered_dataframe)) else: weights = np.ones(len(filtered_dataframe)) - if self.config["freqai"]["feature_parameters"]["stratify"] > 0: + if self.freqai_config["feature_parameters"].get("stratify", 0) > 0: stratification = np.zeros(len(filtered_dataframe)) for i in range(1, len(stratification)): - if i % self.config["freqai"]["feature_parameters"]["stratify"] == 0: + if i % self.freqai_config.get("feature_parameters", {}).get("stratify", 0) == 0: stratification[i] = 1 ( @@ -525,6 +545,14 @@ class FreqaiDataKitchen: return None + def pca_transform(self, filtered_dataframe: DataFrame) -> None: + pca_components = self.pca.transform(filtered_dataframe) + self.data_dictionary["prediction_features"] = pd.DataFrame( + data=pca_components, + columns=["PC" + str(i) for i in range(0, self.data["n_kept_components"])], + index=filtered_dataframe.index, + ) + def compute_distances(self) -> float: logger.info("computing average mean distance for all training points") pairwise = pairwise_distances(self.data_dictionary["train_features"], n_jobs=-1) @@ -675,7 +703,7 @@ class FreqaiDataKitchen: self.full_path = Path( self.config["user_data_dir"] / "models" - / str(full_timerange + self.freqai_config["identifier"]) + / str(full_timerange + self.freqai_config.get("identifier")) ) config_path = Path(self.config["config_files"][0]) @@ -696,13 +724,15 @@ class FreqaiDataKitchen: if trained_timerange.startts != 0: elapsed_time = (time - trained_timerange.stopts) / SECONDS_IN_DAY - retrain = elapsed_time > self.freqai_config['backtest_period'] + retrain = elapsed_time > self.freqai_config.get('backtest_period') if retrain: - trained_timerange.startts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY - trained_timerange.stopts += self.freqai_config['backtest_period'] * SECONDS_IN_DAY + trained_timerange.startts += self.freqai_config.get( + 'backtest_period', 0) * SECONDS_IN_DAY + trained_timerange.stopts += self.freqai_config.get( + 'backtest_period', 0) * SECONDS_IN_DAY else: # user passed no live_trained_timerange in config trained_timerange = TimeRange() - trained_timerange.startts = int(time - self.freqai_config['train_period'] * + trained_timerange.startts = int(time - self.freqai_config.get('train_period') * SECONDS_IN_DAY) trained_timerange.stopts = int(time) retrain = True @@ -725,13 +755,13 @@ class FreqaiDataKitchen: exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config, validate=False) - pairs = self.freqai_config['corr_pairlist'] + pairs = self.freqai_config.get('corr_pairlist', []) if metadata['pair'] not in pairs: pairs += metadata['pair'] # dont include pair twice # timerange = TimeRange.parse_timerange(new_timerange) refresh_backtest_ohlcv_data( - exchange, pairs=pairs, timeframes=self.freqai_config['timeframes'], + exchange, pairs=pairs, timeframes=self.freqai_config.get('timeframes'), datadir=self.config['datadir'], timerange=timerange, new_pairs_days=self.config['new_pairs_days'], erase=False, data_format=self.config['dataformat_ohlcv'], @@ -743,21 +773,22 @@ class FreqaiDataKitchen: DataFrame]: corr_dataframes: Dict[Any, Any] = {} base_dataframes: Dict[Any, Any] = {} - pairs = self.freqai_config['corr_pairlist'] # + [metadata['pair']] + pairs = self.freqai_config.get('corr_pairlist', []) # + [metadata['pair']] # timerange = TimeRange.parse_timerange(new_timerange) - for tf in self.freqai_config['timeframes']: + for tf in self.freqai_config.get('timeframes'): base_dataframes[tf] = load_pair_history(datadir=self.config['datadir'], timeframe=tf, pair=metadata['pair'], timerange=timerange) - for p in pairs: - if metadata['pair'] in p: - continue # dont repeat anything from whitelist - if p not in corr_dataframes: - corr_dataframes[p] = {} - corr_dataframes[p][tf] = load_pair_history(datadir=self.config['datadir'], - timeframe=tf, - pair=p, timerange=timerange) + if pairs: + for p in pairs: + if metadata['pair'] in p: + continue # dont repeat anything from whitelist + if p not in corr_dataframes: + corr_dataframes[p] = {} + corr_dataframes[p][tf] = load_pair_history(datadir=self.config['datadir'], + timeframe=tf, + pair=p, timerange=timerange) return corr_dataframes, base_dataframes @@ -767,23 +798,25 @@ class FreqaiDataKitchen: metadata: dict) -> DataFrame: dataframe = base_dataframes[self.config['timeframe']] + pairs = self.freqai_config.get("corr_pairlist", []) - for tf in self.freqai_config["timeframes"]: + for tf in self.freqai_config.get("timeframes"): dataframe = strategy.populate_any_indicators(metadata['pair'], dataframe.copy(), tf, base_dataframes[tf], coin=metadata['pair'].split("/")[0] + "-" ) - for i in self.freqai_config["corr_pairlist"]: - if metadata['pair'] in i: - continue # dont repeat anything from whitelist - dataframe = strategy.populate_any_indicators(i, - dataframe.copy(), - tf, - corr_dataframes[i][tf], - coin=i.split("/")[0] + "-" - ) + if pairs: + for i in pairs: + if metadata['pair'] in i: + continue # dont repeat anything from whitelist + dataframe = strategy.populate_any_indicators(i, + dataframe.copy(), + tf, + corr_dataframes[i][tf], + coin=i.split("/")[0] + "-" + ) return dataframe diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 3ff98b8ee..2b3addab3 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -20,7 +20,7 @@ from freqtrade.strategy.interface import IStrategy pd.options.mode.chained_assignment = None logger = logging.getLogger(__name__) -# FIXME: suppress stdout for background training +# FIXME: suppress stdout for background training? # class DummyFile(object): # def write(self, x): pass @@ -51,6 +51,7 @@ class IFreqaiModel(ABC): def __init__(self, config: Dict[str, Any]) -> None: self.config = config + self.assert_config(self.config) self.freqai_info = config["freqai"] self.data_split_parameters = config["freqai"]["data_split_parameters"] self.model_training_parameters = config["freqai"]["model_training_parameters"] @@ -64,12 +65,25 @@ class IFreqaiModel(ABC): self.training_on_separate_thread = False self.retrain = False self.first = True - if self.freqai_info['live_trained_timerange']: + if self.freqai_info.get('live_trained_timerange'): self.new_trained_timerange = TimeRange.parse_timerange( self.freqai_info['live_trained_timerange']) else: self.new_trained_timerange = TimeRange() + def assert_config(self, config: Dict[str, Any]) -> None: + + assert config.get('freqai'), "No Freqai parameters found in config file." + assert config.get('freqai', {}).get('data_split_parameters'), ("No Freqai" + "data_split_parameters" + "in config file.") + assert config.get('freqai', {}).get('model_training_parameters'), ("No Freqai" + "modeltrainingparameters" + "found in config file.") + assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai" + "feature_parameters found in" + "config file.") + def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ Entry point to the FreqaiModel, it will train a new model if @@ -192,55 +206,30 @@ class IFreqaiModel(ABC): return - @abstractmethod - def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Any: - """ - Filter the training data and train a model to it. Train makes heavy use of the datahandler - for storing, saving, loading, and analyzing the data. - :params: - :unfiltered_dataframe: Full dataframe for the current training period - :metadata: pair metadata from strategy. - :returns: - :model: Trained model which can be used to inference (self.predict) - """ - - @abstractmethod - def fit(self) -> Any: - """ - Most regressors use the same function names and arguments e.g. user - can drop in LGBMRegressor in place of CatBoostRegressor and all data - management will be properly handled by Freqai. - :params: - :data_dictionary: the dictionary constructed by DataHandler to hold - all the training and test data/labels. - """ - - return - - @abstractmethod - def predict(self, dataframe: DataFrame, metadata: dict) -> Tuple[npt.ArrayLike, npt.ArrayLike]: - """ - Filter the prediction features data and predict with it. - :param: unfiltered_dataframe: Full dataframe for the current backtest period. - :return: - :predictions: np.array of predictions - :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove - data (NaNs) or felt uncertain about data (PCA and DI index) - """ - - @abstractmethod def data_cleaning_train(self) -> None: """ - User can add data analysis and cleaning here. + Base data cleaning method for train Any function inside this method should drop training data points from the filtered_dataframe based on user decided logic. See FreqaiDataKitchen::remove_outliers() for an example of how outlier data points are dropped from the dataframe used for training. """ + if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): + self.dh.principal_component_analysis() - @abstractmethod - def data_cleaning_predict(self) -> None: + # if self.feature_parameters["determine_statistical_distributions"]: + # self.dh.determine_statistical_distributions() + # if self.feature_parameters["remove_outliers"]: + # self.dh.remove_outliers(predict=False) + + if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): + self.dh.use_SVM_to_remove_outliers(predict=False) + + if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): + self.dh.data["avg_mean_dist"] = self.dh.compute_distances() + + def data_cleaning_predict(self, filtered_dataframe: DataFrame) -> None: """ - User can add data analysis and cleaning here. + Base data cleaning method for predict. These functions each modify self.dh.do_predict, which is a dataframe with equal length to the number of candles coming from and returning to the strategy. Inside do_predict, 1 allows prediction and < 0 signals to the strategy that the model is not confident in @@ -249,6 +238,19 @@ class IFreqaiModel(ABC): of how the do_predict vector is modified. do_predict is ultimately passed back to strategy for buy signals. """ + if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): + self.dh.pca_transform() + + # if self.feature_parameters["determine_statistical_distributions"]: + # self.dh.determine_statistical_distributions() + # if self.feature_parameters["remove_outliers"]: + # self.dh.remove_outliers(predict=True) # creates dropped index + + if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): + self.dh.use_SVM_to_remove_outliers(predict=True) + + if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): + self.dh.check_if_pred_in_training_spaces() # sets do_predict def model_exists(self, pair: str, training_timerange: str) -> bool: """ @@ -303,3 +305,42 @@ class IFreqaiModel(ABC): self.model = self.train(unfiltered_dataframe, metadata) self.dh.save_data(self.model) self.retrain = False + + # Methods which are overridden by user made prediction models. + # See freqai/prediction_models/CatboostPredictionModlel.py for an example. + + @abstractmethod + def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Any: + """ + Filter the training data and train a model to it. Train makes heavy use of the datahandler + for storing, saving, loading, and analyzing the data. + :params: + :unfiltered_dataframe: Full dataframe for the current training period + :metadata: pair metadata from strategy. + :returns: + :model: Trained model which can be used to inference (self.predict) + """ + + @abstractmethod + def fit(self) -> Any: + """ + Most regressors use the same function names and arguments e.g. user + can drop in LGBMRegressor in place of CatBoostRegressor and all data + management will be properly handled by Freqai. + :params: + :data_dictionary: the dictionary constructed by DataHandler to hold + all the training and test data/labels. + """ + + return + + @abstractmethod + def predict(self, dataframe: DataFrame, metadata: dict) -> Tuple[npt.ArrayLike, npt.ArrayLike]: + """ + Filter the prediction features data and predict with it. + :param: unfiltered_dataframe: Full dataframe for the current backtest period. + :return: + :predictions: np.array of predictions + :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove + data (NaNs) or felt uncertain about data (PCA and DI index) + """ diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index 3dad6add6..d09554e3e 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -1,7 +1,6 @@ import logging from typing import Any, Dict, Tuple -import pandas as pd from catboost import CatBoostRegressor, Pool from pandas import DataFrame @@ -149,7 +148,7 @@ class CatboostPredictionModel(IFreqaiModel): based on user decided logic. See FreqaiDataKitchen::remove_outliers() for an example of how outlier data points are dropped from the dataframe used for training. """ - if self.feature_parameters["principal_component_analysis"]: + if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): self.dh.principal_component_analysis() # if self.feature_parameters["determine_statistical_distributions"]: @@ -157,9 +156,10 @@ class CatboostPredictionModel(IFreqaiModel): # if self.feature_parameters["remove_outliers"]: # self.dh.remove_outliers(predict=False) - if self.feature_parameters["use_SVM_to_remove_outliers"]: + if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): self.dh.use_SVM_to_remove_outliers(predict=False) - if self.feature_parameters["DI_threshold"]: + + if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): self.dh.data["avg_mean_dist"] = self.dh.compute_distances() def data_cleaning_predict(self, filtered_dataframe: DataFrame) -> None: @@ -173,21 +173,16 @@ class CatboostPredictionModel(IFreqaiModel): of how the do_predict vector is modified. do_predict is ultimately passed back to strategy for buy signals. """ - if self.feature_parameters["principal_component_analysis"]: - pca_components = self.dh.pca.transform(filtered_dataframe) - self.dh.data_dictionary["prediction_features"] = pd.DataFrame( - data=pca_components, - columns=["PC" + str(i) for i in range(0, self.dh.data["n_kept_components"])], - index=filtered_dataframe.index, - ) + if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): + self.dh.pca_transform() # if self.feature_parameters["determine_statistical_distributions"]: # self.dh.determine_statistical_distributions() # if self.feature_parameters["remove_outliers"]: # self.dh.remove_outliers(predict=True) # creates dropped index - if self.feature_parameters["use_SVM_to_remove_outliers"]: + if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): self.dh.use_SVM_to_remove_outliers(predict=True) - if self.feature_parameters["DI_threshold"]: + if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): self.dh.check_if_pred_in_training_spaces() # sets do_predict diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index add864a67..3996dd08d 100755 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -207,7 +207,7 @@ class Backtesting: if self.config.get('freqai') is not None: self.required_startup += int((self.config.get('freqai', {}).get('train_period') * 86400) / timeframe_to_seconds(self.config['timeframe'])) - logger.info("Increasing startup_candle_count for freqai to %s", self.required_startup) + logger.info(f'Increasing startup_candle_count for freqai to {self.required_startup}') self.config['startup_candle_count'] = self.required_startup data = history.load_data( From b0d2d13eb19a5a64a4bec8b5314d36544ec21a38 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 23 May 2022 21:05:05 +0200 Subject: [PATCH 043/130] improve data persistence/mapping for live/dry. This accommodates quick reloads after crash and handles multi-pair cleanly --- freqtrade/freqai/data_drawer.py | 59 +++++++++ freqtrade/freqai/data_kitchen.py | 123 ++++++++++-------- freqtrade/freqai/freqai_interface.py | 102 ++++++++++----- .../CatboostPredictionModel.py | 46 ------- 4 files changed, 199 insertions(+), 131 deletions(-) create mode 100644 freqtrade/freqai/data_drawer.py diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py new file mode 100644 index 000000000..a27a4b67f --- /dev/null +++ b/freqtrade/freqai/data_drawer.py @@ -0,0 +1,59 @@ + +import json +import logging +from pathlib import Path +from typing import Any, Dict, Tuple + +# import pickle as pk +import numpy as np + + +logger = logging.getLogger(__name__) + + +class FreqaiDataDrawer: + """ + Class aimed at holding all pair models/info in memory for better inferencing/retrainig/saving + /loading to/from disk. + This object remains persistent throughout live/dry, unlike FreqaiDataKitchen, which is + reinstantiated for each coin. + """ + def __init__(self, full_path: Path): + + # dictionary holding all pair metadata necessary to load in from disk + self.pair_dict: Dict[str, Any] = {} + # dictionary holding all actively inferenced models in memory given a model filename + self.model_dictionary: Dict[str, Any] = {} + self.full_path = full_path + self.load_drawer_from_disk() + + def load_drawer_from_disk(self): + exists = Path(self.full_path / str('pair_dictionary.json')).resolve().exists() + if exists: + with open(self.full_path / str('pair_dictionary.json'), "r") as fp: + self.pair_dict = json.load(fp) + else: + logger.info("Could not find existing datadrawer, starting from scratch") + return exists + + def save_drawer_to_disk(self): + with open(self.full_path / str('pair_dictionary.json'), "w") as fp: + json.dump(self.pair_dict, fp, default=self.np_encoder) + + def np_encoder(self, object): + if isinstance(object, np.generic): + return object.item() + + def get_pair_dict_info(self, metadata: dict) -> Tuple[str, int, bool]: + pair_in_dict = self.pair_dict.get(metadata['pair']) + if pair_in_dict: + model_filename = self.pair_dict[metadata['pair']]['model_filename'] + trained_timestamp = self.pair_dict[metadata['pair']]['trained_timestamp'] + coin_first = self.pair_dict[metadata['pair']]['first'] + else: + self.pair_dict[metadata['pair']] = {} + model_filename = self.pair_dict[metadata['pair']]['model_filename'] = '' + coin_first = self.pair_dict[metadata['pair']]['first'] = True + trained_timestamp = self.pair_dict[metadata['pair']]['trained_timestamp'] = 0 + + return model_filename, trained_timestamp, coin_first diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 148efd5dd..f5ddf8462 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -19,6 +19,7 @@ from sklearn.model_selection import train_test_split from freqtrade.configuration import TimeRange from freqtrade.data.history import load_pair_history from freqtrade.data.history.history_utils import refresh_backtest_ohlcv_data +from freqtrade.freqai.data_drawer import FreqaiDataDrawer from freqtrade.resolvers import ExchangeResolver from freqtrade.strategy.interface import IStrategy @@ -33,13 +34,13 @@ logger = logging.getLogger(__name__) class FreqaiDataKitchen: """ - Class designed to handle all the data for the IFreqaiModel class model. + Class designed to analyze data for a single pair. Employed by the IFreqaiModel class. Functionalities include holding, saving, loading, and analyzing the data. author: Robert Caulk, rob.caulk@gmail.com """ - def __init__(self, config: Dict[str, Any], dataframe: DataFrame, live: bool = False): - self.full_dataframe = dataframe + def __init__(self, config: Dict[str, Any], data_drawer: FreqaiDataDrawer, live: bool = False, + pair: str = ''): self.data: Dict[Any, Any] = {} self.data_dictionary: Dict[Any, Any] = {} self.config = config @@ -53,10 +54,10 @@ class FreqaiDataKitchen: self.full_do_predict: npt.ArrayLike = np.array([]) self.full_target_mean: npt.ArrayLike = np.array([]) self.full_target_std: npt.ArrayLike = np.array([]) - self.model_path = Path() + self.data_path = Path() self.model_filename: str = "" - self.model_dictionary: Dict[Any, Any] = {} self.live = live + self.pair = pair self.svm_model: linear_model.SGDOneClassSVM = None if not self.live: self.full_timerange = self.create_fulltimerange(self.config["timerange"], @@ -69,6 +70,8 @@ class FreqaiDataKitchen: config["freqai"]["backtest_period"], ) + self.data_drawer = data_drawer + def assert_config(self, config: Dict[str, Any], live: bool) -> None: assert config.get('freqai'), "No Freqai parameters found in config file." assert config.get('freqai', {}).get('train_period'), ("No Freqai train_period found in" @@ -88,18 +91,18 @@ class FreqaiDataKitchen: assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai feature_parameters" "found in config file.") - def set_paths(self) -> None: + def set_paths(self, trained_timestamp: int = None) -> None: self.full_path = Path(self.config['user_data_dir'] / "models" / str(self.freqai_config.get('live_full_backtestrange') + self.freqai_config.get('identifier'))) - self.model_path = Path(self.full_path / str("sub-train" + "-" + - str(self.freqai_config.get('live_trained_timerange')))) + self.data_path = Path(self.full_path / str("sub-train" + "-" + self.pair.split("/")[0] + + str(trained_timestamp))) return - def save_data(self, model: Any) -> None: + def save_data(self, model: Any, coin: str = '') -> None: """ Saves all data associated with a model for a single sub-train time range :params: @@ -107,10 +110,10 @@ class FreqaiDataKitchen: predictions """ - if not self.model_path.is_dir(): - self.model_path.mkdir(parents=True, exist_ok=True) + if not self.data_path.is_dir(): + self.data_path.mkdir(parents=True, exist_ok=True) - save_path = Path(self.model_path) + save_path = Path(self.data_path) # Save the trained model dump(model, save_path / str(self.model_filename + "_model.joblib")) @@ -118,7 +121,7 @@ class FreqaiDataKitchen: if self.svm_model is not None: dump(self.svm_model, save_path / str(self.model_filename + "_svm_model.joblib")) - self.data["model_path"] = str(self.model_path) + self.data["data_path"] = str(self.data_path) self.data["model_filename"] = str(self.model_filename) self.data["training_features_list"] = list(self.data_dictionary["train_features"].columns) # store the metadata @@ -131,7 +134,10 @@ class FreqaiDataKitchen: ) if self.live: - self.model_dictionary[self.model_filename] = model + self.data_drawer.model_dictionary[self.model_filename] = model + self.data_drawer.pair_dict[coin]['model_filename'] = self.model_filename + self.data_drawer.pair_dict[coin]['data_path'] = str(self.data_path) + self.data_drawer.save_drawer_to_disk() # TODO add a helper function to let user save/load any data they are custom adding. We # do not want them having to edit the default save/load methods here. Below is an example @@ -148,19 +154,23 @@ class FreqaiDataKitchen: return - def load_data(self) -> Any: + def load_data(self, coin: str = '') -> Any: """ loads all data required to make a prediction on a sub-train time range :returns: :model: User trained model which can be inferenced for new predictions """ - with open(self.model_path / str(self.model_filename + "_metadata.json"), "r") as fp: + if self.live: + self.model_filename = self.data_drawer.pair_dict[coin]['model_filename'] + self.data_path = Path(self.data_drawer.pair_dict[coin]['data_path']) + + with open(self.data_path / str(self.model_filename + "_metadata.json"), "r") as fp: self.data = json.load(fp) self.training_features_list = self.data["training_features_list"] self.data_dictionary["train_features"] = pd.read_pickle( - self.model_path / str(self.model_filename + "_trained_df.pkl") + self.data_path / str(self.model_filename + "_trained_df.pkl") ) # TODO add a helper function to let user save/load any data they are custom adding. We @@ -169,34 +179,34 @@ class FreqaiDataKitchen: # if self.freqai_config.get('feature_parameters','determine_statistical_distributions'): # self.data_dictionary["upper_quantiles"] = pd.read_pickle( - # self.model_path / str(self.model_filename + "_upper_quantiles.pkl") + # self.data_path / str(self.model_filename + "_upper_quantiles.pkl") # ) # self.data_dictionary["lower_quantiles"] = pd.read_pickle( - # self.model_path / str(self.model_filename + "_lower_quantiles.pkl") + # self.data_path / str(self.model_filename + "_lower_quantiles.pkl") # ) - self.model_path = Path(self.data["model_path"]) - self.model_filename = self.data["model_filename"] + # self.data_path = Path(self.data["data_path"]) + # self.model_filename = self.data["model_filename"] # try to access model in memory instead of loading object from disk to save time - if self.live and self.model_filename in self.model_dictionary: - model = self.model_dictionary[self.model_filename] + if self.live and self.model_filename in self.data_drawer.model_dictionary: + model = self.data_drawer.model_dictionary[self.model_filename] else: - model = load(self.model_path / str(self.model_filename + "_model.joblib")) + model = load(self.data_path / str(self.model_filename + "_model.joblib")) - if Path(self.model_path / str(self.model_filename + + if Path(self.data_path / str(self.model_filename + "_svm_model.joblib")).resolve().exists(): - self.svm_model = load(self.model_path / str(self.model_filename + "_svm_model.joblib")) + self.svm_model = load(self.data_path / str(self.model_filename + "_svm_model.joblib")) assert model, ( f"Unable to load model, ensure model exists at " - f"{self.model_path} " + f"{self.data_path} " ) if self.config["freqai"]["feature_parameters"]["principal_component_analysis"]: self.pca = pk.load( - open(self.model_path / str(self.model_filename + "_pca_object.pkl"), "rb") + open(self.data_path / str(self.model_filename + "_pca_object.pkl"), "rb") ) return model @@ -539,9 +549,9 @@ class FreqaiDataKitchen: logger.info(f'PCA reduced total features from {n_components} to {n_keep_components}') - if not self.model_path.is_dir(): - self.model_path.mkdir(parents=True, exist_ok=True) - pk.dump(pca2, open(self.model_path / str(self.model_filename + "_pca_object.pkl"), "wb")) + if not self.data_path.is_dir(): + self.data_path.mkdir(parents=True, exist_ok=True) + pk.dump(pca2, open(self.data_path / str(self.model_filename + "_pca_object.pkl"), "wb")) return None @@ -717,40 +727,51 @@ class FreqaiDataKitchen: return full_timerange - def check_if_new_training_required(self, trained_timerange: TimeRange, - metadata: dict) -> Tuple[bool, TimeRange]: + def check_if_new_training_required(self, trained_timestamp: int) -> Tuple[bool, TimeRange]: time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() - - if trained_timerange.startts != 0: - elapsed_time = (time - trained_timerange.stopts) / SECONDS_IN_DAY + trained_timerange = TimeRange() + if trained_timestamp != 0: + elapsed_time = (time - trained_timestamp) / SECONDS_IN_DAY retrain = elapsed_time > self.freqai_config.get('backtest_period') if retrain: - trained_timerange.startts += self.freqai_config.get( - 'backtest_period', 0) * SECONDS_IN_DAY - trained_timerange.stopts += self.freqai_config.get( - 'backtest_period', 0) * SECONDS_IN_DAY + trained_timerange.startts = int(time - self.freqai_config.get( + 'backtest_period', 0) * SECONDS_IN_DAY) + trained_timerange.stopts = int(time) else: # user passed no live_trained_timerange in config - trained_timerange = TimeRange() trained_timerange.startts = int(time - self.freqai_config.get('train_period') * SECONDS_IN_DAY) trained_timerange.stopts = int(time) retrain = True - if retrain: - coin, _ = metadata['pair'].split("/") - # set the new model_path - self.model_path = Path(self.full_path / str("sub-train" + "-" + - str(int(trained_timerange.stopts)))) + # if retrain: + # coin, _ = metadata['pair'].split("/") + # # set the new data_path + # self.data_path = Path(self.full_path / str("sub-train" + "-" + + # str(int(trained_timerange.stopts)))) - self.model_filename = "cb_" + coin.lower() + "_" + str(int(trained_timerange.stopts)) - # this is not persistent at the moment TODO - self.freqai_config['live_trained_timerange'] = str(int(trained_timerange.stopts)) - # enables persistence, but not fully implemented into save/load data yer - self.data['live_trained_timerange'] = str(int(trained_timerange.stopts)) + # self.model_filename = "cb_" + coin.lower() + "_" + str(int(trained_timerange.stopts)) + # # this is not persistent at the moment TODO + # self.freqai_config['live_trained_timerange'] = str(int(trained_timerange.stopts)) + # # enables persistence, but not fully implemented into save/load data yer + # self.data['live_trained_timerange'] = str(int(trained_timerange.stopts)) return retrain, trained_timerange + def set_new_model_names(self, metadata: dict, trained_timerange: TimeRange): + + coin, _ = metadata['pair'].split("/") + # set the new data_path + self.data_path = Path(self.full_path / str("sub-train" + "-" + + metadata['pair'].split("/")[0] + + str(int(trained_timerange.stopts)))) + + self.model_filename = "cb_" + coin.lower() + "_" + str(int(trained_timerange.stopts)) + # this is not persistent at the moment TODO + self.freqai_config['live_trained_timerange'] = str(int(trained_timerange.stopts)) + # enables persistence, but not fully implemented into save/load data yer + self.data['live_trained_timerange'] = str(int(trained_timerange.stopts)) + def download_new_data_for_retraining(self, timerange: TimeRange, metadata: dict) -> None: exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 2b3addab3..0b1fb3b86 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -13,6 +13,7 @@ from pandas import DataFrame from freqtrade.configuration import TimeRange from freqtrade.enums import RunMode +from freqtrade.freqai.data_drawer import FreqaiDataDrawer from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.strategy.interface import IStrategy @@ -65,11 +66,14 @@ class IFreqaiModel(ABC): self.training_on_separate_thread = False self.retrain = False self.first = True - if self.freqai_info.get('live_trained_timerange'): - self.new_trained_timerange = TimeRange.parse_timerange( - self.freqai_info['live_trained_timerange']) - else: - self.new_trained_timerange = TimeRange() + # if self.freqai_info.get('live_trained_timerange'): + # self.new_trained_timerange = TimeRange.parse_timerange( + # self.freqai_info['live_trained_timerange']) + # else: + # self.new_trained_timerange = TimeRange() + + self.set_full_path() + self.data_drawer = FreqaiDataDrawer(Path(self.full_path)) def assert_config(self, config: Dict[str, Any]) -> None: @@ -86,7 +90,7 @@ class IFreqaiModel(ABC): def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ - Entry point to the FreqaiModel, it will train a new model if + Entry point to the FreqaiModel from a specific pair, it will train a new model if necessary before making the prediction. The backtesting and training paradigm is a sliding training window with a following backtest window. Both windows slide according to the @@ -103,8 +107,8 @@ class IFreqaiModel(ABC): self.live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE) - self.pair = metadata["pair"] - self.dh = FreqaiDataKitchen(self.config, dataframe, self.live) + # FreqaiDataKitchen is reinstantiated for each coin + self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) if self.live: # logger.info('testing live') @@ -113,7 +117,7 @@ class IFreqaiModel(ABC): return (self.dh.full_predictions, self.dh.full_do_predict, self.dh.full_target_mean, self.dh.full_target_std) - logger.info("going to train %s timeranges", len(self.dh.training_timeranges)) + logger.info(f'Training {len(self.dh.training_timeranges)} timeranges') # Loop enforcing the sliding window training/backtesting paradigm # tr_train is the training time range e.g. 1 historical month @@ -129,9 +133,12 @@ class IFreqaiModel(ABC): self.training_timerange = tr_train dataframe_train = self.dh.slice_dataframe(tr_train, dataframe) dataframe_backtest = self.dh.slice_dataframe(tr_backtest, dataframe) - logger.info("training %s for %s", self.pair, tr_train) - self.dh.model_path = Path(self.dh.full_path / str("sub-train" + "-" + str(tr_train))) - if not self.model_exists(self.pair, training_timerange=tr_train): + logger.info("training %s for %s", metadata["pair"], tr_train) + trained_timestamp = TimeRange.parse_timerange(tr_train) + self.dh.data_path = Path(self.dh.full_path / + str("sub-train" + "-" + metadata['pair'].split("/")[0] + + str(int(trained_timestamp.stopts)))) + if not self.model_exists(metadata["pair"], trained_timestamp=trained_timestamp.stopts): self.model = self.train(dataframe_train, metadata) self.dh.save_data(self.model) else: @@ -161,36 +168,40 @@ class IFreqaiModel(ABC): """ - self.dh.set_paths() + (model_filename, + trained_timestamp, + coin_first) = self.data_drawer.get_pair_dict_info(metadata) - file_exists = self.model_exists(metadata['pair'], - training_timerange=self.freqai_info[ - 'live_trained_timerange']) + if trained_timestamp != 0: + self.dh.set_paths(trained_timestamp) + # data_drawer thinks the file eixts, verify here + file_exists = self.model_exists(metadata['pair'], + trained_timestamp=trained_timestamp, + model_filename=model_filename) if not self.training_on_separate_thread: # this will also prevent other pairs from trying to train simultaneously. (self.retrain, - self.new_trained_timerange) = self.dh.check_if_new_training_required( - self.new_trained_timerange, - metadata) + new_trained_timerange) = self.dh.check_if_new_training_required( + trained_timestamp) + self.dh.set_paths(new_trained_timerange.stopts) else: logger.info("FreqAI training a new model on background thread.") self.retrain = False if self.retrain or not file_exists: - if self.first: - self.train_model_in_series(self.new_trained_timerange, metadata, strategy) - self.first = False + if coin_first: + self.train_model_in_series(new_trained_timerange, metadata, strategy) else: self.training_on_separate_thread = True # acts like a lock - self.retrain_model_on_separate_thread(self.new_trained_timerange, + self.retrain_model_on_separate_thread(new_trained_timerange, metadata, strategy) - self.model = self.dh.load_data() + self.model = self.dh.load_data(coin=metadata['pair']) strategy_provided_features = self.dh.find_features(dataframe) if strategy_provided_features != self.dh.training_features_list: - self.train_model_in_series(self.new_trained_timerange, metadata, strategy) + self.train_model_in_series(new_trained_timerange, metadata, strategy) preds, do_preds = self.predict(dataframe, metadata) self.dh.append_predictions(preds, do_preds, len(dataframe)) @@ -252,24 +263,34 @@ class IFreqaiModel(ABC): if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): self.dh.check_if_pred_in_training_spaces() # sets do_predict - def model_exists(self, pair: str, training_timerange: str) -> bool: + def model_exists(self, pair: str, trained_timestamp: int = None, + model_filename: str = '') -> bool: """ Given a pair and path, check if a model already exists :param pair: pair e.g. BTC/USD :param path: path to model """ - if self.live and training_timerange == "": - return False coin, _ = pair.split("/") - self.dh.model_filename = "cb_" + coin.lower() + "_" + training_timerange - path_to_modelfile = Path(self.dh.model_path / str(self.dh.model_filename + "_model.joblib")) + + if self.live and trained_timestamp is None: + self.dh.model_filename = model_filename + else: + self.dh.model_filename = "cb_" + coin.lower() + "_" + str(trained_timestamp) + + path_to_modelfile = Path(self.dh.data_path / str(self.dh.model_filename + "_model.joblib")) file_exists = path_to_modelfile.is_file() if file_exists: - logger.info("Found model at %s", self.dh.model_path / self.dh.model_filename) + logger.info("Found model at %s", self.dh.data_path / self.dh.model_filename) else: - logger.info("Could not find model at %s", self.dh.model_path / self.dh.model_filename) + logger.info("Could not find model at %s", self.dh.data_path / self.dh.model_filename) return file_exists + def set_full_path(self) -> None: + self.full_path = Path(self.config['user_data_dir'] / + "models" / + str(self.freqai_info.get('live_full_backtestrange') + + self.freqai_info.get('identifier'))) + @threaded def retrain_model_on_separate_thread(self, new_trained_timerange: TimeRange, metadata: dict, strategy: IStrategy): @@ -285,7 +306,13 @@ class IFreqaiModel(ABC): metadata) self.model = self.train(unfiltered_dataframe, metadata) - self.dh.save_data(self.model) + + self.data_drawer.pair_dict[metadata['pair']][ + 'trained_timestamp'] = new_trained_timerange.stopts + + self.dh.set_new_model_names(metadata, new_trained_timerange) + + self.dh.save_data(self.model, coin=metadata['pair']) self.training_on_separate_thread = False self.retrain = False @@ -303,7 +330,14 @@ class IFreqaiModel(ABC): metadata) self.model = self.train(unfiltered_dataframe, metadata) - self.dh.save_data(self.model) + + self.data_drawer.pair_dict[metadata['pair']][ + 'trained_timestamp'] = new_trained_timerange.stopts + + self.dh.set_new_model_names(metadata, new_trained_timerange) + + self.data_drawer.pair_dict[metadata['pair']]['first'] = False + self.dh.save_data(self.model, coin=metadata['pair']) self.retrain = False # Methods which are overridden by user made prediction models. diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index d09554e3e..6349174ad 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -140,49 +140,3 @@ class CatboostPredictionModel(IFreqaiModel): # logger.info("--------------------Finished prediction--------------------") return (self.dh.predictions, self.dh.do_predict) - - def data_cleaning_train(self) -> None: - """ - User can add data analysis and cleaning here. - Any function inside this method should drop training data points from the filtered_dataframe - based on user decided logic. See FreqaiDataKitchen::remove_outliers() for an example - of how outlier data points are dropped from the dataframe used for training. - """ - if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): - self.dh.principal_component_analysis() - - # if self.feature_parameters["determine_statistical_distributions"]: - # self.dh.determine_statistical_distributions() - # if self.feature_parameters["remove_outliers"]: - # self.dh.remove_outliers(predict=False) - - if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): - self.dh.use_SVM_to_remove_outliers(predict=False) - - if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): - self.dh.data["avg_mean_dist"] = self.dh.compute_distances() - - def data_cleaning_predict(self, filtered_dataframe: DataFrame) -> None: - """ - User can add data analysis and cleaning here. - These functions each modify self.dh.do_predict, which is a dataframe with equal length - to the number of candles coming from and returning to the strategy. Inside do_predict, - 1 allows prediction and < 0 signals to the strategy that the model is not confident in - the prediction. - See FreqaiDataKitchen::remove_outliers() for an example - of how the do_predict vector is modified. do_predict is ultimately passed back to strategy - for buy signals. - """ - if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): - self.dh.pca_transform() - - # if self.feature_parameters["determine_statistical_distributions"]: - # self.dh.determine_statistical_distributions() - # if self.feature_parameters["remove_outliers"]: - # self.dh.remove_outliers(predict=True) # creates dropped index - - if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): - self.dh.use_SVM_to_remove_outliers(predict=True) - - if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): - self.dh.check_if_pred_in_training_spaces() # sets do_predict From 059c28542548374ed4e2402f0f9e5c945a8295e8 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 24 May 2022 12:01:01 +0200 Subject: [PATCH 044/130] paying closer attention to managing live retraining on separate thread without affecting prediction of other coins on master thread --- freqtrade/freqai/data_drawer.py | 1 + freqtrade/freqai/data_kitchen.py | 7 +- freqtrade/freqai/freqai_interface.py | 198 ++++++++++-------- .../CatboostPredictionModel.py | 51 ++--- 4 files changed, 139 insertions(+), 118 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index a27a4b67f..51f56fae6 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -24,6 +24,7 @@ class FreqaiDataDrawer: self.pair_dict: Dict[str, Any] = {} # dictionary holding all actively inferenced models in memory given a model filename self.model_dictionary: Dict[str, Any] = {} + self.pair_data_dict: Dict[str, Any] = {} self.full_path = full_path self.load_drawer_from_disk() diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index f5ddf8462..a4867d7eb 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -91,14 +91,15 @@ class FreqaiDataKitchen: assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai feature_parameters" "found in config file.") - def set_paths(self, trained_timestamp: int = None) -> None: + def set_paths(self, metadata: dict, trained_timestamp: int = None,) -> None: self.full_path = Path(self.config['user_data_dir'] / "models" / str(self.freqai_config.get('live_full_backtestrange') + self.freqai_config.get('identifier'))) - self.data_path = Path(self.full_path / str("sub-train" + "-" + self.pair.split("/")[0] + - str(trained_timestamp))) + self.data_path = Path(self.full_path / str("sub-train" + "-" + + metadata['pair'].split("/")[0] + + str(trained_timestamp))) return diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 0b1fb3b86..19b7dbb27 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -108,14 +108,22 @@ class IFreqaiModel(ABC): self.live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE) # FreqaiDataKitchen is reinstantiated for each coin - self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) - if self.live: - # logger.info('testing live') - self.start_live(dataframe, metadata, strategy) + if not self.training_on_separate_thread: + self.dh = FreqaiDataKitchen(self.config, self.data_drawer, + self.live, metadata["pair"]) + dh = self.start_live(dataframe, metadata, strategy, self.dh) + else: + # we will have at max 2 separate instances of the kitchen at once. + self.dh_fg = FreqaiDataKitchen(self.config, self.data_drawer, + self.live, metadata["pair"]) + dh = self.start_live(dataframe, metadata, strategy, self.dh_fg) - return (self.dh.full_predictions, self.dh.full_do_predict, - self.dh.full_target_mean, self.dh.full_target_std) + return (dh.full_predictions, dh.full_do_predict, + dh.full_target_mean, dh.full_target_std) + + # Backtesting only + self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) logger.info(f'Training {len(self.dh.training_timeranges)} timeranges') @@ -138,8 +146,9 @@ class IFreqaiModel(ABC): self.dh.data_path = Path(self.dh.full_path / str("sub-train" + "-" + metadata['pair'].split("/")[0] + str(int(trained_timestamp.stopts)))) - if not self.model_exists(metadata["pair"], trained_timestamp=trained_timestamp.stopts): - self.model = self.train(dataframe_train, metadata) + if not self.model_exists(metadata["pair"], self.dh, + trained_timestamp=trained_timestamp.stopts): + self.model = self.train(dataframe_train, metadata, self.dh) self.dh.save_data(self.model) else: self.model = self.dh.load_data() @@ -150,7 +159,7 @@ class IFreqaiModel(ABC): # self.model = self.train(dataframe_train, metadata) # self.dh.save_data(self.model) - preds, do_preds = self.predict(dataframe_backtest, metadata) + preds, do_preds = self.predict(dataframe_backtest, self.dh) self.dh.append_predictions(preds, do_preds, len(dataframe_backtest)) print('predictions', len(self.dh.full_predictions), @@ -161,7 +170,8 @@ class IFreqaiModel(ABC): return (self.dh.full_predictions, self.dh.full_do_predict, self.dh.full_target_mean, self.dh.full_target_std) - def start_live(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> None: + def start_live(self, dataframe: DataFrame, metadata: dict, + strategy: IStrategy, dh: FreqaiDataKitchen) -> FreqaiDataKitchen: """ The main broad execution for dry/live. This function will check if a retraining should be performed, and if so, retrain and reset the model. @@ -172,52 +182,49 @@ class IFreqaiModel(ABC): trained_timestamp, coin_first) = self.data_drawer.get_pair_dict_info(metadata) - if trained_timestamp != 0: - self.dh.set_paths(trained_timestamp) - # data_drawer thinks the file eixts, verify here - file_exists = self.model_exists(metadata['pair'], - trained_timestamp=trained_timestamp, - model_filename=model_filename) - if not self.training_on_separate_thread: + file_exists = False + + if trained_timestamp != 0: + dh.set_paths(metadata, trained_timestamp) + # data_drawer thinks the file eixts, verify here + file_exists = self.model_exists(metadata['pair'], + dh, + trained_timestamp=trained_timestamp, + model_filename=model_filename) + + # if not self.training_on_separate_thread: # this will also prevent other pairs from trying to train simultaneously. (self.retrain, - new_trained_timerange) = self.dh.check_if_new_training_required( - trained_timestamp) - self.dh.set_paths(new_trained_timerange.stopts) + new_trained_timerange) = dh.check_if_new_training_required(trained_timestamp) + dh.set_paths(metadata, new_trained_timerange.stopts) + # if self.training_on_separate_thread: + # logger.info("FreqAI training a new model on background thread.") + # self.retrain = False + + if self.retrain or not file_exists: + if coin_first: + self.train_model_in_series(new_trained_timerange, metadata, strategy, dh) + else: + self.training_on_separate_thread = True # acts like a lock + self.retrain_model_on_separate_thread(new_trained_timerange, + metadata, strategy, dh) + else: logger.info("FreqAI training a new model on background thread.") - self.retrain = False - if self.retrain or not file_exists: - if coin_first: - self.train_model_in_series(new_trained_timerange, metadata, strategy) - else: - self.training_on_separate_thread = True # acts like a lock - self.retrain_model_on_separate_thread(new_trained_timerange, - metadata, strategy) + self.model = dh.load_data(coin=metadata['pair']) - self.model = self.dh.load_data(coin=metadata['pair']) + # strategy_provided_features = dh.find_features(dataframe) + # if strategy_provided_features != dh.training_features_list: + # self.train_model_in_series(new_trained_timerange, metadata, strategy) - strategy_provided_features = self.dh.find_features(dataframe) - if strategy_provided_features != self.dh.training_features_list: - self.train_model_in_series(new_trained_timerange, metadata, strategy) + preds, do_preds = self.predict(dataframe, dh) + dh.append_predictions(preds, do_preds, len(dataframe)) - preds, do_preds = self.predict(dataframe, metadata) - self.dh.append_predictions(preds, do_preds, len(dataframe)) + return dh - return - - def make_labels(self, dataframe: DataFrame) -> DataFrame: - """ - User defines the labels here (target values). - :params: - :dataframe: the full dataframe for the present training period - """ - - return - - def data_cleaning_train(self) -> None: + def data_cleaning_train(self, dh: FreqaiDataKitchen) -> None: """ Base data cleaning method for train Any function inside this method should drop training data points from the filtered_dataframe @@ -225,23 +232,23 @@ class IFreqaiModel(ABC): of how outlier data points are dropped from the dataframe used for training. """ if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): - self.dh.principal_component_analysis() + dh.principal_component_analysis() # if self.feature_parameters["determine_statistical_distributions"]: - # self.dh.determine_statistical_distributions() + # dh.determine_statistical_distributions() # if self.feature_parameters["remove_outliers"]: - # self.dh.remove_outliers(predict=False) + # dh.remove_outliers(predict=False) if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): - self.dh.use_SVM_to_remove_outliers(predict=False) + dh.use_SVM_to_remove_outliers(predict=False) if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): - self.dh.data["avg_mean_dist"] = self.dh.compute_distances() + dh.data["avg_mean_dist"] = dh.compute_distances() - def data_cleaning_predict(self, filtered_dataframe: DataFrame) -> None: + def data_cleaning_predict(self, dh: FreqaiDataKitchen) -> None: """ Base data cleaning method for predict. - These functions each modify self.dh.do_predict, which is a dataframe with equal length + These functions each modify dh.do_predict, which is a dataframe with equal length to the number of candles coming from and returning to the strategy. Inside do_predict, 1 allows prediction and < 0 signals to the strategy that the model is not confident in the prediction. @@ -250,20 +257,20 @@ class IFreqaiModel(ABC): for buy signals. """ if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): - self.dh.pca_transform() + dh.pca_transform() # if self.feature_parameters["determine_statistical_distributions"]: - # self.dh.determine_statistical_distributions() + # dh.determine_statistical_distributions() # if self.feature_parameters["remove_outliers"]: - # self.dh.remove_outliers(predict=True) # creates dropped index + # dh.remove_outliers(predict=True) # creates dropped index if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): - self.dh.use_SVM_to_remove_outliers(predict=True) + dh.use_SVM_to_remove_outliers(predict=True) if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): - self.dh.check_if_pred_in_training_spaces() # sets do_predict + dh.check_if_pred_in_training_spaces() # sets do_predict - def model_exists(self, pair: str, trained_timestamp: int = None, + def model_exists(self, pair: str, dh: FreqaiDataKitchen, trained_timestamp: int = None, model_filename: str = '') -> bool: """ Given a pair and path, check if a model already exists @@ -272,17 +279,17 @@ class IFreqaiModel(ABC): """ coin, _ = pair.split("/") - if self.live and trained_timestamp is None: - self.dh.model_filename = model_filename - else: - self.dh.model_filename = "cb_" + coin.lower() + "_" + str(trained_timestamp) + # if self.live and trained_timestamp == 0: + # dh.model_filename = model_filename + if not self.live: + dh.model_filename = model_filename = "cb_" + coin.lower() + "_" + str(trained_timestamp) - path_to_modelfile = Path(self.dh.data_path / str(self.dh.model_filename + "_model.joblib")) + path_to_modelfile = Path(dh.data_path / str(model_filename + "_model.joblib")) file_exists = path_to_modelfile.is_file() if file_exists: - logger.info("Found model at %s", self.dh.data_path / self.dh.model_filename) + logger.info("Found model at %s", dh.data_path / dh.model_filename) else: - logger.info("Could not find model at %s", self.dh.data_path / self.dh.model_filename) + logger.info("Could not find model at %s", dh.data_path / dh.model_filename) return file_exists def set_full_path(self) -> None: @@ -293,58 +300,58 @@ class IFreqaiModel(ABC): @threaded def retrain_model_on_separate_thread(self, new_trained_timerange: TimeRange, metadata: dict, - strategy: IStrategy): + strategy: IStrategy, dh: FreqaiDataKitchen): # with nostdout(): - self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) - corr_dataframes, base_dataframes = self.dh.load_pairs_histories(new_trained_timerange, - metadata) + dh.download_new_data_for_retraining(new_trained_timerange, metadata) + corr_dataframes, base_dataframes = dh.load_pairs_histories(new_trained_timerange, + metadata) - unfiltered_dataframe = self.dh.use_strategy_to_populate_indicators(strategy, - corr_dataframes, - base_dataframes, - metadata) + unfiltered_dataframe = dh.use_strategy_to_populate_indicators(strategy, + corr_dataframes, + base_dataframes, + metadata) - self.model = self.train(unfiltered_dataframe, metadata) + self.model = self.train(unfiltered_dataframe, metadata, dh) self.data_drawer.pair_dict[metadata['pair']][ 'trained_timestamp'] = new_trained_timerange.stopts - self.dh.set_new_model_names(metadata, new_trained_timerange) + dh.set_new_model_names(metadata, new_trained_timerange) - self.dh.save_data(self.model, coin=metadata['pair']) + dh.save_data(self.model, coin=metadata['pair']) self.training_on_separate_thread = False self.retrain = False def train_model_in_series(self, new_trained_timerange: TimeRange, metadata: dict, - strategy: IStrategy): + strategy: IStrategy, dh: FreqaiDataKitchen): - self.dh.download_new_data_for_retraining(new_trained_timerange, metadata) - corr_dataframes, base_dataframes = self.dh.load_pairs_histories(new_trained_timerange, - metadata) + dh.download_new_data_for_retraining(new_trained_timerange, metadata) + corr_dataframes, base_dataframes = dh.load_pairs_histories(new_trained_timerange, + metadata) - unfiltered_dataframe = self.dh.use_strategy_to_populate_indicators(strategy, - corr_dataframes, - base_dataframes, - metadata) + unfiltered_dataframe = dh.use_strategy_to_populate_indicators(strategy, + corr_dataframes, + base_dataframes, + metadata) - self.model = self.train(unfiltered_dataframe, metadata) + self.model = self.train(unfiltered_dataframe, metadata, dh) self.data_drawer.pair_dict[metadata['pair']][ 'trained_timestamp'] = new_trained_timerange.stopts - self.dh.set_new_model_names(metadata, new_trained_timerange) + dh.set_new_model_names(metadata, new_trained_timerange) self.data_drawer.pair_dict[metadata['pair']]['first'] = False - self.dh.save_data(self.model, coin=metadata['pair']) + dh.save_data(self.model, coin=metadata['pair']) self.retrain = False # Methods which are overridden by user made prediction models. # See freqai/prediction_models/CatboostPredictionModlel.py for an example. @abstractmethod - def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Any: + def train(self, unfiltered_dataframe: DataFrame, metadata: dict, dh: FreqaiDataKitchen) -> Any: """ Filter the training data and train a model to it. Train makes heavy use of the datahandler for storing, saving, loading, and analyzing the data. @@ -369,7 +376,8 @@ class IFreqaiModel(ABC): return @abstractmethod - def predict(self, dataframe: DataFrame, metadata: dict) -> Tuple[npt.ArrayLike, npt.ArrayLike]: + def predict(self, dataframe: DataFrame, + dh: FreqaiDataKitchen) -> Tuple[npt.ArrayLike, npt.ArrayLike]: """ Filter the prediction features data and predict with it. :param: unfiltered_dataframe: Full dataframe for the current backtest period. @@ -378,3 +386,13 @@ class IFreqaiModel(ABC): :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove data (NaNs) or felt uncertain about data (PCA and DI index) """ + + @abstractmethod + def make_labels(self, dataframe: DataFrame, dh: FreqaiDataKitchen) -> DataFrame: + """ + User defines the labels here (target values). + :params: + :dataframe: the full dataframe for the present training period + """ + + return diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index 6349174ad..87ddfdb66 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -4,6 +4,7 @@ from typing import Any, Dict, Tuple from catboost import CatBoostRegressor, Pool from pandas import DataFrame +from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.freqai_interface import IFreqaiModel @@ -17,7 +18,7 @@ class CatboostPredictionModel(IFreqaiModel): has its own DataHandler where data is held, saved, loaded, and managed. """ - def make_labels(self, dataframe: DataFrame) -> DataFrame: + def make_labels(self, dataframe: DataFrame, dh: FreqaiDataKitchen) -> DataFrame: """ User defines the labels here (target values). :params: @@ -32,14 +33,15 @@ class CatboostPredictionModel(IFreqaiModel): / dataframe["close"] - 1 ) - self.dh.data["s_mean"] = dataframe["s"].mean() - self.dh.data["s_std"] = dataframe["s"].std() + dh.data["s_mean"] = dataframe["s"].mean() + dh.data["s_std"] = dataframe["s"].std() - # logger.info("label mean", self.dh.data["s_mean"], "label std", self.dh.data["s_std"]) + # logger.info("label mean", dh.data["s_mean"], "label std", dh.data["s_std"]) return dataframe["s"] - def train(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, DataFrame]: + def train(self, unfiltered_dataframe: DataFrame, + metadata: dict, dh: FreqaiDataKitchen) -> Tuple[DataFrame, DataFrame]: """ Filter the training data and train a model to it. Train makes heavy use of the datahkitchen for storing, saving, loading, and analyzing the data. @@ -52,25 +54,25 @@ class CatboostPredictionModel(IFreqaiModel): logger.info("--------------------Starting training--------------------") # create the full feature list based on user config info - self.dh.training_features_list = self.dh.find_features(unfiltered_dataframe) - unfiltered_labels = self.make_labels(unfiltered_dataframe) + dh.training_features_list = dh.find_features(unfiltered_dataframe) + unfiltered_labels = self.make_labels(unfiltered_dataframe, dh) # filter the features requested by user in the configuration file and elegantly handle NaNs - features_filtered, labels_filtered = self.dh.filter_features( + features_filtered, labels_filtered = dh.filter_features( unfiltered_dataframe, - self.dh.training_features_list, + dh.training_features_list, unfiltered_labels, training_filter=True, ) # split data into train/test data. - data_dictionary = self.dh.make_train_test_datasets(features_filtered, labels_filtered) + data_dictionary = dh.make_train_test_datasets(features_filtered, labels_filtered) # standardize all data based on train_dataset only - data_dictionary = self.dh.standardize_data(data_dictionary) + data_dictionary = dh.standardize_data(data_dictionary) # optional additional data cleaning/analysis - self.data_cleaning_train() + self.data_cleaning_train(dh) - logger.info(f'Training model on {len(self.dh.training_features_list)} features') + logger.info(f'Training model on {len(dh.training_features_list)} features') logger.info(f'Training model on {len(data_dictionary["train_features"])} data points') model = self.fit(data_dictionary) @@ -107,8 +109,8 @@ class CatboostPredictionModel(IFreqaiModel): return model - def predict(self, unfiltered_dataframe: DataFrame, metadata: dict) -> Tuple[DataFrame, - DataFrame]: + def predict(self, unfiltered_dataframe: DataFrame, + dh: FreqaiDataKitchen) -> Tuple[DataFrame, DataFrame]: """ Filter the prediction features data and predict with it. :param: unfiltered_dataframe: Full dataframe for the current backtest period. @@ -120,23 +122,22 @@ class CatboostPredictionModel(IFreqaiModel): # logger.info("--------------------Starting prediction--------------------") - original_feature_list = self.dh.find_features(unfiltered_dataframe) - filtered_dataframe, _ = self.dh.filter_features( + original_feature_list = dh.find_features(unfiltered_dataframe) + filtered_dataframe, _ = dh.filter_features( unfiltered_dataframe, original_feature_list, training_filter=False ) - filtered_dataframe = self.dh.standardize_data_from_metadata(filtered_dataframe) - self.dh.data_dictionary["prediction_features"] = filtered_dataframe + filtered_dataframe = dh.standardize_data_from_metadata(filtered_dataframe) + dh.data_dictionary["prediction_features"] = filtered_dataframe # optional additional data cleaning/analysis - self.data_cleaning_predict(filtered_dataframe) + self.data_cleaning_predict(dh) - predictions = self.model.predict(self.dh.data_dictionary["prediction_features"]) + predictions = self.model.predict(dh.data_dictionary["prediction_features"]) # compute the non-standardized predictions - self.dh.predictions = (predictions + 1) * (self.dh.data["labels_max"] - - self.dh.data["labels_min"]) / 2 + self.dh.data[ - "labels_min"] + dh.predictions = (predictions + 1) * (dh.data["labels_max"] - + dh.data["labels_min"]) / 2 + dh.data["labels_min"] # logger.info("--------------------Finished prediction--------------------") - return (self.dh.predictions, self.dh.do_predict) + return (dh.predictions, dh.do_predict) From 255d35976e2737573fc09aabee65a5e776f18f09 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 24 May 2022 12:58:53 +0200 Subject: [PATCH 045/130] add priority metadata to pairs to avoid a sync of train time + train period --- freqtrade/freqai/data_drawer.py | 13 +++++++++++++ freqtrade/freqai/freqai_interface.py | 10 +++++++++- .../prediction_models/CatboostPredictionModel.py | 4 +++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index 51f56fae6..a47ff6ec2 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -56,5 +56,18 @@ class FreqaiDataDrawer: model_filename = self.pair_dict[metadata['pair']]['model_filename'] = '' coin_first = self.pair_dict[metadata['pair']]['first'] = True trained_timestamp = self.pair_dict[metadata['pair']]['trained_timestamp'] = 0 + self.pair_dict[metadata['pair']]['priority'] = 1 return model_filename, trained_timestamp, coin_first + + def set_pair_dict_info(self, metadata: dict) -> None: + pair_in_dict = self.pair_dict.get(metadata['pair']) + if pair_in_dict: + return + else: + self.pair_dict[metadata['pair']] = {} + self.pair_dict[metadata['pair']]['model_filename'] = '' + self.pair_dict[metadata['pair']]['first'] = True + self.pair_dict[metadata['pair']]['trained_timestamp'] = 0 + self.pair_dict[metadata['pair']]['priority'] = 1 + return diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 19b7dbb27..3fdd379dc 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -109,7 +109,10 @@ class IFreqaiModel(ABC): # FreqaiDataKitchen is reinstantiated for each coin if self.live: - if not self.training_on_separate_thread: + self.data_drawer.set_pair_dict_info(metadata) + if (not self.training_on_separate_thread and + self.data_drawer.pair_dict[metadata['pair']]['priority'] == 1): + self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) dh = self.start_live(dataframe, metadata, strategy, self.dh) @@ -212,6 +215,7 @@ class IFreqaiModel(ABC): else: logger.info("FreqAI training a new model on background thread.") + self.data_drawer.pair_dict[metadata['pair']]['priority'] = 1 self.model = dh.load_data(coin=metadata['pair']) @@ -319,6 +323,9 @@ class IFreqaiModel(ABC): dh.set_new_model_names(metadata, new_trained_timerange) + # set this coin to lower priority to allow other coins in white list to get trained + self.data_drawer.pair_dict[metadata['pair']]['priority'] = 0 + dh.save_data(self.model, coin=metadata['pair']) self.training_on_separate_thread = False @@ -344,6 +351,7 @@ class IFreqaiModel(ABC): dh.set_new_model_names(metadata, new_trained_timerange) self.data_drawer.pair_dict[metadata['pair']]['first'] = False + dh.save_data(self.model, coin=metadata['pair']) self.retrain = False diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index 87ddfdb66..73ea46032 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -51,7 +51,9 @@ class CatboostPredictionModel(IFreqaiModel): :returns: :model: Trained model which can be used to inference (self.predict) """ - logger.info("--------------------Starting training--------------------") + + logger.info('--------------------Starting training' + f'{metadata["pair"]} --------------------') # create the full feature list based on user config info dh.training_features_list = dh.find_features(unfiltered_dataframe) From 31ae2b30606f3c52fb7ee1e3d170e59a8bc06b31 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 24 May 2022 14:46:16 +0200 Subject: [PATCH 046/130] alleviate FutureWarning in sklearn about ensuring svm model features are passed with identical order --- docs/freqai.md | 41 ++++++++++++++++++-- freqtrade/freqai/data_kitchen.py | 8 +++- freqtrade/strategy/interface.py | 2 +- freqtrade/templates/FreqaiExampleStrategy.py | 21 +++++++--- 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 606b88912..27d393d0a 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -105,11 +105,11 @@ config setup includes: ### Building the feature set -Most of these parameters are controlling the feature data set. Features are added by the user -inside the `populate_any_indicators()` method of the strategy by prepending indicators with `%`: +Features are added by the user inside the `populate_any_indicators()` method of the strategy +by prepending indicators with `%`: ```python - def populate_any_indicators(self, pair, df, tf, informative=None, coin=""): + def populate_any_indicators(self, metadata, pair, df, tf, informative=None, coin=""): informative['%-''%-' + coin + "rsi"] = ta.RSI(informative, timeperiod=14) informative['%-' + coin + "mfi"] = ta.MFI(informative, timeperiod=25) informative['%-' + coin + "adx"] = ta.ADX(informative, window=20) @@ -120,11 +120,46 @@ inside the `populate_any_indicators()` method of the strategy by prepending indi informative['%-' + coin + "bb_width"] = ( informative[coin + "bb_upperband"] - informative[coin + "bb_lowerband"] ) / informative[coin + "bb_middleband"] + + + + # The following code automatically adds features according to the `shift` parameter passed + # in the config. Do not remove + indicators = [col for col in informative if col.startswith('%')] + for n in range(self.freqai_info["feature_parameters"]["shift"] + 1): + if n == 0: + continue + informative_shift = informative[indicators].shift(n) + informative_shift = informative_shift.add_suffix("_shift-" + str(n)) + informative = pd.concat((informative, informative_shift), axis=1) + + # The following code safely merges into the base timeframe. + # Do not remove. + df = merge_informative_pair(df, informative, self.config["timeframe"], tf, ffill=True) + skip_columns = [(s + "_" + tf) for s in ["date", "open", "high", "low", "close", "volume"]] + df = df.drop(columns=skip_columns) ``` The user of the present example does not want to pass the `bb_lowerband` as a feature to the model, and has therefore not prepended it with `%`. The user does, however, wish to pass `bb_width` to the model for training/prediction and has therfore prepended it with `%`._ +Note: features **must** be defined in `populate_any_indicators()`. Making features in `populate_indicators()` +will fail in live/dry. If the user wishes to add generalized features that are not associated with +a specific pair or timeframe, they should use the following structure inside `populate_any_indicators()` +(as exemplified in `freqtrade/templates/FreqaiExampleStrategy.py`: + +```python + def populate_any_indicators(self, metadata, pair, df, tf, informative=None, coin=""): + + + # Add generalized indicators here (because in live, it will call only this function to populate + # indicators for retraining). Notice how we ensure not to add them multiple times by associating + # these generalized indicators to the basepair/timeframe + if pair == metadata['pair'] and tf == self.timeframe: + df['%-day_of_week'] = (df["date"].dt.dayofweek + 1) / 7 + df['%-hour_of_day'] = (df['date'].dt.hour + 1) / 25 + + (Please see the example script located in `freqtrade/templates/FreqaiExampleStrategy.py` for a full example of `populate_any_indicators()`) The `timeframes` from the example config above are the timeframes of each `populate_any_indicator()` diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index a4867d7eb..765c58a37 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -823,7 +823,9 @@ class FreqaiDataKitchen: pairs = self.freqai_config.get("corr_pairlist", []) for tf in self.freqai_config.get("timeframes"): - dataframe = strategy.populate_any_indicators(metadata['pair'], + dataframe = strategy.populate_any_indicators( + metadata, + metadata['pair'], dataframe.copy(), tf, base_dataframes[tf], @@ -833,7 +835,9 @@ class FreqaiDataKitchen: for i in pairs: if metadata['pair'] in i: continue # dont repeat anything from whitelist - dataframe = strategy.populate_any_indicators(i, + dataframe = strategy.populate_any_indicators( + metadata, + i, dataframe.copy(), tf, corr_dataframes[i][tf], diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index e681d70bd..6237e3397 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -532,7 +532,7 @@ class IStrategy(ABC, HyperStrategyMixin): """ return None - def populate_any_indicators(self, pair: str, df: DataFrame, tf: str, + def populate_any_indicators(self, metadata: dict, pair: str, df: DataFrame, tf: str, informative: DataFrame = None, coin: str = "") -> DataFrame: """ Function designed to automatically generate, name and merge features diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 690532c10..d2eb2c306 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -63,7 +63,7 @@ class FreqaiExampleStrategy(IStrategy): def bot_start(self): self.model = CustomModel(self.config) - def populate_any_indicators(self, pair, df, tf, informative=None, coin=""): + def populate_any_indicators(self, metadata, pair, df, tf, informative=None, coin=""): """ Function designed to automatically generate, name and merge features from user indicated timeframes in the configuration file. User controls the indicators @@ -124,8 +124,9 @@ class FreqaiExampleStrategy(IStrategy): informative[coin + "pct-change"] = informative["close"].pct_change() + # The following code automatically adds features according to the `shift` parameter passed + # in the config. Do not remove indicators = [col for col in informative if col.startswith('%')] - for n in range(self.freqai_info["feature_parameters"]["shift"] + 1): if n == 0: continue @@ -133,28 +134,38 @@ class FreqaiExampleStrategy(IStrategy): informative_shift = informative_shift.add_suffix("_shift-" + str(n)) informative = pd.concat((informative, informative_shift), axis=1) + # The following code safely merges into the base timeframe. + # Do not remove. df = merge_informative_pair(df, informative, self.config["timeframe"], tf, ffill=True) skip_columns = [(s + "_" + tf) for s in ["date", "open", "high", "low", "close", "volume"]] df = df.drop(columns=skip_columns) + # Add generalized indicators (not associated to any individual coin or timeframe) here + # because in live, it will call this function to populate + # indicators during training. Notice how we ensure not to add them multiple times + if pair == metadata['pair'] and tf == self.timeframe: + df['%-day_of_week'] = (df["date"].dt.dayofweek + 1) / 7 + df['%-hour_of_day'] = (df['date'].dt.hour + 1) / 25 + return df def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - # the configuration file parameters are stored here self.freqai_info = self.config["freqai"] self.pair = metadata['pair'] # the following loops are necessary for building the features # indicated by the user in the configuration file. + # All indicators must be populated by populate_any_indicators() for live functionality + # to work correctly. for tf in self.freqai_info["timeframes"]: - dataframe = self.populate_any_indicators(self.pair, dataframe.copy(), tf, + dataframe = self.populate_any_indicators(metadata, self.pair, dataframe.copy(), tf, coin=self.pair.split("/")[0] + "-") for pair in self.freqai_info["corr_pairlist"]: if metadata['pair'] in pair: continue # do not include whitelisted pair twice if it is in corr_pairlist dataframe = self.populate_any_indicators( - pair, dataframe.copy(), tf, coin=pair.split("/")[0] + "-" + metadata, pair, dataframe.copy(), tf, coin=pair.split("/")[0] + "-" ) # the model will return 4 values, its prediction, an indication of whether or not the From 58b5abbaa6a3f937dc7c7cf13ebba248a2430219 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Tue, 24 May 2022 15:28:38 +0200 Subject: [PATCH 047/130] improve multithreaded training queue system --- freqtrade/freqai/data_drawer.py | 16 +++++++++++++++- freqtrade/freqai/freqai_interface.py | 11 +++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index a47ff6ec2..a5d8a2123 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -18,7 +18,7 @@ class FreqaiDataDrawer: This object remains persistent throughout live/dry, unlike FreqaiDataKitchen, which is reinstantiated for each coin. """ - def __init__(self, full_path: Path): + def __init__(self, full_path: Path, pair_whitelist): # dictionary holding all pair metadata necessary to load in from disk self.pair_dict: Dict[str, Any] = {} @@ -27,6 +27,8 @@ class FreqaiDataDrawer: self.pair_data_dict: Dict[str, Any] = {} self.full_path = full_path self.load_drawer_from_disk() + self.training_queue: Dict[str, int] = {} + self.create_training_queue(pair_whitelist) def load_drawer_from_disk(self): exists = Path(self.full_path / str('pair_dictionary.json')).resolve().exists() @@ -71,3 +73,15 @@ class FreqaiDataDrawer: self.pair_dict[metadata['pair']]['trained_timestamp'] = 0 self.pair_dict[metadata['pair']]['priority'] = 1 return + + def create_training_queue(self, pairs: list) -> None: + for i, pair in enumerate(pairs): + self.training_queue[pair] = i + 1 + + def pair_to_end_of_training_queue(self, pair: str) -> None: + # march all pairs up in the queue + for p in self.training_queue: + self.training_queue[p] -= 1 + + # send pair to end of queue + self.training_queue[pair] = len(self.training_queue) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 3fdd379dc..55733b844 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -73,7 +73,8 @@ class IFreqaiModel(ABC): # self.new_trained_timerange = TimeRange() self.set_full_path() - self.data_drawer = FreqaiDataDrawer(Path(self.full_path)) + self.data_drawer = FreqaiDataDrawer(Path(self.full_path), + self.config['exchange']['pair_whitelist']) def assert_config(self, config: Dict[str, Any]) -> None: @@ -110,8 +111,9 @@ class IFreqaiModel(ABC): # FreqaiDataKitchen is reinstantiated for each coin if self.live: self.data_drawer.set_pair_dict_info(metadata) + print('Current train queue:', self.data_drawer.training_queue) if (not self.training_on_separate_thread and - self.data_drawer.pair_dict[metadata['pair']]['priority'] == 1): + self.data_drawer.training_queue == 1): self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) @@ -323,8 +325,9 @@ class IFreqaiModel(ABC): dh.set_new_model_names(metadata, new_trained_timerange) - # set this coin to lower priority to allow other coins in white list to get trained - self.data_drawer.pair_dict[metadata['pair']]['priority'] = 0 + # send the pair to the end of the queue so other coins can take on the background thread + # retraining + self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) dh.save_data(self.model, coin=metadata['pair']) From 35bed842cb5ce5ef780544c1a5f6cedb1d6b5532 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 25 May 2022 11:31:03 +0200 Subject: [PATCH 048/130] cleanup, add clarity to comments and docstrings --- freqtrade/freqai/freqai_interface.py | 169 +++++++++++++-------------- 1 file changed, 81 insertions(+), 88 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 55733b844..d60f37ffb 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -21,18 +21,6 @@ from freqtrade.strategy.interface import IStrategy pd.options.mode.chained_assignment = None logger = logging.getLogger(__name__) -# FIXME: suppress stdout for background training? -# class DummyFile(object): -# def write(self, x): pass - - -# @contextlib.contextmanager -# def nostdout(): -# save_stdout = sys.stdout -# sys.stdout = DummyFile() -# yield -# sys.stdout = save_stdout - def threaded(fn): def wrapper(*args, **kwargs): @@ -57,8 +45,6 @@ class IFreqaiModel(ABC): self.data_split_parameters = config["freqai"]["data_split_parameters"] self.model_training_parameters = config["freqai"]["model_training_parameters"] self.feature_parameters = config["freqai"]["feature_parameters"] - # self.backtest_timerange = config["timerange"] - self.time_last_trained = None self.current_time = None self.model = None @@ -66,12 +52,6 @@ class IFreqaiModel(ABC): self.training_on_separate_thread = False self.retrain = False self.first = True - # if self.freqai_info.get('live_trained_timerange'): - # self.new_trained_timerange = TimeRange.parse_timerange( - # self.freqai_info['live_trained_timerange']) - # else: - # self.new_trained_timerange = TimeRange() - self.set_full_path() self.data_drawer = FreqaiDataDrawer(Path(self.full_path), self.config['exchange']['pair_whitelist']) @@ -93,12 +73,7 @@ class IFreqaiModel(ABC): """ Entry point to the FreqaiModel from a specific pair, it will train a new model if necessary before making the prediction. - The backtesting and training paradigm is a sliding training window - with a following backtest window. Both windows slide according to the - length of the backtest window. This function is not intended to be - overridden by children of IFreqaiModel, but technically, it can be - if the user wishes to make deeper changes to the sliding window - logic. + :params: :dataframe: Full dataframe coming from strategy - it contains entire backtesting timerange + additional historical data necessary to train @@ -108,10 +83,12 @@ class IFreqaiModel(ABC): self.live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE) - # FreqaiDataKitchen is reinstantiated for each coin + # For live, we may be training new models on a separate thread while other pairs still need + # to inference their historical models. Here we use a training queue system to handle this + # and we keep the flag self.training_on_separate_threaad in the current object to help + # determine what the current pair will do if self.live: self.data_drawer.set_pair_dict_info(metadata) - print('Current train queue:', self.data_drawer.training_queue) if (not self.training_on_separate_thread and self.data_drawer.training_queue == 1): @@ -124,13 +101,38 @@ class IFreqaiModel(ABC): self.live, metadata["pair"]) dh = self.start_live(dataframe, metadata, strategy, self.dh_fg) - return (dh.full_predictions, dh.full_do_predict, - dh.full_target_mean, dh.full_target_std) + # return (dh.full_predictions, dh.full_do_predict, + # dh.full_target_mean, dh.full_target_std) - # Backtesting only - self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) + # For backtesting, each pair enters and then gets trained for each window along the + # sliding window defined by "train_period" (training window) and "backtest_period" + # (backtest window, i.e. window immediately following the training window). + # FreqAI slides the window and sequentially builds the backtesting results before returning + # the concatenated results for the full backtesting period back to the strategy. + else: + self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) + logger.info(f'Training {len(self.dh.training_timeranges)} timeranges') + dh = self.start_backtesting(dataframe, metadata, self.dh) - logger.info(f'Training {len(self.dh.training_timeranges)} timeranges') + return (dh.full_predictions, dh.full_do_predict, + dh.full_target_mean, dh.full_target_std) + + def start_backtesting(self, dataframe: DataFrame, metadata: dict, + dh: FreqaiDataKitchen) -> FreqaiDataKitchen: + """ + The main broad execution for backtesting. For backtesting, each pair enters and then gets + trained for each window along the sliding window defined by "train_period" (training window) + and "backtest_period" (backtest window, i.e. window immediately following the + training window). FreqAI slides the window and sequentially builds the backtesting results + before returning the concatenated results for the full backtesting period back to the + strategy. + :params: + dataframe: DataFrame = strategy passed dataframe + metadata: Dict = pair metadata + dh: FreqaiDataKitchen = Data management/analysis tool assoicated to present pair only + :returns: + dh: FreqaiDataKitchen = Data management/analysis tool assoicated to present pair only + """ # Loop enforcing the sliding window training/backtesting paradigm # tr_train is the training time range e.g. 1 historical month @@ -138,49 +140,54 @@ class IFreqaiModel(ABC): # following tr_train. Both of these windows slide through the # entire backtest for tr_train, tr_backtest in zip( - self.dh.training_timeranges, self.dh.backtesting_timeranges + dh.training_timeranges, dh.backtesting_timeranges ): gc.collect() - # self.config['timerange'] = tr_train - self.dh.data = {} # clean the pair specific data between models + dh.data = {} # clean the pair specific data between training window sliding self.training_timerange = tr_train - dataframe_train = self.dh.slice_dataframe(tr_train, dataframe) - dataframe_backtest = self.dh.slice_dataframe(tr_backtest, dataframe) + dataframe_train = dh.slice_dataframe(tr_train, dataframe) + dataframe_backtest = dh.slice_dataframe(tr_backtest, dataframe) logger.info("training %s for %s", metadata["pair"], tr_train) trained_timestamp = TimeRange.parse_timerange(tr_train) - self.dh.data_path = Path(self.dh.full_path / - str("sub-train" + "-" + metadata['pair'].split("/")[0] + - str(int(trained_timestamp.stopts)))) - if not self.model_exists(metadata["pair"], self.dh, + dh.data_path = Path(dh.full_path / + str("sub-train" + "-" + metadata['pair'].split("/")[0] + + str(int(trained_timestamp.stopts)))) + if not self.model_exists(metadata["pair"], dh, trained_timestamp=trained_timestamp.stopts): - self.model = self.train(dataframe_train, metadata, self.dh) - self.dh.save_data(self.model) + self.model = self.train(dataframe_train, metadata, dh) + dh.save_data(self.model) else: - self.model = self.dh.load_data() + self.model = dh.load_data() + # strategy_provided_features = self.dh.find_features(dataframe_train) - # # TOFIX doesnt work with PCA + # # FIXME doesnt work with PCA # if strategy_provided_features != self.dh.training_features_list: # logger.info("User changed input features, retraining model.") # self.model = self.train(dataframe_train, metadata) # self.dh.save_data(self.model) - preds, do_preds = self.predict(dataframe_backtest, self.dh) + preds, do_preds = self.predict(dataframe_backtest, dh) - self.dh.append_predictions(preds, do_preds, len(dataframe_backtest)) - print('predictions', len(self.dh.full_predictions), - 'do_predict', len(self.dh.full_do_predict)) + dh.append_predictions(preds, do_preds, len(dataframe_backtest)) + print('predictions', len(dh.full_predictions), + 'do_predict', len(dh.full_do_predict)) - self.dh.fill_predictions(len(dataframe)) + dh.fill_predictions(len(dataframe)) - return (self.dh.full_predictions, self.dh.full_do_predict, - self.dh.full_target_mean, self.dh.full_target_std) + return dh def start_live(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy, dh: FreqaiDataKitchen) -> FreqaiDataKitchen: """ The main broad execution for dry/live. This function will check if a retraining should be performed, and if so, retrain and reset the model. - + :params: + dataframe: DataFrame = strategy passed dataframe + metadata: Dict = pair metadata + strategy: IStrategy = currently employed strategy + dh: FreqaiDataKitchen = Data management/analysis tool assoicated to present pair only + :returns: + dh: FreqaiDataKitchen = Data management/analysis tool assoicated to present pair only """ (model_filename, @@ -190,22 +197,16 @@ class IFreqaiModel(ABC): if not self.training_on_separate_thread: file_exists = False - if trained_timestamp != 0: + if trained_timestamp != 0: # historical model available dh.set_paths(metadata, trained_timestamp) - # data_drawer thinks the file eixts, verify here file_exists = self.model_exists(metadata['pair'], dh, trained_timestamp=trained_timestamp, model_filename=model_filename) - # if not self.training_on_separate_thread: - # this will also prevent other pairs from trying to train simultaneously. (self.retrain, new_trained_timerange) = dh.check_if_new_training_required(trained_timestamp) dh.set_paths(metadata, new_trained_timerange.stopts) - # if self.training_on_separate_thread: - # logger.info("FreqAI training a new model on background thread.") - # self.retrain = False if self.retrain or not file_exists: if coin_first: @@ -217,10 +218,10 @@ class IFreqaiModel(ABC): else: logger.info("FreqAI training a new model on background thread.") - self.data_drawer.pair_dict[metadata['pair']]['priority'] = 1 self.model = dh.load_data(coin=metadata['pair']) + # FIXME # strategy_provided_features = dh.find_features(dataframe) # if strategy_provided_features != dh.training_features_list: # self.train_model_in_series(new_trained_timerange, metadata, strategy) @@ -240,17 +241,17 @@ class IFreqaiModel(ABC): if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): dh.principal_component_analysis() - # if self.feature_parameters["determine_statistical_distributions"]: - # dh.determine_statistical_distributions() - # if self.feature_parameters["remove_outliers"]: - # dh.remove_outliers(predict=False) - if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): dh.use_SVM_to_remove_outliers(predict=False) if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): dh.data["avg_mean_dist"] = dh.compute_distances() + # if self.feature_parameters["determine_statistical_distributions"]: + # dh.determine_statistical_distributions() + # if self.feature_parameters["remove_outliers"]: + # dh.remove_outliers(predict=False) + def data_cleaning_predict(self, dh: FreqaiDataKitchen) -> None: """ Base data cleaning method for predict. @@ -265,16 +266,16 @@ class IFreqaiModel(ABC): if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): dh.pca_transform() - # if self.feature_parameters["determine_statistical_distributions"]: - # dh.determine_statistical_distributions() - # if self.feature_parameters["remove_outliers"]: - # dh.remove_outliers(predict=True) # creates dropped index - if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): dh.use_SVM_to_remove_outliers(predict=True) if self.freqai_info.get('feature_parameters', {}).get('DI_threshold'): - dh.check_if_pred_in_training_spaces() # sets do_predict + dh.check_if_pred_in_training_spaces() + + # if self.feature_parameters["determine_statistical_distributions"]: + # dh.determine_statistical_distributions() + # if self.feature_parameters["remove_outliers"]: + # dh.remove_outliers(predict=True) # creates dropped index def model_exists(self, pair: str, dh: FreqaiDataKitchen, trained_timestamp: int = None, model_filename: str = '') -> bool: @@ -285,8 +286,6 @@ class IFreqaiModel(ABC): """ coin, _ = pair.split("/") - # if self.live and trained_timestamp == 0: - # dh.model_filename = model_filename if not self.live: dh.model_filename = model_filename = "cb_" + coin.lower() + "_" + str(trained_timestamp) @@ -312,7 +311,6 @@ class IFreqaiModel(ABC): dh.download_new_data_for_retraining(new_trained_timerange, metadata) corr_dataframes, base_dataframes = dh.load_pairs_histories(new_trained_timerange, metadata) - unfiltered_dataframe = dh.use_strategy_to_populate_indicators(strategy, corr_dataframes, base_dataframes, @@ -322,13 +320,8 @@ class IFreqaiModel(ABC): self.data_drawer.pair_dict[metadata['pair']][ 'trained_timestamp'] = new_trained_timerange.stopts - dh.set_new_model_names(metadata, new_trained_timerange) - - # send the pair to the end of the queue so other coins can take on the background thread - # retraining self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) - dh.save_data(self.model, coin=metadata['pair']) self.training_on_separate_thread = False @@ -350,11 +343,8 @@ class IFreqaiModel(ABC): self.data_drawer.pair_dict[metadata['pair']][ 'trained_timestamp'] = new_trained_timerange.stopts - dh.set_new_model_names(metadata, new_trained_timerange) - self.data_drawer.pair_dict[metadata['pair']]['first'] = False - dh.save_data(self.model, coin=metadata['pair']) self.retrain = False @@ -380,7 +370,7 @@ class IFreqaiModel(ABC): can drop in LGBMRegressor in place of CatBoostRegressor and all data management will be properly handled by Freqai. :params: - :data_dictionary: the dictionary constructed by DataHandler to hold + data_dictionary: Dict = the dictionary constructed by DataHandler to hold all the training and test data/labels. """ @@ -391,11 +381,13 @@ class IFreqaiModel(ABC): dh: FreqaiDataKitchen) -> Tuple[npt.ArrayLike, npt.ArrayLike]: """ Filter the prediction features data and predict with it. - :param: unfiltered_dataframe: Full dataframe for the current backtest period. + :param: + unfiltered_dataframe: Full dataframe for the current backtest period. + dh: FreqaiDataKitchen = Data management/analysis tool assoicated to present pair only :return: :predictions: np.array of predictions :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove - data (NaNs) or felt uncertain about data (PCA and DI index) + data (NaNs) or felt uncertain about data (i.e. SVM and/or DI index) """ @abstractmethod @@ -403,7 +395,8 @@ class IFreqaiModel(ABC): """ User defines the labels here (target values). :params: - :dataframe: the full dataframe for the present training period + dataframe: DataFrame = the full dataframe for the present training period + dh: FreqaiDataKitchen = Data management/analysis tool assoicated to present pair only """ return From 7ff32586075fdb4266ce53027e8f99054385388c Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 25 May 2022 11:43:45 +0200 Subject: [PATCH 049/130] remove assertions, log error if user has not assigned freqai in config, fix stratify bug --- config_examples/config_freqai.example.json | 1 - freqtrade/freqai/data_kitchen.py | 39 ++++++++++++---------- freqtrade/freqai/freqai_interface.py | 23 +++++++------ 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index ed3782775..5f7f38373 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -62,7 +62,6 @@ "corr_pairlist": [ "BTC/USDT", "ETH/USDT", - "LINK/USDT", "DOT/USDT" ], "feature_parameters": { diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 765c58a37..b0eb8b40d 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -73,23 +73,26 @@ class FreqaiDataKitchen: self.data_drawer = data_drawer def assert_config(self, config: Dict[str, Any], live: bool) -> None: - assert config.get('freqai'), "No Freqai parameters found in config file." - assert config.get('freqai', {}).get('train_period'), ("No Freqai train_period found in" - "config file.") - assert type(config.get('freqai', {}) - .get('train_period')) is int, ('Can only train on full day period.' - 'No fractional days permitted.') - assert config.get('freqai', {}).get('backtest_period'), ("No Freqai backtest_period found" - "in config file.") - if not live: - assert type(config.get('freqai', {}) - .get('backtest_period')) is int, ('Can only backtest on full day' - 'backtest_period. Only live/dry mode' - 'allows fractions of days') - assert config.get('freqai', {}).get('identifier'), ("No Freqai identifier found in config" - "file.") - assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai feature_parameters" - "found in config file.") + if not config.get('freqai'): + logger.error('No Freqai parameters found in config file.') + + # assert config.get('freqai'), "No Freqai parameters found in config file." + # assert config.get('freqai', {}).get('train_period'), ("No Freqai train_period found in" + # "config file.") + # assert type(config.get('freqai', {}) + # .get('train_period')) is int, ('Can only train on full day period.' + # 'No fractional days permitted.') + # assert config.get('freqai', {}).get('backtest_period'), ("No Freqai backtest_period found" + # "in config file.") + # if not live: + # assert type(config.get('freqai', {}) + # .get('backtest_period')) is int, ('Can only backtest on full day' + # 'backtest_period. Only live/dry mode' + # 'allows fractions of days') + # assert config.get('freqai', {}).get('identifier'), ("No Freqai identifier found in config" + # "file.") + # assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai feature_parameters" + # "found in config file.") def set_paths(self, metadata: dict, trained_timestamp: int = None,) -> None: self.full_path = Path(self.config['user_data_dir'] / @@ -234,6 +237,8 @@ class FreqaiDataKitchen: for i in range(1, len(stratification)): if i % self.freqai_config.get("feature_parameters", {}).get("stratify", 0) == 0: stratification[i] = 1 + else: + stratification = None ( train_features, diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index d60f37ffb..041343683 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -57,17 +57,18 @@ class IFreqaiModel(ABC): self.config['exchange']['pair_whitelist']) def assert_config(self, config: Dict[str, Any]) -> None: - - assert config.get('freqai'), "No Freqai parameters found in config file." - assert config.get('freqai', {}).get('data_split_parameters'), ("No Freqai" - "data_split_parameters" - "in config file.") - assert config.get('freqai', {}).get('model_training_parameters'), ("No Freqai" - "modeltrainingparameters" - "found in config file.") - assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai" - "feature_parameters found in" - "config file.") + if not config.get('freqai'): + logger.error('No Freqai parameters found in config file.') + # assert config.get('freqai'), "No Freqai parameters found in config file." + # assert config.get('freqai', {}).get('data_split_parameters'), ("No Freqai" + # "data_split_parameters" + # "in config file.") + # assert config.get('freqai', {}).get('model_training_parameters'), ("No Freqai" + # "modeltrainingparameters" + # "found in config file.") + # assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai" + # "feature_parameters found in" + # "config file.") def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ From 7486d9d9e2880b27591d3bb94498273019ce3f95 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 25 May 2022 12:37:25 +0200 Subject: [PATCH 050/130] proper validation of freqai config parameters --- freqtrade/configuration/config_validation.py | 16 ++++++++++++++ freqtrade/constants.py | 11 ++++++++++ freqtrade/enums/runmode.py | 3 +-- freqtrade/freqai/data_kitchen.py | 23 -------------------- freqtrade/freqai/freqai_interface.py | 18 +++++---------- 5 files changed, 34 insertions(+), 37 deletions(-) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index ee846e7e6..5f1f68554 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -85,6 +85,7 @@ def validate_config_consistency(conf: Dict[str, Any], preliminary: bool = False) _validate_unlimited_amount(conf) _validate_ask_orderbook(conf) validate_migrated_strategy_settings(conf) + _validate_freqai(conf) # validate configuration before returning logger.info('Validating configuration ...') @@ -163,6 +164,21 @@ def _validate_edge(conf: Dict[str, Any]) -> None: ) +def _validate_freqai(conf: Dict[str, Any]) -> None: + """ + Freqai param validator + """ + + if not conf.get('freqai', {}): + return + + for param in constants.SCHEMA_FREQAI_REQUIRED: + if param not in conf.get('freqai', {}): + raise OperationalException( + f'{param} not found in Freqai config' + ) + + def _validate_whitelist(conf: Dict[str, Any]) -> None: """ Dynamic whitelist does not require pair_whitelist to be set - however StaticWhitelist does. diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 05581cc3a..eea657d84 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -515,6 +515,17 @@ SCHEMA_MINIMAL_REQUIRED = [ 'dataformat_trades', ] +SCHEMA_FREQAI_REQUIRED = [ + 'timeframes', + 'train_period', + 'backtest_period', + 'identifier', + 'corr_pairlist', + 'feature_parameters', + 'data_split_parameters', + 'model_training_parameters' +] + CANCEL_REASON = { "TIMEOUT": "cancelled due to timeout", "PARTIALLY_FILLED_KEEP_OPEN": "partially filled - keeping order open", diff --git a/freqtrade/enums/runmode.py b/freqtrade/enums/runmode.py index c280edf7c..6545aaec7 100644 --- a/freqtrade/enums/runmode.py +++ b/freqtrade/enums/runmode.py @@ -15,10 +15,9 @@ class RunMode(Enum): UTIL_NO_EXCHANGE = "util_no_exchange" PLOT = "plot" WEBSERVER = "webserver" - FREQAI = "freqai" OTHER = "other" TRADING_MODES = [RunMode.LIVE, RunMode.DRY_RUN] -OPTIMIZE_MODES = [RunMode.BACKTEST, RunMode.EDGE, RunMode.HYPEROPT, RunMode.FREQAI] +OPTIMIZE_MODES = [RunMode.BACKTEST, RunMode.EDGE, RunMode.HYPEROPT] NON_UTIL_MODES = TRADING_MODES + OPTIMIZE_MODES diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index b0eb8b40d..c5f57bf86 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -44,7 +44,6 @@ class FreqaiDataKitchen: self.data: Dict[Any, Any] = {} self.data_dictionary: Dict[Any, Any] = {} self.config = config - self.assert_config(self.config, live) self.freqai_config = config["freqai"] self.predictions: npt.ArrayLike = np.array([]) self.do_predict: npt.ArrayLike = np.array([]) @@ -72,28 +71,6 @@ class FreqaiDataKitchen: self.data_drawer = data_drawer - def assert_config(self, config: Dict[str, Any], live: bool) -> None: - if not config.get('freqai'): - logger.error('No Freqai parameters found in config file.') - - # assert config.get('freqai'), "No Freqai parameters found in config file." - # assert config.get('freqai', {}).get('train_period'), ("No Freqai train_period found in" - # "config file.") - # assert type(config.get('freqai', {}) - # .get('train_period')) is int, ('Can only train on full day period.' - # 'No fractional days permitted.') - # assert config.get('freqai', {}).get('backtest_period'), ("No Freqai backtest_period found" - # "in config file.") - # if not live: - # assert type(config.get('freqai', {}) - # .get('backtest_period')) is int, ('Can only backtest on full day' - # 'backtest_period. Only live/dry mode' - # 'allows fractions of days') - # assert config.get('freqai', {}).get('identifier'), ("No Freqai identifier found in config" - # "file.") - # assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai feature_parameters" - # "found in config file.") - def set_paths(self, metadata: dict, trained_timestamp: int = None,) -> None: self.full_path = Path(self.config['user_data_dir'] / "models" / diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 041343683..71807ad19 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -13,6 +13,7 @@ from pandas import DataFrame from freqtrade.configuration import TimeRange from freqtrade.enums import RunMode +from freqtrade.exceptions import OperationalException from freqtrade.freqai.data_drawer import FreqaiDataDrawer from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.strategy.interface import IStrategy @@ -57,18 +58,11 @@ class IFreqaiModel(ABC): self.config['exchange']['pair_whitelist']) def assert_config(self, config: Dict[str, Any]) -> None: - if not config.get('freqai'): - logger.error('No Freqai parameters found in config file.') - # assert config.get('freqai'), "No Freqai parameters found in config file." - # assert config.get('freqai', {}).get('data_split_parameters'), ("No Freqai" - # "data_split_parameters" - # "in config file.") - # assert config.get('freqai', {}).get('model_training_parameters'), ("No Freqai" - # "modeltrainingparameters" - # "found in config file.") - # assert config.get('freqai', {}).get('feature_parameters'), ("No Freqai" - # "feature_parameters found in" - # "config file.") + + if not config.get('freqai', {}): + raise OperationalException( + "No freqai parameters found in configuration file." + ) def start(self, dataframe: DataFrame, metadata: dict, strategy: IStrategy) -> DataFrame: """ From b79d4e8876dc612b6f56db75ed8cf18d1c62d7c5 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 25 May 2022 14:40:32 +0200 Subject: [PATCH 051/130] Allow user to go live and start from pretrained models (after a completed backtest) by simply reusing the `identifier` config parameter while dry/live. --- config_examples/config_freqai.example.json | 5 ++-- docs/freqai.md | 23 ++++++++---------- freqtrade/constants.py | 4 +--- freqtrade/freqai/data_kitchen.py | 27 +++++++++++----------- freqtrade/freqai/freqai_interface.py | 13 +++++++---- 5 files changed, 33 insertions(+), 39 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 5f7f38373..7582afef0 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -55,10 +55,9 @@ "15m" ], "train_period": 30, - "backtest_period": 7, + "backtest_period": 10, "identifier": "example", - "live_trained_timerange": "", - "live_full_backtestrange": "", + "live_trained_timestamp": 0, "corr_pairlist": [ "BTC/USDT", "ETH/USDT", diff --git a/docs/freqai.md b/docs/freqai.md index 27d393d0a..1c6a4ec4a 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -158,7 +158,7 @@ a specific pair or timeframe, they should use the following structure inside `po if pair == metadata['pair'] and tf == self.timeframe: df['%-day_of_week'] = (df["date"].dt.dayofweek + 1) / 7 df['%-hour_of_day'] = (df['date'].dt.hour + 1) / 25 - +``` (Please see the example script located in `freqtrade/templates/FreqaiExampleStrategy.py` for a full example of `populate_any_indicators()`) @@ -270,27 +270,22 @@ freqtrade trade --strategy FreqaiExampleStrategy --config config_freqai.example. By default, Freqai will not find find any existing models and will start by training a new one given the user configuration settings. Following training, it will use that model to predict for the duration of `backtest_period`. After a full `backtest_period` has elapsed, Freqai will auto retrain -a new model, and begin making predictions with the updated model. +a new model, and begin making predictions with the updated model. FreqAI in live mode permits +the user to use fractional days (i.e. 0.1) in the `backtest_period`, which enables more frequent +retraining. -If the user wishes to start dry/live from a saved model, the following configuration -parameters need to be set: +If the user wishes to start dry/live from a backtested saved model, the user only needs to reuse +the same `identifier` parameter ```json "freqai": { "identifier": "example", - "live_trained_timerange": "20220330-20220429", - "live_full_backtestrange": "20220302-20220501" } ``` -Where the `identifier` is the same identifier which was set during the backtesting/training. Meanwhile, -the `live_trained_timerange` is the sub-trained timerange (the training window) which was set -during backtesting/training. These are available to the user inside `user_data/models/*/sub-train-*`. -`live_full_backtestrange` was the full data range associated with the backtest/training (the full time -window that the training window and backtesting windows slide through). These values can be located -inside the `user_data/models/` directory. In this case, although Freqai will initiate with a -pre-trained model, if a full `backtest_period` has elapsed since the end of the user set -`live_trained_timerange`, it will self retrain. +In this case, although Freqai will initiate with a +pre-trained model, it will still check to see how much time has elapsed since the model was trained, +and if a full `backtest_period` has elapsed since the end of the loaded model, FreqAI will self retrain. ## Data anylsis techniques diff --git a/freqtrade/constants.py b/freqtrade/constants.py index eea657d84..f6ef462c3 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -440,15 +440,13 @@ CONF_SCHEMA = { "train_period": {"type": "integer", "default": 0}, "backtest_period": {"type": "float", "default": 7}, "identifier": {"type": "str", "default": "example"}, - "live_trained_timerange": {"type": "str"}, - "live_full_backtestrange": {"type": "str"}, "corr_pairlist": {"type": "list"}, "feature_parameters": { "type": "object", "properties": { "period": {"type": "integer"}, "shift": {"type": "integer", "default": 0}, - "DI_threshold": {"type": "integer", "default": 0}, + "DI_threshold": {"type": "float", "default": 0}, "weight_factor": {"type": "number", "default": 0}, "principal_component_analysis": {"type": "boolean", "default": False}, "use_SVM_to_remove_outliers": {"type": "boolean", "default": False}, diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index c5f57bf86..eafb9cc46 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -74,8 +74,7 @@ class FreqaiDataKitchen: def set_paths(self, metadata: dict, trained_timestamp: int = None,) -> None: self.full_path = Path(self.config['user_data_dir'] / "models" / - str(self.freqai_config.get('live_full_backtestrange') + - self.freqai_config.get('identifier'))) + str(self.freqai_config.get('identifier'))) self.data_path = Path(self.full_path / str("sub-train" + "-" + metadata['pair'].split("/")[0] + @@ -114,11 +113,11 @@ class FreqaiDataKitchen: save_path / str(self.model_filename + "_trained_df.pkl") ) - if self.live: - self.data_drawer.model_dictionary[self.model_filename] = model - self.data_drawer.pair_dict[coin]['model_filename'] = self.model_filename - self.data_drawer.pair_dict[coin]['data_path'] = str(self.data_path) - self.data_drawer.save_drawer_to_disk() + # if self.live: + self.data_drawer.model_dictionary[self.model_filename] = model + self.data_drawer.pair_dict[coin]['model_filename'] = self.model_filename + self.data_drawer.pair_dict[coin]['data_path'] = str(self.data_path) + self.data_drawer.save_drawer_to_disk() # TODO add a helper function to let user save/load any data they are custom adding. We # do not want them having to edit the default save/load methods here. Below is an example @@ -142,9 +141,9 @@ class FreqaiDataKitchen: :model: User trained model which can be inferenced for new predictions """ - if self.live: - self.model_filename = self.data_drawer.pair_dict[coin]['model_filename'] - self.data_path = Path(self.data_drawer.pair_dict[coin]['data_path']) + # if self.live: + self.model_filename = self.data_drawer.pair_dict[coin]['model_filename'] + self.data_path = Path(self.data_drawer.pair_dict[coin]['data_path']) with open(self.data_path / str(self.model_filename + "_metadata.json"), "r") as fp: self.data = json.load(fp) @@ -696,7 +695,7 @@ class FreqaiDataKitchen: self.full_path = Path( self.config["user_data_dir"] / "models" - / str(full_timerange + self.freqai_config.get("identifier")) + / str(self.freqai_config.get("identifier")) ) config_path = Path(self.config["config_files"][0]) @@ -750,10 +749,10 @@ class FreqaiDataKitchen: str(int(trained_timerange.stopts)))) self.model_filename = "cb_" + coin.lower() + "_" + str(int(trained_timerange.stopts)) - # this is not persistent at the moment TODO - self.freqai_config['live_trained_timerange'] = str(int(trained_timerange.stopts)) + + # self.freqai_config['live_trained_timerange'] = str(int(trained_timerange.stopts)) # enables persistence, but not fully implemented into save/load data yer - self.data['live_trained_timerange'] = str(int(trained_timerange.stopts)) + # self.data['live_trained_timerange'] = str(int(trained_timerange.stopts)) def download_new_data_for_retraining(self, timerange: TimeRange, metadata: dict) -> None: diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 71807ad19..d7bbc549a 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -77,13 +77,13 @@ class IFreqaiModel(ABC): """ self.live = strategy.dp.runmode in (RunMode.DRY_RUN, RunMode.LIVE) + self.data_drawer.set_pair_dict_info(metadata) # For live, we may be training new models on a separate thread while other pairs still need # to inference their historical models. Here we use a training queue system to handle this # and we keep the flag self.training_on_separate_threaad in the current object to help # determine what the current pair will do if self.live: - self.data_drawer.set_pair_dict_info(metadata) if (not self.training_on_separate_thread and self.data_drawer.training_queue == 1): @@ -137,6 +137,7 @@ class IFreqaiModel(ABC): for tr_train, tr_backtest in zip( dh.training_timeranges, dh.backtesting_timeranges ): + (_, _, _) = self.data_drawer.get_pair_dict_info(metadata) gc.collect() dh.data = {} # clean the pair specific data between training window sliding self.training_timerange = tr_train @@ -150,9 +151,12 @@ class IFreqaiModel(ABC): if not self.model_exists(metadata["pair"], dh, trained_timestamp=trained_timestamp.stopts): self.model = self.train(dataframe_train, metadata, dh) - dh.save_data(self.model) + self.data_drawer.pair_dict[metadata['pair']][ + 'trained_timestamp'] = trained_timestamp.stopts + dh.set_new_model_names(metadata, trained_timestamp) + dh.save_data(self.model, metadata['pair']) else: - self.model = dh.load_data() + self.model = dh.load_data(metadata['pair']) # strategy_provided_features = self.dh.find_features(dataframe_train) # # FIXME doesnt work with PCA @@ -295,8 +299,7 @@ class IFreqaiModel(ABC): def set_full_path(self) -> None: self.full_path = Path(self.config['user_data_dir'] / "models" / - str(self.freqai_info.get('live_full_backtestrange') + - self.freqai_info.get('identifier'))) + str(self.freqai_info.get('identifier'))) @threaded def retrain_model_on_separate_thread(self, new_trained_timerange: TimeRange, metadata: dict, From 7593339c14c0f45011f88f6d7034e845ee6363f8 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 25 May 2022 14:42:46 +0200 Subject: [PATCH 052/130] small cleanup --- docs/freqai.md | 2 +- freqtrade/commands/data_commands.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 1c6a4ec4a..403145525 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -110,7 +110,7 @@ by prepending indicators with `%`: ```python def populate_any_indicators(self, metadata, pair, df, tf, informative=None, coin=""): - informative['%-''%-' + coin + "rsi"] = ta.RSI(informative, timeperiod=14) + informative['%-' + coin + "rsi"] = ta.RSI(informative, timeperiod=14) informative['%-' + coin + "mfi"] = ta.MFI(informative, timeperiod=25) informative['%-' + coin + "adx"] = ta.ADX(informative, window=20) bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(informative), window=14, stds=2.2) diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index 4588bf67b..c7e6a7b84 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -51,7 +51,6 @@ def start_download_data(args: Dict[str, Any]) -> None: markets = [p for p, m in exchange.markets.items() if market_is_active(m) or config.get('include_inactive')] if config.get('freqai') is not None: - assert config['freqai'].get('corr_pairlist'), "No corr_pairlist found in config." full_pairs = config['pairs'] + [pair for pair in config['freqai']['corr_pairlist'] if pair not in config['pairs']] expanded_pairs = expand_pairlist(full_pairs, markets) From d79983c791a3534d083960358d86631a80648d10 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 25 May 2022 14:55:19 +0200 Subject: [PATCH 053/130] try to pass flake8 --- freqtrade/commands/data_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index c7e6a7b84..2019413b5 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -50,7 +50,7 @@ def start_download_data(args: Dict[str, Any]) -> None: exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) markets = [p for p, m in exchange.markets.items() if market_is_active(m) or config.get('include_inactive')] - if config.get('freqai') is not None: + if config.get('freqai', {}): full_pairs = config['pairs'] + [pair for pair in config['freqai']['corr_pairlist'] if pair not in config['pairs']] expanded_pairs = expand_pairlist(full_pairs, markets) From ff531c416f185ddaba02a38f0393f118e288afb4 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Wed, 25 May 2022 15:31:50 +0200 Subject: [PATCH 054/130] reduce `complexity` inside start_download_data() in an effort to appease flake8 --- freqtrade/commands/data_commands.py | 10 +++------- freqtrade/plugins/pairlist/pairlist_helpers.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index 2019413b5..21b7a3194 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -12,7 +12,7 @@ from freqtrade.enums import CandleType, RunMode, TradingMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes from freqtrade.exchange.exchange import market_is_active -from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist +from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist, expand_pairlist from freqtrade.resolvers import ExchangeResolver @@ -50,12 +50,8 @@ def start_download_data(args: Dict[str, Any]) -> None: exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) markets = [p for p, m in exchange.markets.items() if market_is_active(m) or config.get('include_inactive')] - if config.get('freqai', {}): - full_pairs = config['pairs'] + [pair for pair in config['freqai']['corr_pairlist'] - if pair not in config['pairs']] - expanded_pairs = expand_pairlist(full_pairs, markets) - else: - expanded_pairs = expand_pairlist(config['pairs'], markets) + + expanded_pairs = dynamic_expand_pairlist(config, markets) # Manual validations of relevant settings if not config['exchange'].get('skip_pair_validation', False): diff --git a/freqtrade/plugins/pairlist/pairlist_helpers.py b/freqtrade/plugins/pairlist/pairlist_helpers.py index 1de27fcbd..23233481a 100644 --- a/freqtrade/plugins/pairlist/pairlist_helpers.py +++ b/freqtrade/plugins/pairlist/pairlist_helpers.py @@ -40,3 +40,14 @@ def expand_pairlist(wildcardpl: List[str], available_pairs: List[str], except re.error as err: raise ValueError(f"Wildcard error in {pair_wc}, {err}") return result + + +def dynamic_expand_pairlist(config: dict, markets: list) -> List[str]: + if config.get('freqai', {}): + full_pairs = config['pairs'] + [pair for pair in config['freqai']['corr_pairlist'] + if pair not in config['pairs']] + expanded_pairs = expand_pairlist(full_pairs, markets) + else: + expanded_pairs = expand_pairlist(config['pairs'], markets) + + return expanded_pairs From 6193205012f681d233933fee50df66f33b63ddcc Mon Sep 17 00:00:00 2001 From: robcaulk Date: Thu, 26 May 2022 21:07:50 +0200 Subject: [PATCH 055/130] fix bug for target_mean/std array merging in backtesting --- config_examples/config_freqai.example.json | 19 ++- docs/freqai.md | 106 +++++++++++--- freqtrade/freqai/data_kitchen.py | 131 ++++++++++-------- freqtrade/freqai/freqai_interface.py | 23 +-- .../CatboostPredictionModel.py | 13 +- freqtrade/templates/FreqaiExampleStrategy.py | 4 +- 6 files changed, 186 insertions(+), 110 deletions(-) diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 7582afef0..b6c7ba7d8 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -1,7 +1,7 @@ { "max_open_trades": 1, "stake_currency": "USDT", - "stake_amount": 800, + "stake_amount": 900, "tradable_balance_ratio": 1, "fiat_display_currency": "USD", "dry_run": true, @@ -24,8 +24,7 @@ "rateLimit": 200 }, "pair_whitelist": [ - "BTC/USDT", - "ETH/USDT" + "BTC/USDT" ], "pair_blacklist": [] }, @@ -55,7 +54,7 @@ "15m" ], "train_period": 30, - "backtest_period": 10, + "backtest_period": 7, "identifier": "example", "live_trained_timestamp": 0, "corr_pairlist": [ @@ -64,16 +63,16 @@ "DOT/USDT" ], "feature_parameters": { - "period": 12, + "period": 24, "shift": 1, - "DI_threshold": 1, - "weight_factor": 0, + "DI_threshold": 0, + "weight_factor": 0.9, "principal_component_analysis": false, - "use_SVM_to_remove_outliers": false, - "stratify": 0 + "use_SVM_to_remove_outliers": true, + "stratify": 3 }, "data_split_parameters": { - "test_size": 0.25, + "test_size": 0.33, "random_state": 1 }, "model_training_parameters": { diff --git a/docs/freqai.md b/docs/freqai.md index 403145525..821f42258 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -221,33 +221,43 @@ This way, the user can return to using any model they wish by simply changing th ### Building a freqai strategy -The Freqai strategy requires the user to include the following lines of code in `populate_ any _indicators()` +The Freqai strategy requires the user to include the following lines of code in the strategy: ```python - from freqtrade.freqai.strategy_bridge import CustomModel + from freqtrade.freqai.strategy_bridge import CustomModel - def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - # the configuration file parameters are stored here - self.freqai_info = self.config['freqai'] + def informative_pairs(self): + whitelist_pairs = self.dp.current_whitelist() + corr_pairs = self.config["freqai"]["corr_pairlist"] + informative_pairs = [] + for tf in self.config["freqai"]["timeframes"]: + for pair in whitelist_pairs: + informative_pairs.append((pair, tf)) + for pair in corr_pairs: + if pair in whitelist_pairs: + continue # avoid duplication + informative_pairs.append((pair, tf)) + return informative_pairs - # the model is instantiated here - self.model = CustomModel(self.config) + def bot_start(self): + self.model = CustomModel(self.config) - print('Populating indicators...') + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + self.freqai_info = self.config['freqai'] - # the following loops are necessary for building the features - # indicated by the user in the configuration file. - for tf in self.freqai_info['timeframes']: - for i in self.freqai_info['corr_pairlist']: - dataframe = self.populate_any_indicators(i, - dataframe.copy(), tf, coin=i.split("/")[0]+'-') + # the following loops are necessary for building the features + # indicated by the user in the configuration file. + for tf in self.freqai_info['timeframes']: + for i in self.freqai_info['corr_pairlist']: + dataframe = self.populate_any_indicators(i, + dataframe.copy(), tf, coin=i.split("/")[0]+'-') - # the model will return 4 values, its prediction, an indication of whether or not the prediction - # should be accepted, the target mean/std values from the labels used during each training period. - (dataframe['prediction'], dataframe['do_predict'], - dataframe['target_mean'], dataframe['target_std']) = self.model.bridge.start(dataframe, metadata) + # the model will return 4 values, its prediction, an indication of whether or not the prediction + # should be accepted, the target mean/std values from the labels used during each training period. + (dataframe['prediction'], dataframe['do_predict'], + dataframe['target_mean'], dataframe['target_std']) = self.model.bridge.start(dataframe, metadata) - return dataframe + return dataframe ``` The user should also include `populate_any_indicators()` from `templates/FreqaiExampleStrategy.py` which builds @@ -314,7 +324,7 @@ data point and all other training data points: $$ d_{ab} = \sqrt{\sum_{j=1}^p(X_{a,j}-X_{b,j})^2} $$ -where $d_{ab}$ is the distance between the standardized points $a$ and $b$. $p$ +where $d_{ab}$ is the distance between the normalized points $a$ and $b$. $p$ is the number of features i.e. the length of the vector $X$. The characteristic distance, $\overline{d}$ for a set of training data points is simply the mean of the average distances: @@ -392,13 +402,63 @@ The user can stratify the training/testing data using: which will split the data chronolocially so that every X data points is a testing data point. In the present example, the user is asking for every third data point in the dataframe to be used for -testing, the other points are used for training. +testing, the other points are used for training. + + + + ## Additional information -### Feature standardization +### Feature normalization -The feature set created by the user is automatically standardized to the training +The feature set created by the user is automatically normalized to the training data only. This includes all test data and unseen prediction data (dry/live/backtest). ### File structure diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index eafb9cc46..b5f1f6edb 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -141,9 +141,9 @@ class FreqaiDataKitchen: :model: User trained model which can be inferenced for new predictions """ - # if self.live: - self.model_filename = self.data_drawer.pair_dict[coin]['model_filename'] - self.data_path = Path(self.data_drawer.pair_dict[coin]['data_path']) + if self.live: + self.model_filename = self.data_drawer.pair_dict[coin]['model_filename'] + self.data_path = Path(self.data_drawer.pair_dict[coin]['data_path']) with open(self.data_path / str(self.model_filename + "_metadata.json"), "r") as fp: self.data = json.load(fp) @@ -329,42 +329,6 @@ class FreqaiDataKitchen: :data_dictionary: updated dictionary with standardized values. """ # standardize the data by training stats - train_mean = data_dictionary["train_features"].mean() - train_std = data_dictionary["train_features"].std() - data_dictionary["train_features"] = ( - data_dictionary["train_features"] - train_mean - ) / train_std - data_dictionary["test_features"] = ( - data_dictionary["test_features"] - train_mean - ) / train_std - - train_labels_std = data_dictionary["train_labels"].std() - train_labels_mean = data_dictionary["train_labels"].mean() - data_dictionary["train_labels"] = ( - data_dictionary["train_labels"] - train_labels_mean - ) / train_labels_std - data_dictionary["test_labels"] = ( - data_dictionary["test_labels"] - train_labels_mean - ) / train_labels_std - - for item in train_std.keys(): - self.data[item + "_std"] = train_std[item] - self.data[item + "_mean"] = train_mean[item] - - self.data["labels_std"] = train_labels_std - self.data["labels_mean"] = train_labels_mean - - return data_dictionary - - def standardize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: - """ - Standardize all data in the data_dictionary according to the training dataset - :params: - :data_dictionary: dictionary containing the cleaned and split training/test data/labels - :returns: - :data_dictionary: updated dictionary with standardized values. - """ - # standardize the data by training stats train_max = data_dictionary["train_features"].max() train_min = data_dictionary["train_features"].min() data_dictionary["train_features"] = 2 * ( @@ -392,9 +356,9 @@ class FreqaiDataKitchen: return data_dictionary - def standardize_data_from_metadata(self, df: DataFrame) -> DataFrame: + def normalize_data_from_metadata(self, df: DataFrame) -> DataFrame: """ - Standardizes a set of data using the mean and standard deviation from + Normalize a set of data using the mean and standard deviation from the associated training data. :params: :df: Dataframe to be standardized @@ -406,19 +370,6 @@ class FreqaiDataKitchen: return df - def normalize_data_from_metadata(self, df: DataFrame) -> DataFrame: - """ - Normalizes a set of data using the mean and standard deviation from - the associated training data. - :params: - :df: Dataframe to be standardized - """ - - for item in df.keys(): - df[item] = (df[item] - self.data[item + "_mean"]) / self.data[item + "_std"] - - return df - def split_timerange( self, tr: str, train_split: int = 28, bt_split: int = 7 ) -> Tuple[list, list]: @@ -657,12 +608,12 @@ class FreqaiDataKitchen: """ ones = np.ones(len_dataframe) - s_mean, s_std = ones * self.data["s_mean"], ones * self.data["s_std"] + target_mean, target_std = ones * self.data["target_mean"], ones * self.data["target_std"] self.full_predictions = np.append(self.full_predictions, predictions) self.full_do_predict = np.append(self.full_do_predict, do_predict) - self.full_target_mean = np.append(self.full_target_mean, s_mean) - self.full_target_std = np.append(self.full_target_std, s_std) + self.full_target_mean = np.append(self.full_target_mean, target_mean) + self.full_target_std = np.append(self.full_target_std, target_std) return @@ -827,6 +778,23 @@ class FreqaiDataKitchen: return dataframe + def fit_labels(self) -> None: + import scipy as spy + + f = spy.stats.norm.fit(self.data_dictionary["train_labels"]) + + # KEEPME incase we want to let user start to grab quantiles. + # upper_q = spy.stats.norm.ppf(self.freqai_config['feature_parameters'][ + # 'target_quantile'], *f) + # lower_q = spy.stats.norm.ppf(1 - self.freqai_config['feature_parameters'][ + # 'target_quantile'], *f) + + self.data["target_mean"], self.data["target_std"] = f[0], f[1] + # self.data["upper_quantile"] = upper_q + # self.data["lower_quantile"] = lower_q + + return + def np_encoder(self, object): if isinstance(object, np.generic): return object.item() @@ -968,3 +936,52 @@ class FreqaiDataKitchen: # ) # return + + # def standardize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: + # """ + # standardize all data in the data_dictionary according to the training dataset + # :params: + # :data_dictionary: dictionary containing the cleaned and split training/test data/labels + # :returns: + # :data_dictionary: updated dictionary with standardized values. + # """ + # # standardize the data by training stats + # train_mean = data_dictionary["train_features"].mean() + # train_std = data_dictionary["train_features"].std() + # data_dictionary["train_features"] = ( + # data_dictionary["train_features"] - train_mean + # ) / train_std + # data_dictionary["test_features"] = ( + # data_dictionary["test_features"] - train_mean + # ) / train_std + + # train_labels_std = data_dictionary["train_labels"].std() + # train_labels_mean = data_dictionary["train_labels"].mean() + # data_dictionary["train_labels"] = ( + # data_dictionary["train_labels"] - train_labels_mean + # ) / train_labels_std + # data_dictionary["test_labels"] = ( + # data_dictionary["test_labels"] - train_labels_mean + # ) / train_labels_std + + # for item in train_std.keys(): + # self.data[item + "_std"] = train_std[item] + # self.data[item + "_mean"] = train_mean[item] + + # self.data["labels_std"] = train_labels_std + # self.data["labels_mean"] = train_labels_mean + + # return data_dictionary + + # def standardize_data_from_metadata(self, df: DataFrame) -> DataFrame: + # """ + # Normalizes a set of data using the mean and standard deviation from + # the associated training data. + # :params: + # :df: Dataframe to be standardized + # """ + + # for item in df.keys(): + # df[item] = (df[item] - self.data[item + "_mean"]) / self.data[item + "_std"] + + # return df diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index d7bbc549a..68d21ecdc 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -158,12 +158,7 @@ class IFreqaiModel(ABC): else: self.model = dh.load_data(metadata['pair']) - # strategy_provided_features = self.dh.find_features(dataframe_train) - # # FIXME doesnt work with PCA - # if strategy_provided_features != self.dh.training_features_list: - # logger.info("User changed input features, retraining model.") - # self.model = self.train(dataframe_train, metadata) - # self.dh.save_data(self.model) + self.check_if_feature_list_matches_strategy(dataframe_train, dh) preds, do_preds = self.predict(dataframe_backtest, dh) @@ -220,16 +215,23 @@ class IFreqaiModel(ABC): self.model = dh.load_data(coin=metadata['pair']) - # FIXME - # strategy_provided_features = dh.find_features(dataframe) - # if strategy_provided_features != dh.training_features_list: - # self.train_model_in_series(new_trained_timerange, metadata, strategy) + self.check_if_feature_list_matches_strategy(dataframe, dh) preds, do_preds = self.predict(dataframe, dh) dh.append_predictions(preds, do_preds, len(dataframe)) return dh + def check_if_feature_list_matches_strategy(self, dataframe: DataFrame, + dh: FreqaiDataKitchen) -> None: + strategy_provided_features = dh.find_features(dataframe) + if strategy_provided_features != dh.training_features_list: + raise OperationalException("Trying to access pretrained model with `identifier` " + "but found different features furnished by current strategy." + "Change `identifer` to train from scratch, or ensure the" + "strategy is furnishing the same features as the pretrained" + "model") + def data_cleaning_train(self, dh: FreqaiDataKitchen) -> None: """ Base data cleaning method for train @@ -237,6 +239,7 @@ class IFreqaiModel(ABC): based on user decided logic. See FreqaiDataKitchen::remove_outliers() for an example of how outlier data points are dropped from the dataframe used for training. """ + if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): dh.principal_component_analysis() diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index 73ea46032..3f70400d8 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -33,10 +33,6 @@ class CatboostPredictionModel(IFreqaiModel): / dataframe["close"] - 1 ) - dh.data["s_mean"] = dataframe["s"].mean() - dh.data["s_std"] = dataframe["s"].std() - - # logger.info("label mean", dh.data["s_mean"], "label std", dh.data["s_std"]) return dataframe["s"] @@ -68,8 +64,9 @@ class CatboostPredictionModel(IFreqaiModel): # split data into train/test data. data_dictionary = dh.make_train_test_datasets(features_filtered, labels_filtered) - # standardize all data based on train_dataset only - data_dictionary = dh.standardize_data(data_dictionary) + dh.fit_labels() # fit labels to a cauchy distribution so we know what to expect in strategy + # normalize all data based on train_dataset only + data_dictionary = dh.normalize_data(data_dictionary) # optional additional data cleaning/analysis self.data_cleaning_train(dh) @@ -128,7 +125,7 @@ class CatboostPredictionModel(IFreqaiModel): filtered_dataframe, _ = dh.filter_features( unfiltered_dataframe, original_feature_list, training_filter=False ) - filtered_dataframe = dh.standardize_data_from_metadata(filtered_dataframe) + filtered_dataframe = dh.normalize_data_from_metadata(filtered_dataframe) dh.data_dictionary["prediction_features"] = filtered_dataframe # optional additional data cleaning/analysis @@ -136,7 +133,7 @@ class CatboostPredictionModel(IFreqaiModel): predictions = self.model.predict(dh.data_dictionary["prediction_features"]) - # compute the non-standardized predictions + # compute the non-normalized predictions dh.predictions = (predictions + 1) * (dh.data["labels_max"] - dh.data["labels_min"]) / 2 + dh.data["labels_min"] diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index d2eb2c306..ed7c828cc 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -178,8 +178,8 @@ class FreqaiExampleStrategy(IStrategy): dataframe["target_std"], ) = self.model.bridge.start(dataframe, metadata, self) - dataframe["target_roi"] = dataframe["target_mean"] + dataframe["target_std"] * 1.5 - dataframe["sell_roi"] = dataframe["target_mean"] - dataframe["target_std"] * 1 + dataframe["target_roi"] = dataframe["target_mean"] + dataframe["target_std"] + dataframe["sell_roi"] = dataframe["target_mean"] - dataframe["target_std"] return dataframe def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: From 23c30dbc10703bda5b9a872d9c81ecf2155790c5 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 27 May 2022 00:43:52 +0200 Subject: [PATCH 056/130] add error for user trying to backtest with backtest_period<1 --- freqtrade/freqai/data_kitchen.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index b5f1f6edb..1089797d1 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -19,6 +19,7 @@ from sklearn.model_selection import train_test_split from freqtrade.configuration import TimeRange from freqtrade.data.history import load_pair_history from freqtrade.data.history.history_utils import refresh_backtest_ohlcv_data +from freqtrade.exceptions import OperationalException from freqtrade.freqai.data_drawer import FreqaiDataDrawer from freqtrade.resolvers import ExchangeResolver from freqtrade.strategy.interface import IStrategy @@ -59,6 +60,11 @@ class FreqaiDataKitchen: self.pair = pair self.svm_model: linear_model.SGDOneClassSVM = None if not self.live: + if config.get('freqai', {}).get('backtest_period') is not int: + raise OperationalException('backtest_period < 1,' + 'Can only backtest on full day increments' + 'backtest_period. Only live/dry mode' + 'allows fractions of days') self.full_timerange = self.create_fulltimerange(self.config["timerange"], self.freqai_config.get("train_period") ) From 8a501831d6626fd37302d156917bbd935c3d8531 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 27 May 2022 01:15:55 +0200 Subject: [PATCH 057/130] fix the error logic on previous commit --- freqtrade/freqai/data_kitchen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 1089797d1..63420a52b 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -60,7 +60,7 @@ class FreqaiDataKitchen: self.pair = pair self.svm_model: linear_model.SGDOneClassSVM = None if not self.live: - if config.get('freqai', {}).get('backtest_period') is not int: + if config.get('freqai', {}).get('backtest_period') < 1: raise OperationalException('backtest_period < 1,' 'Can only backtest on full day increments' 'backtest_period. Only live/dry mode' From c080571b7a98ad3c29fd5c89ef229983040c2583 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 27 May 2022 12:23:32 +0200 Subject: [PATCH 058/130] help futures go dry/live with auto download feature --- docs/freqai.md | 2 +- freqtrade/freqai/data_kitchen.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 821f42258..78e25a234 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -400,7 +400,7 @@ The user can stratify the training/testing data using: } ``` -which will split the data chronolocially so that every X data points is a testing data point. In the +which will split the data chronologically so that every X data points is a testing data point. In the present example, the user is asking for every third data point in the dataframe to be used for testing, the other points are used for training. diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 63420a52b..45131f3d0 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -715,10 +715,9 @@ class FreqaiDataKitchen: exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config, validate=False) - pairs = self.freqai_config.get('corr_pairlist', []) - if metadata['pair'] not in pairs: - pairs += metadata['pair'] # dont include pair twice - # timerange = TimeRange.parse_timerange(new_timerange) + pairs = copy.deepcopy(self.freqai_config.get('corr_pairlist', [])) + if str(metadata['pair']) not in pairs: + pairs.append(str(metadata['pair'])) refresh_backtest_ohlcv_data( exchange, pairs=pairs, timeframes=self.freqai_config.get('timeframes'), From 65fdebab75bd6238228620aa53172be01813d3fd Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 27 May 2022 13:01:33 +0200 Subject: [PATCH 059/130] let load_pairs_histories load futures candles in live --- freqtrade/freqai/data_kitchen.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 45131f3d0..5aa84620c 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -738,7 +738,9 @@ class FreqaiDataKitchen: for tf in self.freqai_config.get('timeframes'): base_dataframes[tf] = load_pair_history(datadir=self.config['datadir'], timeframe=tf, - pair=metadata['pair'], timerange=timerange) + pair=metadata['pair'], timerange=timerange, + candle_type=self.config.get( + 'trading_mode', 'spot')) if pairs: for p in pairs: if metadata['pair'] in p: @@ -747,7 +749,9 @@ class FreqaiDataKitchen: corr_dataframes[p] = {} corr_dataframes[p][tf] = load_pair_history(datadir=self.config['datadir'], timeframe=tf, - pair=p, timerange=timerange) + pair=p, timerange=timerange, + candle_type=self.config.get( + 'trading_mode', 'spot')) return corr_dataframes, base_dataframes From 891fb87712ae01adf2a578f457ff64251b4bcb4a Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 27 May 2022 13:38:22 +0200 Subject: [PATCH 060/130] give load_cached_data_for_updating the right flags to avoid redownloading data in dry/live --- freqtrade/freqai/data_kitchen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 5aa84620c..9866ee33a 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -669,7 +669,7 @@ class FreqaiDataKitchen: def check_if_new_training_required(self, trained_timestamp: int) -> Tuple[bool, TimeRange]: time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() - trained_timerange = TimeRange() + trained_timerange = TimeRange('date', 'date') if trained_timestamp != 0: elapsed_time = (time - trained_timestamp) / SECONDS_IN_DAY retrain = elapsed_time > self.freqai_config.get('backtest_period') From b8f9c3557bd03a87217b12a6ba824e45e26fcea7 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Fri, 27 May 2022 13:56:34 +0200 Subject: [PATCH 061/130] dirty dirty, dont look here (hacking a flag to avoid reloading leverage_tiers in dry/live) --- freqtrade/exchange/exchange.py | 4 ++-- freqtrade/freqai/data_kitchen.py | 2 +- freqtrade/resolvers/exchange_resolver.py | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 65b9fb628..348f9c395 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -86,7 +86,7 @@ class Exchange: # TradingMode.SPOT always supported and not required in this list ] - def __init__(self, config: Dict[str, Any], validate: bool = True) -> None: + def __init__(self, config: Dict[str, Any], validate: bool = True, freqai: bool = False) -> None: """ Initializes this module with the given config, it does basic validation whether the specified exchange and pairs are valid. @@ -196,7 +196,7 @@ class Exchange: self.markets_refresh_interval: int = exchange_config.get( "markets_refresh_interval", 60) * 60 - if self.trading_mode != TradingMode.SPOT: + if self.trading_mode != TradingMode.SPOT and freqai is False: self.fill_leverage_tiers() self.additional_exchange_init() diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 9866ee33a..93e7b74ad 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -714,7 +714,7 @@ class FreqaiDataKitchen: def download_new_data_for_retraining(self, timerange: TimeRange, metadata: dict) -> None: exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], - self.config, validate=False) + self.config, validate=False, freqai=True) pairs = copy.deepcopy(self.freqai_config.get('corr_pairlist', [])) if str(metadata['pair']) not in pairs: pairs.append(str(metadata['pair'])) diff --git a/freqtrade/resolvers/exchange_resolver.py b/freqtrade/resolvers/exchange_resolver.py index 4dfbf445b..c1ec8b69c 100644 --- a/freqtrade/resolvers/exchange_resolver.py +++ b/freqtrade/resolvers/exchange_resolver.py @@ -18,7 +18,8 @@ class ExchangeResolver(IResolver): object_type = Exchange @staticmethod - def load_exchange(exchange_name: str, config: dict, validate: bool = True) -> Exchange: + def load_exchange(exchange_name: str, config: dict, validate: bool = True, + freqai: bool = False) -> Exchange: """ Load the custom class from config parameter :param exchange_name: name of the Exchange to load @@ -31,7 +32,8 @@ class ExchangeResolver(IResolver): try: exchange = ExchangeResolver._load_exchange(exchange_name, kwargs={'config': config, - 'validate': validate}) + 'validate': validate, + 'freqai': freqai}) except ImportError: logger.info( f"No {exchange_name} specific subclass found. Using the generic class instead.") From c5a16e91fbb3052d01e4311ee72f739ba97fbf51 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sat, 28 May 2022 11:11:41 +0200 Subject: [PATCH 062/130] throw user error if user tries to load models but feeds the wrong features (while using PCA) --- docs/freqai.md | 2 ++ freqtrade/freqai/data_kitchen.py | 8 +++++++- freqtrade/freqai/freqai_interface.py | 10 +++++++--- .../prediction_models/CatboostPredictionModel.py | 5 +++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 78e25a234..57ff8f897 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -313,6 +313,8 @@ $$ W_i = \exp(\frac{-i}{\alpha*n}) $$ where $W_i$ is the weight of data point $i$ in a total set of $n$ data points._ +![weight-factor](assets/weights_factor.png) + Finally, `period` defines the offset used for the `labels`. In the present example, the user is asking for `labels` that are 24 candles in the future. diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 93e7b74ad..58b14b9f1 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -477,6 +477,11 @@ class FreqaiDataKitchen: index=self.data_dictionary["train_features"].index, ) + # keeping a copy of the non-transformed features so we can check for errors during + # model load from disk + self.data['training_features_list_raw'] = copy.deepcopy(self.training_features_list) + self.training_features_list = self.data_dictionary["train_features"].columns + self.data_dictionary["test_features"] = pd.DataFrame( data=test_components, columns=["PC" + str(i) for i in range(0, n_keep_components)], @@ -563,7 +568,8 @@ class FreqaiDataKitchen: def find_features(self, dataframe: DataFrame) -> list: column_names = dataframe.columns features = [c for c in column_names if '%' in c] - assert features, ("Could not find any features!") + if not features: + raise OperationalException("Could not find any features!") return features def check_if_pred_in_training_spaces(self) -> None: diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 68d21ecdc..ab2d37753 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -225,7 +225,11 @@ class IFreqaiModel(ABC): def check_if_feature_list_matches_strategy(self, dataframe: DataFrame, dh: FreqaiDataKitchen) -> None: strategy_provided_features = dh.find_features(dataframe) - if strategy_provided_features != dh.training_features_list: + if dh.data['training_features_list_raw']: + feature_list = dh.data['training_features_list_raw'] + else: + feature_list = dh.training_features_list + if strategy_provided_features != feature_list: raise OperationalException("Trying to access pretrained model with `identifier` " "but found different features furnished by current strategy." "Change `identifer` to train from scratch, or ensure the" @@ -254,7 +258,7 @@ class IFreqaiModel(ABC): # if self.feature_parameters["remove_outliers"]: # dh.remove_outliers(predict=False) - def data_cleaning_predict(self, dh: FreqaiDataKitchen) -> None: + def data_cleaning_predict(self, dh: FreqaiDataKitchen, dataframe: DataFrame) -> None: """ Base data cleaning method for predict. These functions each modify dh.do_predict, which is a dataframe with equal length @@ -266,7 +270,7 @@ class IFreqaiModel(ABC): for buy signals. """ if self.freqai_info.get('feature_parameters', {}).get('principal_component_analysis'): - dh.pca_transform() + dh.pca_transform(dataframe) if self.freqai_info.get('feature_parameters', {}).get('use_SVM_to_remove_outliers'): dh.use_SVM_to_remove_outliers(predict=True) diff --git a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py index 3f70400d8..5147faf0c 100644 --- a/freqtrade/freqai/prediction_models/CatboostPredictionModel.py +++ b/freqtrade/freqai/prediction_models/CatboostPredictionModel.py @@ -71,7 +71,8 @@ class CatboostPredictionModel(IFreqaiModel): # optional additional data cleaning/analysis self.data_cleaning_train(dh) - logger.info(f'Training model on {len(dh.training_features_list)} features') + logger.info(f'Training model on {len(dh.data_dictionary["train_features"].columns)}' + 'features') logger.info(f'Training model on {len(data_dictionary["train_features"])} data points') model = self.fit(data_dictionary) @@ -129,7 +130,7 @@ class CatboostPredictionModel(IFreqaiModel): dh.data_dictionary["prediction_features"] = filtered_dataframe # optional additional data cleaning/analysis - self.data_cleaning_predict(dh) + self.data_cleaning_predict(dh, filtered_dataframe) predictions = self.model.predict(dh.data_dictionary["prediction_features"]) From 0bf915054d95f82bd5bb5f313a825d25c2ad0219 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sat, 28 May 2022 11:22:32 +0200 Subject: [PATCH 063/130] handle key check correctly --- freqtrade/freqai/freqai_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index ab2d37753..75c00988f 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -225,7 +225,7 @@ class IFreqaiModel(ABC): def check_if_feature_list_matches_strategy(self, dataframe: DataFrame, dh: FreqaiDataKitchen) -> None: strategy_provided_features = dh.find_features(dataframe) - if dh.data['training_features_list_raw']: + if 'training_features_list_raw' in dh.data: feature_list = dh.data['training_features_list_raw'] else: feature_list = dh.training_features_list From 7870a86e9acbc3962e63a2fbacef993ab00500d3 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sat, 28 May 2022 11:38:57 +0200 Subject: [PATCH 064/130] fix live retraining bug --- docs/assets/weights_factor.png | Bin 0 -> 129065 bytes freqtrade/freqai/data_kitchen.py | 2 +- freqtrade/freqai/freqai_interface.py | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 docs/assets/weights_factor.png diff --git a/docs/assets/weights_factor.png b/docs/assets/weights_factor.png new file mode 100644 index 0000000000000000000000000000000000000000..1171a49ba4d9300c6828b766fc8d80aecc907cf5 GIT binary patch literal 129065 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

%|IStn!TE@v9Zyy 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 literal 0 HcmV?d00001 diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 58b14b9f1..69a311925 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -675,7 +675,7 @@ class FreqaiDataKitchen: def check_if_new_training_required(self, trained_timestamp: int) -> Tuple[bool, TimeRange]: time = datetime.datetime.now(tz=datetime.timezone.utc).timestamp() - trained_timerange = TimeRange('date', 'date') + trained_timerange = TimeRange() if trained_timestamp != 0: elapsed_time = (time - trained_timestamp) / SECONDS_IN_DAY retrain = elapsed_time > self.freqai_config.get('backtest_period') diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 75c00988f..3e737e5e7 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -187,7 +187,8 @@ class IFreqaiModel(ABC): (model_filename, trained_timestamp, coin_first) = self.data_drawer.get_pair_dict_info(metadata) - + if self.training_on_separate_thread: + print('debug_here') if not self.training_on_separate_thread: file_exists = False From 2a4d1e2d64df6584b5c54849f33b96e49eb21e60 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sat, 28 May 2022 12:23:26 +0200 Subject: [PATCH 065/130] fix bug in setting new timerange for retraining --- freqtrade/freqai/data_kitchen.py | 2 +- freqtrade/freqai/freqai_interface.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 69a311925..629019549 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -681,7 +681,7 @@ class FreqaiDataKitchen: retrain = elapsed_time > self.freqai_config.get('backtest_period') if retrain: trained_timerange.startts = int(time - self.freqai_config.get( - 'backtest_period', 0) * SECONDS_IN_DAY) + 'train_period', 0) * SECONDS_IN_DAY) trained_timerange.stopts = int(time) else: # user passed no live_trained_timerange in config trained_timerange.startts = int(time - self.freqai_config.get('train_period') * diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 3e737e5e7..75c00988f 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -187,8 +187,7 @@ class IFreqaiModel(ABC): (model_filename, trained_timestamp, coin_first) = self.data_drawer.get_pair_dict_info(metadata) - if self.training_on_separate_thread: - print('debug_here') + if not self.training_on_separate_thread: file_exists = False From e54614fa2f190970234fcf7e4b67299c0eacbe38 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sat, 28 May 2022 14:55:07 +0200 Subject: [PATCH 066/130] remove remnants of single threaded version, ensure pair queue priority is checked before retraining --- freqtrade/freqai/freqai_interface.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 75c00988f..1952cd234 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -85,7 +85,7 @@ class IFreqaiModel(ABC): # determine what the current pair will do if self.live: if (not self.training_on_separate_thread and - self.data_drawer.training_queue == 1): + self.data_drawer.training_queue[metadata['pair']] == 1): self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) @@ -321,16 +321,26 @@ class IFreqaiModel(ABC): base_dataframes, metadata) - self.model = self.train(unfiltered_dataframe, metadata, dh) + try: + model = self.train(unfiltered_dataframe, metadata, dh) + except ValueError: + logger.warning('Value error encountered during training') + self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) + self.training_on_separate_thread = False + self.retrain = False + return self.data_drawer.pair_dict[metadata['pair']][ 'trained_timestamp'] = new_trained_timerange.stopts dh.set_new_model_names(metadata, new_trained_timerange) - self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) - dh.save_data(self.model, coin=metadata['pair']) + logger.info('Training queue' + f'{sorted(self.data_drawer.training_queue.items(), key=lambda item: item[1])}') + dh.save_data(model, coin=metadata['pair']) + self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) self.training_on_separate_thread = False self.retrain = False + return def train_model_in_series(self, new_trained_timerange: TimeRange, metadata: dict, strategy: IStrategy, dh: FreqaiDataKitchen): @@ -344,13 +354,13 @@ class IFreqaiModel(ABC): base_dataframes, metadata) - self.model = self.train(unfiltered_dataframe, metadata, dh) + model = self.train(unfiltered_dataframe, metadata, dh) self.data_drawer.pair_dict[metadata['pair']][ 'trained_timestamp'] = new_trained_timerange.stopts dh.set_new_model_names(metadata, new_trained_timerange) self.data_drawer.pair_dict[metadata['pair']]['first'] = False - dh.save_data(self.model, coin=metadata['pair']) + dh.save_data(model, coin=metadata['pair']) self.retrain = False # Methods which are overridden by user made prediction models. From 83dd45372356c62b17cd4789d377e2e25d7e1378 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sat, 28 May 2022 18:26:19 +0200 Subject: [PATCH 067/130] catch errors occuring on background thread, and make sure to keep the ball rolling. Improve pair retraining queue. --- freqtrade/freqai/data_drawer.py | 18 ++++++++--------- freqtrade/freqai/data_kitchen.py | 6 ++++-- freqtrade/freqai/freqai_interface.py | 29 ++++++++++++++++++---------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index a5d8a2123..477f45d84 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -28,7 +28,7 @@ class FreqaiDataDrawer: self.full_path = full_path self.load_drawer_from_disk() self.training_queue: Dict[str, int] = {} - self.create_training_queue(pair_whitelist) + # self.create_training_queue(pair_whitelist) def load_drawer_from_disk(self): exists = Path(self.full_path / str('pair_dictionary.json')).resolve().exists() @@ -58,7 +58,6 @@ class FreqaiDataDrawer: model_filename = self.pair_dict[metadata['pair']]['model_filename'] = '' coin_first = self.pair_dict[metadata['pair']]['first'] = True trained_timestamp = self.pair_dict[metadata['pair']]['trained_timestamp'] = 0 - self.pair_dict[metadata['pair']]['priority'] = 1 return model_filename, trained_timestamp, coin_first @@ -71,17 +70,16 @@ class FreqaiDataDrawer: self.pair_dict[metadata['pair']]['model_filename'] = '' self.pair_dict[metadata['pair']]['first'] = True self.pair_dict[metadata['pair']]['trained_timestamp'] = 0 - self.pair_dict[metadata['pair']]['priority'] = 1 + self.pair_dict[metadata['pair']]['priority'] = len(self.pair_dict) return - def create_training_queue(self, pairs: list) -> None: - for i, pair in enumerate(pairs): - self.training_queue[pair] = i + 1 + # def create_training_queue(self, pairs: list) -> None: + # for i, pair in enumerate(pairs): + # self.training_queue[pair] = i + 1 def pair_to_end_of_training_queue(self, pair: str) -> None: # march all pairs up in the queue - for p in self.training_queue: - self.training_queue[p] -= 1 - + for p in self.pair_dict: + self.pair_dict[p]['priority'] -= 1 # send pair to end of queue - self.training_queue[pair] = len(self.training_queue) + self.pair_dict[pair]['priority'] = len(self.pair_dict) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 629019549..a1bda34bf 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -717,10 +717,12 @@ class FreqaiDataKitchen: # enables persistence, but not fully implemented into save/load data yer # self.data['live_trained_timerange'] = str(int(trained_timerange.stopts)) - def download_new_data_for_retraining(self, timerange: TimeRange, metadata: dict) -> None: + def download_new_data_for_retraining(self, timerange: TimeRange, metadata: dict, + strategy: IStrategy) -> None: exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config, validate=False, freqai=True) + # exchange = strategy.dp._exchange # closes ccxt session pairs = copy.deepcopy(self.freqai_config.get('corr_pairlist', [])) if str(metadata['pair']) not in pairs: pairs.append(str(metadata['pair'])) @@ -766,7 +768,7 @@ class FreqaiDataKitchen: base_dataframes: dict, metadata: dict) -> DataFrame: - dataframe = base_dataframes[self.config['timeframe']] + dataframe = base_dataframes[self.config['timeframe']].copy() pairs = self.freqai_config.get("corr_pairlist", []) for tf in self.freqai_config.get("timeframes"): diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 1952cd234..dfd2f0bbd 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -85,7 +85,7 @@ class IFreqaiModel(ABC): # determine what the current pair will do if self.live: if (not self.training_on_separate_thread and - self.data_drawer.training_queue[metadata['pair']] == 1): + self.data_drawer.pair_dict[metadata['pair']]['priority'] == 1): self.dh = FreqaiDataKitchen(self.config, self.data_drawer, self.live, metadata["pair"]) @@ -313,13 +313,22 @@ class IFreqaiModel(ABC): strategy: IStrategy, dh: FreqaiDataKitchen): # with nostdout(): - dh.download_new_data_for_retraining(new_trained_timerange, metadata) + dh.download_new_data_for_retraining(new_trained_timerange, metadata, strategy) corr_dataframes, base_dataframes = dh.load_pairs_histories(new_trained_timerange, metadata) - unfiltered_dataframe = dh.use_strategy_to_populate_indicators(strategy, - corr_dataframes, - base_dataframes, - metadata) + + # protecting from common benign errors associated with grabbing new data from exchange: + try: + unfiltered_dataframe = dh.use_strategy_to_populate_indicators(strategy, + corr_dataframes, + base_dataframes, + metadata) + except Exception: + logger.warning('Mismatched sizes encountered in strategy') + self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) + self.training_on_separate_thread = False + self.retrain = False + return try: model = self.train(unfiltered_dataframe, metadata, dh) @@ -333,8 +342,8 @@ class IFreqaiModel(ABC): self.data_drawer.pair_dict[metadata['pair']][ 'trained_timestamp'] = new_trained_timerange.stopts dh.set_new_model_names(metadata, new_trained_timerange) - logger.info('Training queue' - f'{sorted(self.data_drawer.training_queue.items(), key=lambda item: item[1])}') + # logger.info('Training queue' + # f'{sorted(self.data_drawer.pair_dict.items(), key=lambda item: item[1])}') dh.save_data(model, coin=metadata['pair']) self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) @@ -345,7 +354,7 @@ class IFreqaiModel(ABC): def train_model_in_series(self, new_trained_timerange: TimeRange, metadata: dict, strategy: IStrategy, dh: FreqaiDataKitchen): - dh.download_new_data_for_retraining(new_trained_timerange, metadata) + dh.download_new_data_for_retraining(new_trained_timerange, metadata, strategy) corr_dataframes, base_dataframes = dh.load_pairs_histories(new_trained_timerange, metadata) @@ -363,7 +372,7 @@ class IFreqaiModel(ABC): dh.save_data(model, coin=metadata['pair']) self.retrain = False - # Methods which are overridden by user made prediction models. + # Following methods which are overridden by user made prediction models. # See freqai/prediction_models/CatboostPredictionModlel.py for an example. @abstractmethod From 4eb29c8810bad526f79206f2688eeaf7b5a2da1a Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sat, 28 May 2022 18:34:26 +0200 Subject: [PATCH 068/130] Dont reset pair priority if it doesnt successfully train --- freqtrade/freqai/freqai_interface.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index dfd2f0bbd..ac0ad9d5a 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -325,7 +325,7 @@ class IFreqaiModel(ABC): metadata) except Exception: logger.warning('Mismatched sizes encountered in strategy') - self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) + # self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) self.training_on_separate_thread = False self.retrain = False return @@ -334,7 +334,7 @@ class IFreqaiModel(ABC): model = self.train(unfiltered_dataframe, metadata, dh) except ValueError: logger.warning('Value error encountered during training') - self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) + # self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) self.training_on_separate_thread = False self.retrain = False return From ce365eb9e3a12e83d21e696ff0c4c9033e7ce1a7 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 29 May 2022 14:45:46 +0200 Subject: [PATCH 069/130] improve example strat so that it has dynamic buy and sell logic according to original prediction --- .gitignore | 3 +- .../config_freqai_futures.example.json | 93 ++++++++++++++++++ ...e.json => config_freqai_spot.example.json} | 17 ++-- freqtrade/freqai/freqai_interface.py | 1 + freqtrade/templates/FreqaiExampleStrategy.py | 98 ++++++++++++++++--- 5 files changed, 189 insertions(+), 23 deletions(-) create mode 100644 config_examples/config_freqai_futures.example.json rename config_examples/{config_freqai.example.json => config_freqai_spot.example.json} (89%) diff --git a/.gitignore b/.gitignore index dc87402b4..a066001db 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,5 @@ target/ !config_examples/config_ftx.example.json !config_examples/config_full.example.json !config_examples/config_kraken.example.json -!config_examples/config_freqai.example.json +!config_examples/config_freqai_futures.example.json +!config_examples/config_freqai_spot.example.json diff --git a/config_examples/config_freqai_futures.example.json b/config_examples/config_freqai_futures.example.json new file mode 100644 index 000000000..cb545acdc --- /dev/null +++ b/config_examples/config_freqai_futures.example.json @@ -0,0 +1,93 @@ +{ + "trading_mode": "futures", + "margin_mode": "isolated", + "max_open_trades": 5, + "stake_currency": "USDT", + "stake_amount": 200, + "tradable_balance_ratio": 1, + "fiat_display_currency": "USD", + "dry_run": true, + "timeframe": "3m", + "dry_run_wallet": 1000, + "cancel_open_orders_on_exit": true, + "unfilledtimeout": { + "entry": 10, + "exit": 30 + }, + "exchange": { + "name": "okx", + "key": "", + "secret": "", + "ccxt_config": { + "enableRateLimit": true + }, + "ccxt_async_config": { + "enableRateLimit": true, + "rateLimit": 200 + }, + "pair_whitelist": [ + "AGLD/USDT:USDT", + "1INCH/USDT:USDT", + "AAVE/USDT:USDT", + "ALGO/USDT:USDT", + "ALPHA/USDT:USDT", + "API3/USDT:USDT", + "AVAX/USDT:USDT", + "AXS/USDT:USDT", + "BCH/USDT:USDT" + ], + "pair_blacklist": [] + }, + "entry_pricing": { + "price_side": "same", + "use_order_book": true, + "order_book_top": 1, + "price_last_balance": 0.0, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "exit_pricing": { + "price_side": "other", + "use_order_book": true, + "order_book_top": 1 + }, + "pairlists": [ + { + "method": "StaticPairList" + } + ], + "freqai": { + "timeframes": [ + "3m", + "15m", + "1h" + ], + "train_period": 20, + "backtest_period": 2, + "identifier": "example", + "live_trained_timestamp": 0, + "corr_pairlist": [ + "BTC/USDT:USDT", + "ETH/USDT:USDT" + ], + "feature_parameters": { + "period": 20, + "shift": 2, + "DI_threshold": 0, + "weight_factor": 0.9, + "principal_component_analysis": false, + "use_SVM_to_remove_outliers": true, + "stratify": 0 + }, + "data_split_parameters": { + "test_size": 0.33, + "random_state": 1 + }, + "model_training_parameters": { + "n_estimators": 1000, + "task_type": "CPU" + } + } +} diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai_spot.example.json similarity index 89% rename from config_examples/config_freqai.example.json rename to config_examples/config_freqai_spot.example.json index b6c7ba7d8..e311fe280 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai_spot.example.json @@ -6,7 +6,7 @@ "fiat_display_currency": "USD", "dry_run": true, "timeframe": "5m", - "dry_run_wallet": 1000, + "dry_run_wallet": 4000, "cancel_open_orders_on_exit": true, "unfilledtimeout": { "entry": 10, @@ -51,7 +51,8 @@ "freqai": { "timeframes": [ "5m", - "15m" + "15m", + "4h" ], "train_period": 30, "backtest_period": 7, @@ -60,16 +61,18 @@ "corr_pairlist": [ "BTC/USDT", "ETH/USDT", - "DOT/USDT" + "DOT/USDT", + "MATIC/USDT", + "SOL/USDT" ], "feature_parameters": { - "period": 24, + "period": 20, "shift": 1, "DI_threshold": 0, "weight_factor": 0.9, "principal_component_analysis": false, - "use_SVM_to_remove_outliers": true, - "stratify": 3 + "use_SVM_to_remove_outliers": false, + "stratify": 0 }, "data_split_parameters": { "test_size": 0.33, @@ -77,8 +80,6 @@ }, "model_training_parameters": { "n_estimators": 1000, - "random_state": 1, - "learning_rate": 0.1, "task_type": "CPU" } }, diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index ac0ad9d5a..0d5b27385 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -56,6 +56,7 @@ class IFreqaiModel(ABC): self.set_full_path() self.data_drawer = FreqaiDataDrawer(Path(self.full_path), self.config['exchange']['pair_whitelist']) + self.lock = threading.Lock() def assert_config(self, config: Dict[str, Any]) -> None: diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index ed7c828cc..94fa0266d 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -7,8 +7,10 @@ import talib.abstract as ta from pandas import DataFrame from technical import qtpylib +from freqtrade.exchange import timeframe_to_prev_date from freqtrade.freqai.strategy_bridge import CustomModel -from freqtrade.strategy import merge_informative_pair +from freqtrade.persistence import Trade +from freqtrade.strategy import DecimalParameter, IntParameter, merge_informative_pair from freqtrade.strategy.interface import IStrategy @@ -46,6 +48,11 @@ class FreqaiExampleStrategy(IStrategy): stoploss = -0.05 use_sell_signal = True startup_candle_count: int = 300 + can_short = False + + linear_roi_offset = DecimalParameter(0.00, 0.02, default=0.005, space='sell', + optimize=False, load=True) + max_roi_time_long = IntParameter(0, 800, default=400, space='sell', optimize=False, load=True) def informative_pairs(self): whitelist_pairs = self.dp.current_whitelist() @@ -182,25 +189,88 @@ class FreqaiExampleStrategy(IStrategy): dataframe["sell_roi"] = dataframe["target_mean"] - dataframe["target_std"] return dataframe - def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_entry_trend(self, df: DataFrame, metadata: dict) -> DataFrame: - buy_conditions = [ - (dataframe["prediction"] > dataframe["target_roi"]) & (dataframe["do_predict"] == 1) + enter_long_conditions = [ + df['do_predict'] == 1, + df['prediction'] > df["target_roi"] ] - if buy_conditions: - dataframe.loc[reduce(lambda x, y: x | y, buy_conditions), "buy"] = 1 + if enter_long_conditions: + df.loc[reduce(lambda x, y: x & y, + enter_long_conditions), ["enter_long", "enter_tag"]] = (1, 'long') - return dataframe - - def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - sell_conditions = [ - (dataframe["do_predict"] <= 0) + enter_short_conditions = [ + df['do_predict'] == 1, + df['prediction'] < df["sell_roi"] ] - if sell_conditions: - dataframe.loc[reduce(lambda x, y: x | y, sell_conditions), "sell"] = 1 - return dataframe + if enter_short_conditions: + df.loc[reduce(lambda x, y: x & y, + enter_short_conditions), ["enter_short", "enter_tag"]] = (1, 'short') + + return df + + def populate_exit_trend(self, df: DataFrame, metadata: dict) -> DataFrame: + exit_long_conditions = [ + df['do_predict'] == 1, + df['prediction'] < df['sell_roi'] * 0.25 + ] + if exit_long_conditions: + df.loc[reduce(lambda x, y: x & y, exit_long_conditions), "exit_long"] = 1 + + exit_short_conditions = [ + df['do_predict'] == 1, + df['prediction'] > df['target_roi'] * 0.25 + ] + if exit_short_conditions: + df.loc[reduce(lambda x, y: x & y, exit_short_conditions), "exit_short"] = 1 + + return df def get_ticker_indicator(self): return int(self.config["timeframe"][:-1]) + + def custom_exit(self, pair: str, trade: Trade, current_time, current_rate, + current_profit, **kwargs): + + dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) + + trade_date = timeframe_to_prev_date(self.config['timeframe'], trade.open_date_utc) + trade_candle = dataframe.loc[(dataframe['date'] == trade_date)] + + if trade_candle.empty: + return None + trade_candle = trade_candle.squeeze() + pair_dict = self.model.bridge.data_drawer.pair_dict + entry_tag = trade.enter_tag + + if 'prediction' + entry_tag not in pair_dict[pair]: + with self.model.bridge.lock: + self.model.bridge.data_drawer.pair_dict[pair][ + 'prediction' + entry_tag] = abs(trade_candle['prediction']) + self.model.bridge.data_drawer.save_drawer_to_disk() + else: + if pair_dict[pair]['prediction' + entry_tag] > 0: + roi_price = abs(trade_candle['prediction' + entry_tag]) + else: + with self.model.bridge.lock: + self.model.bridge.data_drawer.pair_dict[pair][ + 'prediction' + entry_tag] = abs(trade_candle['prediction']) + self.model.bridge.data_drawer.save_drawer_to_disk() + + roi_price = abs(trade_candle['prediction']) + roi_time = self.max_roi_time_long.value + + roi_decay = roi_price * (1 - ((current_time - trade.open_date_utc).seconds) / + (roi_time * 60)) + if roi_decay < 0: + roi_decay = self.linear_roi_offset.value + else: + roi_decay += self.linear_roi_offset.value + + if current_profit > roi_price: # roi_decay: + with self.model.bridge.lock: + self.model.bridge.data_drawer.pair_dict[pair]['prediction' + entry_tag] = 0 + self.model.bridge.data_drawer.save_drawer_to_disk() + return 'roi_custom_win' From fe36b08fcec9e2f4b52349e1aa6dbd4db0f3eaf7 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 29 May 2022 16:26:34 +0200 Subject: [PATCH 070/130] fix key error in example strat --- freqtrade/templates/FreqaiExampleStrategy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 94fa0266d..13df1f846 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -252,7 +252,7 @@ class FreqaiExampleStrategy(IStrategy): self.model.bridge.data_drawer.save_drawer_to_disk() else: if pair_dict[pair]['prediction' + entry_tag] > 0: - roi_price = abs(trade_candle['prediction' + entry_tag]) + roi_price = abs(trade_candle['prediction']) else: with self.model.bridge.lock: self.model.bridge.data_drawer.pair_dict[pair][ From 0aa71620553a8e72162939a2ccd7dc8eefe11fec Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 29 May 2022 16:36:46 +0200 Subject: [PATCH 071/130] ensure the prediction is reset in the pair_dict after any trade exit, not just custom_exit --- freqtrade/templates/FreqaiExampleStrategy.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 13df1f846..ecda10919 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -269,8 +269,17 @@ class FreqaiExampleStrategy(IStrategy): else: roi_decay += self.linear_roi_offset.value - if current_profit > roi_price: # roi_decay: - with self.model.bridge.lock: - self.model.bridge.data_drawer.pair_dict[pair]['prediction' + entry_tag] = 0 - self.model.bridge.data_drawer.save_drawer_to_disk() + if current_profit > roi_price: return 'roi_custom_win' + + def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float, + rate: float, time_in_force: str, exit_reason: str, + current_time, **kwargs) -> bool: + + entry_tag = trade.enter_tag + + with self.model.bridge.lock: + self.model.bridge.data_drawer.pair_dict[pair]['prediction' + entry_tag] = 0 + self.model.bridge.data_drawer.save_drawer_to_disk() + + return True From 4eb4753e20434ce8c4010f14f2e305dd44995a74 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 29 May 2022 17:44:35 +0200 Subject: [PATCH 072/130] allow subdaily retraining for backtesting --- docs/freqai.md | 7 ++++--- freqtrade/freqai/data_kitchen.py | 22 +++++++++++++--------- freqtrade/freqai/freqai_interface.py | 3 ++- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/freqai.md b/docs/freqai.md index 57ff8f897..d6998a8b6 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -280,9 +280,10 @@ freqtrade trade --strategy FreqaiExampleStrategy --config config_freqai.example. By default, Freqai will not find find any existing models and will start by training a new one given the user configuration settings. Following training, it will use that model to predict for the duration of `backtest_period`. After a full `backtest_period` has elapsed, Freqai will auto retrain -a new model, and begin making predictions with the updated model. FreqAI in live mode permits -the user to use fractional days (i.e. 0.1) in the `backtest_period`, which enables more frequent -retraining. +a new model, and begin making predictions with the updated model. FreqAI backtesting and live both +permit the user to use fractional days (i.e. 0.1) in the `backtest_period`, which enables more frequent +retraining. But the user should be careful that using a fractional `backtest_period` with a large +`--timerange` in backtesting will result in a huge amount of required trainings/models. If the user wishes to start dry/live from a backtested saved model, the user only needs to reuse the same `identifier` parameter diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index a1bda34bf..08a00c68d 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -60,11 +60,11 @@ class FreqaiDataKitchen: self.pair = pair self.svm_model: linear_model.SGDOneClassSVM = None if not self.live: - if config.get('freqai', {}).get('backtest_period') < 1: - raise OperationalException('backtest_period < 1,' - 'Can only backtest on full day increments' - 'backtest_period. Only live/dry mode' - 'allows fractions of days') + # if config.get('freqai', {}).get('backtest_period') < 1: + # raise OperationalException('backtest_period < 1,' + # 'Can only backtest on full day increments' + # 'backtest_period. Only live/dry mode' + # 'allows fractions of days') self.full_timerange = self.create_fulltimerange(self.config["timerange"], self.freqai_config.get("train_period") ) @@ -401,6 +401,8 @@ class FreqaiDataKitchen: tr_training_list = [] tr_backtesting_list = [] + tr_training_list_timerange = [] + tr_backtesting_list_timerange = [] first = True # within_config_timerange = True while True: @@ -412,6 +414,7 @@ class FreqaiDataKitchen: start = datetime.datetime.utcfromtimestamp(timerange_train.startts) stop = datetime.datetime.utcfromtimestamp(timerange_train.stopts) tr_training_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")) + tr_training_list_timerange.append(copy.deepcopy(timerange_train)) # associated backtest period @@ -425,16 +428,17 @@ class FreqaiDataKitchen: start = datetime.datetime.utcfromtimestamp(timerange_backtest.startts) stop = datetime.datetime.utcfromtimestamp(timerange_backtest.stopts) tr_backtesting_list.append(start.strftime("%Y%m%d") + "-" + stop.strftime("%Y%m%d")) + tr_backtesting_list_timerange.append(copy.deepcopy(timerange_backtest)) # ensure we are predicting on exactly same amount of data as requested by user defined # --timerange if timerange_backtest.stopts == config_timerange.stopts: break - print(tr_training_list, tr_backtesting_list) - return tr_training_list, tr_backtesting_list + # print(tr_training_list, tr_backtesting_list) + return tr_training_list_timerange, tr_backtesting_list_timerange - def slice_dataframe(self, tr: str, df: DataFrame) -> DataFrame: + def slice_dataframe(self, timerange: TimeRange, df: DataFrame) -> DataFrame: """ Given a full dataframe, extract the user desired window :params: @@ -442,7 +446,7 @@ class FreqaiDataKitchen: :df: Dataframe containing all candles to run the entire backtest. Here it is sliced down to just the present training period. """ - timerange = TimeRange.parse_timerange(tr) + # timerange = TimeRange.parse_timerange(tr) start = datetime.datetime.fromtimestamp(timerange.startts, tz=datetime.timezone.utc) stop = datetime.datetime.fromtimestamp(timerange.stopts, tz=datetime.timezone.utc) df = df.loc[df["date"] >= start, :] diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 0d5b27385..db66ef033 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -142,10 +142,11 @@ class IFreqaiModel(ABC): gc.collect() dh.data = {} # clean the pair specific data between training window sliding self.training_timerange = tr_train + # self.training_timerange_timerange = tr_train dataframe_train = dh.slice_dataframe(tr_train, dataframe) dataframe_backtest = dh.slice_dataframe(tr_backtest, dataframe) logger.info("training %s for %s", metadata["pair"], tr_train) - trained_timestamp = TimeRange.parse_timerange(tr_train) + trained_timestamp = tr_train # TimeRange.parse_timerange(tr_train) dh.data_path = Path(dh.full_path / str("sub-train" + "-" + metadata['pair'].split("/")[0] + str(int(trained_timestamp.stopts)))) From cc6cae47ec53009922e941a6118cf555b0113ee6 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 29 May 2022 19:49:43 +0200 Subject: [PATCH 073/130] allow pairs deeper in the queue to get trained if the higher priority pairs dont need training --- freqtrade/freqai/freqai_interface.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index db66ef033..90d96ad87 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -348,7 +348,8 @@ class IFreqaiModel(ABC): # f'{sorted(self.data_drawer.pair_dict.items(), key=lambda item: item[1])}') dh.save_data(model, coin=metadata['pair']) - self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) + if self.data_drawer.pair_dict[metadata['pair']['priority']] == 1: + self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) self.training_on_separate_thread = False self.retrain = False return From 3f722632788f13e471b9ed5fe4f0301f38634d8d Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 29 May 2022 20:02:43 +0200 Subject: [PATCH 074/130] allow pairs deeper in the queue to get trained if the higher priority pairs dont need training --- freqtrade/freqai/freqai_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 90d96ad87..319240248 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -348,7 +348,7 @@ class IFreqaiModel(ABC): # f'{sorted(self.data_drawer.pair_dict.items(), key=lambda item: item[1])}') dh.save_data(model, coin=metadata['pair']) - if self.data_drawer.pair_dict[metadata['pair']['priority']] == 1: + if self.data_drawer.pair_dict[metadata['pair']]['priority'] == 1: self.data_drawer.pair_to_end_of_training_queue(metadata['pair']) self.training_on_separate_thread = False self.retrain = False From a79032bf75cf299c576ee7bf9f8cccbb29814c52 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 29 May 2022 20:19:32 +0200 Subject: [PATCH 075/130] fixing bug in training queue --- freqtrade/freqai/freqai_interface.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 319240248..c09200a30 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -190,7 +190,8 @@ class IFreqaiModel(ABC): trained_timestamp, coin_first) = self.data_drawer.get_pair_dict_info(metadata) - if not self.training_on_separate_thread: + if (not self.training_on_separate_thread and + self.data_drawer.pair_dict[metadata['pair']]['priority'] == 1): file_exists = False if trained_timestamp != 0: # historical model available From d59eac3321f83301079b5a7326096f04ecc66212 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Sun, 29 May 2022 21:33:38 +0200 Subject: [PATCH 076/130] revert a79032b --- freqtrade/freqai/freqai_interface.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index c09200a30..4d02afb69 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -190,8 +190,7 @@ class IFreqaiModel(ABC): trained_timestamp, coin_first) = self.data_drawer.get_pair_dict_info(metadata) - if (not self.training_on_separate_thread and - self.data_drawer.pair_dict[metadata['pair']]['priority'] == 1): + if (not self.training_on_separate_thread): file_exists = False if trained_timestamp != 0: # historical model available From 2f1a2c1cd7651ab7f5e4bb16ffc4e8083824ca56 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 30 May 2022 02:12:31 +0200 Subject: [PATCH 077/130] allow users to store data in custom formats, update spot config to reflect better target horizon to training period ratio --- config_examples/config_freqai_spot.example.json | 10 ++++++---- freqtrade/freqai/data_kitchen.py | 6 +++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/config_examples/config_freqai_spot.example.json b/config_examples/config_freqai_spot.example.json index e311fe280..e730335e9 100644 --- a/config_examples/config_freqai_spot.example.json +++ b/config_examples/config_freqai_spot.example.json @@ -7,6 +7,7 @@ "dry_run": true, "timeframe": "5m", "dry_run_wallet": 4000, + "dataformat_ohlcv": "hdf5", "cancel_open_orders_on_exit": true, "unfilledtimeout": { "entry": 10, @@ -24,7 +25,8 @@ "rateLimit": 200 }, "pair_whitelist": [ - "BTC/USDT" + "BTC/USDT", + "ETH/USDT" ], "pair_blacklist": [] }, @@ -39,7 +41,7 @@ } }, "exit_pricing": { - "price_side": "same", + "price_side": "other", "use_order_book": true, "order_book_top": 1 }, @@ -54,7 +56,7 @@ "15m", "4h" ], - "train_period": 30, + "train_period": 60, "backtest_period": 7, "identifier": "example", "live_trained_timestamp": 0, @@ -66,7 +68,7 @@ "SOL/USDT" ], "feature_parameters": { - "period": 20, + "period": 500, "shift": 1, "DI_threshold": 0, "weight_factor": 0.9, diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 08a00c68d..5ab066bfd 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -735,7 +735,7 @@ class FreqaiDataKitchen: exchange, pairs=pairs, timeframes=self.freqai_config.get('timeframes'), datadir=self.config['datadir'], timerange=timerange, new_pairs_days=self.config['new_pairs_days'], - erase=False, data_format=self.config['dataformat_ohlcv'], + erase=False, data_format=self.config.get('dataformat_ohlcv', 'json'), trading_mode=self.config.get('trading_mode', 'spot'), prepend=self.config.get('prepend_data', False) ) @@ -751,6 +751,8 @@ class FreqaiDataKitchen: base_dataframes[tf] = load_pair_history(datadir=self.config['datadir'], timeframe=tf, pair=metadata['pair'], timerange=timerange, + data_format=self.config.get( + 'dataformat_ohlcv', 'json'), candle_type=self.config.get( 'trading_mode', 'spot')) if pairs: @@ -762,6 +764,8 @@ class FreqaiDataKitchen: corr_dataframes[p][tf] = load_pair_history(datadir=self.config['datadir'], timeframe=tf, pair=p, timerange=timerange, + data_format=self.config.get( + 'dataformat_ohlcv', 'json'), candle_type=self.config.get( 'trading_mode', 'spot')) From a20651efd8f99bef142e1219b4c80d3f2f4283e2 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 30 May 2022 11:37:05 +0200 Subject: [PATCH 078/130] Increase performance by only predicting on most recent candle instead of full strat provided dataframe. Collect predictions and store them so that we can feed true predictions back to strategy (so that frequi isnt updating historic predictions based on newly trained models). --- freqtrade/freqai/data_drawer.py | 26 ++++++++++++++++++++++++++ freqtrade/freqai/data_kitchen.py | 2 +- freqtrade/freqai/freqai_interface.py | 12 ++++++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index 477f45d84..6a5393cb8 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -1,4 +1,5 @@ +import copy import json import logging from pathlib import Path @@ -24,6 +25,7 @@ class FreqaiDataDrawer: self.pair_dict: Dict[str, Any] = {} # dictionary holding all actively inferenced models in memory given a model filename self.model_dictionary: Dict[str, Any] = {} + self.model_return_values: Dict[str, Any] = {} self.pair_data_dict: Dict[str, Any] = {} self.full_path = full_path self.load_drawer_from_disk() @@ -83,3 +85,27 @@ class FreqaiDataDrawer: self.pair_dict[p]['priority'] -= 1 # send pair to end of queue self.pair_dict[pair]['priority'] = len(self.pair_dict) + + def set_initial_return_values(self, pair, dh): + self.model_return_values[pair] = {} + self.model_return_values[pair]['predictions'] = dh.full_predictions + self.model_return_values[pair]['do_preds'] = dh.full_do_predict + self.model_return_values[pair]['target_mean'] = dh.full_target_mean + self.model_return_values[pair]['target_std'] = dh.full_target_std + + def append_model_predictions(self, pair, predictions, do_preds, + target_mean, target_std, dh) -> None: + + pred_store = self.model_return_values[pair]['predictions'] + do_pred_store = self.model_return_values[pair]['do_preds'] + tm_store = self.model_return_values[pair]['target_mean'] + ts_store = self.model_return_values[pair]['target_std'] + pred_store = np.append(pred_store[1:], predictions[-1]) + do_pred_store = np.append(do_pred_store[1:], do_preds[-1]) + tm_store = np.append(tm_store[1:], target_mean) + ts_store = np.append(ts_store[1:], target_std) + + dh.full_predictions = copy.deepcopy(pred_store) + dh.full_do_predict = copy.deepcopy(do_pred_store) + dh.full_target_mean = copy.deepcopy(tm_store) + dh.full_target_std = copy.deepcopy(ts_store) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 5ab066bfd..2b8306b5c 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -623,7 +623,7 @@ class FreqaiDataKitchen: Append backtest prediction from current backtest period to all previous periods """ - ones = np.ones(len_dataframe) + ones = np.ones(len(predictions)) target_mean, target_std = ones * self.data["target_mean"], ones * self.data["target_std"] self.full_predictions = np.append(self.full_predictions, predictions) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 4d02afb69..386fab9fc 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -219,8 +219,16 @@ class IFreqaiModel(ABC): self.check_if_feature_list_matches_strategy(dataframe, dh) - preds, do_preds = self.predict(dataframe, dh) - dh.append_predictions(preds, do_preds, len(dataframe)) + if metadata['pair'] not in self.data_drawer.model_return_values: + preds, do_preds = self.predict(dataframe, dh) + dh.append_predictions(preds, do_preds, len(dataframe)) + dh.fill_predictions(len(dataframe)) + self.data_drawer.set_initial_return_values(metadata['pair'], dh) + else: + preds, do_preds = self.predict(dataframe.iloc[-2:], dh) + self.data_drawer.append_model_predictions(metadata['pair'], preds, do_preds, + self.dh.data["target_mean"], + self.dh.data["target_std"], dh) return dh From e229902381ae21107cf379170ca92acb0cc841a2 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 30 May 2022 12:48:22 +0200 Subject: [PATCH 079/130] fix bug in previous commit --- freqtrade/freqai/data_drawer.py | 5 +++-- freqtrade/freqai/freqai_interface.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index 6a5393cb8..5421db6cf 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -86,14 +86,15 @@ class FreqaiDataDrawer: # send pair to end of queue self.pair_dict[pair]['priority'] = len(self.pair_dict) - def set_initial_return_values(self, pair, dh): + def set_initial_return_values(self, pair: str, dh): + self.model_return_values[pair] = {} self.model_return_values[pair]['predictions'] = dh.full_predictions self.model_return_values[pair]['do_preds'] = dh.full_do_predict self.model_return_values[pair]['target_mean'] = dh.full_target_mean self.model_return_values[pair]['target_std'] = dh.full_target_std - def append_model_predictions(self, pair, predictions, do_preds, + def append_model_predictions(self, pair: str, predictions, do_preds, target_mean, target_std, dh) -> None: pred_store = self.model_return_values[pair]['predictions'] diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 386fab9fc..014b80208 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -227,8 +227,8 @@ class IFreqaiModel(ABC): else: preds, do_preds = self.predict(dataframe.iloc[-2:], dh) self.data_drawer.append_model_predictions(metadata['pair'], preds, do_preds, - self.dh.data["target_mean"], - self.dh.data["target_std"], dh) + dh.data["target_mean"], + dh.data["target_std"], dh) return dh From 5b4c649d434801af8485d36c68b89c31814db59f Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 30 May 2022 13:55:46 +0200 Subject: [PATCH 080/130] detect variable sized dataframes coming from strat, adjust our stored/returned data accordingly --- freqtrade/freqai/data_drawer.py | 48 ++++++++++++++++++++-------- freqtrade/freqai/freqai_interface.py | 3 +- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index 5421db6cf..fc816ea48 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -95,18 +95,40 @@ class FreqaiDataDrawer: self.model_return_values[pair]['target_std'] = dh.full_target_std def append_model_predictions(self, pair: str, predictions, do_preds, - target_mean, target_std, dh) -> None: + target_mean, target_std, dh, len_df) -> None: - pred_store = self.model_return_values[pair]['predictions'] - do_pred_store = self.model_return_values[pair]['do_preds'] - tm_store = self.model_return_values[pair]['target_mean'] - ts_store = self.model_return_values[pair]['target_std'] - pred_store = np.append(pred_store[1:], predictions[-1]) - do_pred_store = np.append(do_pred_store[1:], do_preds[-1]) - tm_store = np.append(tm_store[1:], target_mean) - ts_store = np.append(ts_store[1:], target_std) + # strat seems to feed us variable sized dataframes - and since we are trying to build our + # own return array in the same shape, we need to figure out how the size has changed + # and adapt our stored/returned info accordingly. + length_difference = len(self.model_return_values[pair]['predictions']) - len_df + i = 0 - dh.full_predictions = copy.deepcopy(pred_store) - dh.full_do_predict = copy.deepcopy(do_pred_store) - dh.full_target_mean = copy.deepcopy(tm_store) - dh.full_target_std = copy.deepcopy(ts_store) + if length_difference == 0: + i = 1 + elif length_difference > 0: + i = length_difference + 1 + + self.model_return_values[pair]['predictions'] = np.append( + self.model_return_values[pair]['predictions'][i:], predictions[-1]) + self.model_return_values[pair]['do_preds'] = np.append( + self.model_return_values[pair]['do_preds'][i:], do_preds[-1]) + self.model_return_values[pair]['target_mean'] = np.append( + self.model_return_values[pair]['target_mean'][i:], target_mean) + self.model_return_values[pair]['target_std'] = np.append( + self.model_return_values[pair]['target_std'][i:], target_std) + + if length_difference < 0: + prepend = np.zeros(abs(length_difference) - 1) + self.model_return_values[pair]['predictions'] = np.insert( + self.model_return_values[pair]['predictions'], 0, prepend) + self.model_return_values[pair]['do_preds'] = np.insert( + self.model_return_values[pair]['do_preds'], 0, prepend) + self.model_return_values[pair]['target_mean'] = np.insert( + self.model_return_values[pair]['target_mean'], 0, prepend) + self.model_return_values[pair]['target_std'] = np.insert( + self.model_return_values[pair]['target_std'], 0, prepend) + + dh.full_predictions = copy.deepcopy(self.model_return_values[pair]['predictions']) + dh.full_do_predict = copy.deepcopy(self.model_return_values[pair]['do_preds']) + dh.full_target_mean = copy.deepcopy(self.model_return_values[pair]['target_mean']) + dh.full_target_std = copy.deepcopy(self.model_return_values[pair]['target_std']) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 014b80208..2831475bb 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -228,7 +228,8 @@ class IFreqaiModel(ABC): preds, do_preds = self.predict(dataframe.iloc[-2:], dh) self.data_drawer.append_model_predictions(metadata['pair'], preds, do_preds, dh.data["target_mean"], - dh.data["target_std"], dh) + dh.data["target_std"], dh, + len(dataframe)) return dh From 606f18e5c1c9043ba0954e215652e33e2413dc88 Mon Sep 17 00:00:00 2001 From: robcaulk Date: Mon, 30 May 2022 21:35:48 +0200 Subject: [PATCH 081/130] Add follow_mode feature so that secondary bots can be launched with the same identifier and load models trained by the leader --- .../config_freqai_futures.example.json | 10 +++- .../config_freqai_spot.example.json | 2 +- docs/freqai.md | 20 ++++++- freqtrade/freqai/data_drawer.py | 58 +++++++++++++++++-- freqtrade/freqai/freqai_interface.py | 25 ++++++-- 5 files changed, 99 insertions(+), 16 deletions(-) diff --git a/config_examples/config_freqai_futures.example.json b/config_examples/config_freqai_futures.example.json index cb545acdc..5cd867e53 100644 --- a/config_examples/config_freqai_futures.example.json +++ b/config_examples/config_freqai_futures.example.json @@ -66,7 +66,7 @@ ], "train_period": 20, "backtest_period": 2, - "identifier": "example", + "identifier": "example2", "live_trained_timestamp": 0, "corr_pairlist": [ "BTC/USDT:USDT", @@ -86,8 +86,14 @@ "random_state": 1 }, "model_training_parameters": { - "n_estimators": 1000, + "n_estimators": 200, "task_type": "CPU" } + }, + "bot_name": "", + "force_entry_enable": true, + "initial_state": "running", + "internals": { + "process_throttle_secs": 5 } } diff --git a/config_examples/config_freqai_spot.example.json b/config_examples/config_freqai_spot.example.json index e730335e9..0b4d4e7c5 100644 --- a/config_examples/config_freqai_spot.example.json +++ b/config_examples/config_freqai_spot.example.json @@ -56,7 +56,7 @@ "15m", "4h" ], - "train_period": 60, + "train_period": 30, "backtest_period": 7, "identifier": "example", "live_trained_timestamp": 0, diff --git a/docs/freqai.md b/docs/freqai.md index d6998a8b6..c4ac30415 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -391,7 +391,7 @@ Freqai will train an SVM on the training data (or components if the user activat `principal_component_analysis`) and remove any data point that it deems to be sit beyond the feature space. -## Stratifying the data +### Stratifying the data The user can stratify the training/testing data using: @@ -403,10 +403,26 @@ The user can stratify the training/testing data using: } ``` -which will split the data chronologically so that every X data points is a testing data point. In the +which will split the data chronologically so that every Xth data points is a testing data point. In the present example, the user is asking for every third data point in the dataframe to be used for testing, the other points are used for training. +### Setting up a follower + +The user can define: + +```json + "freqai": { + "follow_mode": true, + "identifier": "example" + } +``` + +to indicate to the bot that it should not train models, but instead should look for models trained +by a leader with the same `identifier`. In this example, the user has a leader bot with the +`identifier: "example"` already running or launching simultaneously as the present follower. +The follower will load models created by the leader and inference them to obtain predictions. +