diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 195370339..47b9a9279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: strategy: matrix: os: [ ubuntu-18.04, ubuntu-20.04, ubuntu-22.04 ] - python-version: ["3.8", "3.9", "3.10.6"] + python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v3 @@ -74,7 +74,7 @@ jobs: if: matrix.python-version == '3.9' && matrix.os == 'ubuntu-22.04' - name: Coveralls - if: (runner.os == 'Linux' && matrix.python-version == '3.9') + if: (runner.os == 'Linux' && matrix.python-version == '3.10' && matrix.os == 'ubuntu-22.04') env: # Coveralls token. Not used as secret due to github not providing secrets to forked repositories COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu @@ -121,7 +121,7 @@ jobs: strategy: matrix: os: [ macos-latest ] - python-version: ["3.8", "3.9", "3.10.6"] + python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v3 @@ -205,7 +205,7 @@ jobs: strategy: matrix: os: [ windows-latest ] - python-version: ["3.8", "3.9", "3.10.6"] + python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v3 @@ -441,4 +441,4 @@ jobs: with: severity: info details: Deploy Succeeded! - webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} \ No newline at end of file + webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2cad0a7d3..7abe5659a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,8 +15,8 @@ repos: additional_dependencies: - types-cachetools==5.2.1 - types-filelock==3.2.7 - - types-requests==2.28.11 - - types-tabulate==0.8.11 + - types-requests==2.28.11.2 + - types-tabulate==0.9.0.0 - types-python-dateutil==2.8.19 # stages: [push] diff --git a/docs/assets/binance_futures_settings.png b/docs/assets/binance_futures_settings.png new file mode 100644 index 000000000..a3f7a2c70 Binary files /dev/null and b/docs/assets/binance_futures_settings.png differ diff --git a/docs/exchanges.md b/docs/exchanges.md index a9ba16c64..980d102b2 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -60,11 +60,18 @@ Binance supports [time_in_force](configuration.md#understand-order_time_in_force Binance supports `stoploss_on_exchange` and uses `stop-loss-limit` orders. It provides great advantages, so we recommend to benefit from it by enabling stoploss on exchange. On futures, Binance supports both `stop-limit` as well as `stop-market` orders. You can use either `"limit"` or `"market"` in the `order_types.stoploss` configuration setting to decide which type to use. -### Binance Blacklist +### Binance Blacklist recommendation For Binance, it is suggested to add `"BNB/"` to your blacklist to avoid issues, unless you are willing to maintain enough extra `BNB` on the account or unless you're willing to disable using `BNB` for fees. Binance accounts may use `BNB` for fees, and if a trade happens to be on `BNB`, further trades may consume this position and make the initial BNB trade unsellable as the expected amount is not there anymore. +### Binance sites + +Binance has been split into 2, and users must use the correct ccxt exchange ID for their exchange, otherwise API keys are not recognized. + +* [binance.com](https://www.binance.com/) - International users. Use exchange id: `binance`. +* [binance.us](https://www.binance.us/) - US based users. Use exchange id: `binanceus`. + ### Binance Futures Binance has specific (unfortunately complex) [Futures Trading Quantitative Rules](https://www.binance.com/en/support/faq/4f462ebe6ff445d4a170be7d9e897272) which need to be followed, and which prohibit a too low stake-amount (among others) for too many orders. @@ -87,12 +94,14 @@ When trading on Binance Futures market, orderbook must be used because there is }, ``` -### Binance sites +#### Binance futures settings -Binance has been split into 2, and users must use the correct ccxt exchange ID for their exchange, otherwise API keys are not recognized. +Users will also have to have the futures-setting "Position Mode" set to "One-way Mode", and "Asset Mode" set to "Single-Asset Mode". +These settings will be checked on startup, and freqtrade will show an error if this setting is wrong. -* [binance.com](https://www.binance.com/) - International users. Use exchange id: `binance`. -* [binance.us](https://www.binance.us/) - US based users. Use exchange id: `binanceus`. +![Binance futures settings](assets/binance_futures_settings.png) + +Freqtrade will not attempt to change these settings. ## Kraken diff --git a/docs/freqai-configuration.md b/docs/freqai-configuration.md index 50e75b658..d24c60057 100644 --- a/docs/freqai-configuration.md +++ b/docs/freqai-configuration.md @@ -1,10 +1,10 @@ # Configuration -`FreqAI` is configured through the typical [Freqtrade config file](configuration.md) and the standard [Freqtrade strategy](strategy-customization.md). Examples of `FreqAI` config and strategy files can be found in `config_examples/config_freqai.example.json` and `freqtrade/templates/FreqaiExampleStrategy.py`, respectively. +FreqAI is configured through the typical [Freqtrade config file](configuration.md) and the standard [Freqtrade strategy](strategy-customization.md). Examples of FreqAI config and strategy files can be found in `config_examples/config_freqai.example.json` and `freqtrade/templates/FreqaiExampleStrategy.py`, respectively. ## Setting up the configuration file - Although there are plenty of additional parameters to choose from, as highlighted in the [parameter table](freqai-parameter-table.md#parameter-table), a `FreqAI` config must at minimum include the following parameters (the parameter values are only examples): + Although there are plenty of additional parameters to choose from, as highlighted in the [parameter table](freqai-parameter-table.md#parameter-table), a FreqAI config must at minimum include the following parameters (the parameter values are only examples): ```json "freqai": { @@ -35,9 +35,9 @@ A full example config is available in `config_examples/config_freqai.example.json`. -## Building a `FreqAI` strategy +## Building a FreqAI strategy -The `FreqAI` strategy requires including the following lines of code in the standard [Freqtrade strategy](strategy-customization.md): +The FreqAI strategy requires including the following lines of code in the standard [Freqtrade strategy](strategy-customization.md): ```python # user should define the maximum startup candle count (the largest number of candles @@ -129,7 +129,7 @@ Notice also the location of the labels under `if set_generalized_indicators:` at The `self.freqai.start()` function cannot be called outside the `populate_indicators()`. !!! Note - Features **must** be defined in `populate_any_indicators()`. Defining `FreqAI` features in `populate_indicators()` + Features **must** be defined in `populate_any_indicators()`. Defining FreqAI features in `populate_indicators()` will cause the algorithm to fail in live/dry mode. In order to add generalized features that are not associated with a specific pair or timeframe, the following structure inside `populate_any_indicators()` should be used (as exemplified in `freqtrade/templates/FreqaiExampleStrategy.py`): @@ -166,15 +166,15 @@ Below are the values you can expect to include/use inside a typical strategy dat | DataFrame Key | Description | |------------|-------------| -| `df['&*']` | Any dataframe column prepended with `&` in `populate_any_indicators()` is treated as a training target (label) inside `FreqAI` (typically following the naming convention `&-s*`). The names of these dataframe columns are fed back as the predictions. For example, to predict the price change in the next 40 candles (similar to `templates/FreqaiExampleStrategy.py`), you would set `df['&-s_close']`. `FreqAI` makes the predictions and gives them back under the same key (`df['&-s_close']`) to be used in `populate_entry/exit_trend()`.
**Datatype:** Depends on the output of the model. +| `df['&*']` | Any dataframe column prepended with `&` in `populate_any_indicators()` is treated as a training target (label) inside FreqAI (typically following the naming convention `&-s*`). For example, to predict the close price 40 candles into the future, you would set `df['&-s_close'] = df['close'].shift(-self.freqai_info["feature_parameters"]["label_period_candles"])` with `"label_period_candles": 40` in the config. FreqAI makes the predictions and gives them back under the same key (`df['&-s_close']`) to be used in `populate_entry/exit_trend()`.
**Datatype:** Depends on the output of the model. | `df['&*_std/mean']` | Standard deviation and mean values of the defined labels during training (or live tracking with `fit_live_predictions_candles`). Commonly used to understand the rarity of a prediction (use the z-score as shown in `templates/FreqaiExampleStrategy.py` and explained [here](#creating-a-dynamic-target-threshold) to evaluate how often a particular prediction was observed during training or historically with `fit_live_predictions_candles`).
**Datatype:** Float. -| `df['do_predict']` | Indication of an outlier data point. The return value is integer between -1 and 2, which lets you know if the prediction is trustworthy or not. `do_predict==1` means that the prediction is trustworthy. If the Dissimilarity Index (DI, see details [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di)) of the input data point is above the threshold defined in the config, `FreqAI` will subtract 1 from `do_predict`, resulting in `do_predict==0`. If `use_SVM_to_remove_outliers()` is active, the Support Vector Machine (SVM, see details [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm)) may also detect outliers in training and prediction data. In this case, the SVM will also subtract 1 from `do_predict`. If the input data point was considered an outlier by the SVM but not by the DI, or vice versa, the result will be `do_predict==0`. If both the DI and the SVM considers the input data point to be an outlier, the result will be `do_predict==-1`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`.
**Datatype:** Integer between -1 and 2. -| `df['DI_values']` | Dissimilarity Index (DI) values are proxies for the level of confidence `FreqAI` has in the prediction. A lower DI means the prediction is close to the training data, i.e., higher prediction confidence. See details about the DI [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di).
**Datatype:** Float. -| `df['%*']` | Any dataframe column prepended with `%` in `populate_any_indicators()` is treated as a training feature. For example, you can include the RSI in the training feature set (similar to in `templates/FreqaiExampleStrategy.py`) by setting `df['%-rsi']`. See more details on how this is done [here](freqai-feature-engineering.md).
**Note:** Since the number of features prepended with `%` can multiply very quickly (10s of thousands of features is easily engineered using the multiplictative functionality described in the `feature_parameters` table shown above), these features are removed from the dataframe upon return from `FreqAI`. To keep a particular type of feature for plotting purposes, you would prepend it with `%%`.
**Datatype:** Depends on the output of the model. +| `df['do_predict']` | Indication of an outlier data point. The return value is integer between -2 and 2, which lets you know if the prediction is trustworthy or not. `do_predict==1` means that the prediction is trustworthy. If the Dissimilarity Index (DI, see details [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di)) of the input data point is above the threshold defined in the config, FreqAI will subtract 1 from `do_predict`, resulting in `do_predict==0`. If `use_SVM_to_remove_outliers()` is active, the Support Vector Machine (SVM, see details [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm)) may also detect outliers in training and prediction data. In this case, the SVM will also subtract 1 from `do_predict`. If the input data point was considered an outlier by the SVM but not by the DI, or vice versa, the result will be `do_predict==0`. If both the DI and the SVM considers the input data point to be an outlier, the result will be `do_predict==-1`. As with the SVM, if `use_DBSCAN_to_remove_outliers` is active, DBSCAN (see details [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan)) may also detect outliers and subtract 1 from `do_predict`. Hence, if both the SVM and DBSCAN are active and identify a datapoint that was above the DI threshold as an outlier, the result will be `do_predict==-2`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`.
**Datatype:** Integer between -2 and 2. +| `df['DI_values']` | Dissimilarity Index (DI) values are proxies for the level of confidence FreqAI has in the prediction. A lower DI means the prediction is close to the training data, i.e., higher prediction confidence. See details about the DI [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di).
**Datatype:** Float. +| `df['%*']` | Any dataframe column prepended with `%` in `populate_any_indicators()` is treated as a training feature. For example, you can include the RSI in the training feature set (similar to in `templates/FreqaiExampleStrategy.py`) by setting `df['%-rsi']`. See more details on how this is done [here](freqai-feature-engineering.md).
**Note:** Since the number of features prepended with `%` can multiply very quickly (10s of thousands of features are easily engineered using the multiplictative functionality of, e.g., `include_shifted_candles` and `include_timeframes` as described in the [parameter table](freqai-parameter-table.md)), these features are removed from the dataframe that is returned from FreqAI to the strategy. To keep a particular type of feature for plotting purposes, you would prepend it with `%%`.
**Datatype:** Depends on the output of the model. ## Setting the `startup_candle_count` -The `startup_candle_count` in the `FreqAI` strategy needs to be set up in the same way as in the standard Freqtrade strategy (see details [here](strategy-customization.md#strategy-startup-period)). This value is used by Freqtrade to ensure that a sufficient amount of data is provided when calling the `dataprovider`, to avoid any NaNs at the beginning of the first training. You can easily set this value by identifying the longest period (in candle units) which is passed to the indicator creation functions (e.g., Ta-Lib functions). In the presented example, `startup_candle_count` is 20 since this is the maximum value in `indicators_periods_candles`. +The `startup_candle_count` in the FreqAI strategy needs to be set up in the same way as in the standard Freqtrade strategy (see details [here](strategy-customization.md#strategy-startup-period)). This value is used by Freqtrade to ensure that a sufficient amount of data is provided when calling the `dataprovider`, to avoid any NaNs at the beginning of the first training. You can easily set this value by identifying the longest period (in candle units) which is passed to the indicator creation functions (e.g., Ta-Lib functions). In the presented example, `startup_candle_count` is 20 since this is the maximum value in `indicators_periods_candles`. !!! Note There are instances where the Ta-Lib functions actually require more data than just the passed `period` or else the feature dataset gets populated with NaNs. Anecdotally, multiplying the `startup_candle_count` by 2 always leads to a fully NaN free training dataset. Hence, it is typically safest to multiply the expected `startup_candle_count` by 2. Look out for this log message to confirm that the data is clean: @@ -185,7 +185,7 @@ The `startup_candle_count` in the `FreqAI` strategy needs to be set up in the sa ## Creating a dynamic target threshold -Deciding when to enter or exit a trade can be done in a dynamic way to reflect current market conditions. `FreqAI` allows you to return additional information from the training of a model (more info [here](freqai-feature-engineering.md#returning-additional-info-from-training)). For example, the `&*_std/mean` return values describe the statistical distribution of the target/label *during the most recent training*. Comparing a given prediction to these values allows you to know the rarity of the prediction. In `templates/FreqaiExampleStrategy.py`, the `target_roi` and `sell_roi` are defined to be 1.25 z-scores away from the mean which causes predictions that are closer to the mean to be filtered out. +Deciding when to enter or exit a trade can be done in a dynamic way to reflect current market conditions. FreqAI allows you to return additional information from the training of a model (more info [here](freqai-feature-engineering.md#returning-additional-info-from-training)). For example, the `&*_std/mean` return values describe the statistical distribution of the target/label *during the most recent training*. Comparing a given prediction to these values allows you to know the rarity of the prediction. In `templates/FreqaiExampleStrategy.py`, the `target_roi` and `sell_roi` are defined to be 1.25 z-scores away from the mean which causes predictions that are closer to the mean to be filtered out. ```python dataframe["target_roi"] = dataframe["&-s_close_mean"] + dataframe["&-s_close_std"] * 1.25 @@ -200,15 +200,15 @@ To consider the population of *historical predictions* for creating the dynamic } ``` -If this value is set, `FreqAI` will initially use the predictions from the training data and subsequently begin introducing real prediction data as it is generated. `FreqAI` will save this historical data to be reloaded if you stop and restart a model with the same `identifier`. +If this value is set, FreqAI will initially use the predictions from the training data and subsequently begin introducing real prediction data as it is generated. FreqAI will save this historical data to be reloaded if you stop and restart a model with the same `identifier`. ## Using different prediction models -`FreqAI` has multiple example prediction model libraries that are ready to be used as is via the flag `--freqaimodel`. These libraries include `Catboost`, `LightGBM`, and `XGBoost` regression, classification, and multi-target models, and can be found in `freqai/prediction_models/`. However, it is possible to customize and create your own prediction models using the `IFreqaiModel` class. You are encouraged to inherit `fit()`, `train()`, and `predict()` to let these customize various aspects of the training procedures. +FreqAI has multiple example prediction model libraries that are ready to be used as is via the flag `--freqaimodel`. These libraries include `Catboost`, `LightGBM`, and `XGBoost` regression, classification, and multi-target models, and can be found in `freqai/prediction_models/`. However, it is possible to customize and create your own prediction models using the `IFreqaiModel` class. You are encouraged to inherit `fit()`, `train()`, and `predict()` to let these customize various aspects of the training procedures. ### Setting classifier targets -`FreqAI` includes a variety of classifiers, such as the `CatboostClassifier` via the flag `--freqaimodel CatboostClassifier`. If you elects to use a classifier, the classes need to be set using strings. For example: +FreqAI includes a variety of classifiers, such as the `CatboostClassifier` via the flag `--freqaimodel CatboostClassifier`. If you elects to use a classifier, the classes need to be set using strings. For example: ```python df['&s-up_or_down'] = np.where( df["close"].shift(-100) > df["close"], 'up', 'down') diff --git a/docs/freqai-developers.md b/docs/freqai-developers.md index 4bff46f2f..37f4543b7 100644 --- a/docs/freqai-developers.md +++ b/docs/freqai-developers.md @@ -2,13 +2,13 @@ ## Project architecture -The architecture and functions of `FreqAI` are generalized to encourages development of unique features, functions, models, etc. +The architecture and functions of FreqAI are generalized to encourages development of unique features, functions, models, etc. The class structure and a detailed algorithmic overview is depicted in the following diagram: ![image](assets/freqai_algorithm-diagram.jpg) -As shown, there are three distinct objects comprising `FreqAI`: +As shown, there are three distinct objects comprising FreqAI: * **IFreqaiModel** - A singular persistent object containing all the necessary logic to collect, store, and process data, engineer features, run training, and inference models. * **FreqaiDataKitchen** - A non-persistent object which is created uniquely for each unique asset/model. Beyond metadata, it also contains a variety of data processing tools. @@ -18,7 +18,7 @@ There are a variety of built-in [prediction models](freqai-configuration.md#usin ## Data handling -`FreqAI` aims to organize model files, prediction data, and meta data in a way that simplifies post-processing and enhances crash resilience by automatic data reloading. The data is saved in a file structure,`user_data_dir/models/`, which contains all the data associated with the trainings and backtests. The `FreqaiDataKitchen()` relies heavily on the file structure for proper training and inferencing and should therefore not be manually modified. +FreqAI aims to organize model files, prediction data, and meta data in a way that simplifies post-processing and enhances crash resilience by automatic data reloading. The data is saved in a file structure,`user_data_dir/models/`, which contains all the data associated with the trainings and backtests. The `FreqaiDataKitchen()` relies heavily on the file structure for proper training and inferencing and should therefore not be manually modified. ### File structure @@ -27,13 +27,13 @@ The file structure is automatically generated based on the model `identifier` se | Structure | Description | |-----------|-------------| | `config_*.json` | A copy of the model specific configuration file. | -| `historic_predictions.pkl` | A file containing all historic predictions generated during the lifetime of the `identifier` model during live deployment. `historic_predictions.pkl` is used to reload the model after a crash or a config change. A backup file is always held incase of corruption on the main file. **`FreqAI` automatically detects corruption and replaces the corrupted file with the backup**. | +| `historic_predictions.pkl` | A file containing all historic predictions generated during the lifetime of the `identifier` model during live deployment. `historic_predictions.pkl` is used to reload the model after a crash or a config change. A backup file is always held in case of corruption on the main file. FreqAI **automatically** detects corruption and replaces the corrupted file with the backup. | | `pair_dictionary.json` | A file containing the training queue as well as the on disk location of the most recently trained model. | | `sub-train-*_TIMESTAMP` | A folder containing all the files associated with a single model, such as:
-|| `*_metadata.json` - Metadata for the model, such as normalization max/mins, expected training feature list, etc.
+|| `*_metadata.json` - Metadata for the model, such as normalization max/min, expected training feature list, etc.
|| `*_model.*` - The model file saved to disk for reloading from a crash. Can be `joblib` (typical boosting libs), `zip` (stable_baselines), `hd5` (keras type), etc.
-|| `*_pca_object.pkl` - The [Principal component analysis (PCA)](freqai-feature-engineering.md#data-dimensionality-reduction-with-principal-component-analysis) transform (if `principal_component_analysis: true` is set in the config) which will be used to transform unseen prediction features.
-|| `*_svm_model.pkl` - The [Support Vector Machine (SVM)](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm) model which is used to detect outliers in unseen prediction features.
+|| `*_pca_object.pkl` - The [Principal component analysis (PCA)](freqai-feature-engineering.md#data-dimensionality-reduction-with-principal-component-analysis) transform (if `principal_component_analysis: True` is set in the config) which will be used to transform unseen prediction features.
+|| `*_svm_model.pkl` - The [Support Vector Machine (SVM)](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm) model (if `use_SVM_to_remove_outliers: True` is set in the config) which is used to detect outliers in unseen prediction features.
|| `*_trained_df.pkl` - The dataframe containing all the training features used to train the `identifier` model. This is used for computing the [Dissimilarity Index (DI)](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di) and can also be used for post-processing.
|| `*_trained_dates.df.pkl` - The dates associated with the `trained_df.pkl`, which is useful for post-processing. | diff --git a/docs/freqai-feature-engineering.md b/docs/freqai-feature-engineering.md index 8f061b9fd..b7c23aa60 100644 --- a/docs/freqai-feature-engineering.md +++ b/docs/freqai-feature-engineering.md @@ -4,7 +4,7 @@ Low level feature engineering is performed in the user strategy within a function called `populate_any_indicators()`. That function sets the `base features` such as, `RSI`, `MFI`, `EMA`, `SMA`, time of day, volume, etc. The `base features` can be custom indicators or they can be imported from any technical-analysis library that you can find. One important syntax rule is that all `base features` string names are prepended with `%`, while labels/targets are prepended with `&`. -Meanwhile, high level feature engineering is handled within `"feature_parameters":{}` in the `FreqAI` config. Within this file, it is possible to decide large scale feature expansions on top of the `base_features` such as "including correlated pairs" or "including informative timeframes" or even "including recent candles." +Meanwhile, high level feature engineering is handled within `"feature_parameters":{}` in the FreqAI config. Within this file, it is possible to decide large scale feature expansions on top of the `base_features` such as "including correlated pairs" or "including informative timeframes" or even "including recent candles." It is advisable to start from the template `populate_any_indicators()` in the source provided example strategy (found in `templates/FreqaiExampleStrategy.py`) to ensure that the feature definitions are following the correct conventions. Here is an example of how to set the indicators and labels in the strategy: @@ -122,7 +122,7 @@ The `include_timeframes` in the config above are the timeframes (`tf`) of each c You can ask for each of the defined features to be included also for informative pairs using the `include_corr_pairlist`. This means that the feature set will include all the features from `populate_any_indicators` on all the `include_timeframes` for each of the correlated pairs defined in the config (`ETH/USD`, `LINK/USD`, and `BNB/USD` in the presented example). -`include_shifted_candles` indicates the number of previous candles to include in the feature set. For example, `include_shifted_candles: 2` tells `FreqAI` to include the past 2 candles for each of the features in the feature set. +`include_shifted_candles` indicates the number of previous candles to include in the feature set. For example, `include_shifted_candles: 2` tells FreqAI to include the past 2 candles for each of the features in the feature set. In total, the number of features the user of the presented example strat has created is: length of `include_timeframes` * no. features in `populate_any_indicators()` * length of `include_corr_pairlist` * no. `include_shifted_candles` * length of `indicator_periods_candles` $= 3 * 3 * 3 * 2 * 2 = 108$. @@ -131,7 +131,7 @@ In total, the number of features the user of the presented example strat has cre Important metrics can be returned to the strategy at the end of each model training by assigning them to `dk.data['extra_returns_per_train']['my_new_value'] = XYZ` inside the custom prediction model class. -`FreqAI` takes the `my_new_value` assigned in this dictionary and expands it to fit the dataframe that is returned to the strategy. You can then use the returned metrics in your strategy through `dataframe['my_new_value']`. An example of how return values can be used in `FreqAI` are the `&*_mean` and `&*_std` values that are used to [created a dynamic target threshold](freqai-configuration.md#creating-a-dynamic-target-threshold). +FreqAI takes the `my_new_value` assigned in this dictionary and expands it to fit the dataframe that is returned to the strategy. You can then use the returned metrics in your strategy through `dataframe['my_new_value']`. An example of how return values can be used in FreqAI are the `&*_mean` and `&*_std` values that are used to [created a dynamic target threshold](freqai-configuration.md#creating-a-dynamic-target-threshold). Another example, where the user wants to use live metrics from the trade database, is shown below: @@ -141,15 +141,15 @@ Another example, where the user wants to use live metrics from the trade databas } ``` -You need to set the standard dictionary in the config so that `FreqAI` can return proper dataframe shapes. These values will likely be overridden by the prediction model, but in the case where the model has yet to set them, or needs a default initial value, the preset values are what will be returned. +You need to set the standard dictionary in the config so that FreqAI can return proper dataframe shapes. These values will likely be overridden by the prediction model, but in the case where the model has yet to set them, or needs a default initial value, the pre-set values are what will be returned. ## Feature normalization -`FreqAI` is strict when it comes to data normalization. The train features, $X^{train}$, are always normalized to [-1, 1] using a shifted min-max normalization: +FreqAI is strict when it comes to data normalization. The train features, $X^{train}$, are always normalized to [-1, 1] using a shifted min-max normalization: $$X^{train}_{norm} = 2 * \frac{X^{train} - X^{train}.min()}{X^{train}.max() - X^{train}.min()} - 1$$ -All other data (test data and unseen prediction data in dry/live/backtest) is always automatically normalized to the training feature space according to industry standards. `FreqAI` stores all the metadata required to ensure that test and prediction features will be properly normalized and that predictions are properly denormalized. For this reason, it is not recommended to eschew industry standards and modify `FreqAI` internals - however - advanced users can do so by inheriting `train()` in their custom `IFreqaiModel` and using their own normalization functions. +All other data (test data and unseen prediction data in dry/live/backtest) is always automatically normalized to the training feature space according to industry standards. FreqAI stores all the metadata required to ensure that test and prediction features will be properly normalized and that predictions are properly denormalized. For this reason, it is not recommended to eschew industry standards and modify FreqAI internals - however - advanced users can do so by inheriting `train()` in their custom `IFreqaiModel` and using their own normalization functions. ## Data dimensionality reduction with Principal Component Analysis @@ -169,17 +169,17 @@ This will perform PCA on the features and reduce their dimensionality so that th The `inlier_metric` is a metric aimed at quantifying how similar a the features of a data point are to the most recent historic data points. -You define the lookback window by setting `inlier_metric_window` and `FreqAI` computes the distance between the present time point and each of the previous `inlier_metric_window` lookback points. A Weibull function is fit to each of the lookback distributions and its cumulative distribution function (CDF) is used to produce a quantile for each lookback point. The `inlier_metric` is then computed for each time point as the average of the corresponding lookback quantiles. The figure below explains the concept for an `inlier_metric_window` of 5. +You define the lookback window by setting `inlier_metric_window` and FreqAI computes the distance between the present time point and each of the previous `inlier_metric_window` lookback points. A Weibull function is fit to each of the lookback distributions and its cumulative distribution function (CDF) is used to produce a quantile for each lookback point. The `inlier_metric` is then computed for each time point as the average of the corresponding lookback quantiles. The figure below explains the concept for an `inlier_metric_window` of 5. ![inlier-metric](assets/freqai_inlier-metric.jpg) -`FreqAI` adds the `inlier_metric` to the training features and hence gives the model access to a novel type of temporal information. +FreqAI adds the `inlier_metric` to the training features and hence gives the model access to a novel type of temporal information. This function does **not** remove outliers from the data set. ## Weighting features for temporal importance -`FreqAI` allows you to set a `weight_factor` to weight recent data more strongly than past data via an exponential function: +FreqAI allows you to set a `weight_factor` to weight recent data more strongly than past data via an exponential function: $$ W_i = \exp(\frac{-i}{\alpha*n}) $$ @@ -189,13 +189,13 @@ where $W_i$ is the weight of data point $i$ in a total set of $n$ data points. B ## Outlier detection -Equity and crypto markets suffer from a high level of non-patterned noise in the form of outlier data points. `FreqAI` implements a variety of methods to identify such outliers and hence mitigate risk. +Equity and crypto markets suffer from a high level of non-patterned noise in the form of outlier data points. FreqAI implements a variety of methods to identify such outliers and hence mitigate risk. ### Identifying outliers with the Dissimilarity Index (DI) The Dissimilarity Index (DI) aims to quantify the uncertainty associated with each prediction made by the model. -You can tell `FreqAI` to remove outlier data points from the training/test data sets using the DI by including the following statement in the config: +You can tell FreqAI to remove outlier data points from the training/test data sets using the DI by including the following statement in the config: ```json "freqai": { @@ -205,7 +205,7 @@ You can tell `FreqAI` to remove outlier data points from the training/test data } ``` - The DI allows predictions which are outliers (not existent in the model feature space) to be thrown out due to low levels of certainty. To do so, `FreqAI` measures the distance between each training data point (feature vector), $X_{a}$, and all other training data points: + The DI allows predictions which are outliers (not existent in the model feature space) to be thrown out due to low levels of certainty. To do so, FreqAI measures the distance between each training data point (feature vector), $X_{a}$, and all other training data points: $$ d_{ab} = \sqrt{\sum_{j=1}^p(X_{a,j}-X_{b,j})^2} $$ @@ -229,7 +229,7 @@ Below is a figure that describes the DI for a 3D data set. ### Identifying outliers using a Support Vector Machine (SVM) -You can tell `FreqAI` to remove outlier data points from the training/test data sets using a Support Vector Machine (SVM) by including the following statement in the config: +You can tell FreqAI to remove outlier data points from the training/test data sets using a Support Vector Machine (SVM) by including the following statement in the config: ```json "freqai": { @@ -241,7 +241,7 @@ You can tell `FreqAI` to remove outlier data points from the training/test data The SVM will be trained on the training data and any data point that the SVM deems to be beyond the feature space will be removed. -`FreqAI` uses `sklearn.linear_model.SGDOneClassSVM` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDOneClassSVM.html) (external website)) and you can elect to provide additional parameters for the SVM, such as `shuffle`, and `nu`. +FreqAI uses `sklearn.linear_model.SGDOneClassSVM` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDOneClassSVM.html) (external website)) and you can elect to provide additional parameters for the SVM, such as `shuffle`, and `nu`. The parameter `shuffle` is by default set to `False` to ensure consistent results. If it is set to `True`, running the SVM multiple times on the same data set might result in different outcomes due to `max_iter` being to low for the algorithm to reach the demanded `tol`. Increasing `max_iter` solves this issue but causes the procedure to take longer time. @@ -249,7 +249,7 @@ The parameter `nu`, *very* broadly, is the amount of data points that should be ### Identifying outliers with DBSCAN -You can configure `FreqAI` to use DBSCAN to cluster and remove outliers from the training/test data set or incoming outliers from predictions, by activating `use_DBSCAN_to_remove_outliers` in the config: +You can configure FreqAI to use DBSCAN to cluster and remove outliers from the training/test data set or incoming outliers from predictions, by activating `use_DBSCAN_to_remove_outliers` in the config: ```json "freqai": { @@ -265,4 +265,4 @@ Given a number of data points $N$, and a distance $\varepsilon$, DBSCAN clusters ![dbscan](assets/freqai_dbscan.jpg) -`FreqAI` uses `sklearn.cluster.DBSCAN` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html) (external website)) with `min_samples` ($N$) taken as 1/4 of the no. of time points in the feature set. `eps` ($\varepsilon$) is computed automatically as the elbow point in the *k-distance graph* computed from the nearest neighbors in the pairwise distances of all data points in the feature set. +FreqAI uses `sklearn.cluster.DBSCAN` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html) (external website)) with `min_samples` ($N$) taken as 1/4 of the no. of time points (candles) in the feature set. `eps` ($\varepsilon$) is computed automatically as the elbow point in the *k-distance graph* computed from the nearest neighbors in the pairwise distances of all data points in the feature set. diff --git a/docs/freqai-parameter-table.md b/docs/freqai-parameter-table.md index 5969f43c6..38d7ece94 100644 --- a/docs/freqai-parameter-table.md +++ b/docs/freqai-parameter-table.md @@ -1,18 +1,18 @@ # Parameter table -The table below will list all configuration parameters available for `FreqAI`. Some of the parameters are exemplified in `config_examples/config_freqai.example.json`. +The table below will list all configuration parameters available for FreqAI. Some of the parameters are exemplified in `config_examples/config_freqai.example.json`. Mandatory parameters are marked as **Required** and have to be set in one of the suggested ways. | Parameter | Description | |------------|-------------| | | **General configuration parameters** -| `freqai` | **Required.**
The parent dictionary containing all the parameters for controlling `FreqAI`.
**Datatype:** Dictionary. +| `freqai` | **Required.**
The parent dictionary containing all the parameters for controlling FreqAI.
**Datatype:** Dictionary. | `train_period_days` | **Required.**
Number of days to use for the training data (width of the sliding window).
**Datatype:** Positive integer. | `backtest_period_days` | **Required.**
Number of days to inference from the trained model before sliding the `train_period_days` window defined above, and retraining the model during backtesting (more info [here](freqai-running.md#backtesting)). This can be fractional days, but beware that the provided `timerange` will be divided by this number to yield the number of trainings necessary to complete the backtest.
**Datatype:** Float. | `identifier` | **Required.**
A unique ID for the current model. If models are saved to disk, the `identifier` allows for reloading specific pre-trained models/data.
**Datatype:** String. -| `live_retrain_hours` | Frequency of retraining during dry/live runs.
**Datatype:** Float > 0.
Default: 0 (models retrain as often as possible). -| `expiration_hours` | Avoid making predictions if a model is more than `expiration_hours` old.
**Datatype:** Positive integer.
Default: 0 (models never expire). +| `live_retrain_hours` | Frequency of retraining during dry/live runs.
**Datatype:** Float > 0.
Default: `0` (models retrain as often as possible). +| `expiration_hours` | Avoid making predictions if a model is more than `expiration_hours` old.
**Datatype:** Positive integer.
Default: `0` (models never expire). | `purge_old_models` | Delete obsolete models.
**Datatype:** Boolean.
Default: `False` (all historic models remain on disk). | `save_backtest_models` | Save models to disk when running backtesting. Backtesting operates most efficiently by saving the prediction data and reusing them directly for subsequent runs (when you wish to tune entry/exit parameters). Saving backtesting models to disk also allows to use the same model files for starting a dry/live instance with the same model `identifier`.
**Datatype:** Boolean.
Default: `False` (no models are saved). | `fit_live_predictions_candles` | Number of historical candles to use for computing target (label) statistics from prediction data, instead of from the training dataset (more information can be found [here](freqai-configuration.md#creating-a-dynamic-target-threshold)).
**Datatype:** Positive integer. @@ -21,32 +21,31 @@ Mandatory parameters are marked as **Required** and have to be set in one of the | | **Feature parameters** | `feature_parameters` | A dictionary containing the parameters used to engineer the feature set. Details and examples are shown [here](freqai-feature-engineering.md).
**Datatype:** Dictionary. | `include_timeframes` | A list of timeframes that all indicators in `populate_any_indicators` will be created for. The list is added as features to the base indicators dataset.
**Datatype:** List of timeframes (strings). -| `include_corr_pairlist` | A list of correlated coins that `FreqAI` will add as additional features to all `pair_whitelist` coins. All indicators set in `populate_any_indicators` during feature engineering (see details [here](freqai-feature-engineering.md)) will be created for each correlated coin. The correlated coins features are added to the base indicators dataset.
**Datatype:** List of assets (strings). +| `include_corr_pairlist` | A list of correlated coins that FreqAI will add as additional features to all `pair_whitelist` coins. All indicators set in `populate_any_indicators` during feature engineering (see details [here](freqai-feature-engineering.md)) will be created for each correlated coin. The correlated coins features are added to the base indicators dataset.
**Datatype:** List of assets (strings). | `label_period_candles` | Number of candles into the future that the labels are created for. This is used in `populate_any_indicators` (see `templates/FreqaiExampleStrategy.py` for detailed usage). You can create custom labels and choose whether to make use of this parameter or not.
**Datatype:** Positive integer. -| `include_shifted_candles` | Add features from previous candles to subsequent candles with the intent of adding historical information. If used, `FreqAI` will duplicate and shift all features from the `include_shifted_candles` previous candles so that the information is available for the subsequent candle.
**Datatype:** Positive integer. +| `include_shifted_candles` | Add features from previous candles to subsequent candles with the intent of adding historical information. If used, FreqAI will duplicate and shift all features from the `include_shifted_candles` previous candles so that the information is available for the subsequent candle.
**Datatype:** Positive integer. | `weight_factor` | Weight training data points according to their recency (see details [here](freqai-feature-engineering.md#weighting-features-for-temporal-importance)).
**Datatype:** Positive float (typically < 1). -| `indicator_max_period_candles` | **No longer used (#7325)**. Replaced by `startup_candle_count` which is set in the [strategy](freqai-configuration.md#building-a-freqai-strategy). `startup_candle_count` is timeframe independent and defines the maximum *period* used in `populate_any_indicators()` for indicator creation. `FreqAI` uses this parameter together with the maximum timeframe in `include_time_frames` to calculate how many data points to download such that the first data point does not include a NaN
**Datatype:** Positive integer. +| `indicator_max_period_candles` | **No longer used (#7325)**. Replaced by `startup_candle_count` which is set in the [strategy](freqai-configuration.md#building-a-freqai-strategy). `startup_candle_count` is timeframe independent and defines the maximum *period* used in `populate_any_indicators()` for indicator creation. FreqAI uses this parameter together with the maximum timeframe in `include_time_frames` to calculate how many data points to download such that the first data point does not include a NaN.
**Datatype:** Positive integer. | `indicator_periods_candles` | Time periods to calculate indicators for. The indicators are added to the base indicator dataset.
**Datatype:** List of positive integers. -| `stratify_training_data` | Split the feature set into training and testing datasets. For example, `stratify_training_data: 2` would set every 2nd data point into a separate dataset to be pulled from during training/testing. See details about how it works [here](freqai-running.md#data-stratification-for-training-and-testing-the-model).
**Datatype:** Positive integer. -| `principal_component_analysis` | Automatically reduce the dimensionality of the data set using Principal Component Analysis. See details about how it works [here](#reducing-data-dimensionality-with-principal-component-analysis)
**Datatype:** Boolean. defaults to `false`. -| `plot_feature_importances` | Create a feature importance plot for each model for the top/bottom `plot_feature_importances` number of features.
**Datatype:** Integer, defaults to `0`. +| `principal_component_analysis` | Automatically reduce the dimensionality of the data set using Principal Component Analysis. See details about how it works [here](#reducing-data-dimensionality-with-principal-component-analysis)
**Datatype:** Boolean.
Default: `False`. +| `plot_feature_importances` | Create a feature importance plot for each model for the top/bottom `plot_feature_importances` number of features.
**Datatype:** Integer.
Default: `0`. | `DI_threshold` | Activates the use of the Dissimilarity Index for outlier detection when set to > 0. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di).
**Datatype:** Positive float (typically < 1). | `use_SVM_to_remove_outliers` | Train a support vector machine to detect and remove outliers from the training dataset, as well as from incoming data points. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm).
**Datatype:** Boolean. | `svm_params` | All parameters available in Sklearn's `SGDOneClassSVM()`. See details about some select parameters [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm).
**Datatype:** Dictionary. | `use_DBSCAN_to_remove_outliers` | Cluster data using the DBSCAN algorithm to identify and remove outliers from training and prediction data. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan).
**Datatype:** Boolean. -| `inlier_metric_window` | If set, `FreqAI` adds an `inlier_metric` to the training feature set and set the lookback to be the `inlier_metric_window`, i.e., the number of previous time points to compare the current candle to. Details of how the `inlier_metric` is computed can be found [here](freqai-feature-engineering.md#inlier-metric).
**Datatype:** Integer.
Default: 0. -| `noise_standard_deviation` | If set, `FreqAI` adds noise to the training features with the aim of preventing overfitting. `FreqAI` generates random deviates from a gaussian distribution with a standard deviation of `noise_standard_deviation` and adds them to all data points. `noise_standard_deviation` should be kept relative to the normalized space, i.e., between -1 and 1. In other words, since data in `FreqAI` is always normalized to be between -1 and 1, `noise_standard_deviation: 0.05` would result in 32% of the data being randomly increased/decreased by more than 2.5% (i.e., the percent of data falling within the first standard deviation).
**Datatype:** Integer.
Default: 0. -| `outlier_protection_percentage` | Enable to prevent outlier detection methods from discarding too much data. If more than `outlier_protection_percentage` % of points are detected as outliers by the SVM or DBSCAN, `FreqAI` will log a warning message and ignore outlier detection, i.e., the original dataset will be kept intact. If the outlier protection is triggered, no predictions will be made based on the training dataset.
**Datatype:** Float.
Default: `30`. +| `inlier_metric_window` | If set, FreqAI adds an `inlier_metric` to the training feature set and set the lookback to be the `inlier_metric_window`, i.e., the number of previous time points to compare the current candle to. Details of how the `inlier_metric` is computed can be found [here](freqai-feature-engineering.md#inlier-metric).
**Datatype:** Integer.
Default: `0`. +| `noise_standard_deviation` | If set, FreqAI adds noise to the training features with the aim of preventing overfitting. FreqAI generates random deviates from a gaussian distribution with a standard deviation of `noise_standard_deviation` and adds them to all data points. `noise_standard_deviation` should be kept relative to the normalized space, i.e., between -1 and 1. In other words, since data in FreqAI is always normalized to be between -1 and 1, `noise_standard_deviation: 0.05` would result in 32% of the data being randomly increased/decreased by more than 2.5% (i.e., the percent of data falling within the first standard deviation).
**Datatype:** Integer.
Default: `0`. +| `outlier_protection_percentage` | Enable to prevent outlier detection methods from discarding too much data. If more than `outlier_protection_percentage` % of points are detected as outliers by the SVM or DBSCAN, FreqAI will log a warning message and ignore outlier detection, i.e., the original dataset will be kept intact. If the outlier protection is triggered, no predictions will be made based on the training dataset.
**Datatype:** Float.
Default: `30`. | `reverse_train_test_order` | Split the feature dataset (see below) and use the latest data split for training and test on historical split of the data. This allows the model to be trained up to the most recent data point, while avoiding overfitting. However, you should be careful to understand the unorthodox nature of this parameter before employing it.
**Datatype:** Boolean.
Default: `False` (no reversal). | | **Data split parameters** | `data_split_parameters` | Include any additional parameters available from Scikit-learn `test_train_split()`, which are shown [here](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html) (external website).
**Datatype:** Dictionary. | `test_size` | The fraction of data that should be used for testing instead of training.
**Datatype:** Positive float < 1. -| `shuffle` | Shuffle the training data points during training. Typically, for time-series forecasting, this is set to `False`.
**Datatype:** Boolean. +| `shuffle` | Shuffle the training data points during training. Typically, to not remove the chronological order of data in time-series forecasting, this is set to `False`.
**Datatype:** Boolean.
Defaut: `False`. | | **Model training parameters** | `model_training_parameters` | A flexible dictionary that includes all parameters available by the selected model library. For example, if you use `LightGBMRegressor`, this dictionary can contain any parameter available by the `LightGBMRegressor` [here](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.LGBMRegressor.html) (external website). If you select a different model, this dictionary can contain any parameter from that model.
**Datatype:** Dictionary. -| `n_estimators` | The number of boosted trees to fit in regression.
**Datatype:** Integer. -| `learning_rate` | Boosting learning rate during regression.
**Datatype:** Float. +| `n_estimators` | The number of boosted trees to fit in the training of the model.
**Datatype:** Integer. +| `learning_rate` | Boosting learning rate during training of the model.
**Datatype:** Float. | `n_jobs`, `thread_count`, `task_type` | Set the number of threads for parallel processing and the `task_type` (`gpu` or `cpu`). Different model libraries use different parameter names.
**Datatype:** Float. | | **Extraneous parameters** | `keras` | If the selected model makes use of Keras (typical for Tensorflow-based prediction models), this flag needs to be activated so that the model save/loading follows Keras standards.
**Datatype:** Boolean.
Default: `False`. -| `conv_width` | The width of a convolutional neural network input tensor. This replaces the need for shifting candles (`include_shifted_candles`) by feeding in historical data points as the second dimension of the tensor. Technically, this parameter can also be used for regressors, but it only adds computational overhead and does not change the model training/prediction.
**Datatype:** Integer.
Default: 2. +| `conv_width` | The width of a convolutional neural network input tensor. This replaces the need for shifting candles (`include_shifted_candles`) by feeding in historical data points as the second dimension of the tensor. Technically, this parameter can also be used for regressors, but it only adds computational overhead and does not change the model training/prediction.
**Datatype:** Integer.
Default: `2`. diff --git a/docs/freqai-running.md b/docs/freqai-running.md index 6c7b56da1..b8994aed9 100644 --- a/docs/freqai-running.md +++ b/docs/freqai-running.md @@ -1,6 +1,6 @@ # Running FreqAI -There are two ways to train and deploy an adaptive machine learning model - live deployment and historical backtesting. In both cases, `FreqAI` runs/simulates periodic retraining of models as shown in the following figure: +There are two ways to train and deploy an adaptive machine learning model - live deployment and historical backtesting. In both cases, FreqAI runs/simulates periodic retraining of models as shown in the following figure: ![freqai-window](assets/freqai_moving-window.jpg) @@ -33,7 +33,7 @@ FreqAI automatically downloads the proper amount of data needed to ensure traini ### Saving prediction data -All predictions made during the lifetime of a specific `identifier` model are stored in `historical_predictions.pkl` to allow for reloading after a crash or changes made to the config. +All predictions made during the lifetime of a specific `identifier` model are stored in `historic_predictions.pkl` to allow for reloading after a crash or changes made to the config. ### Purging old model data @@ -75,19 +75,19 @@ To allow for tweaking your strategy (**not** the features!), FreqAI will automat An additional directory called `predictions`, which contains all the predictions stored in `hdf` format, will be created in the `unique-id` folder. -To change your **features**, you **must** set a new `identifier` in the config to signal to `FreqAI` to train new models. +To change your **features**, you **must** set a new `identifier` in the config to signal to FreqAI to train new models. To save the models generated during a particular backtest so that you can start a live deployment from one of them instead of training a new model, you must set `save_backtest_models` to `True` in the config. ### Downloading data to cover the full backtest period -For live/dry deployments, FreqAI will download the necessary data automatically. However, to use backtesting functionality, you need to download the necessary data using `download-data` (details [here](data-download.md#data-downloading)). You need to pay careful attention to understanding how much *additional* data needs to be downloaded to ensure that there is a sufficient amount of training data *before* the start of the backtesting timerange. The amount of additional data can be roughly estimated by moving the start date of the timerange backwards by `train_period_days` and the `startup_candle_count` (see the [parameter table](freqai-parameter-table.md) for detailed descriptions of these parameters) from the beginning of the desired backtesting timerange. +For live/dry deployments, FreqAI will download the necessary data automatically. However, to use backtesting functionality, you need to download the necessary data using `download-data` (details [here](data-download.md#data-downloading)). You need to pay careful attention to understanding how much *additional* data needs to be downloaded to ensure that there is a sufficient amount of training data *before* the start of the backtesting time range. The amount of additional data can be roughly estimated by moving the start date of the time range backwards by `train_period_days` and the `startup_candle_count` (see the [parameter table](freqai-parameter-table.md) for detailed descriptions of these parameters) from the beginning of the desired backtesting time range. -As an example, to backtest the `--timerange 20210501-20210701` using the [example config](freqai-configuration.md#setting-up-the-configuration-file) which sets `train_period_days` to 30, together with `startup_candle_count: 40` on a maximum `include_timeframes` of 1h, the start date for the downloaded data needs to be `20210501` - 30 days - 40 * 1h / 24 hours = 20210330 (31.7 days earlier than the start of the desired training timerange). +As an example, to backtest the `--timerange 20210501-20210701` using the [example config](freqai-configuration.md#setting-up-the-configuration-file) which sets `train_period_days` to 30, together with `startup_candle_count: 40` on a maximum `include_timeframes` of 1h, the start date for the downloaded data needs to be `20210501` - 30 days - 40 * 1h / 24 hours = 20210330 (31.7 days earlier than the start of the desired training time range). ### Deciding the size of the sliding training window and backtesting duration -The backtesting timerange is defined with the typical `--timerange` parameter in the configuration file. The duration of the sliding training window is set by `train_period_days`, whilst `backtest_period_days` is the sliding backtesting window, both in number of days (`backtest_period_days` can be +The backtesting time range is defined with the typical `--timerange` parameter in the configuration file. The duration of the sliding training window is set by `train_period_days`, whilst `backtest_period_days` is the sliding backtesting window, both in number of days (`backtest_period_days` can be a float to indicate sub-daily retraining in live/dry mode). In the presented [example config](freqai-configuration.md#setting-up-the-configuration-file) (found in `config_examples/config_freqai.example.json`), the user is asking FreqAI to use a training period of 30 days and backtest on the subsequent 7 days. After the training of the model, FreqAI will backtest the subsequent 7 days. The "sliding window" then moves one week forward (emulating FreqAI retraining once per week in live mode) and the new model uses the previous 30 days (including the 7 days used for backtesting by the previous model) to train. This is repeated until the end of `--timerange`. This means that if you set `--timerange 20210501-20210701`, FreqAI will have trained 8 separate models at the end of `--timerange` (because the full range comprises 8 weeks). !!! Note @@ -105,23 +105,6 @@ During dry/live mode, FreqAI trains each coin pair sequentially (on separate thr In the presented example config, the user will only allow predictions on models that are less than 1/2 hours old. -## Data stratification for training and testing the model - -You can stratify (group) the training/testing data using: - -```json - "freqai": { - "feature_parameters" : { - "stratify_training_data": 3 - } - } -``` - -This will split the data chronologically so that every Xth data point is used to test the model after training. In the example above, the user is asking for every third data point in the dataframe to be used for -testing; the other points are used for training. - -The test data is used to evaluate the performance of the model after training. If the test score is high, the model is able to capture the behavior of the data well. If the test score is low, either the model does not capture the complexity of the data, the test data is significantly different from the train data, or a different type of model should be used. - ## Controlling the model learning process Model training parameters are unique to the selected machine learning library. FreqAI allows you to set any parameter for any library using the `model_training_parameters` dictionary in the config. The example config (found in `config_examples/config_freqai.example.json`) shows some of the example parameters associated with `Catboost` and `LightGBM`, but you can add any parameters available in those libraries or any other machine learning library you choose to implement. @@ -132,7 +115,7 @@ The FreqAI specific parameter `label_period_candles` defines the offset (number ## Continual learning -You can choose to adopt a continual learning scheme by setting `"continual_learning": true` in the config. By enabling `continual_learning`, after training an initial model from scratch, subsequent trainings will start from the final model state of the preceding training. This gives the new model a "memory" of the previous state. By default, this is set to `false` which means that all new models are trained from scratch, without input from previous models. +You can choose to adopt a continual learning scheme by setting `"continual_learning": true` in the config. By enabling `continual_learning`, after training an initial model from scratch, subsequent trainings will start from the final model state of the preceding training. This gives the new model a "memory" of the previous state. By default, this is set to `False` which means that all new models are trained from scratch, without input from previous models. ## Hyperopt diff --git a/docs/freqai.md b/docs/freqai.md index 91adbf7ef..b7f0fe21a 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -1,10 +1,10 @@ ![freqai-logo](assets/freqai_doc_logo.svg) -# `FreqAI` +# FreqAI ## Introduction -`FreqAI` is a software designed to automate a variety of tasks associated with training a predictive machine learning model to generate market forecasts given a set of input features. +FreqAI is a software designed to automate a variety of tasks associated with training a predictive machine learning model to generate market forecasts given a set of input features. Features include: @@ -23,7 +23,7 @@ Features include: ## Quick start -The easiest way to quickly test `FreqAI` is to run it in dry mode with the following command: +The easiest way to quickly test FreqAI is to run it in dry mode with the following command: ```bash freqtrade trade --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel LightGBMRegressor --strategy-path freqtrade/templates @@ -37,7 +37,7 @@ An example strategy, prediction model, and config to use as a starting points ca ## General approach -You provide `FreqAI` with a set of custom *base indicators* (the same way as in a [typical Freqtrade strategy](strategy-customization.md)) as well as target values (*labels*). For each pair in the whitelist, `FreqAI` trains a model to predict the target values based on the input of custom indicators. The models are then consistently retrained, with a predetermined frequency, to adapt to market conditions. `FreqAI` offers the ability to both backtest strategies (emulating reality with periodic retraining on historic data) and deploy dry/live runs. In dry/live conditions, `FreqAI` can be set to constant retraining in a background thread to keep models as up to date as possible. +You provide FreqAI with a set of custom *base indicators* (the same way as in a [typical Freqtrade strategy](strategy-customization.md)) as well as target values (*labels*). For each pair in the whitelist, FreqAI trains a model to predict the target values based on the input of custom indicators. The models are then consistently retrained, with a predetermined frequency, to adapt to market conditions. FreqAI offers the ability to both backtest strategies (emulating reality with periodic retraining on historic data) and deploy dry/live runs. In dry/live conditions, FreqAI can be set to constant retraining in a background thread to keep models as up to date as possible. An overview of the algorithm, explaining the data processing pipeline and model usage, is shown below. @@ -45,21 +45,21 @@ An overview of the algorithm, explaining the data processing pipeline and model ### Important machine learning vocabulary -**Features** - the parameters, based on historic data, on which a model is trained. All features for a single candle is stored as a vector. In `FreqAI`, you build a feature data sets from anything you can construct in the strategy. +**Features** - the parameters, based on historic data, on which a model is trained. All features for a single candle are stored as a vector. In FreqAI, you build a feature data set from anything you can construct in the strategy. -**Labels** - the target values that a model is trained toward. Each feature vector is associated with a single label that is defined by you within the strategy. These labels intentionally look into the future, and are not available to the model during dry/live/backtesting. +**Labels** - the target values that the model is trained toward. Each feature vector is associated with a single label that is defined by you within the strategy. These labels intentionally look into the future and are what you are training the model to be able to predict. -**Training** - the process of "teaching" the model to match the feature sets to the associated labels. Different types of models "learn" in different ways. More information about the different models can be found [here](freqai-configuration.md#using-different-prediction-models). +**Training** - the process of "teaching" the model to match the feature sets to the associated labels. Different types of models "learn" in different ways which means that one might be better than another for a specific application. More information about the different models that are already implemented in FreqAI can be found [here](freqai-configuration.md#using-different-prediction-models). -**Train data** - a subset of the feature data set that is fed to the model during training. This data directly influences weight connections in the model. +**Train data** - a subset of the feature data set that is fed to the model during training to "teach" the model how to predict the targets. This data directly influences weight connections in the model. **Test data** - a subset of the feature data set that is used to evaluate the performance of the model after training. This data does not influence nodal weights within the model. -**Inferencing** - the process of feeding a trained model new data on which it will make a prediction. +**Inferencing** - the process of feeding a trained model new unseen data on which it will make a prediction. ## Install prerequisites -The normal Freqtrade install process will ask if you wish to install `FreqAI` dependencies. You should reply "yes" to this question if you wish to use `FreqAI`. If you did not reply yes, you can manually install these dependencies after the install with: +The normal Freqtrade install process will ask if you wish to install FreqAI dependencies. You should reply "yes" to this question if you wish to use FreqAI. If you did not reply yes, you can manually install these dependencies after the install with: ``` bash pip install -r requirements-freqai.txt @@ -70,18 +70,18 @@ pip install -r requirements-freqai.txt ### Usage with docker -If you are using docker, a dedicated tag with `FreqAI` dependencies is available as `:freqai`. As such - you can replace the image line in your docker-compose file with `image: freqtradeorg/freqtrade:develop_freqai`. This image contains the regular `FreqAI` dependencies. Similar to native installs, Catboost will not be available on ARM based devices. +If you are using docker, a dedicated tag with FreqAI dependencies is available as `:freqai`. As such - you can replace the image line in your docker-compose file with `image: freqtradeorg/freqtrade:develop_freqai`. This image contains the regular FreqAI dependencies. Similar to native installs, Catboost will not be available on ARM based devices. ## Common pitfalls -`FreqAI` cannot be combined with dynamic `VolumePairlists` (or any pairlist filter that adds and removes pairs dynamically). -This is for performance reasons - `FreqAI` relies on making quick predictions/retrains. To do this effectively, -it needs to download all the training data at the beginning of a dry/live instance. `FreqAI` stores and appends -new candles automatically for future retrains. This means that if new pairs arrive later in the dry run due to a volume pairlist, it will not have the data ready. However, `FreqAI` does work with the `ShufflePairlist` or a `VolumePairlist` which keeps the total pairlist constant (but reorders the pairs according to volume). +FreqAI cannot be combined with dynamic `VolumePairlists` (or any pairlist filter that adds and removes pairs dynamically). +This is for performance reasons - FreqAI relies on making quick predictions/retrains. To do this effectively, +it needs to download all the training data at the beginning of a dry/live instance. FreqAI stores and appends +new candles automatically for future retrains. This means that if new pairs arrive later in the dry run due to a volume pairlist, it will not have the data ready. However, FreqAI does work with the `ShufflePairlist` or a `VolumePairlist` which keeps the total pairlist constant (but reorders the pairs according to volume). ## Credits -`FreqAI` is developed by a group of individuals who all contribute specific skillsets to the project. +FreqAI is developed by a group of individuals who all contribute specific skillsets to the project. Conception and software development: Robert Caulk @robcaulk @@ -96,5 +96,4 @@ Software development: Wagner Costa @wagnercosta Beta testing and bug reporting: -Stefan Gehring @bloodhunter4rc, @longyu, Andrew Robert Lawless @paranoidandy, Pascal Schmidt @smidelis, Ryan McMullan @smarmau, -Juha Nykänen @suikula, Johan van der Vlugt @jooopiert, Richárd Józsa @richardjosza +Stefan Gehring @bloodhunter4rc, @longyu, Andrew Lawless @paranoidandy, Pascal Schmidt @smidelis, Ryan McMullan @smarmau, Juha Nykänen @suikula, Johan van der Vlugt @jooopiert, Richárd Józsa @richardjosza, Timothy Pogue @wizrds diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 0f55c1b79..7dff75a02 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -22,6 +22,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`StaticPairList`](#static-pair-list) (default, if not configured differently) * [`VolumePairList`](#volume-pair-list) +* [`ProducerPairList`](#producerpairlist) * [`AgeFilter`](#agefilter) * [`OffsetFilter`](#offsetfilter) * [`PerformanceFilter`](#performancefilter) @@ -84,7 +85,7 @@ Filtering instances (not the first position in the list) will not apply any cach You can define a minimum volume with `min_value` - which will filter out pairs with a volume lower than the specified value in the specified timerange. -### VolumePairList Advanced mode +##### VolumePairList Advanced mode `VolumePairList` can also operate in an advanced mode to build volume over a given timerange of specified candle size. It utilizes exchange historical candle data, builds a typical price (calculated by (open+high+low)/3) and multiplies the typical price with every candle's volume. The sum is the `quoteVolume` over the given range. This allows different scenarios, for a more smoothened volume, when using longer ranges with larger candle sizes, or the opposite when using a short range with small candles. @@ -146,6 +147,32 @@ More sophisticated approach can be used, by using `lookback_timeframe` for candl !!! Note `VolumePairList` does not support backtesting mode. +#### ProducerPairList + +With `ProducerPairList`, you can reuse the pairlist from a [Producer](producer-consumer.md) without explicitly defining the pairlist on each consumer. + +[Consumer mode](producer-consumer.md) is required for this pairlist to work. + +The pairlist will perform a check on active pairs against the current exchange configuration to avoid attempting to trade on invalid markets. + +You can limit the length of the pairlist with the optional parameter `number_assets`. Using `"number_assets"=0` or omitting this key will result in the reuse of all producer pairs valid for the current setup. + +```json +"pairlists": [ + { + "method": "ProducerPairList", + "number_assets": 5, + "producer_name": "default", + } +], +``` + + +!!! Tip "Combining pairlists" + This pairlist can be combined with all other pairlists and filters for further pairlist reduction, and can also act as an "additional" pairlist, on top of already defined pairs. + `ProducerPairList` can also be used multiple times in sequence, combining the pairs from multiple producers. + Obviously in complex such configurations, the Producer may not provide data for all pairs, so the strategy must be fit for this. + #### AgeFilter Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`) or more than `max_days_listed` days (defaults `None` mean infinity). diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 176947438..4ff1780cf 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,6 +1,6 @@ markdown==3.3.7 -mkdocs==1.3.1 -mkdocs-material==8.5.3 +mkdocs==1.4.0 +mkdocs-material==8.5.6 mdx_truly_sane_lists==1.3 -pymdown-extensions==9.5 +pymdown-extensions==9.6 jinja2==3.1.2 diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 0b8403414..ea10fc472 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -643,7 +643,7 @@ This callback is **not** called when there is an open order (either buy or sell) Additional Buys are ignored once you have reached the maximum amount of extra buys that you have set on `max_entry_position_adjustment`, but the callback is called anyway looking for partial exits. -Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position (negative values will decrease your position), no matter if it's a long or short trade. Modifications to leverage are not possible. +Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position (negative values will decrease your position), no matter if it's a long or short trade. Modifications to leverage are not possible, and the stake-amount is assumed to be before applying leverage. !!! Note "About stake size" Using fixed stake size means it will be the amount used for the first order, just like without position adjustment. diff --git a/docs/updating.md b/docs/updating.md index 8dc7279a4..893bc846e 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -37,3 +37,12 @@ pip install -e . # Ensure freqUI is at the latest version freqtrade install-ui ``` + +### Problems updating + +Update-problems usually come missing dependencies (you didn't follow the above instructions) - or from updated dependencies, which fail to install (for example TA-lib). +Please refer to the corresponding installation sections (common problems linked below) + +Common problems and their solutions: + +* [ta-lib update on windows](windows_installation.md#2-install-ta-lib) diff --git a/docs/windows_installation.md b/docs/windows_installation.md index 9fbbf8250..5cfae8c10 100644 --- a/docs/windows_installation.md +++ b/docs/windows_installation.md @@ -34,7 +34,7 @@ python -m venv .env .env\Scripts\activate.ps1 # optionally install ta-lib from wheel # Eventually adjust the below filename to match the downloaded wheel -pip install --find-links build_helpers\ TA-Lib +pip install --find-links build_helpers\ TA-Lib -U pip install -r requirements.txt pip install -e . freqtrade diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 77c305c66..1e62266a8 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2022.9.dev' +__version__ = '2022.10.dev' if 'dev' in __version__: try: diff --git a/freqtrade/configuration/__init__.py b/freqtrade/configuration/__init__.py index 730a4e47f..dee42d535 100644 --- a/freqtrade/configuration/__init__.py +++ b/freqtrade/configuration/__init__.py @@ -1,6 +1,5 @@ # flake8: noqa: F401 -from freqtrade.configuration.check_exchange import check_exchange from freqtrade.configuration.config_setup import setup_utils_configuration from freqtrade.configuration.config_validation import validate_config_consistency from freqtrade.configuration.configuration import Configuration diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index eafba2a28..bf0657994 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -87,6 +87,7 @@ def validate_config_consistency(conf: Dict[str, Any], preliminary: bool = False) _validate_ask_orderbook(conf) _validate_freqai_hyperopt(conf) _validate_freqai_backtest(conf) + _validate_freqai_include_timeframes(conf) _validate_consumers(conf) validate_migrated_strategy_settings(conf) @@ -335,6 +336,26 @@ def _validate_freqai_hyperopt(conf: Dict[str, Any]) -> None: 'Using analyze-per-epoch parameter is not supported with a FreqAI strategy.') +def _validate_freqai_include_timeframes(conf: Dict[str, Any]) -> None: + freqai_enabled = conf.get('freqai', {}).get('enabled', False) + if freqai_enabled: + main_tf = conf.get('timeframe', '5m') + freqai_include_timeframes = conf.get('freqai', {}).get('feature_parameters', {} + ).get('include_timeframes', []) + + from freqtrade.exchange import timeframe_to_seconds + main_tf_s = timeframe_to_seconds(main_tf) + offending_lines = [] + for tf in freqai_include_timeframes: + tf_s = timeframe_to_seconds(tf) + if tf_s < main_tf_s: + offending_lines.append(tf) + if offending_lines: + raise OperationalException( + f"Main timeframe of {main_tf} must be smaller or equal to FreqAI " + f"`include_timeframes`.Offending include-timeframes: {', '.join(offending_lines)}") + + def _validate_freqai_backtest(conf: Dict[str, Any]) -> None: if conf.get('runmode', RunMode.OTHER) == RunMode.BACKTEST: freqai_enabled = conf.get('freqai', {}).get('enabled', False) diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index 22b6fc05b..4929c023d 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional from freqtrade import constants -from freqtrade.configuration.check_exchange import check_exchange from freqtrade.configuration.deprecated_settings import process_temporary_deprecated_settings from freqtrade.configuration.directory_operations import create_datadir, create_userdata_dir from freqtrade.configuration.environment_vars import enironment_vars_to_dict @@ -100,6 +99,9 @@ class Configuration: self._process_freqai_options(config) + # Import check_exchange here to avoid import cycle problems + from freqtrade.exchange.check_exchange import check_exchange + # Check if the exchange set by the user is supported check_exchange(config, config.get('experimental', {}).get('block_bad_exchanges', True)) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index e14e81343..e0eb5e288 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -31,7 +31,7 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', 'CalmarHyperOptLoss', 'MaxDrawDownHyperOptLoss', 'MaxDrawDownRelativeHyperOptLoss', 'ProfitDrawDownHyperOptLoss'] -AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', +AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'ProducerPairList', 'AgeFilter', 'OffsetFilter', 'PerformanceFilter', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] @@ -567,6 +567,7 @@ CONF_SCHEMA = { "properties": { "test_size": {"type": "number"}, "random_state": {"type": "integer"}, + "shuffle": {"type": "boolean", "default": False} }, }, "model_training_parameters": { diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index 67461973f..98ed15489 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -47,8 +47,7 @@ def ohlcv_to_dataframe(ohlcv: list, timeframe: str, pair: str, *, def clean_ohlcv_dataframe(data: DataFrame, timeframe: str, pair: str, *, - fill_missing: bool = True, - drop_incomplete: bool = True) -> DataFrame: + fill_missing: bool, drop_incomplete: bool) -> DataFrame: """ Cleanse a OHLCV dataframe by * Grouping it by date (removes duplicate tics) diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index 6a6e29429..93534e919 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -26,7 +26,7 @@ def load_pair_history(pair: str, datadir: Path, *, timerange: Optional[TimeRange] = None, fill_up_missing: bool = True, - drop_incomplete: bool = True, + drop_incomplete: bool = False, startup_candles: int = 0, data_format: str = None, data_handler: IDataHandler = None, diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index c2d92fc4f..80e29f4c0 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -275,7 +275,7 @@ class IDataHandler(ABC): candle_type: CandleType, *, timerange: Optional[TimeRange] = None, fill_missing: bool = True, - drop_incomplete: bool = True, + drop_incomplete: bool = False, startup_candles: int = 0, warn_no_data: bool = True, ) -> DataFrame: diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index ff7ec7e04..1b5ca11ee 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -12,8 +12,8 @@ from freqtrade.exchange.coinbasepro import Coinbasepro from freqtrade.exchange.exchange import (amount_to_contract_precision, amount_to_contracts, amount_to_precision, available_exchanges, ccxt_exchanges, contracts_to_amount, date_minus_candles, - is_exchange_known_ccxt, is_exchange_officially_supported, - market_is_active, price_to_precision, timeframe_to_minutes, + is_exchange_known_ccxt, market_is_active, + price_to_precision, timeframe_to_minutes, timeframe_to_msecs, timeframe_to_next_date, timeframe_to_prev_date, timeframe_to_seconds, validate_exchange, validate_exchanges) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index f9fb4a8b1..a0d4b2d82 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -68,6 +68,37 @@ class Binance(Exchange): tickers = deep_merge_dicts(bidsasks, tickers, allow_null_overrides=False) return tickers + @retrier + def additional_exchange_init(self) -> None: + """ + Additional exchange initialization logic. + .api will be available at this point. + Must be overridden in child methods if required. + """ + try: + if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']: + position_side = self._api.fapiPrivateGetPositionsideDual() + self._log_exchange_response('position_side_setting', position_side) + assets_margin = self._api.fapiPrivateGetMultiAssetsMargin() + self._log_exchange_response('multi_asset_margin', assets_margin) + msg = "" + if position_side.get('dualSidePosition') is True: + msg += ( + "\nHedge Mode is not supported by freqtrade. " + "Please change 'Position Mode' on your binance futures account.") + if assets_margin.get('multiAssetsMargin') is True: + msg += ("\nMulti-Asset Mode is not supported by freqtrade. " + "Please change 'Asset Mode' on your binance futures account.") + if msg: + raise OperationalException(msg) + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + @retrier def _set_leverage( self, diff --git a/freqtrade/configuration/check_exchange.py b/freqtrade/exchange/check_exchange.py similarity index 91% rename from freqtrade/configuration/check_exchange.py rename to freqtrade/exchange/check_exchange.py index c3d859275..69330bcd0 100644 --- a/freqtrade/configuration/check_exchange.py +++ b/freqtrade/exchange/check_exchange.py @@ -3,8 +3,8 @@ import logging from freqtrade.constants import Config from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException -from freqtrade.exchange import (available_exchanges, is_exchange_known_ccxt, - is_exchange_officially_supported, validate_exchange) +from freqtrade.exchange import available_exchanges, is_exchange_known_ccxt, validate_exchange +from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS, SUPPORTED_EXCHANGES logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def check_exchange(config: Config, check_for_bad: bool = True) -> bool: else: logger.warning(f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}') - if is_exchange_officially_supported(exchange): + if MAP_EXCHANGE_CHILDCLASS.get(exchange, exchange) in SUPPORTED_EXCHANGES: logger.info(f'Exchange "{exchange}" is officially supported ' f'by the Freqtrade development team.') else: diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index f01e464fa..c41a84450 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -18,20 +18,19 @@ import ccxt.async_support as ccxt_async from cachetools import TTLCache from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision from dateutil import parser -from pandas import DataFrame +from pandas import DataFrame, concat from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, BuySell, Config, EntryExit, ListPairsWithTimeframes, MakerTaker, PairWithTimeframe) -from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list +from freqtrade.data.converter import clean_ohlcv_dataframe, ohlcv_to_dataframe, trades_dict_to_list from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError, InvalidOrderException, OperationalException, PricingError, RetryableOrderError, TemporaryError) from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, BAD_EXCHANGES, EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED, - SUPPORTED_EXCHANGES, remove_credentials, retrier, - retrier_async) + remove_credentials, retrier, retrier_async) from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_json, safe_value_fallback2) from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -180,13 +179,14 @@ class Exchange: exchange_config, ccxt_async, ccxt_kwargs=ccxt_async_config) logger.info(f'Using Exchange "{self.name}"') - + self.required_candle_call_count = 1 if validate: # Initial markets load self._load_markets() self.validate_config(config) + self._startup_candle_count: int = config.get('startup_candle_count', 0) self.required_candle_call_count = self.validate_required_startup_candles( - config.get('startup_candle_count', 0), config.get('timeframe', '')) + self._startup_candle_count, config.get('timeframe', '')) # Converts the interval provided in minutes in config to seconds self.markets_refresh_interval: int = exchange_config.get( @@ -1292,7 +1292,14 @@ class Exchange: order = self.fetch_order(order_id, pair) except InvalidOrderException: logger.warning(f"Could not fetch cancelled order {order_id}.") - order = {'fee': {}, 'status': 'canceled', 'amount': amount, 'info': {}} + order = { + 'id': order_id, + 'status': 'canceled', + 'amount': amount, + 'filled': 0.0, + 'fee': {}, + 'info': {} + } return order @@ -1844,10 +1851,22 @@ class Exchange: return pair, timeframe, candle_type, data def _build_coroutine(self, pair: str, timeframe: str, candle_type: CandleType, - since_ms: Optional[int]) -> Coroutine: + since_ms: Optional[int], cache: bool) -> Coroutine: + not_all_data = cache and self.required_candle_call_count > 1 + if cache and (pair, timeframe, candle_type) in self._klines: + candle_limit = self.ohlcv_candle_limit(timeframe, candle_type) + min_date = date_minus_candles(timeframe, candle_limit - 5).timestamp() + # Check if 1 call can get us updated candles without hole in the data. + if min_date < self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0): + # Cache can be used - do one-off call. + not_all_data = False + else: + # Time jump detected, evict cache + logger.info( + f"Time jump detected. Evicting cache for {pair}, {timeframe}, {candle_type}") + del self._klines[(pair, timeframe, candle_type)] - if (not since_ms - and (self._ft_has["ohlcv_require_since"] or self.required_candle_call_count > 1)): + if (not since_ms and (self._ft_has["ohlcv_require_since"] or not_all_data)): # Multiple calls for one pair - to get more history one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit( timeframe, candle_type, since_ms) @@ -1863,6 +1882,59 @@ class Exchange: return self._async_get_candle_history( pair, timeframe, since_ms=since_ms, candle_type=candle_type) + def _build_ohlcv_dl_jobs( + self, pair_list: ListPairsWithTimeframes, since_ms: Optional[int], + cache: bool) -> Tuple[List[Coroutine], List[Tuple[str, str, CandleType]]]: + """ + Build Coroutines to execute as part of refresh_latest_ohlcv + """ + input_coroutines = [] + cached_pairs = [] + for pair, timeframe, candle_type in set(pair_list): + if (timeframe not in self.timeframes + and candle_type in (CandleType.SPOT, CandleType.FUTURES)): + logger.warning( + f"Cannot download ({pair}, {timeframe}) combination as this timeframe is " + f"not available on {self.name}. Available timeframes are " + f"{', '.join(self.timeframes)}.") + continue + + if ((pair, timeframe, candle_type) not in self._klines or not cache + or self._now_is_time_to_refresh(pair, timeframe, candle_type)): + + input_coroutines.append( + self._build_coroutine(pair, timeframe, candle_type, since_ms, cache)) + + else: + logger.debug( + f"Using cached candle (OHLCV) data for {pair}, {timeframe}, {candle_type} ..." + ) + cached_pairs.append((pair, timeframe, candle_type)) + + return input_coroutines, cached_pairs + + def _process_ohlcv_df(self, pair: str, timeframe: str, c_type: CandleType, ticks: List[List], + cache: bool, drop_incomplete: bool) -> DataFrame: + # keeping last candle time as last refreshed time of the pair + if ticks and cache: + self._pairs_last_refresh_time[(pair, timeframe, c_type)] = ticks[-1][0] // 1000 + # keeping parsed dataframe in cache + ohlcv_df = ohlcv_to_dataframe(ticks, timeframe, pair=pair, fill_missing=True, + drop_incomplete=drop_incomplete) + if cache: + if (pair, timeframe, c_type) in self._klines: + old = self._klines[(pair, timeframe, c_type)] + # Reassign so we return the updated, combined df + ohlcv_df = clean_ohlcv_dataframe(concat([old, ohlcv_df], axis=0), timeframe, pair, + fill_missing=True, drop_incomplete=False) + candle_limit = self.ohlcv_candle_limit(timeframe, self._config['candle_type_def']) + # Age out old candles + ohlcv_df = ohlcv_df.tail(candle_limit + self._startup_candle_count) + self._klines[(pair, timeframe, c_type)] = ohlcv_df + else: + self._klines[(pair, timeframe, c_type)] = ohlcv_df + return ohlcv_df + def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *, since_ms: Optional[int] = None, cache: bool = True, drop_incomplete: Optional[bool] = None @@ -1880,27 +1952,9 @@ class Exchange: """ logger.debug("Refreshing candle (OHLCV) data for %d pairs", len(pair_list)) drop_incomplete = self._ohlcv_partial_candle if drop_incomplete is None else drop_incomplete - input_coroutines = [] - cached_pairs = [] - # Gather coroutines to run - for pair, timeframe, candle_type in set(pair_list): - if (timeframe not in self.timeframes - and candle_type in (CandleType.SPOT, CandleType.FUTURES)): - logger.warning( - f"Cannot download ({pair}, {timeframe}) combination as this timeframe is " - f"not available on {self.name}. Available timeframes are " - f"{', '.join(self.timeframes)}.") - continue - if ((pair, timeframe, candle_type) not in self._klines or not cache - or self._now_is_time_to_refresh(pair, timeframe, candle_type)): - input_coroutines.append(self._build_coroutine( - pair, timeframe, candle_type=candle_type, since_ms=since_ms)) - else: - logger.debug( - f"Using cached candle (OHLCV) data for {pair}, {timeframe}, {candle_type} ..." - ) - cached_pairs.append((pair, timeframe, candle_type)) + # Gather coroutines to run + input_coroutines, cached_pairs = self._build_ohlcv_dl_jobs(pair_list, since_ms, cache) results_df = {} # Chunk requests into batches of 100 to avoid overwelming ccxt Throttling @@ -1917,16 +1971,11 @@ class Exchange: continue # Deconstruct tuple (has 4 elements) pair, timeframe, c_type, ticks = res - # keeping last candle time as last refreshed time of the pair - if ticks: - self._pairs_last_refresh_time[(pair, timeframe, c_type)] = ticks[-1][0] // 1000 - # keeping parsed dataframe in cache - ohlcv_df = ohlcv_to_dataframe( - ticks, timeframe, pair=pair, fill_missing=True, - drop_incomplete=drop_incomplete) + ohlcv_df = self._process_ohlcv_df( + pair, timeframe, c_type, ticks, cache, drop_incomplete) + results_df[(pair, timeframe, c_type)] = ohlcv_df - if cache: - self._klines[(pair, timeframe, c_type)] = ohlcv_df + # Return cached klines for pair, timeframe, c_type in cached_pairs: results_df[(pair, timeframe, c_type)] = self.klines( @@ -1941,10 +1990,8 @@ class Exchange: interval_in_sec = timeframe_to_seconds(timeframe) return not ( - (self._pairs_last_refresh_time.get( - (pair, timeframe, candle_type), - 0 - ) + interval_in_sec) >= arrow.utcnow().int_timestamp + (self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0) + + interval_in_sec) >= arrow.utcnow().int_timestamp ) @retrier_async @@ -1971,8 +2018,8 @@ class Exchange: candle_limit = self.ohlcv_candle_limit( timeframe, candle_type=candle_type, since_ms=since_ms) - if candle_type != CandleType.SPOT: - params.update({'price': candle_type}) + if candle_type and candle_type != CandleType.SPOT: + params.update({'price': candle_type.value}) if candle_type != CandleType.FUNDING_RATE: data = await self._api_async.fetch_ohlcv( pair, timeframe=timeframe, since=since_ms, @@ -2754,10 +2801,6 @@ def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = Non return exchange_name in ccxt_exchanges(ccxt_module) -def is_exchange_officially_supported(exchange_name: str) -> bool: - return exchange_name in SUPPORTED_EXCHANGES - - def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]: """ Return the list of all exchanges known to ccxt diff --git a/freqtrade/exchange/okx.py b/freqtrade/exchange/okx.py index 2db5fb6a9..6792c2cba 100644 --- a/freqtrade/exchange/okx.py +++ b/freqtrade/exchange/okx.py @@ -78,7 +78,8 @@ class Okx(Exchange): raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( - f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e + f'Error in additional_exchange_init due to {e.__class__.__name__}. Message: {e}' + ) from e except ccxt.BaseError as e: raise OperationalException(e) from e diff --git a/freqtrade/freqai/base_models/BaseClassifierModel.py b/freqtrade/freqai/base_models/BaseClassifierModel.py index 70f212d2a..691c27e23 100644 --- a/freqtrade/freqai/base_models/BaseClassifierModel.py +++ b/freqtrade/freqai/base_models/BaseClassifierModel.py @@ -78,7 +78,7 @@ class BaseClassifierModel(IFreqaiModel): ) -> Tuple[DataFrame, npt.NDArray[np.int_]]: """ Filter the prediction features data and predict with it. - :param: unfiltered_df: Full dataframe for the current backtest period. + :param unfiltered_df: Full dataframe for the current backtest period. :return: :pred_df: dataframe containing the predictions :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove @@ -92,7 +92,7 @@ class BaseClassifierModel(IFreqaiModel): filtered_df = dk.normalize_data_from_metadata(filtered_df) dk.data_dictionary["prediction_features"] = filtered_df - self.data_cleaning_predict(dk, filtered_df) + self.data_cleaning_predict(dk) predictions = self.model.predict(dk.data_dictionary["prediction_features"]) pred_df = DataFrame(predictions, columns=dk.label_list) diff --git a/freqtrade/freqai/base_models/BaseRegressionModel.py b/freqtrade/freqai/base_models/BaseRegressionModel.py index 2450bf305..79f6f0d3c 100644 --- a/freqtrade/freqai/base_models/BaseRegressionModel.py +++ b/freqtrade/freqai/base_models/BaseRegressionModel.py @@ -77,7 +77,7 @@ class BaseRegressionModel(IFreqaiModel): ) -> Tuple[DataFrame, npt.NDArray[np.int_]]: """ Filter the prediction features data and predict with it. - :param: unfiltered_df: Full dataframe for the current backtest period. + :param unfiltered_df: Full dataframe for the current backtest period. :return: :pred_df: dataframe containing the predictions :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove @@ -92,7 +92,7 @@ class BaseRegressionModel(IFreqaiModel): dk.data_dictionary["prediction_features"] = filtered_df # optional additional data cleaning/analysis - self.data_cleaning_predict(dk, filtered_df) + self.data_cleaning_predict(dk) predictions = self.model.predict(dk.data_dictionary["prediction_features"]) pred_df = DataFrame(predictions, columns=dk.label_list) diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index 1839724f8..465ba27f5 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -257,7 +257,7 @@ class FreqaiDataDrawer: def append_model_predictions(self, pair: str, predictions: DataFrame, do_preds: NDArray[np.int_], - dk: FreqaiDataKitchen, len_df: int) -> None: + dk: FreqaiDataKitchen, strat_df: DataFrame) -> None: """ Append model predictions to historic predictions dataframe, then set the strategy return dataframe to the tail of the historic predictions. The length of @@ -266,6 +266,7 @@ class FreqaiDataDrawer: historic predictions. """ + len_df = len(strat_df) index = self.historic_predictions[pair].index[-1:] columns = self.historic_predictions[pair].columns @@ -293,6 +294,15 @@ class FreqaiDataDrawer: for return_str in rets: df[return_str].iloc[-1] = rets[return_str] + # this logic carries users between version without needing to + # change their identifier + if 'close_price' not in df.columns: + df['close_price'] = np.nan + df['date_pred'] = np.nan + + df['close_price'].iloc[-1] = strat_df['close'].iloc[-1] + df['date_pred'].iloc[-1] = strat_df['date'].iloc[-1] + self.model_return_values[pair] = df.tail(len_df).reset_index(drop=True) def attach_return_values_to_return_dataframe( @@ -402,9 +412,8 @@ class FreqaiDataDrawer: def save_data(self, model: Any, coin: str, dk: FreqaiDataKitchen) -> 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 + :param model: User trained model which can be reused for inferencing to generate + predictions """ if not dk.data_path.is_dir(): @@ -423,7 +432,7 @@ class FreqaiDataDrawer: dk.data["data_path"] = str(dk.data_path) dk.data["model_filename"] = str(dk.model_filename) - dk.data["training_features_list"] = list(dk.data_dictionary["train_features"].columns) + dk.data["training_features_list"] = dk.training_features_list dk.data["label_list"] = dk.label_list # store the metadata with open(save_path / f"{dk.model_filename}_metadata.json", "w") as fp: @@ -522,8 +531,7 @@ class FreqaiDataDrawer: Append new candles to our stores historic data (in memory) so that we do not need to load candle history from disk and we dont need to pinging exchange multiple times for the same candle. - :params: - dataframe: DataFrame = strategy provided dataframe + :param dataframe: DataFrame = strategy provided dataframe """ feat_params = self.freqai_info["feature_parameters"] with self.history_lock: @@ -569,9 +577,8 @@ class FreqaiDataDrawer: """ Load pair histories for all whitelist and corr_pairlist pairs. Only called once upon startup of bot. - :params: - timerange: TimeRange = full timerange required to populate all indicators - for training according to user defined train_period_days + :param timerange: TimeRange = full timerange required to populate all indicators + for training according to user defined train_period_days """ history_data = self.historic_data @@ -594,10 +601,9 @@ class FreqaiDataDrawer: """ Searches through our historic_data in memory and returns the dataframes relevant to the present pair. - :params: - timerange: TimeRange = full timerange required to populate all indicators - for training according to user defined train_period_days - metadata: dict = strategy furnished pair metadata + :param timerange: TimeRange = full timerange required to populate all indicators + for training according to user defined train_period_days + :param metadata: dict = strategy furnished pair metadata """ with self.history_lock: corr_dataframes: Dict[Any, Any] = {} @@ -608,7 +614,8 @@ class FreqaiDataDrawer: ) for tf in self.freqai_info["feature_parameters"].get("include_timeframes"): - base_dataframes[tf] = dk.slice_dataframe(timerange, historic_data[pair][tf]) + base_dataframes[tf] = dk.slice_dataframe( + timerange, historic_data[pair][tf]).reset_index(drop=True) if pairs: for p in pairs: if pair in p: @@ -617,7 +624,7 @@ class FreqaiDataDrawer: corr_dataframes[p] = {} corr_dataframes[p][tf] = dk.slice_dataframe( timerange, historic_data[p][tf] - ) + ).reset_index(drop=True) return corr_dataframes, base_dataframes diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index b8030f547..9a9e7631b 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -114,9 +114,8 @@ class FreqaiDataKitchen: ) -> None: """ Set the paths to the data for the present coin/botloop - :params: - metadata: dict = strategy furnished pair metadata - trained_timestamp: int = timestamp of most recent training + :param metadata: dict = strategy furnished pair metadata + :param trained_timestamp: int = timestamp of most recent training """ self.full_path = freqai_util.get_full_models_path(self.config) self.data_path = Path( @@ -133,25 +132,20 @@ class FreqaiDataKitchen: 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. + :param filtered_dataframe: cleaned dataframe ready to be split. + :param labels: cleaned labels ready to be split. """ feat_dict = self.freqai_config["feature_parameters"] + if 'shuffle' not in self.freqai_config['data_split_parameters']: + self.freqai_config["data_split_parameters"].update({'shuffle': False}) + weights: npt.ArrayLike if feat_dict.get("weight_factor", 0) > 0: weights = self.set_weights_higher_recent(len(filtered_dataframe)) else: weights = np.ones(len(filtered_dataframe)) - if feat_dict.get("stratify_training_data", 0) > 0: - stratification = np.zeros(len(filtered_dataframe)) - for i in range(1, len(stratification)): - if i % feat_dict.get("stratify_training_data", 0) == 0: - stratification[i] = 1 - else: - stratification = None - if self.freqai_config.get('data_split_parameters', {}).get('test_size', 0.1) != 0: ( train_features, @@ -164,7 +158,6 @@ class FreqaiDataKitchen: filtered_dataframe[: filtered_dataframe.shape[0]], labels, weights, - stratify=stratification, **self.config["freqai"]["data_split_parameters"], ) else: @@ -199,13 +192,14 @@ class FreqaiDataKitchen: 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_df: 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. + + :param unfiltered_df: the full dataframe for the present training period + :param training_feature_list: list, the training feature list constructed by + self.build_feature_list() according to user specified + parameters in the configuration file. + :param labels: the labels for the dataset + :param training_filter: boolean which lets the function know if it is training data or + prediction data to be filtered. :returns: :filtered_df: dataframe cleaned of NaNs and only containing the user requested feature set. @@ -214,7 +208,7 @@ class FreqaiDataKitchen: filtered_df = unfiltered_df.filter(training_feature_list, axis=1) filtered_df = filtered_df.replace([np.inf, -np.inf], np.nan) - drop_index = pd.isnull(filtered_df).any(1) # get the rows that have NaNs, + drop_index = pd.isnull(filtered_df).any(axis=1) # get the rows that have NaNs, drop_index = drop_index.replace(True, 1).replace(False, 0) # pep8 requirement. if (training_filter): const_cols = list((filtered_df.nunique() == 1).loc[lambda x: x].index) @@ -225,7 +219,7 @@ class FreqaiDataKitchen: # about removing any row with NaNs # if labels has multiple columns (user wants to train multiple modelEs), we detect here labels = unfiltered_df.filter(label_list, axis=1) - drop_index_labels = pd.isnull(labels).any(1) + drop_index_labels = pd.isnull(labels).any(axis=1) drop_index_labels = drop_index_labels.replace(True, 1).replace(False, 0) dates = unfiltered_df['date'] filtered_df = filtered_df[ @@ -253,7 +247,7 @@ class FreqaiDataKitchen: 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_df).any(1) + drop_index = pd.isnull(filtered_df).any(axis=1) self.data["filter_drop_index_prediction"] = drop_index filtered_df.fillna(0, inplace=True) # replacing all NaNs with zeros to avoid issues in 'prediction', but any prediction @@ -295,8 +289,8 @@ class FreqaiDataKitchen: def normalize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: """ 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 + :param data_dictionary: dictionary containing the cleaned and + split training/test data/labels :returns: :data_dictionary: updated dictionary with standardized values. """ @@ -549,8 +543,7 @@ class FreqaiDataKitchen: def pca_transform(self, filtered_dataframe: DataFrame) -> None: """ Use an existing pca transform to transform data into components - :params: - filtered_dataframe: DataFrame = the cleaned dataframe + :param filtered_dataframe: DataFrame = the cleaned dataframe """ pca_components = self.pca.transform(filtered_dataframe) self.data_dictionary["prediction_features"] = pd.DataFrame( @@ -594,8 +587,7 @@ class FreqaiDataKitchen: """ Build/inference a Support Vector Machine to detect outliers in training data and prediction - :params: - predict: bool = If true, inference an existing SVM model, else construct one + :param predict: bool = If true, inference an existing SVM model, else construct one """ if self.keras: @@ -680,11 +672,11 @@ class FreqaiDataKitchen: Use DBSCAN to cluster training data and remove "noisy" data (read outliers). User controls this via the config param `DBSCAN_outlier_pct` which indicates the pct of training data that they want to be considered outliers. - :params: - predict: bool = If False (training), iterate to find the best hyper parameters to match - user requested outlier percent target. If True (prediction), use the parameters - determined from the previous training to estimate if the current prediction point - is an outlier. + :param predict: bool = If False (training), iterate to find the best hyper parameters + to match user requested outlier percent target. + If True (prediction), use the parameters determined from + the previous training to estimate if the current prediction point + is an outlier. """ if predict: @@ -835,7 +827,7 @@ class FreqaiDataKitchen: :, :no_prev_pts ] distances = distances.replace([np.inf, -np.inf], np.nan) - drop_index = pd.isnull(distances).any(1) + drop_index = pd.isnull(distances).any(axis=1) distances = distances[drop_index == 0] inliers = pd.DataFrame(index=distances.index) @@ -908,6 +900,7 @@ class FreqaiDataKitchen: """ column_names = dataframe.columns features = [c for c in column_names if "%" in c] + if not features: raise OperationalException("Could not find any features!") @@ -1145,15 +1138,13 @@ class FreqaiDataKitchen: prediction_dataframe: DataFrame = pd.DataFrame(), ) -> DataFrame: """ - Use the user defined strategy for populating indicators during - retrain - :params: - strategy: IStrategy = user defined strategy object - corr_dataframes: dict = dict containing the informative pair dataframes - (for user defined timeframes) - base_dataframes: dict = dict containing the current pair dataframes - (for user defined timeframes) - metadata: dict = strategy furnished pair metadata + Use the user defined strategy for populating indicators during retrain + :param strategy: IStrategy = user defined strategy object + :param corr_dataframes: dict = dict containing the informative pair dataframes + (for user defined timeframes) + :param base_dataframes: dict = dict containing the current pair dataframes + (for user defined timeframes) + :param metadata: dict = strategy furnished pair metadata :returns: dataframe: DataFrame = dataframe containing populated indicators """ diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 8106d034a..58407ca5a 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -215,7 +215,8 @@ class IFreqaiModel(ABC): new_trained_timerange, pair, strategy, dk, data_load_timerange ) except Exception as msg: - logger.warning(f'Training {pair} raised exception {msg}, skipping.') + logger.warning(f"Training {pair} raised exception {msg.__class__.__name__}. " + f"Message: {msg}, skipping.") self.train_timer('stop') @@ -297,7 +298,8 @@ class IFreqaiModel(ABC): if dk.check_if_backtest_prediction_exists(): self.dd.load_metadata(dk) - self.check_if_feature_list_matches_strategy(dataframe_train, dk) + dk.find_features(dataframe_train) + self.check_if_feature_list_matches_strategy(dk) append_df = dk.get_backtesting_prediction() dk.append_predictions(append_df) else: @@ -318,7 +320,6 @@ class IFreqaiModel(ABC): else: self.model = self.dd.load_data(pair, dk) - # self.check_if_feature_list_matches_strategy(dataframe_train, dk) pred_df, do_preds = self.predict(dataframe_backtest, dk) append_df = dk.get_predictions_to_append(pred_df, do_preds) dk.append_predictions(append_df) @@ -415,7 +416,7 @@ class IFreqaiModel(ABC): # allows FreqUI to show full return values. pred_df, do_preds = self.predict(dataframe, dk) if pair not in self.dd.historic_predictions: - self.set_initial_historic_predictions(pred_df, dk, pair) + self.set_initial_historic_predictions(pred_df, dk, pair, dataframe) self.dd.set_initial_return_values(pair, pred_df) dk.return_dataframe = self.dd.attach_return_values_to_return_dataframe(pair, dataframe) @@ -436,13 +437,13 @@ class IFreqaiModel(ABC): if self.freqai_info.get('fit_live_predictions_candles', 0) and self.live: self.fit_live_predictions(dk, pair) - self.dd.append_model_predictions(pair, pred_df, do_preds, dk, len(dataframe)) + self.dd.append_model_predictions(pair, pred_df, do_preds, dk, dataframe) dk.return_dataframe = self.dd.attach_return_values_to_return_dataframe(pair, dataframe) return def check_if_feature_list_matches_strategy( - self, dataframe: DataFrame, dk: FreqaiDataKitchen + self, dk: FreqaiDataKitchen ) -> None: """ Ensure user is passing the proper feature set if they are reusing an `identifier` pointing @@ -451,11 +452,12 @@ class IFreqaiModel(ABC): :param dk: FreqaiDataKitchen = non-persistent data container/analyzer for current coin/bot loop """ - dk.find_features(dataframe) + if "training_features_list_raw" in dk.data: feature_list = dk.data["training_features_list_raw"] else: feature_list = dk.data['training_features_list'] + if dk.training_features_list != feature_list: raise OperationalException( "Trying to access pretrained model with `identifier` " @@ -503,13 +505,16 @@ class IFreqaiModel(ABC): if self.freqai_info["feature_parameters"].get('noise_standard_deviation', 0): dk.add_noise_to_training_features() - def data_cleaning_predict(self, dk: FreqaiDataKitchen, dataframe: DataFrame) -> None: + def data_cleaning_predict(self, dk: FreqaiDataKitchen) -> None: """ Base data cleaning method for predict. Functions here are complementary to the functions of data_cleaning_train. """ ft_params = self.freqai_info["feature_parameters"] + # ensure user is feeding the correct indicators to the model + self.check_if_feature_list_matches_strategy(dk) + if ft_params.get('inlier_metric_window', 0): dk.compute_inlier_metric(set_='predict') @@ -527,9 +532,6 @@ class IFreqaiModel(ABC): if ft_params.get("use_DBSCAN_to_remove_outliers", False): dk.use_DBSCAN_to_remove_outliers(predict=True) - # ensure user is feeding the correct indicators to the model - self.check_if_feature_list_matches_strategy(dk.data_dictionary['prediction_features'], dk) - def model_exists(self, dk: FreqaiDataKitchen) -> bool: """ Given a pair and path, check if a model already exists @@ -604,7 +606,7 @@ class IFreqaiModel(ABC): self.dd.purge_old_models() def set_initial_historic_predictions( - self, pred_df: DataFrame, dk: FreqaiDataKitchen, pair: str + self, pred_df: DataFrame, dk: FreqaiDataKitchen, pair: str, strat_df: DataFrame ) -> None: """ This function is called only if the datadrawer failed to load an @@ -623,11 +625,11 @@ class IFreqaiModel(ABC): If the user reuses an identifier on a subsequent instance, this function will not be called. In that case, "real" predictions will be appended to the loaded set of historic predictions. - :param: df: DataFrame = the dataframe containing the training feature data - :param: model: Any = A model which was `fit` using a common library such as - catboost or lightgbm - :param: dk: FreqaiDataKitchen = object containing methods for data analysis - :param: pair: str = current pair + :param df: DataFrame = the dataframe containing the training feature data + :param model: Any = A model which was `fit` using a common library such as + catboost or lightgbm + :param dk: FreqaiDataKitchen = object containing methods for data analysis + :param pair: str = current pair """ self.dd.historic_predictions[pair] = pred_df @@ -647,6 +649,9 @@ class IFreqaiModel(ABC): for return_str in dk.data['extra_returns_per_train']: hist_preds_df[return_str] = 0 + hist_preds_df['close_price'] = strat_df['close'] + hist_preds_df['date_pred'] = strat_df['date'] + # # for keras type models, the conv_window needs to be prepended so # # viewing is correct in frequi if self.freqai_info.get('keras', False) or self.ft_params.get('inlier_metric_window', 0): diff --git a/freqtrade/freqai/prediction_models/CatboostClassifier.py b/freqtrade/freqai/prediction_models/CatboostClassifier.py index 60536e6de..9a77644ed 100644 --- a/freqtrade/freqai/prediction_models/CatboostClassifier.py +++ b/freqtrade/freqai/prediction_models/CatboostClassifier.py @@ -20,9 +20,8 @@ class CatboostClassifier(BaseClassifierModel): def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: """ 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. + :param data_dictionary: the dictionary constructed by DataHandler to hold + all the training and test data/labels. """ train_data = Pool( diff --git a/freqtrade/freqai/prediction_models/LightGBMClassifier.py b/freqtrade/freqai/prediction_models/LightGBMClassifier.py index 3eec516ba..e467ad3c1 100644 --- a/freqtrade/freqai/prediction_models/LightGBMClassifier.py +++ b/freqtrade/freqai/prediction_models/LightGBMClassifier.py @@ -20,9 +20,8 @@ class LightGBMClassifier(BaseClassifierModel): def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: """ 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. + :param data_dictionary: the dictionary constructed by DataHandler to hold + all the training and test data/labels. """ if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) == 0: diff --git a/freqtrade/freqai/prediction_models/XGBoostClassifier.py b/freqtrade/freqai/prediction_models/XGBoostClassifier.py index 8bf5d6281..67c7c7783 100644 --- a/freqtrade/freqai/prediction_models/XGBoostClassifier.py +++ b/freqtrade/freqai/prediction_models/XGBoostClassifier.py @@ -26,9 +26,8 @@ class XGBoostClassifier(BaseClassifierModel): def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: """ 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. + :param data_dictionary: the dictionary constructed by DataHandler to hold + all the training and test data/labels. """ X = data_dictionary["train_features"].to_numpy() @@ -65,7 +64,7 @@ class XGBoostClassifier(BaseClassifierModel): ) -> Tuple[DataFrame, npt.NDArray[np.int_]]: """ Filter the prediction features data and predict with it. - :param: unfiltered_df: Full dataframe for the current backtest period. + :param unfiltered_df: Full dataframe for the current backtest period. :return: :pred_df: dataframe containing the predictions :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 72b88a82f..cd111679c 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -82,7 +82,10 @@ class FreqtradeBot(LoggingMixin): # Keep this at the end of this initialization method. self.rpc: RPCManager = RPCManager(self) - self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists, self.rpc) + self.dataprovider = DataProvider(self.config, self.exchange, rpc=self.rpc) + self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider) + + self.dataprovider.add_pairlisthandler(self.pairlists) # Attach Dataprovider to strategy instance self.strategy.dp = self.dataprovider @@ -597,7 +600,7 @@ class FreqtradeBot(LoggingMixin): # We should decrease our position amount = self.exchange.amount_to_contract_precision( trade.pair, - abs(float(FtPrecise(stake_amount) / FtPrecise(current_exit_rate)))) + abs(float(FtPrecise(stake_amount * trade.leverage) / FtPrecise(current_exit_rate)))) if amount > trade.amount: # This is currently ineffective as remaining would become < min tradable # Fixing this would require checking for 0.0 there - @@ -1308,7 +1311,7 @@ class FreqtradeBot(LoggingMixin): # place new order only if new price is supplied self.execute_entry( pair=trade.pair, - stake_amount=(order_obj.remaining * order_obj.price), + stake_amount=(order_obj.remaining * order_obj.price / trade.leverage), price=adjusted_entry_price, trade=trade, is_short=trade.is_short, @@ -1340,11 +1343,12 @@ class FreqtradeBot(LoggingMixin): replacing: Optional[bool] = False ) -> bool: """ - Buy cancel - cancel order + entry cancel - cancel order :param replacing: Replacing order - prevent trade deletion. - :return: True if order was fully cancelled + :return: True if trade was fully cancelled """ was_trade_fully_canceled = False + side = trade.entry_side.capitalize() # Cancelled orders may have the status of 'canceled' or 'closed' if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES: @@ -1371,7 +1375,6 @@ class FreqtradeBot(LoggingMixin): corder = order reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - side = trade.entry_side.capitalize() logger.info('%s order %s for %s.', side, reason, trade) # Using filled to determine the filled amount @@ -1385,24 +1388,15 @@ class FreqtradeBot(LoggingMixin): was_trade_fully_canceled = True reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}" else: - # FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below. self.update_trade_state(trade, trade.open_order_id, corder) trade.open_order_id = None logger.info(f'{side} Order timeout for {trade}.') else: - # if trade is partially complete, edit the stake details for the trade - # and close the order - # cancel_order may not contain the full order dict, so we need to fallback - # to the order dict acquired before cancelling. - # we need to fall back to the values from order if corder does not contain these keys. - trade.amount = filled_amount - # * Check edge cases, we don't want to make leverage > 1.0 if we don't have to - # * (for leverage modes which aren't isolated futures) - - trade.stake_amount = trade.amount * trade.open_rate / trade.leverage + # update_trade_state (and subsequently recalc_trade_from_orders) will handle updates + # to the trade object self.update_trade_state(trade, trade.open_order_id, corder) - trade.open_order_id = None + logger.info(f'Partial {trade.entry_side} order timeout for {trade}.') reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}" @@ -1417,49 +1411,63 @@ class FreqtradeBot(LoggingMixin): :return: True if exit order was cancelled, false otherwise """ cancelled = False - # if trade is not partially completed, just cancel the order - if order['remaining'] == order['amount'] or order.get('filled') == 0.0: - if not self.exchange.check_order_canceled_empty(order): - try: - # if trade is not partially completed, just delete the order - co = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, - trade.amount) - trade.update_order(co) - except InvalidOrderException: - logger.exception( - f"Could not cancel {trade.exit_side} order {trade.open_order_id}") - return False - logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade) - else: - reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade) - trade.update_order(order) + # Cancelled orders may have the status of 'canceled' or 'closed' + if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES: + filled_val: float = order.get('filled', 0.0) or 0.0 + filled_rem_stake = trade.stake_amount - filled_val * trade.open_rate + minstake = self.exchange.get_min_pair_stake_amount( + trade.pair, trade.open_rate, self.strategy.stoploss) + # Double-check remaining amount + if filled_val > 0: + reason = constants.CANCEL_REASON['PARTIALLY_FILLED'] + if minstake and filled_rem_stake < minstake: + logger.warning( + f"Order {trade.open_order_id} for {trade.pair} not cancelled, as " + f"the filled amount of {filled_val} would result in an unexitable trade.") + reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] + self._notify_exit_cancel( + trade, + order_type=self.strategy.order_types['exit'], + reason=reason, order_id=order['id'], + sub_trade=trade.amount != order['amount'] + ) + return False + + try: + co = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, + trade.amount) + except InvalidOrderException: + logger.exception( + f"Could not cancel {trade.exit_side} order {trade.open_order_id}") + return False trade.close_rate = None trade.close_rate_requested = None trade.close_profit = None trade.close_profit_abs = None - trade.close_date = None - trade.is_open = True - trade.open_order_id = None - trade.exit_reason = None + # Set exit_reason for fill message + exit_reason_prev = trade.exit_reason + trade.exit_reason = trade.exit_reason + f", {reason}" if trade.exit_reason else reason + self.update_trade_state(trade, trade.open_order_id, co) + # Order might be filled above in odd timing issues. + if co.get('status') in ('canceled', 'cancelled'): + trade.exit_reason = None + trade.open_order_id = None + else: + trade.exit_reason = exit_reason_prev + + logger.info(f'{trade.exit_side.capitalize()} order {reason} for {trade}.') cancelled = True - self.wallets.update() else: - # TODO: figure out how to handle partially complete sell orders - reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] - cancelled = False + reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] + logger.info(f'{trade.exit_side.capitalize()} order {reason} for {trade}.') + self.update_trade_state(trade, trade.open_order_id, order) + trade.open_order_id = None - order_obj = trade.select_order_by_order_id(order['id']) - if not order_obj: - raise DependencyException( - f"Order_obj not found for {order['id']}. This should not have happened.") - - sub_trade = order_obj.amount != trade.amount self._notify_exit_cancel( trade, order_type=self.strategy.order_types['exit'], - reason=reason, order=order_obj, sub_trade=sub_trade + reason=reason, order_id=order['id'], sub_trade=trade.amount != order['amount'] ) return cancelled @@ -1656,7 +1664,7 @@ class FreqtradeBot(LoggingMixin): self.rpc.send_msg(msg) def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str, - order: Order, sub_trade: bool = False) -> None: + order_id: str, sub_trade: bool = False) -> None: """ Sends rpc notification when a sell cancel occurred. """ @@ -1665,6 +1673,11 @@ class FreqtradeBot(LoggingMixin): else: trade.exit_order_status = reason + order = trade.select_order_by_order_id(order_id) + if not order: + raise DependencyException( + f"Order_obj not found for {order_id}. This should not have happened.") + profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested profit_trade = trade.calc_profit(rate=profit_rate) current_rate = self.exchange.get_rate( @@ -1700,11 +1713,6 @@ class FreqtradeBot(LoggingMixin): 'stake_amount': trade.stake_amount, } - if 'fiat_display_currency' in self.config: - msg.update({ - 'fiat_currency': self.config['fiat_display_currency'], - }) - # Send the message self.rpc.send_msg(msg) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 626051700..b3f2e71f2 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -110,7 +110,7 @@ class Backtesting: self.timeframe = str(self.config.get('timeframe')) self.timeframe_min = timeframe_to_minutes(self.timeframe) self.init_backtest_detail() - self.pairlists = PairListManager(self.exchange, self.config) + self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider) if 'VolumePairList' in self.pairlists.name_list: raise OperationalException("VolumePairList not allowed for backtesting. " "Please use StaticPairList instead.") @@ -544,7 +544,7 @@ class Backtesting: if stake_amount is not None and stake_amount < 0.0: amount = amount_to_contract_precision( - abs(stake_amount) / current_rate, trade.amount_precision, + abs(stake_amount * trade.leverage) / current_rate, trade.amount_precision, self.precision_mode, trade.contract_size) if amount == 0.0: return trade @@ -1049,7 +1049,7 @@ class Backtesting: if requested_rate: self._enter_trade(pair=trade.pair, row=row, trade=trade, requested_rate=requested_rate, - requested_stake=(order.remaining * order.price), + requested_stake=(order.remaining * order.price / trade.leverage), direction='short' if trade.is_short else 'long') self.replaced_entry_orders += 1 else: diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index 162556705..d93bbbfc1 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -24,6 +24,7 @@ from pandas import DataFrame from freqtrade.constants import DATETIME_PRINT_FORMAT, FTHYPT_FILEVERSION, LAST_BT_RESULT_FN, Config from freqtrade.data.converter import trim_dataframes from freqtrade.data.history import get_timerange +from freqtrade.data.metrics import calculate_market_change from freqtrade.enums import HyperoptState from freqtrade.exceptions import OperationalException from freqtrade.misc import deep_merge_dicts, file_dump_json, plural @@ -111,6 +112,7 @@ class Hyperopt: self.clean_hyperopt() + self.market_change = 0.0 self.num_epochs_saved = 0 self.current_best_epoch: Optional[Dict[str, Any]] = None @@ -357,7 +359,7 @@ class Hyperopt: strat_stats = generate_strategy_stats( self.pairlist, self.backtesting.strategy.get_strategy_name(), - backtesting_results, min_date, max_date, market_change=0 + backtesting_results, min_date, max_date, market_change=self.market_change ) results_explanation = HyperoptTools.format_results_explanation_string( strat_stats, self.config['stake_currency']) @@ -425,6 +427,9 @@ class Hyperopt: # Trim startup period from analyzed dataframe to get correct dates for output. trimmed = trim_dataframes(preprocessed, self.timerange, self.backtesting.required_startup) self.min_date, self.max_date = get_timerange(trimmed) + if not self.market_change: + self.market_change = calculate_market_change(trimmed, 'close') + # Real trimming will happen as part of backtesting. return preprocessed diff --git a/freqtrade/plugins/pairlist/AgeFilter.py b/freqtrade/plugins/pairlist/AgeFilter.py index 70638936a..7c8cdb5ab 100644 --- a/freqtrade/plugins/pairlist/AgeFilter.py +++ b/freqtrade/plugins/pairlist/AgeFilter.py @@ -70,7 +70,7 @@ class AgeFilter(IPairList): def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: """ :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new allowlist """ needed_pairs: ListPairsWithTimeframes = [ diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index c02ba5ef5..60abac6a1 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -69,7 +69,7 @@ class IPairList(LoggingMixin, ABC): filter_pairlist() method. :param pair: Pair that's currently validated - :param ticker: ticker dict as returned from ccxt.fetch_tickers() + :param ticker: ticker dict as returned from ccxt.fetch_ticker :return: True if the pair can stay, false if it should be removed """ raise NotImplementedError() @@ -85,7 +85,7 @@ class IPairList(LoggingMixin, ABC): it will raise the exception if a Pairlist Handler is used at the first position in the chain. - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: List of pairs """ raise OperationalException("This Pairlist Handler should not be used " @@ -103,7 +103,7 @@ class IPairList(LoggingMixin, ABC): own filtration. :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new whitelist """ if self._enabled: diff --git a/freqtrade/plugins/pairlist/OffsetFilter.py b/freqtrade/plugins/pairlist/OffsetFilter.py index 149befdeb..c9531ece1 100644 --- a/freqtrade/plugins/pairlist/OffsetFilter.py +++ b/freqtrade/plugins/pairlist/OffsetFilter.py @@ -47,7 +47,7 @@ class OffsetFilter(IPairList): Filters and sorts pairlist and returns the whitelist again. Called on each bot iteration - please use internal caching if necessary :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new whitelist """ if self._offset > len(pairlist): diff --git a/freqtrade/plugins/pairlist/PerformanceFilter.py b/freqtrade/plugins/pairlist/PerformanceFilter.py index c29b4f337..4cc92175a 100644 --- a/freqtrade/plugins/pairlist/PerformanceFilter.py +++ b/freqtrade/plugins/pairlist/PerformanceFilter.py @@ -44,7 +44,7 @@ class PerformanceFilter(IPairList): Filters and sorts pairlist and returns the allowlist again. Called on each bot iteration - please use internal caching if necessary :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new allowlist """ # Get the trading performance for pairs from database diff --git a/freqtrade/plugins/pairlist/PrecisionFilter.py b/freqtrade/plugins/pairlist/PrecisionFilter.py index 8f1c9b839..98cb3ba46 100644 --- a/freqtrade/plugins/pairlist/PrecisionFilter.py +++ b/freqtrade/plugins/pairlist/PrecisionFilter.py @@ -49,7 +49,7 @@ class PrecisionFilter(IPairList): Check if pair has enough room to add a stoploss to avoid "unsellable" buys of very low value pairs. :param pair: Pair that's currently validated - :param ticker: ticker dict as returned from ccxt.fetch_tickers() + :param ticker: ticker dict as returned from ccxt.fetch_ticker :return: True if the pair can stay, false if it should be removed """ if ticker.get('last', None) is None: diff --git a/freqtrade/plugins/pairlist/PriceFilter.py b/freqtrade/plugins/pairlist/PriceFilter.py index f2952001a..a6b400a38 100644 --- a/freqtrade/plugins/pairlist/PriceFilter.py +++ b/freqtrade/plugins/pairlist/PriceFilter.py @@ -68,7 +68,7 @@ class PriceFilter(IPairList): """ Check if if one price-step (pip) is > than a certain barrier. :param pair: Pair that's currently validated - :param ticker: ticker dict as returned from ccxt.fetch_tickers() + :param ticker: ticker dict as returned from ccxt.fetch_ticker :return: True if the pair can stay, false if it should be removed """ if ticker.get('last', None) is None or ticker.get('last') == 0: diff --git a/freqtrade/plugins/pairlist/ProducerPairList.py b/freqtrade/plugins/pairlist/ProducerPairList.py new file mode 100644 index 000000000..740cb4ec2 --- /dev/null +++ b/freqtrade/plugins/pairlist/ProducerPairList.py @@ -0,0 +1,90 @@ +""" +External Pair List provider + +Provides pair list from Leader data +""" +import logging +from typing import Any, Dict, List, Optional + +from freqtrade.exceptions import OperationalException +from freqtrade.plugins.pairlist.IPairList import IPairList + + +logger = logging.getLogger(__name__) + + +class ProducerPairList(IPairList): + """ + PairList plugin for use with external_message_consumer. + Will use pairs given from leader data. + + Usage: + "pairlists": [ + { + "method": "ProducerPairList", + "number_assets": 5, + "producer_name": "default", + } + ], + """ + + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + self._num_assets: int = self._pairlistconfig.get('number_assets', 0) + self._producer_name = self._pairlistconfig.get('producer_name', 'default') + if not config.get('external_message_consumer', {}).get('enabled'): + raise OperationalException( + "ProducerPairList requires external_message_consumer to be enabled.") + + @property + def needstickers(self) -> bool: + """ + Boolean property defining if tickers are necessary. + If no Pairlist requires tickers, an empty Dict is passed + as tickers argument to filter_pairlist + """ + return False + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + -> Please overwrite in subclasses + """ + return f"{self.name} - {self._producer_name}" + + def _filter_pairlist(self, pairlist: Optional[List[str]]): + upstream_pairlist = self._pairlistmanager._dataprovider.get_producer_pairs( + self._producer_name) + + if pairlist is None: + pairlist = self._pairlistmanager._dataprovider.get_producer_pairs(self._producer_name) + + pairs = list(dict.fromkeys(pairlist + upstream_pairlist)) + if self._num_assets: + pairs = pairs[:self._num_assets] + + return pairs + + def gen_pairlist(self, tickers: Dict) -> List[str]: + """ + Generate the pairlist + :param tickers: Tickers (from exchange.get_tickers). May be cached. + :return: List of pairs + """ + pairs = self._filter_pairlist(None) + self.log_once(f"Received pairs: {pairs}", logger.debug) + pairs = self._whitelist_for_active_markets(self.verify_whitelist(pairs, logger.info)) + return pairs + + def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: + """ + Filters and sorts pairlist and returns the whitelist again. + Called on each bot iteration - please use internal caching if necessary + :param pairlist: pairlist to filter or sort + :param tickers: Tickers (from exchange.get_tickers). May be cached. + :return: new whitelist + """ + return self._filter_pairlist(pairlist) diff --git a/freqtrade/plugins/pairlist/ShuffleFilter.py b/freqtrade/plugins/pairlist/ShuffleFilter.py index b6b5fc3c8..6eb4231bc 100644 --- a/freqtrade/plugins/pairlist/ShuffleFilter.py +++ b/freqtrade/plugins/pairlist/ShuffleFilter.py @@ -52,7 +52,7 @@ class ShuffleFilter(IPairList): Filters and sorts pairlist and returns the whitelist again. Called on each bot iteration - please use internal caching if necessary :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new whitelist """ # Shuffle is done inplace diff --git a/freqtrade/plugins/pairlist/SpreadFilter.py b/freqtrade/plugins/pairlist/SpreadFilter.py index 1f20af305..1f6d4f687 100644 --- a/freqtrade/plugins/pairlist/SpreadFilter.py +++ b/freqtrade/plugins/pairlist/SpreadFilter.py @@ -48,7 +48,7 @@ class SpreadFilter(IPairList): """ Validate spread for the ticker :param pair: Pair that's currently validated - :param ticker: ticker dict as returned from ccxt.fetch_tickers() + :param ticker: ticker dict as returned from ccxt.fetch_ticker :return: True if the pair can stay, false if it should be removed """ if 'bid' in ticker and 'ask' in ticker and ticker['ask'] and ticker['bid']: diff --git a/freqtrade/plugins/pairlist/StaticPairList.py b/freqtrade/plugins/pairlist/StaticPairList.py index 83a0fa0c8..5b1337754 100644 --- a/freqtrade/plugins/pairlist/StaticPairList.py +++ b/freqtrade/plugins/pairlist/StaticPairList.py @@ -42,7 +42,7 @@ class StaticPairList(IPairList): def gen_pairlist(self, tickers: Dict) -> List[str]: """ Generate the pairlist - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: List of pairs """ if self._allow_inactive: @@ -58,7 +58,7 @@ class StaticPairList(IPairList): Filters and sorts pairlist and returns the whitelist again. Called on each bot iteration - please use internal caching if necessary :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new whitelist """ pairlist_ = deepcopy(pairlist) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index c9af3a7b3..c06fc09ba 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -66,7 +66,7 @@ class VolatilityFilter(IPairList): """ Validate trading range :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new allowlist """ needed_pairs: ListPairsWithTimeframes = [ diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 9dcada291..bfecbd62a 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -113,7 +113,7 @@ class VolumePairList(IPairList): def gen_pairlist(self, tickers: Dict) -> List[str]: """ Generate the pairlist - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: List of pairs """ # Generate dynamic whitelist @@ -150,7 +150,7 @@ class VolumePairList(IPairList): Filters and sorts pairlist and returns the whitelist again. Called on each bot iteration - please use internal caching if necessary :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new whitelist """ if self._use_range: @@ -232,6 +232,4 @@ class VolumePairList(IPairList): # Limit pairlist to the requested number of pairs pairs = pairs[:self._number_pairs] - self.log_once(f"Searching {self._number_pairs} pairs: {pairs}", logger.info) - return pairs diff --git a/freqtrade/plugins/pairlist/pairlist_helpers.py b/freqtrade/plugins/pairlist/pairlist_helpers.py index 9ef3e4614..93d4fc308 100644 --- a/freqtrade/plugins/pairlist/pairlist_helpers.py +++ b/freqtrade/plugins/pairlist/pairlist_helpers.py @@ -12,7 +12,7 @@ def expand_pairlist(wildcardpl: List[str], available_pairs: List[str], :param wildcardpl: List of Pairlists, which may contain regex :param available_pairs: List of all available pairs (`exchange.get_markets().keys()`) :param keep_invalid: If sets to True, drops invalid pairs silently while expanding regexes - :return expanded pairlist, with Regexes from wildcardpl applied to match all available pairs. + :return: expanded pairlist, with Regexes from wildcardpl applied to match all available pairs. :raises: ValueError if a wildcard is invalid (like '*/BTC' - which should be `.*/BTC`) """ result = [] diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 1bc7ad48f..ca844f003 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -64,7 +64,7 @@ class RangeStabilityFilter(IPairList): """ Validate trading range :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new allowlist """ needed_pairs: ListPairsWithTimeframes = [ diff --git a/freqtrade/plugins/pairlistmanager.py b/freqtrade/plugins/pairlistmanager.py index e01abb297..5ed319e93 100644 --- a/freqtrade/plugins/pairlistmanager.py +++ b/freqtrade/plugins/pairlistmanager.py @@ -3,11 +3,12 @@ PairList manager class """ import logging from functools import partial -from typing import Dict, List +from typing import Dict, List, Optional from cachetools import TTLCache, cached from freqtrade.constants import Config, ListPairsWithTimeframes +from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import CandleType from freqtrade.exceptions import OperationalException from freqtrade.mixins import LoggingMixin @@ -21,13 +22,14 @@ logger = logging.getLogger(__name__) class PairListManager(LoggingMixin): - def __init__(self, exchange, config: Config) -> None: + def __init__(self, exchange, config: Config, dataprovider: DataProvider = None) -> None: self._exchange = exchange self._config = config self._whitelist = self._config['exchange'].get('pair_whitelist') self._blacklist = self._config['exchange'].get('pair_blacklist', []) self._pairlist_handlers: List[IPairList] = [] self._tickers_needed = False + self._dataprovider: Optional[DataProvider] = dataprovider for pairlist_handler_config in self._config.get('pairlists', []): pairlist_handler = PairListResolver.load_pairlist( pairlist_handler_config['method'], @@ -96,6 +98,8 @@ class PairListManager(LoggingMixin): # to ensure blacklist is respected. pairlist = self.verify_blacklist(pairlist, logger.warning) + self.log_once(f"Whitelist with {len(pairlist)} pairs: {pairlist}", logger.info) + self._whitelist = pairlist def verify_blacklist(self, pairlist: List[str], logmethod) -> List[str]: diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index df4324740..53af91477 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -198,8 +198,10 @@ class ApiServer(RPCHandler): logger.debug(f"Found message of type: {message.get('type')}") # Broadcast it await self._ws_channel_manager.broadcast(message) - # Sleep, make this configurable? - await asyncio.sleep(0.1) + # Limit messages per sec. + # Could cause problems with queue size if too low, and + # problems with network traffik if too high. + await asyncio.sleep(0.001) except asyncio.CancelledError: pass diff --git a/freqtrade/rpc/discord.py b/freqtrade/rpc/discord.py index 9efe6f427..c48508300 100644 --- a/freqtrade/rpc/discord.py +++ b/freqtrade/rpc/discord.py @@ -30,9 +30,9 @@ class Discord(Webhook): pass def send_msg(self, msg) -> None: - logger.info(f"Sending discord message: {msg}") if msg['type'].value in self.config['discord']: + logger.info(f"Sending discord message: {msg}") msg['strategy'] = self.strategy msg['timeframe'] = self.timeframe diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index dcfe1d109..f5ba4b490 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -284,7 +284,7 @@ class ExternalMessageConsumer: logger.error(f"Empty message received from `{producer_name}`") return - logger.info(f"Received message of type `{producer_message.type}` from `{producer_name}`") + logger.debug(f"Received message of type `{producer_message.type}` from `{producer_name}`") message_handler = self._message_handlers.get(producer_message.type) diff --git a/freqtrade/rpc/fiat_convert.py b/freqtrade/rpc/fiat_convert.py index cbe4c0045..24c34af72 100644 --- a/freqtrade/rpc/fiat_convert.py +++ b/freqtrade/rpc/fiat_convert.py @@ -3,8 +3,8 @@ Module that define classes to convert Crypto-currency to FIAT e.g BTC to USD """ -import datetime import logging +from datetime import datetime from typing import Dict, List from cachetools import TTLCache @@ -46,7 +46,9 @@ class CryptoToFiatConverter(LoggingMixin): if CryptoToFiatConverter.__instance is None: CryptoToFiatConverter.__instance = object.__new__(cls) try: - CryptoToFiatConverter._coingekko = CoinGeckoAPI() + # Limit retires to 1 (0 and 1) + # otherwise we risk bot impact if coingecko is down. + CryptoToFiatConverter._coingekko = CoinGeckoAPI(retries=1) except BaseException: CryptoToFiatConverter._coingekko = None return CryptoToFiatConverter.__instance @@ -67,7 +69,7 @@ class CryptoToFiatConverter(LoggingMixin): logger.warning( "Too many requests for CoinGecko API, backing off and trying again later.") # Set backoff timestamp to 60 seconds in the future - self._backoff = datetime.datetime.now().timestamp() + 60 + self._backoff = datetime.now().timestamp() + 60 return # If the request is not a 429 error we want to raise the normal error logger.error( @@ -81,7 +83,7 @@ class CryptoToFiatConverter(LoggingMixin): def _get_gekko_id(self, crypto_symbol): if not self._coinlistings: - if self._backoff <= datetime.datetime.now().timestamp(): + if self._backoff <= datetime.now().timestamp(): self._load_cryptomap() # Still not loaded. if not self._coinlistings: diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index e286487ff..e3b31d225 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -67,7 +67,7 @@ class RPCManager: 'status': 'stopping bot' } """ - if msg.get('type') is not RPCMessageType.ANALYZED_DF: + if msg.get('type') not in (RPCMessageType.ANALYZED_DF, RPCMessageType.WHITELIST): logger.info('Sending rpc message: %s', msg) if 'pair' in msg: msg.update({ diff --git a/freqtrade/rpc/webhook.py b/freqtrade/rpc/webhook.py index 6109e80bc..bb3b3922f 100644 --- a/freqtrade/rpc/webhook.py +++ b/freqtrade/rpc/webhook.py @@ -61,6 +61,14 @@ class Webhook(RPCHandler): RPCMessageType.STARTUP, RPCMessageType.WARNING): valuedict = whconfig.get('webhookstatus') + elif msg['type'] in ( + RPCMessageType.PROTECTION_TRIGGER, + RPCMessageType.PROTECTION_TRIGGER_GLOBAL, + RPCMessageType.WHITELIST, + RPCMessageType.ANALYZED_DF, + RPCMessageType.STRATEGY_MSG): + # Don't fail for non-implemented types + return else: raise NotImplementedError('Unknown message type: {}'.format(msg['type'])) if not valuedict: diff --git a/freqtrade/templates/sample_strategy.py b/freqtrade/templates/sample_strategy.py index 1eb3d4256..fd81570fe 100644 --- a/freqtrade/templates/sample_strategy.py +++ b/freqtrade/templates/sample_strategy.py @@ -5,6 +5,7 @@ import numpy as np # noqa import pandas as pd # noqa from pandas import DataFrame +from typing import Optional, Union from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter, IStrategy, IntParameter) diff --git a/requirements-dev.txt b/requirements-dev.txt index d50105662..a3ac21985 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,23 +8,23 @@ coveralls==3.3.1 flake8==5.0.4 flake8-tidy-imports==4.8.0 -mypy==0.971 +mypy==0.982 pre-commit==2.20.0 pytest==7.1.3 pytest-asyncio==0.19.0 -pytest-cov==3.0.0 -pytest-mock==3.8.2 +pytest-cov==4.0.0 +pytest-mock==3.10.0 pytest-random-order==1.0.4 isort==5.10.1 # For datetime mocking -time-machine==2.8.1 +time-machine==2.8.2 # Convert jupyter notebooks to markdown documents -nbconvert==7.0.0 +nbconvert==7.2.1 # mypy types types-cachetools==5.2.1 types-filelock==3.2.7 -types-requests==2.28.11 -types-tabulate==0.8.11 +types-requests==2.28.11.2 +types-tabulate==0.9.0.0 types-python-dateutil==2.8.19 diff --git a/requirements-freqai.txt b/requirements-freqai.txt index 9cdd431fe..cf0d2eb07 100644 --- a/requirements-freqai.txt +++ b/requirements-freqai.txt @@ -4,6 +4,6 @@ # Required for freqai scikit-learn==1.1.2 joblib==1.2.0 -catboost==1.0.6; platform_machine != 'aarch64' +catboost==1.1; platform_machine != 'aarch64' lightgbm==3.3.2 xgboost==1.6.2 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index efa31272a..3e76a6c22 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,7 +2,7 @@ -r requirements.txt # Required for hyperopt -scipy==1.9.1 +scipy==1.9.2 scikit-learn==1.1.2 scikit-optimize==0.9.0 filelock==3.8.0 diff --git a/requirements.txt b/requirements.txt index 366b3c3fa..b7d4162b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ pandas==1.5.0; platform_machine != 'armv7l' pandas==1.4.3; platform_machine == 'armv7l' pandas-ta==0.3.14b -ccxt==1.93.98 +ccxt==1.95.30 # Pin cryptography for now due to rust build errors with piwheels cryptography==38.0.1 aiohttp==3.8.3 @@ -17,7 +17,7 @@ urllib3==1.26.12 jsonschema==4.16.0 TA-Lib==0.4.25 technical==1.3.0 -tabulate==0.8.10 +tabulate==0.9.0 pycoingecko==3.0.0 jinja2==3.1.2 tables==3.7.0 @@ -38,6 +38,7 @@ sdnotify==0.3.2 # API Server fastapi==0.85.0 +pydantic>=1.8.0 uvicorn==0.18.3 pyjwt==2.5.0 aiofiles==22.1.0 diff --git a/scripts/ws_client.py b/scripts/ws_client.py new file mode 100644 index 000000000..83182ae9e --- /dev/null +++ b/scripts/ws_client.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +Simple command line client for Testing/debugging +a Freqtrade bot's message websocket + +Should not import anything from freqtrade, +so it can be used as a standalone script. +""" +import argparse +import asyncio +import logging +import socket +import sys +import time +from pathlib import Path + +import orjson +import pandas +import rapidjson +import websockets +from dateutil.relativedelta import relativedelta + + +logger = logging.getLogger("WebSocketClient") + + +# --------------------------------------------------------------------------- + +def setup_logging(filename: str): + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(filename), + logging.StreamHandler() + ] + ) + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + '-c', + '--config', + help='Specify configuration file (default: %(default)s). ', + dest='config', + type=str, + metavar='PATH', + default='config.json' + ) + parser.add_argument( + '-l', + '--logfile', + help='The filename to log to.', + dest='logfile', + type=str, + default='ws_client.log' + ) + + args = parser.parse_args() + return vars(args) + + +def load_config(configfile): + file = Path(configfile) + if file.is_file(): + with file.open("r") as f: + config = rapidjson.load(f, parse_mode=rapidjson.PM_COMMENTS | + rapidjson.PM_TRAILING_COMMAS) + return config + else: + logger.warning(f"Could not load config file {file}.") + sys.exit(1) + + +def readable_timedelta(delta): + """ + Convert a dateutil.relativedelta to a readable format + + :param delta: A dateutil.relativedelta + :returns: The readable time difference string + """ + attrs = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'microseconds'] + return ", ".join([ + '%d %s' % (getattr(delta, attr), attr if getattr(delta, attr) > 1 else attr[:-1]) + for attr in attrs if getattr(delta, attr) + ]) + +# ---------------------------------------------------------------------------- + + +def json_serialize(message): + """ + Serialize a message to JSON using orjson + :param message: The message to serialize + """ + return str(orjson.dumps(message), "utf-8") + + +def json_deserialize(message): + """ + Deserialize JSON to a dict + :param message: The message to deserialize + """ + def json_to_dataframe(data: str) -> pandas.DataFrame: + dataframe = pandas.read_json(data, orient='split') + if 'date' in dataframe.columns: + dataframe['date'] = pandas.to_datetime(dataframe['date'], unit='ms', utc=True) + + return dataframe + + def _json_object_hook(z): + if z.get('__type__') == 'dataframe': + return json_to_dataframe(z.get('__value__')) + return z + + return rapidjson.loads(message, object_hook=_json_object_hook) + + +# --------------------------------------------------------------------------- + + +class ClientProtocol: + logger = logging.getLogger("WebSocketClient.Protocol") + _MESSAGE_COUNT = 0 + _LAST_RECEIVED_AT = 0 # The epoch we received a message most recently + + async def on_connect(self, websocket): + # On connection we have to send our initial requests + initial_requests = [ + { + "type": "subscribe", # The subscribe request should always be first + "data": ["analyzed_df", "whitelist"] # The message types we want + }, + { + "type": "whitelist", + "data": None, + }, + { + "type": "analyzed_df", + "data": {"limit": 1500} + } + ] + + for request in initial_requests: + await websocket.send(json_serialize(request)) + + async def on_message(self, websocket, name, message): + deserialized = json_deserialize(message) + + message_size = sys.getsizeof(message) + message_type = deserialized.get('type') + message_data = deserialized.get('data') + + self.logger.info( + f"Received message of type {message_type} [{message_size} bytes] @ [{name}]" + ) + + time_difference = self._calculate_time_difference() + + if self._MESSAGE_COUNT > 0: + self.logger.info(f"Time since last message: {time_difference}") + + message_handler = getattr(self, f"_handle_{message_type}", None) or self._handle_default + await message_handler(name, message_type, message_data) + + self._MESSAGE_COUNT += 1 + self.logger.info(f"[{self._MESSAGE_COUNT}] total messages..") + self.logger.info("-" * 80) + + def _calculate_time_difference(self): + old_last_received_at = self._LAST_RECEIVED_AT + self._LAST_RECEIVED_AT = time.time() * 1000 + time_delta = relativedelta(microseconds=(self._LAST_RECEIVED_AT - old_last_received_at)) + + return readable_timedelta(time_delta) + + async def _handle_whitelist(self, name, type, data): + self.logger.info(data) + + async def _handle_analyzed_df(self, name, type, data): + key, la, df = data['key'], data['la'], data['df'] + + if not df.empty: + columns = ", ".join([str(column) for column in df.columns]) + + self.logger.info(key) + self.logger.info(f"Last analyzed datetime: {la}") + self.logger.info(f"Latest candle datetime: {df.iloc[-1]['date']}") + self.logger.info(f"DataFrame length: {len(df)}") + self.logger.info(f"DataFrame columns: {columns}") + else: + self.logger.info("Empty DataFrame") + + async def _handle_default(self, name, type, data): + self.logger.info("Unkown message of type {type} received...") + self.logger.info(data) + + +async def create_client( + host, + port, + token, + name='default', + protocol=ClientProtocol(), + sleep_time=10, + ping_timeout=10, + wait_timeout=30, + **kwargs +): + """ + Create a websocket client and listen for messages + :param host: The host + :param port: The port + :param token: The websocket auth token + :param name: The name of the producer + :param **kwargs: Any extra kwargs passed to websockets.connect + """ + + while 1: + try: + websocket_url = f"ws://{host}:{port}/api/v1/message/ws?token={token}" + logger.info(f"Attempting to connect to {name} @ {host}:{port}") + + async with websockets.connect(websocket_url, **kwargs) as ws: + logger.info("Connection successful...") + await protocol.on_connect(ws) + + # Now listen for messages + while 1: + try: + message = await asyncio.wait_for( + ws.recv(), + timeout=wait_timeout + ) + + await protocol.on_message(ws, name, message) + + except ( + asyncio.TimeoutError, + websockets.exceptions.ConnectionClosed + ): + # Try pinging + try: + pong = ws.ping() + await asyncio.wait_for( + pong, + timeout=ping_timeout + ) + logger.info("Connection still alive...") + + continue + + except asyncio.TimeoutError: + logger.error(f"Ping timed out, retrying in {sleep_time}s") + await asyncio.sleep(sleep_time) + + break + + except ( + socket.gaierror, + ConnectionRefusedError, + websockets.exceptions.InvalidStatusCode, + websockets.exceptions.InvalidMessage + ) as e: + logger.error(f"Connection Refused - {e} retrying in {sleep_time}s") + await asyncio.sleep(sleep_time) + + continue + + except ( + websockets.exceptions.ConnectionClosedError, + websockets.exceptions.ConnectionClosedOK + ): + # Just keep trying to connect again indefinitely + await asyncio.sleep(sleep_time) + + continue + + except Exception as e: + # An unforseen error has occurred, log and try reconnecting again + logger.error("Unexpected error has occurred:") + logger.exception(e) + + await asyncio.sleep(sleep_time) + continue + + +# --------------------------------------------------------------------------- + + +async def _main(args): + setup_logging(args['logfile']) + config = load_config(args['config']) + + emc_config = config.get('external_message_consumer', {}) + + producers = emc_config.get('producers', []) + producer = producers[0] + + wait_timeout = emc_config.get('wait_timeout', 300) + ping_timeout = emc_config.get('ping_timeout', 10) + sleep_time = emc_config.get('sleep_time', 10) + message_size_limit = (emc_config.get('message_size_limit', 8) << 20) + + await create_client( + producer['host'], + producer['port'], + producer['ws_token'], + producer['name'], + sleep_time=sleep_time, + ping_timeout=ping_timeout, + wait_timeout=wait_timeout, + max_size=message_size_limit + ) + + +def main(): + args = parse_args() + try: + asyncio.run(_main(args)) + except KeyboardInterrupt: + logger.info("Exiting...") + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 0581081fa..b3693c9f9 100644 --- a/setup.py +++ b/setup.py @@ -72,9 +72,10 @@ setup( 'pandas', 'tables', 'blosc', - 'joblib', + 'joblib>=1.2.0', 'pyarrow; platform_machine != "armv7l"', 'fastapi', + 'pydantic>=1.8.0', 'uvicorn', 'psutil', 'pyjwt', diff --git a/tests/conftest.py b/tests/conftest.py index 51b1b03e3..9f71709f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock, Mock, PropertyMock import arrow import numpy as np +import pandas as pd import pytest from telegram import Chat, Message, Update @@ -19,6 +20,7 @@ from freqtrade.data.converter import ohlcv_to_dataframe from freqtrade.edge import PairInfo from freqtrade.enums import CandleType, MarginMode, RunMode, SignalDirection, TradingMode from freqtrade.exchange import Exchange +from freqtrade.exchange.exchange import timeframe_to_minutes from freqtrade.freqtradebot import FreqtradeBot from freqtrade.persistence import LocalTrade, Order, Trade, init_db from freqtrade.resolvers import ExchangeResolver @@ -82,6 +84,33 @@ def get_args(args): return Arguments(args).get_parsed_arg() +def generate_test_data(timeframe: str, size: int, start: str = '2020-07-05'): + np.random.seed(42) + tf_mins = timeframe_to_minutes(timeframe) + + base = np.random.normal(20, 2, size=size) + + date = pd.date_range(start, periods=size, freq=f'{tf_mins}min', tz='UTC') + df = pd.DataFrame({ + 'date': date, + 'open': base, + 'high': base + np.random.normal(2, 1, size=size), + 'low': base - np.random.normal(2, 1, size=size), + 'close': base + np.random.normal(0, 1, size=size), + 'volume': np.random.normal(200, size=size) + } + ) + df = df.dropna() + return df + + +def generate_test_data_raw(timeframe: str, size: int, start: str = '2020-07-05'): + """ Generates data in the ohlcv format used by ccxt """ + df = generate_test_data(timeframe, size, start) + df['date'] = df.loc[:, 'date'].view(np.int64) // 1000 // 1000 + return list(list(x) for x in zip(*(df[x].values.tolist() for x in df.columns))) + + # Source: https://stackoverflow.com/questions/29881236/how-to-mock-asyncio-coroutines # TODO: This should be replaced with AsyncMock once support for python 3.7 is dropped. def get_mock_coro(return_value=None, side_effect=None): @@ -200,6 +229,8 @@ def patch_freqtradebot(mocker, config) -> None: mocker.patch('freqtrade.freqtradebot.RPCManager._init', MagicMock()) mocker.patch('freqtrade.freqtradebot.RPCManager.send_msg', MagicMock()) patch_whitelist(mocker, config) + mocker.patch('freqtrade.freqtradebot.ExternalMessageConsumer') + mocker.patch('freqtrade.configuration.config_validation._validate_consumers') def get_patched_freqtradebot(mocker, config) -> FreqtradeBot: diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index dab76d0cb..ec7b457ea 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -235,7 +235,7 @@ def test_calculate_market_change(testdatadir): data = load_data(datadir=testdatadir, pairs=pairs, timeframe='5m') result = calculate_market_change(data) assert isinstance(result, float) - assert pytest.approx(result) == 0.00955514 + assert pytest.approx(result) == 0.01100002 def test_combine_dataframes_with_mean(testdatadir): diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py index 8e1b0050a..5d6d60f84 100644 --- a/tests/data/test_datahandler.py +++ b/tests/data/test_datahandler.py @@ -139,10 +139,10 @@ def test_jsondatahandler_ohlcv_purge(mocker, testdatadir): def test_jsondatahandler_ohlcv_load(testdatadir, caplog): dh = JsonDataHandler(testdatadir) df = dh.ohlcv_load('XRP/ETH', '5m', 'spot') - assert len(df) == 711 + assert len(df) == 712 df_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', candle_type="mark") - assert len(df_mark) == 99 + assert len(df_mark) == 100 df_no_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', 'spot') assert len(df_no_mark) == 0 diff --git a/tests/data/test_entryexitanalysis.py b/tests/data/test_entryexitanalysis.py index 09fbe9957..588220465 100755 --- a/tests/data/test_entryexitanalysis.py +++ b/tests/data/test_entryexitanalysis.py @@ -124,8 +124,8 @@ def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, tmp assert '0' in captured.out assert '0.01616' in captured.out assert '34.049' in captured.out - assert '0.104104' in captured.out - assert '47.0996' in captured.out + assert '0.104411' in captured.out + assert '52.8292' in captured.out # test group 1 args = get_args(base_args + ['--analysis-groups', "1"]) diff --git a/tests/data/test_history.py b/tests/data/test_history.py index 5642442b2..b985666cc 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -377,8 +377,8 @@ def test_load_partial_missing(testdatadir, caplog) -> None: td = ((end - start).total_seconds() // 60 // 5) + 1 assert td != len(data['UNITTEST/BTC']) - # Shift endtime with +5 - as last candle is dropped (partial candle) - end_real = arrow.get(data['UNITTEST/BTC'].iloc[-1, 0]).shift(minutes=5) + # Shift endtime with +5 + end_real = arrow.get(data['UNITTEST/BTC'].iloc[-1, 0]) assert log_has(f'UNITTEST/BTC, spot, 5m, ' f'data ends at {end_real.strftime(DATETIME_PRINT_FORMAT)}', caplog) @@ -447,7 +447,7 @@ def test_get_timerange(default_conf, mocker, testdatadir) -> None: ) min_date, max_date = get_timerange(data) assert min_date.isoformat() == '2017-11-04T23:02:00+00:00' - assert max_date.isoformat() == '2017-11-14T22:58:00+00:00' + assert max_date.isoformat() == '2017-11-14T22:59:00+00:00' def test_validate_backtest_data_warn(default_conf, mocker, caplog, testdatadir) -> None: @@ -470,7 +470,7 @@ def test_validate_backtest_data_warn(default_conf, mocker, caplog, testdatadir) min_date, max_date, timeframe_to_minutes('1m')) assert len(caplog.record_tuples) == 1 assert log_has( - "UNITTEST/BTC has missing frames: expected 14396, got 13680, that's 716 missing values", + "UNITTEST/BTC has missing frames: expected 14397, got 13681, that's 716 missing values", caplog) @@ -480,7 +480,7 @@ def test_validate_backtest_data(default_conf, mocker, caplog, testdatadir) -> No default_conf.update({'strategy': CURRENT_TEST_STRATEGY}) strategy = StrategyResolver.load_strategy(default_conf) - timerange = TimeRange('index', 'index', 200, 250) + timerange = TimeRange() data = strategy.advise_all_indicators( load_data( datadir=testdatadir, diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index e9f4dfa8a..75aaa0081 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, PropertyMock import ccxt import pytest -from freqtrade.enums import MarginMode, TradingMode +from freqtrade.enums import CandleType, MarginMode, TradingMode from freqtrade.exceptions import DependencyException, InvalidOrderException, OperationalException from tests.conftest import get_mock_coro, get_patched_exchange, log_has_re from tests.exchange.test_exchange import ccxt_exceptionhandlers @@ -501,6 +501,24 @@ def test_fill_leverage_tiers_binance_dryrun(default_conf, mocker, leverage_tiers assert len(v) == len(value) +def test_additional_exchange_init_binance(default_conf, mocker): + api_mock = MagicMock() + api_mock.fapiPrivateGetPositionsideDual = MagicMock(return_value={"dualSidePosition": True}) + api_mock.fapiPrivateGetMultiAssetsMargin = MagicMock(return_value={"multiAssetsMargin": True}) + default_conf['dry_run'] = False + default_conf['trading_mode'] = TradingMode.FUTURES + default_conf['margin_mode'] = MarginMode.ISOLATED + with pytest.raises(OperationalException, + match=r"Hedge Mode is not supported.*\nMulti-Asset Mode is not supported.*"): + get_patched_exchange(mocker, default_conf, id="binance", api_mock=api_mock) + api_mock.fapiPrivateGetPositionsideDual = MagicMock(return_value={"dualSidePosition": False}) + api_mock.fapiPrivateGetMultiAssetsMargin = MagicMock(return_value={"multiAssetsMargin": False}) + exchange = get_patched_exchange(mocker, default_conf, id="binance", api_mock=api_mock) + assert exchange + ccxt_exceptionhandlers(mocker, default_conf, api_mock, 'binance', + "additional_exchange_init", "fapiPrivateGetPositionsideDual") + + def test__set_leverage_binance(mocker, default_conf): api_mock = MagicMock() @@ -524,7 +542,7 @@ def test__set_leverage_binance(mocker, default_conf): @pytest.mark.asyncio -@pytest.mark.parametrize('candle_type', ['mark', '']) +@pytest.mark.parametrize('candle_type', [CandleType.MARK, '']) async def test__async_get_historic_ohlcv_binance(default_conf, mocker, caplog, candle_type): ohlcv = [ [ diff --git a/tests/exchange/test_ccxt_compat.py b/tests/exchange/test_ccxt_compat.py index 82be6196a..9ff65cdc4 100644 --- a/tests/exchange/test_ccxt_compat.py +++ b/tests/exchange/test_ccxt_compat.py @@ -137,6 +137,7 @@ def exchange_futures(request, exchange_conf, class_mocker): 'freqtrade.exchange.binance.Binance.fill_leverage_tiers') class_mocker.patch('freqtrade.exchange.exchange.Exchange.fetch_trading_fees') class_mocker.patch('freqtrade.exchange.okx.Okx.additional_exchange_init') + class_mocker.patch('freqtrade.exchange.binance.Binance.additional_exchange_init') class_mocker.patch('freqtrade.exchange.exchange.Exchange.load_cached_leverage_tiers', return_value=None) class_mocker.patch('freqtrade.exchange.exchange.Exchange.cache_leverage_tiers') @@ -267,9 +268,8 @@ class TestCCXTExchange(): now = datetime.now(timezone.utc) - timedelta(minutes=(timeframe_to_minutes(timeframe) * 2)) assert exchange.klines(pair_tf).iloc[-1]['date'] >= timeframe_to_prev_date(timeframe, now) - def ccxt__async_get_candle_history(self, exchange, exchangename, pair, timeframe): + def ccxt__async_get_candle_history(self, exchange, exchangename, pair, timeframe, candle_type): - candle_type = CandleType.SPOT timeframe_ms = timeframe_to_msecs(timeframe) now = timeframe_to_prev_date( timeframe, datetime.now(timezone.utc)) @@ -301,7 +301,8 @@ class TestCCXTExchange(): return pair = EXCHANGES[exchangename]['pair'] timeframe = EXCHANGES[exchangename]['timeframe'] - self.ccxt__async_get_candle_history(exchange, exchangename, pair, timeframe) + self.ccxt__async_get_candle_history( + exchange, exchangename, pair, timeframe, CandleType.SPOT) def test_ccxt__async_get_candle_history_futures(self, exchange_futures): exchange, exchangename = exchange_futures @@ -310,7 +311,8 @@ class TestCCXTExchange(): return pair = EXCHANGES[exchangename].get('futures_pair', EXCHANGES[exchangename]['pair']) timeframe = EXCHANGES[exchangename]['timeframe'] - self.ccxt__async_get_candle_history(exchange, exchangename, pair, timeframe) + self.ccxt__async_get_candle_history( + exchange, exchangename, pair, timeframe, CandleType.FUTURES) def test_ccxt_fetch_funding_rate_history(self, exchange_futures): exchange, exchangename = exchange_futures diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 37ba2ca97..b07892f72 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -22,7 +22,8 @@ from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, API_RETRY_CO calculate_backoff, remove_credentials) from freqtrade.exchange.exchange import amount_to_contract_precision from freqtrade.resolvers.exchange_resolver import ExchangeResolver -from tests.conftest import get_mock_coro, get_patched_exchange, log_has, log_has_re, num_log_has_re +from tests.conftest import (generate_test_data_raw, get_mock_coro, get_patched_exchange, log_has, + log_has_re, num_log_has_re) # Make sure to always keep one exchange here which is NOT subclassed!! @@ -2083,7 +2084,7 @@ async def test__async_get_historic_ohlcv(default_conf, mocker, caplog, exchange_ def test_refresh_latest_ohlcv(mocker, default_conf, caplog, candle_type) -> None: ohlcv = [ [ - (arrow.utcnow().int_timestamp - 1) * 1000, # unix timestamp ms + (arrow.utcnow().shift(minutes=-5).int_timestamp) * 1000, # unix timestamp ms 1, # open 2, # high 3, # low @@ -2140,10 +2141,22 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog, candle_type) -> None assert len(res) == len(pairs) assert exchange._api_async.fetch_ohlcv.call_count == 0 - exchange.required_candle_call_count = 1 assert log_has(f"Using cached candle (OHLCV) data for {pairs[0][0]}, " f"{pairs[0][1]}, {candle_type} ...", caplog) + caplog.clear() + # Reset refresh times - must do 2 call per pair as cache is expired + exchange._pairs_last_refresh_time = {} + res = exchange.refresh_latest_ohlcv( + [('IOTA/ETH', '5m', candle_type), ('XRP/ETH', '5m', candle_type)]) + assert len(res) == len(pairs) + + assert exchange._api_async.fetch_ohlcv.call_count == 4 + + # cache - but disabled caching + exchange._api_async.fetch_ohlcv.reset_mock() + exchange.required_candle_call_count = 1 + pairlist = [ ('IOTA/ETH', '5m', candle_type), ('XRP/ETH', '5m', candle_type), @@ -2159,6 +2172,7 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog, candle_type) -> None assert exchange._api_async.fetch_ohlcv.call_count == 3 exchange._api_async.fetch_ohlcv.reset_mock() caplog.clear() + # Call with invalid timeframe res = exchange.refresh_latest_ohlcv([('IOTA/ETH', '3m', candle_type)], cache=False) if candle_type != CandleType.MARK: @@ -2169,6 +2183,91 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog, candle_type) -> None assert len(res) == 1 +@pytest.mark.parametrize('candle_type', [CandleType.FUTURES, CandleType.MARK, CandleType.SPOT]) +def test_refresh_latest_ohlcv_cache(mocker, default_conf, candle_type, time_machine) -> None: + start = datetime(2021, 8, 1, 0, 0, 0, 0, tzinfo=timezone.utc) + ohlcv = generate_test_data_raw('1h', 100, start.strftime('%Y-%m-%d')) + time_machine.move_to(start + timedelta(hours=99, minutes=30)) + + exchange = get_patched_exchange(mocker, default_conf) + exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv) + pair1 = ('IOTA/ETH', '1h', candle_type) + pair2 = ('XRP/ETH', '1h', candle_type) + pairs = [pair1, pair2] + + # No caching + assert not exchange._klines + res = exchange.refresh_latest_ohlcv(pairs, cache=False) + assert exchange._api_async.fetch_ohlcv.call_count == 2 + assert len(res) == 2 + assert len(res[pair1]) == 99 + assert len(res[pair2]) == 99 + assert not exchange._klines + exchange._api_async.fetch_ohlcv.reset_mock() + + # With caching + res = exchange.refresh_latest_ohlcv(pairs) + assert exchange._api_async.fetch_ohlcv.call_count == 2 + assert len(res) == 2 + assert len(res[pair1]) == 99 + assert len(res[pair2]) == 99 + assert exchange._klines + assert exchange._pairs_last_refresh_time[pair1] == ohlcv[-1][0] // 1000 + exchange._api_async.fetch_ohlcv.reset_mock() + + # Returned from cache + res = exchange.refresh_latest_ohlcv(pairs) + assert exchange._api_async.fetch_ohlcv.call_count == 0 + assert len(res) == 2 + assert len(res[pair1]) == 99 + assert len(res[pair2]) == 99 + assert exchange._pairs_last_refresh_time[pair1] == ohlcv[-1][0] // 1000 + + # Move time 1 candle further but result didn't change yet + time_machine.move_to(start + timedelta(hours=101)) + res = exchange.refresh_latest_ohlcv(pairs) + assert exchange._api_async.fetch_ohlcv.call_count == 2 + assert len(res) == 2 + assert len(res[pair1]) == 99 + assert len(res[pair2]) == 99 + assert exchange._pairs_last_refresh_time[pair1] == ohlcv[-1][0] // 1000 + refresh_pior = exchange._pairs_last_refresh_time[pair1] + + # New candle on exchange - only return 50 candles (but one candle further) + new_startdate = (start + timedelta(hours=51)).strftime('%Y-%m-%d %H:%M') + ohlcv = generate_test_data_raw('1h', 50, new_startdate) + exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv) + res = exchange.refresh_latest_ohlcv(pairs) + assert exchange._api_async.fetch_ohlcv.call_count == 2 + assert len(res) == 2 + assert len(res[pair1]) == 100 + assert len(res[pair2]) == 100 + assert refresh_pior != exchange._pairs_last_refresh_time[pair1] + + assert exchange._pairs_last_refresh_time[pair1] == ohlcv[-1][0] // 1000 + assert exchange._pairs_last_refresh_time[pair2] == ohlcv[-1][0] // 1000 + exchange._api_async.fetch_ohlcv.reset_mock() + + # Retry same call - no action. + res = exchange.refresh_latest_ohlcv(pairs) + assert exchange._api_async.fetch_ohlcv.call_count == 0 + assert len(res) == 2 + assert len(res[pair1]) == 100 + assert len(res[pair2]) == 100 + + # Move to distant future (so a 1 call would cause a hole in the data) + time_machine.move_to(start + timedelta(hours=2000)) + ohlcv = generate_test_data_raw('1h', 100, start + timedelta(hours=1900)) + exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv) + res = exchange.refresh_latest_ohlcv(pairs) + + assert exchange._api_async.fetch_ohlcv.call_count == 2 + assert len(res) == 2 + # Cache eviction - new data. + assert len(res[pair1]) == 99 + assert len(res[pair2]) == 99 + + @pytest.mark.asyncio @pytest.mark.parametrize("exchange_name", EXCHANGES) async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_name): @@ -2240,7 +2339,8 @@ async def test__async_kucoin_get_candle_history(default_conf, mocker, caplog): for _ in range(3): with pytest.raises(DDosProtection, match=r'429 Too Many Requests'): await exchange._async_get_candle_history( - "ETH/BTC", "5m", (arrow.utcnow().int_timestamp - 2000) * 1000, count=3) + "ETH/BTC", "5m", CandleType.SPOT, + since_ms=(arrow.utcnow().int_timestamp - 2000) * 1000, count=3) assert num_log_has_re(msg, caplog) == 3 caplog.clear() @@ -2256,7 +2356,8 @@ async def test__async_kucoin_get_candle_history(default_conf, mocker, caplog): for _ in range(3): with pytest.raises(DDosProtection, match=r'429 Too Many Requests'): await exchange._async_get_candle_history( - "ETH/BTC", "5m", (arrow.utcnow().int_timestamp - 2000) * 1000, count=3) + "ETH/BTC", "5m", CandleType.SPOT, + (arrow.utcnow().int_timestamp - 2000) * 1000, count=3) # Expect the "returned exception" message 12 times (4 retries * 3 (loop)) assert num_log_has_re(msg, caplog) == 12 assert num_log_has_re(msg2, caplog) == 9 diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py new file mode 100644 index 000000000..db206ab98 --- /dev/null +++ b/tests/exchange/test_exchange_utils.py @@ -0,0 +1,85 @@ +# pragma pylint: disable=missing-docstring, protected-access, invalid-name + +import pytest + +from freqtrade.enums import RunMode +from freqtrade.exceptions import OperationalException +from freqtrade.exchange.check_exchange import check_exchange +from tests.conftest import log_has_re + + +def test_check_exchange(default_conf, caplog) -> None: + # Test an officially supported by Freqtrade team exchange + default_conf['runmode'] = RunMode.DRY_RUN + default_conf.get('exchange').update({'name': 'BITTREX'}) + assert check_exchange(default_conf) + assert log_has_re(r"Exchange .* is officially supported by the Freqtrade development team\.", + caplog) + caplog.clear() + + # Test an officially supported by Freqtrade team exchange + default_conf.get('exchange').update({'name': 'binance'}) + assert check_exchange(default_conf) + assert log_has_re( + r"Exchange \"binance\" is officially supported by the Freqtrade development team\.", + caplog) + caplog.clear() + + # Test an officially supported by Freqtrade team exchange + default_conf.get('exchange').update({'name': 'binanceus'}) + assert check_exchange(default_conf) + assert log_has_re( + r"Exchange \"binanceus\" is officially supported by the Freqtrade development team\.", + caplog) + caplog.clear() + + # Test an officially supported by Freqtrade team exchange - with remapping + default_conf.get('exchange').update({'name': 'okex'}) + assert check_exchange(default_conf) + assert log_has_re( + r"Exchange \"okex\" is officially supported by the Freqtrade development team\.", + caplog) + caplog.clear() + # Test an available exchange, supported by ccxt + default_conf.get('exchange').update({'name': 'huobipro'}) + assert check_exchange(default_conf) + assert log_has_re(r"Exchange .* is known to the the ccxt library, available for the bot, " + r"but not officially supported " + r"by the Freqtrade development team\. .*", caplog) + caplog.clear() + + # Test a 'bad' exchange, which known to have serious problems + default_conf.get('exchange').update({'name': 'bitmex'}) + with pytest.raises(OperationalException, + match=r"Exchange .* will not work with Freqtrade\..*"): + check_exchange(default_conf) + caplog.clear() + + # Test a 'bad' exchange with check_for_bad=False + default_conf.get('exchange').update({'name': 'bitmex'}) + assert check_exchange(default_conf, False) + assert log_has_re(r"Exchange .* is known to the the ccxt library, available for the bot, " + r"but not officially supported " + r"by the Freqtrade development team\. .*", caplog) + caplog.clear() + + # Test an invalid exchange + default_conf.get('exchange').update({'name': 'unknown_exchange'}) + with pytest.raises( + OperationalException, + match=r'Exchange "unknown_exchange" is not known to the ccxt library ' + r'and therefore not available for the bot.*' + ): + check_exchange(default_conf) + + # Test no exchange... + default_conf.get('exchange').update({'name': ''}) + default_conf['runmode'] = RunMode.PLOT + assert check_exchange(default_conf) + + # Test no exchange... + default_conf.get('exchange').update({'name': ''}) + default_conf['runmode'] = RunMode.UTIL_EXCHANGE + with pytest.raises(OperationalException, + match=r'This command requires a configured exchange.*'): + check_exchange(default_conf) diff --git a/tests/freqai/conftest.py b/tests/freqai/conftest.py index 2c6210a0e..026b45afc 100644 --- a/tests/freqai/conftest.py +++ b/tests/freqai/conftest.py @@ -29,15 +29,16 @@ def freqai_conf(default_conf, tmpdir): "enabled": True, "startup_candles": 10000, "purge_old_models": True, - "train_period_days": 5, + "train_period_days": 2, "backtest_period_days": 2, "live_retrain_hours": 0, "expiration_hours": 1, "identifier": "uniqe-id100", "live_trained_timestamp": 0, + "data_kitchen_thread_count": 2, "feature_parameters": { "include_timeframes": ["5m"], - "include_corr_pairlist": ["ADA/BTC", "DASH/BTC"], + "include_corr_pairlist": ["ADA/BTC"], "label_period_candles": 20, "include_shifted_candles": 1, "DI_threshold": 0.9, @@ -47,7 +48,7 @@ def freqai_conf(default_conf, tmpdir): "stratify_training_data": 0, "indicator_periods_candles": [10], }, - "data_split_parameters": {"test_size": 0.33, "random_state": 1}, + "data_split_parameters": {"test_size": 0.33, "shuffle": False}, "model_training_parameters": {"n_estimators": 100}, }, "config_files": [Path('config_examples', 'config_freqai.example.json')] diff --git a/tests/freqai/test_freqai_backtesting.py b/tests/freqai/test_freqai_backtesting.py index 446e8295a..9d44cc656 100644 --- a/tests/freqai/test_freqai_backtesting.py +++ b/tests/freqai/test_freqai_backtesting.py @@ -29,7 +29,7 @@ def test_freqai_backtest_start_backtest_list(freqai_conf, mocker, testdatadir, c '--config', 'config.json', '--datadir', str(testdatadir), '--strategy-path', str(Path(__file__).parents[1] / 'strategy/strats'), - '--timeframe', '1h', + '--timeframe', '1m', '--strategy-list', CURRENT_TEST_STRATEGY ] args = get_args(args) diff --git a/tests/freqai/test_freqai_datadrawer.py b/tests/freqai/test_freqai_datadrawer.py index a6df60e61..1d1c44a1e 100644 --- a/tests/freqai/test_freqai_datadrawer.py +++ b/tests/freqai/test_freqai_datadrawer.py @@ -90,5 +90,5 @@ def test_use_strategy_to_populate_indicators(mocker, freqai_conf): df = freqai.dk.use_strategy_to_populate_indicators(strategy, corr_df, base_df, 'LTC/BTC') - assert len(df.columns) == 45 + assert len(df.columns) == 33 shutil.rmtree(Path(freqai.dk.full_path)) diff --git a/tests/freqai/test_freqai_datakitchen.py b/tests/freqai/test_freqai_datakitchen.py index f7446420d..f60b29bf1 100644 --- a/tests/freqai/test_freqai_datakitchen.py +++ b/tests/freqai/test_freqai_datakitchen.py @@ -71,14 +71,14 @@ def test_use_DBSCAN_to_remove_outliers(mocker, freqai_conf, caplog): freqai = make_data_dictionary(mocker, freqai_conf) # freqai_conf['freqai']['feature_parameters'].update({"outlier_protection_percentage": 1}) freqai.dk.use_DBSCAN_to_remove_outliers(predict=False) - assert log_has_re(r"DBSCAN found eps of 2\.3\d\.", caplog) + assert log_has_re(r"DBSCAN found eps of 1\.7\d\.", caplog) def test_compute_distances(mocker, freqai_conf): freqai = make_data_dictionary(mocker, freqai_conf) freqai_conf['freqai']['feature_parameters'].update({"DI_threshold": 1}) avg_mean_dist = freqai.dk.compute_distances() - assert round(avg_mean_dist, 2) == 2.54 + assert round(avg_mean_dist, 2) == 1.99 def test_use_SVM_to_remove_outliers_and_outlier_protection(mocker, freqai_conf, caplog): @@ -86,7 +86,7 @@ def test_use_SVM_to_remove_outliers_and_outlier_protection(mocker, freqai_conf, freqai_conf['freqai']['feature_parameters'].update({"outlier_protection_percentage": 0.1}) freqai.dk.use_SVM_to_remove_outliers(predict=False) assert log_has_re( - "SVM detected 8.09%", + "SVM detected 7.36%", caplog, ) @@ -125,7 +125,7 @@ def test_normalize_data(mocker, freqai_conf): freqai = make_data_dictionary(mocker, freqai_conf) data_dict = freqai.dk.data_dictionary freqai.dk.normalize_data(data_dict) - assert len(freqai.dk.data) == 56 + assert len(freqai.dk.data) == 32 def test_filter_features(mocker, freqai_conf): @@ -139,7 +139,7 @@ def test_filter_features(mocker, freqai_conf): training_filter=True, ) - assert len(filtered_df.columns) == 26 + assert len(filtered_df.columns) == 14 def test_make_train_test_datasets(mocker, freqai_conf): diff --git a/tests/freqai/test_freqai_interface.py b/tests/freqai/test_freqai_interface.py index 4512a43f0..a61853c47 100644 --- a/tests/freqai/test_freqai_interface.py +++ b/tests/freqai/test_freqai_interface.py @@ -7,7 +7,11 @@ import pytest from freqtrade.configuration import TimeRange from freqtrade.data.dataprovider import DataProvider +from freqtrade.enums import RunMode from freqtrade.freqai.data_kitchen import FreqaiDataKitchen +from freqtrade.freqai.utils import download_all_data_for_training, get_required_data_timerange +from freqtrade.optimize.backtesting import Backtesting +from freqtrade.persistence import Trade from freqtrade.plugins.pairlistmanager import PairListManager from tests.conftest import get_patched_exchange, log_has_re from tests.freqai.conftest import get_patched_freqai_strategy @@ -18,15 +22,21 @@ def is_arm() -> bool: return "arm" in machine or "aarch64" in machine +def is_mac() -> bool: + machine = platform.system() + return "Darwin" in machine + + @pytest.mark.parametrize('model', [ 'LightGBMRegressor', 'XGBoostRegressor', 'CatboostRegressor', ]) -def test_extract_data_and_train_model_Regressors(mocker, freqai_conf, model): +def test_extract_data_and_train_model_Standard(mocker, freqai_conf, model): if is_arm() and model == 'CatboostRegressor': pytest.skip("CatBoost is not supported on ARM") + model_save_ext = 'joblib' freqai_conf.update({"freqaimodel": model}) freqai_conf.update({"timerange": "20180110-20180130"}) freqai_conf.update({"strategy": "freqai_test_strat"}) @@ -43,16 +53,16 @@ def test_extract_data_and_train_model_Regressors(mocker, freqai_conf, model): freqai.dd.pair_dict = MagicMock() - data_load_timerange = TimeRange.parse_timerange("20180110-20180130") - new_timerange = TimeRange.parse_timerange("20180120-20180130") + data_load_timerange = TimeRange.parse_timerange("20180125-20180130") + new_timerange = TimeRange.parse_timerange("20180127-20180130") freqai.extract_data_and_train_model( new_timerange, "ADA/BTC", strategy, freqai.dk, data_load_timerange) - assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_model.joblib").is_file() + assert Path(freqai.dk.data_path / + f"{freqai.dk.model_filename}_model.{model_save_ext}").is_file() assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_metadata.json").is_file() assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_trained_df.pkl").is_file() - assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_svm_model.joblib").is_file() shutil.rmtree(Path(freqai.dk.full_path)) @@ -92,7 +102,7 @@ def test_extract_data_and_train_model_MultiTargets(mocker, freqai_conf, model): assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_metadata.json").is_file() assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_trained_df.pkl").is_file() assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_svm_model.joblib").is_file() - assert len(freqai.dk.data['training_features_list']) == 26 + assert len(freqai.dk.data['training_features_list']) == 14 shutil.rmtree(Path(freqai.dk.full_path)) @@ -136,9 +146,28 @@ def test_extract_data_and_train_model_Classifiers(mocker, freqai_conf, model): shutil.rmtree(Path(freqai.dk.full_path)) -def test_start_backtesting(mocker, freqai_conf): - freqai_conf.update({"timerange": "20180120-20180130"}) +@pytest.mark.parametrize( + "model, num_files, strat", + [ + ("LightGBMRegressor", 6, "freqai_test_strat"), + ("XGBoostRegressor", 6, "freqai_test_strat"), + ("CatboostRegressor", 6, "freqai_test_strat"), + ("XGBoostClassifier", 6, "freqai_test_classifier"), + ("LightGBMClassifier", 6, "freqai_test_classifier"), + ("CatboostClassifier", 6, "freqai_test_classifier") + ], + ) +def test_start_backtesting(mocker, freqai_conf, model, num_files, strat): freqai_conf.get("freqai", {}).update({"save_backtest_models": True}) + freqai_conf['runmode'] = RunMode.BACKTEST + Trade.use_db = False + if is_arm() and "Catboost" in model: + pytest.skip("CatBoost is not supported on ARM") + + freqai_conf.update({"freqaimodel": model}) + freqai_conf.update({"timerange": "20180120-20180130"}) + freqai_conf.update({"strategy": strat}) + strategy = get_patched_freqai_strategy(mocker, freqai_conf) exchange = get_patched_exchange(mocker, freqai_conf) strategy.dp = DataProvider(freqai_conf, exchange) @@ -157,8 +186,8 @@ def test_start_backtesting(mocker, freqai_conf): freqai.start_backtesting(df, metadata, freqai.dk) model_folders = [x for x in freqai.dd.full_path.iterdir() if x.is_dir()] - assert len(model_folders) == 6 - + assert len(model_folders) == num_files + Backtesting.cleanup() shutil.rmtree(Path(freqai.dk.full_path)) @@ -211,7 +240,7 @@ def test_start_backtesting_from_existing_folder(mocker, freqai_conf, caplog): assert len(model_folders) == 6 - # without deleting the exiting folder structure, re-run + # without deleting the existing folder structure, re-run freqai_conf.update({"timerange": "20180120-20180130"}) strategy = get_patched_freqai_strategy(mocker, freqai_conf) @@ -375,3 +404,40 @@ def test_freqai_informative_pairs(mocker, freqai_conf, timeframes, corr_pairs): pairs_b = strategy.gather_informative_pairs() # we expect unique pairs * timeframes assert len(pairs_b) == len(set(pairlist + corr_pairs)) * len(timeframes) + + +def test_start_set_train_queue(mocker, freqai_conf, caplog): + strategy = get_patched_freqai_strategy(mocker, freqai_conf) + exchange = get_patched_exchange(mocker, freqai_conf) + pairlist = PairListManager(exchange, freqai_conf) + strategy.dp = DataProvider(freqai_conf, exchange, pairlist) + strategy.freqai_info = freqai_conf.get("freqai", {}) + freqai = strategy.freqai + freqai.live = False + + freqai.train_queue = freqai._set_train_queue() + + assert log_has_re( + "Set fresh train queue from whitelist.", + caplog, + ) + + +def test_get_required_data_timerange(mocker, freqai_conf): + time_range = get_required_data_timerange(freqai_conf) + assert (time_range.stopts - time_range.startts) == 177300 + + +def test_download_all_data_for_training(mocker, freqai_conf, caplog, tmpdir): + strategy = get_patched_freqai_strategy(mocker, freqai_conf) + exchange = get_patched_exchange(mocker, freqai_conf) + pairlist = PairListManager(exchange, freqai_conf) + strategy.dp = DataProvider(freqai_conf, exchange, pairlist) + freqai_conf['pairs'] = freqai_conf['exchange']['pair_whitelist'] + freqai_conf['datadir'] = Path(tmpdir) + download_all_data_for_training(strategy.dp, freqai_conf) + + assert log_has_re( + "Downloading", + caplog, + ) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index bd87b2b42..907e97fb7 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -80,7 +80,7 @@ def load_data_test(what, testdatadir): data.loc[:, 'close'] = np.sin(data.index * hz) / 1000 + base return {'UNITTEST/BTC': clean_ohlcv_dataframe(data, timeframe='1m', pair='UNITTEST/BTC', - fill_missing=True)} + fill_missing=True, drop_incomplete=True)} # FIX: fixturize this? @@ -323,7 +323,7 @@ def test_data_to_dataframe_bt(default_conf, mocker, testdatadir) -> None: backtesting = Backtesting(default_conf) backtesting._set_strategy(backtesting.strategylist[0]) processed = backtesting.strategy.advise_all_indicators(data) - assert len(processed['UNITTEST/BTC']) == 102 + assert len(processed['UNITTEST/BTC']) == 103 # Load strategy to compare the result between Backtesting function and strategy are the same strategy = StrategyResolver.load_strategy(default_conf) @@ -1165,9 +1165,9 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2017-11-14 20:57:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Backtesting with data from 2017-11-14 21:17:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Parameter --enable-position-stacking detected ...' ] @@ -1244,9 +1244,9 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2017-11-14 20:57:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Backtesting with data from 2017-11-14 21:17:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Parameter --enable-position-stacking detected ...', f'Running backtesting for Strategy {CURRENT_TEST_STRATEGY}', 'Running backtesting for Strategy StrategyTestV2', @@ -1355,9 +1355,9 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2017-11-14 20:57:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Backtesting with data from 2017-11-14 21:17:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Parameter --enable-position-stacking detected ...', f'Running backtesting for Strategy {CURRENT_TEST_STRATEGY}', 'Running backtesting for Strategy StrategyTestV2', @@ -1371,7 +1371,7 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat assert 'EXIT REASON STATS' in captured.out assert 'DAY BREAKDOWN' in captured.out assert 'LEFT OPEN TRADES REPORT' in captured.out - assert '2017-11-14 21:17:00 -> 2017-11-14 22:58:00 | Max open trades : 1' in captured.out + assert '2017-11-14 21:17:00 -> 2017-11-14 22:59:00 | Max open trades : 1' in captured.out assert 'STRATEGY SUMMARY' in captured.out @@ -1503,9 +1503,9 @@ def test_backtest_start_nomock_futures(default_conf_usdt, mocker, 'Parameter -i/--timeframe detected ... Using timeframe: 1h ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2021-11-17 01:00:00 ' - 'up to 2021-11-21 03:00:00 (4 days).', + 'up to 2021-11-21 04:00:00 (4 days).', 'Backtesting with data from 2021-11-17 21:00:00 ' - 'up to 2021-11-21 03:00:00 (3 days).', + 'up to 2021-11-21 04:00:00 (3 days).', 'XRP/USDT, funding_rate, 8h, data starts at 2021-11-18 00:00:00', 'XRP/USDT, mark, 8h, data starts at 2021-11-18 00:00:00', f'Running backtesting for Strategy {CURRENT_TEST_STRATEGY}', @@ -1616,9 +1616,9 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker, 'Parameter --timeframe-detail detected, using 1m for intra-candle backtesting ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2019-10-11 00:00:00 ' - 'up to 2019-10-13 11:10:00 (2 days).', + 'up to 2019-10-13 11:15:00 (2 days).', 'Backtesting with data from 2019-10-11 01:40:00 ' - 'up to 2019-10-13 11:10:00 (2 days).', + 'up to 2019-10-13 11:15:00 (2 days).', f'Running backtesting for Strategy {CURRENT_TEST_STRATEGY}', ] @@ -1719,7 +1719,7 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2017-11-14 20:57:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Parameter --enable-position-stacking detected ...', ] @@ -1732,7 +1732,7 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'Running backtesting for Strategy StrategyTestV2', 'Running backtesting for Strategy StrategyTestV3', 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', - 'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:58:00 (0 days).', + 'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:59:00 (0 days).', ] elif run_id == '2' and min_backtest_date < start_time: assert backtestmock.call_count == 0 @@ -1745,7 +1745,7 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'Reusing result of previous backtest for StrategyTestV2', 'Running backtesting for Strategy StrategyTestV3', 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', - 'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:58:00 (0 days).', + 'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:59:00 (0 days).', ] assert backtestmock.call_count == 1 diff --git a/tests/optimize/test_backtesting_adjust_position.py b/tests/optimize/test_backtesting_adjust_position.py index 71f8cdcea..99c160a40 100644 --- a/tests/optimize/test_backtesting_adjust_position.py +++ b/tests/optimize/test_backtesting_adjust_position.py @@ -93,11 +93,16 @@ def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) -> t["close_rate"], 6) < round(ln.iloc[0]["high"], 6)) -def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> None: +@pytest.mark.parametrize('leverage', [ + 1, 2 +]) +def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, leverage) -> None: default_conf['use_exit_signal'] = False mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) mocker.patch("freqtrade.exchange.Exchange.get_min_pair_stake_amount", return_value=10) mocker.patch("freqtrade.exchange.Exchange.get_max_pair_stake_amount", return_value=float('inf')) + mocker.patch("freqtrade.exchange.Exchange.get_max_leverage", return_value=10) + patch_exchange(mocker) default_conf.update({ "stake_amount": 100.0, @@ -105,6 +110,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non "strategy": "StrategyTestV3" }) backtesting = Backtesting(default_conf) + backtesting._can_short = True backtesting._set_strategy(backtesting.strategylist[0]) pair = 'XRP/USDT' row = [ @@ -120,18 +126,19 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non '', # enter_tag '', # exit_tag ] + backtesting.strategy.leverage = MagicMock(return_value=leverage) trade = backtesting._enter_trade(pair, row=row, direction='long') trade.orders[0].close_bt_order(row[0], trade) assert trade assert pytest.approx(trade.stake_amount) == 100.0 - assert pytest.approx(trade.amount) == 47.61904762 + assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 1 backtesting.strategy.adjust_trade_position = MagicMock(return_value=None) trade = backtesting._get_adjust_trade_entry_for_candle(trade, row) assert trade assert pytest.approx(trade.stake_amount) == 100.0 - assert pytest.approx(trade.amount) == 47.61904762 + assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 1 # Increase position by 100 backtesting.strategy.adjust_trade_position = MagicMock(return_value=100) @@ -140,7 +147,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non assert trade assert pytest.approx(trade.stake_amount) == 200.0 - assert pytest.approx(trade.amount) == 95.23809524 + assert pytest.approx(trade.amount) == 95.23809524 * leverage assert len(trade.orders) == 2 # Reduce by more than amount - no change to trade. @@ -150,7 +157,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non assert trade assert pytest.approx(trade.stake_amount) == 200.0 - assert pytest.approx(trade.amount) == 95.23809524 + assert pytest.approx(trade.amount) == 95.23809524 * leverage assert len(trade.orders) == 2 assert trade.nr_of_successful_entries == 2 @@ -160,7 +167,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non assert trade assert pytest.approx(trade.stake_amount) == 100.0 - assert pytest.approx(trade.amount) == 47.61904762 + assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 3 assert trade.nr_of_successful_entries == 2 assert trade.nr_of_successful_exits == 1 @@ -171,7 +178,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non assert trade assert pytest.approx(trade.stake_amount) == 100.0 - assert pytest.approx(trade.amount) == 47.61904762 + assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 3 assert trade.nr_of_successful_entries == 2 assert trade.nr_of_successful_exits == 1 diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index eaea8aee7..5666ebabc 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -297,6 +297,7 @@ def test_params_no_optimize_details(hyperopt) -> None: def test_start_calls_optimizer(mocker, hyperopt_conf, capsys) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump') dumper2 = mocker.patch('freqtrade.optimize.hyperopt.Hyperopt._save_result') + mocker.patch('freqtrade.optimize.hyperopt.calculate_market_change', return_value=1.5) mocker.patch('freqtrade.optimize.hyperopt.file_dump_json') mocker.patch('freqtrade.optimize.backtesting.Backtesting.load_bt_data', @@ -530,6 +531,7 @@ def test_print_json_spaces_all(mocker, hyperopt_conf, capsys) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump') dumper2 = mocker.patch('freqtrade.optimize.hyperopt.Hyperopt._save_result') mocker.patch('freqtrade.optimize.hyperopt.file_dump_json') + mocker.patch('freqtrade.optimize.hyperopt.calculate_market_change', return_value=1.5) mocker.patch('freqtrade.optimize.backtesting.Backtesting.load_bt_data', MagicMock(return_value=(MagicMock(), None))) @@ -581,6 +583,7 @@ def test_print_json_spaces_default(mocker, hyperopt_conf, capsys) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump') dumper2 = mocker.patch('freqtrade.optimize.hyperopt.Hyperopt._save_result') mocker.patch('freqtrade.optimize.hyperopt.file_dump_json') + mocker.patch('freqtrade.optimize.hyperopt.calculate_market_change', return_value=1.5) mocker.patch('freqtrade.optimize.backtesting.Backtesting.load_bt_data', MagicMock(return_value=(MagicMock(), None))) mocker.patch( @@ -622,6 +625,7 @@ def test_print_json_spaces_default(mocker, hyperopt_conf, capsys) -> None: def test_print_json_spaces_roi_stoploss(mocker, hyperopt_conf, capsys) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump') dumper2 = mocker.patch('freqtrade.optimize.hyperopt.Hyperopt._save_result') + mocker.patch('freqtrade.optimize.hyperopt.calculate_market_change', return_value=1.5) mocker.patch('freqtrade.optimize.hyperopt.file_dump_json') mocker.patch('freqtrade.optimize.backtesting.Backtesting.load_bt_data', MagicMock(return_value=(MagicMock(), None))) @@ -663,6 +667,7 @@ def test_print_json_spaces_roi_stoploss(mocker, hyperopt_conf, capsys) -> None: def test_simplified_interface_roi_stoploss(mocker, hyperopt_conf, capsys) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump') dumper2 = mocker.patch('freqtrade.optimize.hyperopt.Hyperopt._save_result') + mocker.patch('freqtrade.optimize.hyperopt.calculate_market_change', return_value=1.5) mocker.patch('freqtrade.optimize.hyperopt.file_dump_json') mocker.patch('freqtrade.optimize.backtesting.Backtesting.load_bt_data', MagicMock(return_value=(MagicMock(), None))) @@ -736,6 +741,7 @@ def test_simplified_interface_all_failed(mocker, hyperopt_conf, caplog) -> None: def test_simplified_interface_buy(mocker, hyperopt_conf, capsys) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump') dumper2 = mocker.patch('freqtrade.optimize.hyperopt.Hyperopt._save_result') + mocker.patch('freqtrade.optimize.hyperopt.calculate_market_change', return_value=1.5) mocker.patch('freqtrade.optimize.hyperopt.file_dump_json') mocker.patch('freqtrade.optimize.backtesting.Backtesting.load_bt_data', MagicMock(return_value=(MagicMock(), None))) @@ -778,6 +784,7 @@ def test_simplified_interface_buy(mocker, hyperopt_conf, capsys) -> None: def test_simplified_interface_sell(mocker, hyperopt_conf, capsys) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump') dumper2 = mocker.patch('freqtrade.optimize.hyperopt.Hyperopt._save_result') + mocker.patch('freqtrade.optimize.hyperopt.calculate_market_change', return_value=1.5) mocker.patch('freqtrade.optimize.hyperopt.file_dump_json') mocker.patch('freqtrade.optimize.backtesting.Backtesting.load_bt_data', MagicMock(return_value=(MagicMock(), None))) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index d6f074edb..f0b983063 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -9,6 +9,7 @@ import pytest import time_machine from freqtrade.constants import AVAILABLE_PAIRLISTS +from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import CandleType, RunMode from freqtrade.exceptions import OperationalException from freqtrade.persistence import Trade @@ -40,6 +41,12 @@ def whitelist_conf(default_conf): "sort_key": "quoteVolume", }, ] + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [], + } + }) return default_conf @@ -126,7 +133,7 @@ def test_log_cached(mocker, static_pl_conf, markets, tickers): def test_load_pairlist_noexist(mocker, markets, default_conf): freqtrade = get_patched_freqtradebot(mocker, default_conf) mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets)) - plm = PairListManager(freqtrade.exchange, default_conf) + plm = PairListManager(freqtrade.exchange, default_conf, MagicMock()) with pytest.raises(OperationalException, match=r"Impossible to load Pairlist 'NonexistingPairList'. " r"This class does not exist or contains Python code errors."): @@ -137,7 +144,7 @@ def test_load_pairlist_noexist(mocker, markets, default_conf): def test_load_pairlist_verify_multi(mocker, markets_static, default_conf): freqtrade = get_patched_freqtradebot(mocker, default_conf) mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets_static)) - plm = PairListManager(freqtrade.exchange, default_conf) + plm = PairListManager(freqtrade.exchange, default_conf, MagicMock()) # Call different versions one after the other, should always consider what was passed in # and have no side-effects (therefore the same check multiple times) assert plm.verify_whitelist(['ETH/BTC', 'XRP/BTC', ], print) == ['ETH/BTC', 'XRP/BTC'] @@ -269,7 +276,7 @@ def test_refresh_pairlist_dynamic(mocker, shitcoinmarkets, tickers, whitelist_co with pytest.raises(OperationalException, match=r'`number_assets` not specified. Please check your configuration ' r'for "pairlist.config.number_assets"'): - PairListManager(freqtrade.exchange, whitelist_conf) + PairListManager(freqtrade.exchange, whitelist_conf, MagicMock()) def test_refresh_pairlist_dynamic_2(mocker, shitcoinmarkets, tickers, whitelist_conf_2): @@ -694,7 +701,7 @@ def test_PrecisionFilter_error(mocker, whitelist_conf) -> None: with pytest.raises(OperationalException, match=r"PrecisionFilter can only work with stoploss defined\..*"): - PairListManager(MagicMock, whitelist_conf) + PairListManager(MagicMock, whitelist_conf, MagicMock()) def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None: @@ -703,7 +710,7 @@ def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None: del Trade.query mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) exchange = get_patched_exchange(mocker, whitelist_conf) - pm = PairListManager(exchange, whitelist_conf) + pm = PairListManager(exchange, whitelist_conf, MagicMock()) pm.refresh_pairlist() assert log_has("PerformanceFilter is not available in this mode.", caplog) @@ -1167,6 +1174,10 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo "[{'OffsetFilter': 'OffsetFilter - Taking 10 Pairs, starting from 5.'}]", None ), + ({"method": "ProducerPairList"}, + "[{'ProducerPairList': 'ProducerPairList - default'}]", + None + ), ]) def test_pricefilter_desc(mocker, whitelist_conf, markets, pairlistconfig, desc_expected, exception_expected): @@ -1341,3 +1352,77 @@ def test_expand_pairlist_keep_invalid(wildcardlist, pairs, expected): expand_pairlist(wildcardlist, pairs, keep_invalid=True) else: assert sorted(expand_pairlist(wildcardlist, pairs, keep_invalid=True)) == sorted(expected) + + +def test_ProducerPairlist_no_emc(mocker, whitelist_conf): + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + + whitelist_conf['pairlists'] = [ + { + "method": "ProducerPairList", + "number_assets": 10, + "producer_name": "hello_world", + } + ] + del whitelist_conf['external_message_consumer'] + + with pytest.raises(OperationalException, + match=r"ProducerPairList requires external_message_consumer to be enabled."): + get_patched_freqtradebot(mocker, whitelist_conf) + + +def test_ProducerPairlist(mocker, whitelist_conf, markets): + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + ) + whitelist_conf['pairlists'] = [ + { + "method": "ProducerPairList", + "number_assets": 2, + "producer_name": "hello_world", + } + ] + whitelist_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "hello_world", + "host": "null", + "port": 9891, + "ws_token": "dummy", + } + ] + } + }) + + exchange = get_patched_exchange(mocker, whitelist_conf) + dp = DataProvider(whitelist_conf, exchange, None) + pairs = ['ETH/BTC', 'LTC/BTC', 'XRP/BTC'] + # different producer + dp._set_producer_pairs(pairs + ['MEEP/USDT'], 'default') + pm = PairListManager(exchange, whitelist_conf, dp) + pm.refresh_pairlist() + assert pm.whitelist == [] + # proper producer + dp._set_producer_pairs(pairs, 'hello_world') + pm.refresh_pairlist() + + # Pairlist reduced to 2 + assert pm.whitelist == pairs[:2] + assert len(pm.whitelist) == 2 + whitelist_conf['exchange']['pair_whitelist'] = ['TKN/BTC'] + + whitelist_conf['pairlists'] = [ + {"method": "StaticPairList"}, + { + "method": "ProducerPairList", + "producer_name": "hello_world", + } + ] + pm = PairListManager(exchange, whitelist_conf, dp) + pm.refresh_pairlist() + assert len(pm.whitelist) == 4 + assert pm.whitelist == ['TKN/BTC'] + pairs diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py index 2649c5460..93ae829d5 100644 --- a/tests/rpc/test_rpc_emc.py +++ b/tests/rpc/test_rpc_emc.py @@ -188,15 +188,19 @@ async def test_emc_create_connection_success(default_conf, caplog, mocker): emc.shutdown() -async def test_emc_create_connection_invalid_port(default_conf, caplog, mocker): +@pytest.mark.parametrize('host,port', [ + (_TEST_WS_HOST, -1), + ("10000.1241..2121/", _TEST_WS_PORT), +]) +async def test_emc_create_connection_invalid_url(default_conf, caplog, mocker, host, port): default_conf.update({ "external_message_consumer": { "enabled": True, "producers": [ { "name": "default", - "host": _TEST_WS_HOST, - "port": -1, + "host": host, + "port": port, "ws_token": _TEST_WS_TOKEN } ], @@ -207,38 +211,13 @@ async def test_emc_create_connection_invalid_port(default_conf, caplog, mocker): }) dp = DataProvider(default_conf, None, None, None) + # Handle start explicitly to avoid messing with threading in tests + mocker.patch("freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start",) emc = ExternalMessageConsumer(default_conf, dp) try: - await asyncio.sleep(0.01) - assert log_has_re(r".+ is an invalid WebSocket URL .+", caplog) - finally: - emc.shutdown() - - -async def test_emc_create_connection_invalid_host(default_conf, caplog, mocker): - default_conf.update({ - "external_message_consumer": { - "enabled": True, - "producers": [ - { - "name": "default", - "host": "10000.1241..2121/", - "port": _TEST_WS_PORT, - "ws_token": _TEST_WS_TOKEN - } - ], - "wait_timeout": 60, - "ping_timeout": 60, - "sleep_timeout": 60 - } - }) - - dp = DataProvider(default_conf, None, None, None) - emc = ExternalMessageConsumer(default_conf, dp) - - try: - await asyncio.sleep(0.01) + emc._running = True + await emc._create_connection(emc.producers[0], asyncio.Lock()) assert log_has_re(r".+ is an invalid WebSocket URL .+", caplog) finally: emc.shutdown() @@ -276,6 +255,8 @@ async def test_emc_create_connection_error(default_conf, caplog, mocker): async def test_emc_receive_messages_valid(default_conf, caplog, mocker): + caplog.set_level(logging.DEBUG) + default_conf.update({ "external_message_consumer": { "enabled": True, diff --git a/tests/rpc/test_rpc_webhook.py b/tests/rpc/test_rpc_webhook.py index 4d65b4966..3bbb85d54 100644 --- a/tests/rpc/test_rpc_webhook.py +++ b/tests/rpc/test_rpc_webhook.py @@ -365,6 +365,14 @@ def test_exception_send_msg(default_conf, mocker, caplog): with pytest.raises(NotImplementedError): webhook.send_msg(msg) + # Test no failure for not implemented but known messagetypes + for e in RPCMessageType: + msg = { + 'type': e, + 'status': 'whatever' + } + webhook.send_msg(msg) + def test__send_msg(default_conf, mocker, caplog): default_conf["webhook"] = get_webhook_dict() diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index 070e78b1d..294021c83 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -288,7 +288,7 @@ def test_advise_all_indicators(default_conf, testdatadir) -> None: data = load_data(testdatadir, '1m', ['UNITTEST/BTC'], timerange=timerange, fill_up_missing=True) processed = strategy.advise_all_indicators(data) - assert len(processed['UNITTEST/BTC']) == 102 # partial candle was removed + assert len(processed['UNITTEST/BTC']) == 103 def test_populate_any_indicators(default_conf, testdatadir) -> None: @@ -300,7 +300,7 @@ def test_populate_any_indicators(default_conf, testdatadir) -> None: processed = strategy.populate_any_indicators('UNITTEST/BTC', data, '5m') assert processed == data assert id(processed) == id(data) - assert len(processed['UNITTEST/BTC']) == 102 # partial candle was removed + assert len(processed['UNITTEST/BTC']) == 103 def test_freqai_not_initialized(default_conf) -> None: diff --git a/tests/strategy/test_strategy_helpers.py b/tests/strategy/test_strategy_helpers.py index 8cb990e87..f42f9e681 100644 --- a/tests/strategy/test_strategy_helpers.py +++ b/tests/strategy/test_strategy_helpers.py @@ -5,29 +5,8 @@ import pytest from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import CandleType from freqtrade.resolvers.strategy_resolver import StrategyResolver -from freqtrade.strategy import (merge_informative_pair, stoploss_from_absolute, stoploss_from_open, - timeframe_to_minutes) -from tests.conftest import get_patched_exchange - - -def generate_test_data(timeframe: str, size: int, start: str = '2020-07-05'): - np.random.seed(42) - tf_mins = timeframe_to_minutes(timeframe) - - base = np.random.normal(20, 2, size=size) - - date = pd.date_range(start, periods=size, freq=f'{tf_mins}min', tz='UTC') - df = pd.DataFrame({ - 'date': date, - 'open': base, - 'high': base + np.random.normal(2, 1, size=size), - 'low': base - np.random.normal(2, 1, size=size), - 'close': base + np.random.normal(0, 1, size=size), - 'volume': np.random.normal(200, size=size) - } - ) - df = df.dropna() - return df +from freqtrade.strategy import merge_informative_pair, stoploss_from_absolute, stoploss_from_open +from tests.conftest import generate_test_data, get_patched_exchange def test_merge_informative_pair(): diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 9905be88c..1bcff20db 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -11,7 +11,7 @@ import pytest from jsonschema import ValidationError from freqtrade.commands import Arguments -from freqtrade.configuration import Configuration, check_exchange, validate_config_consistency +from freqtrade.configuration import Configuration, validate_config_consistency from freqtrade.configuration.config_validation import validate_config_schema from freqtrade.configuration.deprecated_settings import (check_conflicting_settings, process_deprecated_setting, @@ -584,67 +584,6 @@ def test_hyperopt_with_arguments(mocker, default_conf, caplog) -> None: assert config['runmode'] == RunMode.HYPEROPT -def test_check_exchange(default_conf, caplog) -> None: - # Test an officially supported by Freqtrade team exchange - default_conf['runmode'] = RunMode.DRY_RUN - default_conf.get('exchange').update({'name': 'BITTREX'}) - assert check_exchange(default_conf) - assert log_has_re(r"Exchange .* is officially supported by the Freqtrade development team\.", - caplog) - caplog.clear() - - # Test an officially supported by Freqtrade team exchange - default_conf.get('exchange').update({'name': 'binance'}) - assert check_exchange(default_conf) - assert log_has_re(r"Exchange .* is officially supported by the Freqtrade development team\.", - caplog) - caplog.clear() - - # Test an available exchange, supported by ccxt - default_conf.get('exchange').update({'name': 'huobipro'}) - assert check_exchange(default_conf) - assert log_has_re(r"Exchange .* is known to the the ccxt library, available for the bot, " - r"but not officially supported " - r"by the Freqtrade development team\. .*", caplog) - caplog.clear() - - # Test a 'bad' exchange, which known to have serious problems - default_conf.get('exchange').update({'name': 'bitmex'}) - with pytest.raises(OperationalException, - match=r"Exchange .* will not work with Freqtrade\..*"): - check_exchange(default_conf) - caplog.clear() - - # Test a 'bad' exchange with check_for_bad=False - default_conf.get('exchange').update({'name': 'bitmex'}) - assert check_exchange(default_conf, False) - assert log_has_re(r"Exchange .* is known to the the ccxt library, available for the bot, " - r"but not officially supported " - r"by the Freqtrade development team\. .*", caplog) - caplog.clear() - - # Test an invalid exchange - default_conf.get('exchange').update({'name': 'unknown_exchange'}) - with pytest.raises( - OperationalException, - match=r'Exchange "unknown_exchange" is not known to the ccxt library ' - r'and therefore not available for the bot.*' - ): - check_exchange(default_conf) - - # Test no exchange... - default_conf.get('exchange').update({'name': ''}) - default_conf['runmode'] = RunMode.PLOT - assert check_exchange(default_conf) - - # Test no exchange... - default_conf.get('exchange').update({'name': ''}) - default_conf['runmode'] = RunMode.UTIL_EXCHANGE - with pytest.raises(OperationalException, - match=r'This command requires a configured exchange.*'): - check_exchange(default_conf) - - def test_cli_verbose_with_params(default_conf, mocker, caplog) -> None: patched_configuration_load_config_file(mocker, default_conf) @@ -1089,6 +1028,31 @@ def test__validate_pricing_rules(default_conf, caplog) -> None: validate_config_consistency(conf) +def test__validate_freqai_include_timeframes(default_conf, caplog) -> None: + conf = deepcopy(default_conf) + conf.update({ + "freqai": { + "enabled": True, + "feature_parameters": { + "include_timeframes": ["1m", "5m"], + "include_corr_pairlist": [], + }, + "data_split_parameters": {}, + "model_training_parameters": {} + } + }) + with pytest.raises(OperationalException, match=r"Main timeframe of .*"): + validate_config_consistency(conf) + # Validation pass + conf.update({'timeframe': '1m'}) + validate_config_consistency(conf) + conf.update({'analyze_per_epoch': True}) + + with pytest.raises(OperationalException, + match=r"Using analyze-per-epoch .* not supported with a FreqAI strategy."): + validate_config_consistency(conf) + + def test__validate_consumers(default_conf, caplog) -> None: conf = deepcopy(default_conf) conf.update({ diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 5fe4d4011..c127e3850 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -28,6 +28,7 @@ from tests.conftest import (create_mock_trades, create_mock_trades_usdt, get_pat from tests.conftest_trades import (MOCK_TRADE_COUNT, entry_side, exit_side, mock_order_1, mock_order_2, mock_order_2_sell, mock_order_3, mock_order_3_sell, mock_order_4, mock_order_5_stoploss, mock_order_6_sell) +from tests.conftest_trades_usdt import mock_trade_usdt_4 def patch_RPCManager(mocker) -> MagicMock: @@ -1060,6 +1061,7 @@ def test_add_stoploss_on_exchange(mocker, default_conf_usdt, limit_order, is_sho freqtrade = FreqtradeBot(default_conf_usdt) freqtrade.strategy.order_types['stoploss_on_exchange'] = True + # TODO: should not be magicmock trade = MagicMock() trade.is_short = is_short trade.open_order_id = None @@ -1101,6 +1103,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_ # First case: when stoploss is not yet set but the order is open # should get the stoploss order id immediately # and should return false as no trade actually happened + # TODO: should not be magicmock trade = MagicMock() trade.is_short = is_short trade.is_open = True @@ -1879,6 +1882,7 @@ def test_exit_positions(mocker, default_conf_usdt, limit_order, is_short, caplog return_value=limit_order[entry_side(is_short)]) mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=[]) + # TODO: should not be magicmock trade = MagicMock() trade.is_short = is_short trade.open_order_id = '123' @@ -1902,6 +1906,7 @@ def test_exit_positions_exception(mocker, default_conf_usdt, limit_order, caplog order = limit_order[entry_side(is_short)] mocker.patch('freqtrade.exchange.Exchange.fetch_order', return_value=order) + # TODO: should not be magicmock trade = MagicMock() trade.is_short = is_short trade.open_order_id = None @@ -2042,6 +2047,7 @@ def test_update_trade_state_exception(mocker, default_conf_usdt, is_short, limit freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) mocker.patch('freqtrade.exchange.Exchange.fetch_order', return_value=order) + # TODO: should not be magicmock trade = MagicMock() trade.open_order_id = '123' trade.amount = 123 @@ -2060,6 +2066,7 @@ def test_update_trade_state_orderexception(mocker, default_conf_usdt, caplog) -> mocker.patch('freqtrade.exchange.Exchange.fetch_order', MagicMock(side_effect=InvalidOrderException)) + # TODO: should not be magicmock trade = MagicMock() trade.open_order_id = '123' @@ -2661,6 +2668,7 @@ def test_manage_open_orders_exit_usercustom( rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() patch_exchange(mocker) + mocker.patch('freqtrade.exchange.Exchange.get_min_pair_stake_amount', return_value=0.0) et_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit') mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -2673,7 +2681,6 @@ def test_manage_open_orders_exit_usercustom( open_trade_usdt.open_date = arrow.utcnow().shift(hours=-5).datetime open_trade_usdt.close_date = arrow.utcnow().shift(minutes=-601).datetime open_trade_usdt.close_profit_abs = 0.001 - open_trade_usdt.is_open = False Trade.query.session.add(open_trade_usdt) Trade.commit() @@ -2687,7 +2694,6 @@ def test_manage_open_orders_exit_usercustom( freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 assert rpc_mock.call_count == 1 - assert open_trade_usdt.is_open is False assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2697,7 +2703,6 @@ def test_manage_open_orders_exit_usercustom( freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 assert rpc_mock.call_count == 1 - assert open_trade_usdt.is_open is False assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2707,7 +2712,6 @@ def test_manage_open_orders_exit_usercustom( freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 assert rpc_mock.call_count == 2 - assert open_trade_usdt.is_open is True assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2748,14 +2752,14 @@ def test_manage_open_orders_exit( 'freqtrade.exchange.Exchange', fetch_ticker=ticker_usdt, fetch_order=MagicMock(return_value=limit_sell_order_old), - cancel_order=cancel_order_mock + cancel_order=cancel_order_mock, + get_min_pair_stake_amount=MagicMock(return_value=0), ) freqtrade = FreqtradeBot(default_conf_usdt) open_trade_usdt.open_date = arrow.utcnow().shift(hours=-5).datetime open_trade_usdt.close_date = arrow.utcnow().shift(minutes=-601).datetime open_trade_usdt.close_profit_abs = 0.001 - open_trade_usdt.is_open = False open_trade_usdt.is_short = is_short Trade.query.session.add(open_trade_usdt) @@ -2796,7 +2800,6 @@ def test_check_handle_cancelled_exit( open_trade_usdt.open_date = arrow.utcnow().shift(hours=-5).datetime open_trade_usdt.close_date = arrow.utcnow().shift(minutes=-601).datetime - open_trade_usdt.is_open = False open_trade_usdt.is_short = is_short Trade.query.session.add(open_trade_usdt) @@ -2984,7 +2987,7 @@ def test_manage_open_orders_exception(default_conf_usdt, ticker_usdt, open_trade @pytest.mark.parametrize("is_short", [False, True]) -def test_handle_cancel_enter(mocker, caplog, default_conf_usdt, limit_order, is_short) -> None: +def test_handle_cancel_enter(mocker, caplog, default_conf_usdt, limit_order, is_short, fee) -> None: patch_RPCManager(mocker) patch_exchange(mocker) l_order = limit_order[entry_side(is_short)] @@ -2998,15 +3001,12 @@ def test_handle_cancel_enter(mocker, caplog, default_conf_usdt, limit_order, is_ freqtrade = FreqtradeBot(default_conf_usdt) freqtrade._notify_enter_cancel = MagicMock() - # TODO: Convert to real trade - trade = MagicMock() - trade.pair = 'LTC/USDT' - trade.open_rate = 200 - trade.is_short = False - trade.entry_side = "buy" + trade = mock_trade_usdt_4(fee, is_short) + Trade.query.session.add(trade) + Trade.commit() + l_order['filled'] = 0.0 l_order['status'] = 'open' - trade.nr_of_successful_entries = 0 reason = CANCEL_REASON['TIMEOUT'] assert freqtrade.handle_cancel_enter(trade, l_order, reason) assert cancel_order_mock.call_count == 1 @@ -3038,7 +3038,7 @@ def test_handle_cancel_enter(mocker, caplog, default_conf_usdt, limit_order, is_ @pytest.mark.parametrize("is_short", [False, True]) @pytest.mark.parametrize("limit_buy_order_canceled_empty", ['binance', 'ftx', 'kraken', 'bittrex'], indirect=['limit_buy_order_canceled_empty']) -def test_handle_cancel_enter_exchanges(mocker, caplog, default_conf_usdt, is_short, +def test_handle_cancel_enter_exchanges(mocker, caplog, default_conf_usdt, is_short, fee, limit_buy_order_canceled_empty) -> None: patch_RPCManager(mocker) patch_exchange(mocker) @@ -3049,11 +3049,10 @@ def test_handle_cancel_enter_exchanges(mocker, caplog, default_conf_usdt, is_sho freqtrade = FreqtradeBot(default_conf_usdt) reason = CANCEL_REASON['TIMEOUT'] - # TODO: Convert to real trade - trade = MagicMock() - trade.nr_of_successful_entries = 0 - trade.pair = 'LTC/ETH' - trade.entry_side = "sell" if is_short else "buy" + + trade = mock_trade_usdt_4(fee, is_short) + Trade.query.session.add(trade) + Trade.commit() assert freqtrade.handle_cancel_enter(trade, limit_buy_order_canceled_empty, reason) assert cancel_order_mock.call_count == 0 assert log_has_re( @@ -3071,7 +3070,7 @@ def test_handle_cancel_enter_exchanges(mocker, caplog, default_conf_usdt, is_sho 'String Return value', 123 ]) -def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order, is_short, +def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order, is_short, fee, cancelorder) -> None: patch_RPCManager(mocker) patch_exchange(mocker) @@ -3079,19 +3078,15 @@ def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order cancel_order_mock = MagicMock(return_value=cancelorder) mocker.patch.multiple( 'freqtrade.exchange.Exchange', - cancel_order=cancel_order_mock + cancel_order=cancel_order_mock, + fetch_order=MagicMock(side_effect=InvalidOrderException) ) freqtrade = FreqtradeBot(default_conf_usdt) freqtrade._notify_enter_cancel = MagicMock() - # TODO: Convert to real trade - trade = MagicMock() - trade.pair = 'LTC/USDT' - trade.entry_side = "buy" - trade.open_rate = 200 - trade.entry_side = "buy" - trade.open_order_id = "open_order_noop" - trade.nr_of_successful_entries = 0 + trade = mock_trade_usdt_4(fee, is_short) + Trade.query.session.add(trade) + Trade.commit() l_order['filled'] = 0.0 l_order['status'] = 'open' reason = CANCEL_REASON['TIMEOUT'] @@ -3100,6 +3095,9 @@ def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order cancel_order_mock.reset_mock() l_order['filled'] = 1.0 + order = deepcopy(l_order) + order['status'] = 'canceled' + mocker.patch('freqtrade.exchange.Exchange.fetch_order', return_value=order) assert not freqtrade.handle_cancel_enter(trade, l_order, reason) assert cancel_order_mock.call_count == 1 @@ -3113,6 +3111,9 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: cancel_order=cancel_order_mock, ) mocker.patch('freqtrade.exchange.Exchange.get_rate', return_value=0.245441) + mocker.patch('freqtrade.exchange.Exchange.get_min_pair_stake_amount', return_value=0.2) + + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_order_fee') freqtrade = FreqtradeBot(default_conf_usdt) @@ -3121,20 +3122,21 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: amount=2, exchange='binance', open_rate=0.245441, - open_order_id="123456", + open_order_id="sell_123456", open_date=arrow.utcnow().shift(days=-2).datetime, fee_open=fee.return_value, fee_close=fee.return_value, close_rate=0.555, close_date=arrow.utcnow().datetime, exit_reason="sell_reason_whatever", + stake_amount=0.245441 * 2, ) trade.orders = [ - Order( + Order( ft_order_side='buy', ft_pair=trade.pair, - ft_is_open=True, - order_id='123456', + ft_is_open=False, + order_id='buy_123456', status="closed", symbol=trade.pair, order_type="market", @@ -3147,21 +3149,42 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: order_date=trade.open_date, order_filled_date=trade.open_date, ), + Order( + ft_order_side='sell', + ft_pair=trade.pair, + ft_is_open=True, + order_id='sell_123456', + status="open", + symbol=trade.pair, + order_type="limit", + side="sell", + price=trade.open_rate, + average=trade.open_rate, + filled=0.0, + remaining=trade.amount, + cost=trade.open_rate * trade.amount, + order_date=trade.open_date, + order_filled_date=trade.open_date, + ), ] - order = {'id': "123456", + order = {'id': "sell_123456", 'remaining': 1, 'amount': 1, 'status': "open"} reason = CANCEL_REASON['TIMEOUT'] + send_msg_mock.reset_mock() assert freqtrade.handle_cancel_exit(trade, order, reason) assert cancel_order_mock.call_count == 1 - assert send_msg_mock.call_count == 2 + assert send_msg_mock.call_count == 1 assert trade.close_rate is None assert trade.exit_reason is None + assert trade.open_order_id is None send_msg_mock.reset_mock() + # Partial exit - below exit threshold order['amount'] = 2 + order['filled'] = 1.9 assert not freqtrade.handle_cancel_exit(trade, order, reason) # Assert cancel_order was not called (callcount remains unchanged) assert cancel_order_mock.call_count == 1 @@ -3171,21 +3194,32 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: assert not freqtrade.handle_cancel_exit(trade, order, reason) - send_msg_mock.call_args_list[0][0][0]['reason'] = CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] + assert (send_msg_mock.call_args_list[0][0][0]['reason'] + == CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN']) # Message should not be iterated again assert trade.exit_order_status == CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] assert send_msg_mock.call_count == 1 + send_msg_mock.reset_mock() + + order['filled'] = 1 + assert freqtrade.handle_cancel_exit(trade, order, reason) + assert send_msg_mock.call_count == 1 + assert (send_msg_mock.call_args_list[0][0][0]['reason'] + == CANCEL_REASON['PARTIALLY_FILLED']) + def test_handle_cancel_exit_cancel_exception(mocker, default_conf_usdt) -> None: patch_RPCManager(mocker) patch_exchange(mocker) - mocker.patch( - 'freqtrade.exchange.Exchange.cancel_order_with_result', side_effect=InvalidOrderException()) + mocker.patch('freqtrade.exchange.Exchange.get_min_pair_stake_amount', return_value=0.0) + mocker.patch('freqtrade.exchange.Exchange.cancel_order_with_result', + side_effect=InvalidOrderException()) freqtrade = FreqtradeBot(default_conf_usdt) + # TODO: should not be magicmock trade = MagicMock() reason = CANCEL_REASON['TIMEOUT'] order = {'remaining': 1, diff --git a/tests/test_integration.py b/tests/test_integration.py index a7b4fbdd3..f2504c23a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock import pytest -from freqtrade.enums import ExitCheckTuple, ExitType +from freqtrade.enums import ExitCheckTuple, ExitType, TradingMode from freqtrade.persistence import Trade from freqtrade.persistence.models import Order from freqtrade.rpc.rpc import RPC @@ -351,8 +351,13 @@ def test_dca_short(default_conf_usdt, ticker_usdt, fee, mocker) -> None: assert trade.nr_of_successful_exits == 1 -def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: +@pytest.mark.parametrize('leverage', [ + 1, 2 +]) +def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) -> None: default_conf_usdt['position_adjustment_enable'] = True + default_conf_usdt['trading_mode'] = 'futures' + default_conf_usdt['margin_mode'] = 'isolated' freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) mocker.patch.multiple( @@ -363,9 +368,14 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: price_to_precision=lambda s, x, y: y, ) mocker.patch('freqtrade.exchange.Exchange._is_dry_limit_order_filled', return_value=False) + mocker.patch("freqtrade.exchange.Exchange.get_max_leverage", return_value=10) + mocker.patch("freqtrade.exchange.Exchange.get_funding_fees", return_value=0) + mocker.patch("freqtrade.exchange.Exchange.get_maintenance_ratio_and_amt", return_value=(0, 0)) patch_get_signal(freqtrade) freqtrade.strategy.custom_entry_price = lambda **kwargs: ticker_usdt['ask'] * 0.96 + freqtrade.strategy.leverage = MagicMock(return_value=leverage) + freqtrade.strategy.minimal_roi = {0: 0.2} freqtrade.enter_positions() @@ -377,6 +387,8 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: assert trade.open_rate == 1.96 assert trade.stop_loss_pct is None assert trade.stop_loss == 0.0 + assert trade.leverage == leverage + assert trade.stake_amount == 60 assert trade.initial_stop_loss == 0.0 assert trade.initial_stop_loss_pct is None # No adjustment @@ -396,6 +408,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: assert trade.open_rate == 1.96 assert trade.stop_loss_pct is None assert trade.stop_loss == 0.0 + assert trade.stake_amount == 60 assert trade.initial_stop_loss == 0.0 assert trade.initial_stop_loss_pct is None @@ -407,9 +420,10 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: assert trade.open_order_id is None # Open rate is not adjusted yet assert trade.open_rate == 1.99 + assert trade.stake_amount == 60 assert trade.stop_loss_pct == -0.1 - assert trade.stop_loss == 1.99 * 0.9 - assert trade.initial_stop_loss == 1.99 * 0.9 + assert pytest.approx(trade.stop_loss) == 1.99 * (1 - 0.1 / leverage) + assert pytest.approx(trade.initial_stop_loss) == 1.99 * (1 - 0.1 / leverage) assert trade.initial_stop_loss_pct == -0.1 # 2nd order - not filling @@ -422,7 +436,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: assert trade.open_order_id is not None assert trade.open_rate == 1.99 assert trade.orders[-1].price == 1.96 - assert trade.orders[-1].cost == 120 + assert trade.orders[-1].cost == 120 * leverage # Replace new order with diff. order at a lower price freqtrade.strategy.adjust_entry_price = MagicMock(return_value=1.95) @@ -432,8 +446,9 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: assert len(trade.orders) == 4 assert trade.open_order_id is not None assert trade.open_rate == 1.99 + assert trade.stake_amount == 60 assert trade.orders[-1].price == 1.95 - assert pytest.approx(trade.orders[-1].cost) == 120 + assert pytest.approx(trade.orders[-1].cost) == 120 * leverage # Fill DCA order freqtrade.strategy.adjust_trade_position = MagicMock(return_value=None) @@ -446,19 +461,21 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: assert trade.open_order_id is None assert pytest.approx(trade.open_rate) == 1.963153456 assert trade.orders[-1].price == 1.95 - assert pytest.approx(trade.orders[-1].cost) == 120 + assert pytest.approx(trade.orders[-1].cost) == 120 * leverage assert trade.orders[-1].status == 'closed' - assert pytest.approx(trade.amount) == 91.689215 + assert pytest.approx(trade.amount) == 91.689215 * leverage # Check the 2 filled orders equal the above amount - assert pytest.approx(trade.orders[1].amount) == 30.150753768 - assert pytest.approx(trade.orders[-1].amount) == 61.538461232 + assert pytest.approx(trade.orders[1].amount) == 30.150753768 * leverage + assert pytest.approx(trade.orders[-1].amount) == 61.538461232 * leverage -def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> None: +@pytest.mark.parametrize('leverage', [1, 2]) +def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, leverage) -> None: default_conf_usdt['position_adjustment_enable'] = True freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + freqtrade.trading_mode = TradingMode.FUTURES mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker_usdt, @@ -467,15 +484,17 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> Non price_to_precision=lambda s, x, y: y, get_min_pair_stake_amount=MagicMock(return_value=10), ) + mocker.patch("freqtrade.exchange.Exchange.get_max_leverage", return_value=10) patch_get_signal(freqtrade) + freqtrade.strategy.leverage = MagicMock(return_value=leverage) freqtrade.enter_positions() assert len(Trade.get_trades().all()) == 1 trade = Trade.get_trades().first() assert len(trade.orders) == 1 assert pytest.approx(trade.stake_amount) == 60 - assert pytest.approx(trade.amount) == 30.0 + assert pytest.approx(trade.amount) == 30.0 * leverage assert trade.open_rate == 2.0 # Too small size @@ -484,8 +503,9 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> Non trade = Trade.get_trades().first() assert len(trade.orders) == 1 assert pytest.approx(trade.stake_amount) == 60 - assert pytest.approx(trade.amount) == 30.0 - assert log_has_re("Remaining amount of 1.6.* would be smaller than the minimum of 10.", caplog) + assert pytest.approx(trade.amount) == 30.0 * leverage + assert log_has_re( + r"Remaining amount of \d\.\d+.* would be smaller than the minimum of 10.", caplog) freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-20) @@ -494,7 +514,7 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> Non assert len(trade.orders) == 2 assert trade.orders[-1].ft_order_side == 'sell' assert pytest.approx(trade.stake_amount) == 40.198 - assert pytest.approx(trade.amount) == 20.099 + assert pytest.approx(trade.amount) == 20.099 * leverage assert trade.open_rate == 2.0 assert trade.is_open caplog.clear() diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 52e96e477..f13bdee13 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -63,7 +63,7 @@ def test_init_plotscript(default_conf, mocker, testdatadir): def test_add_indicators(default_conf, testdatadir, caplog): pair = "UNITTEST/BTC" - timerange = TimeRange(None, 'line', 0, -1000) + timerange = TimeRange() data = history.load_pair_history(pair=pair, timeframe='1m', datadir=testdatadir, timerange=timerange)