merge develop into feat/freqai-rl-dev

This commit is contained in:
robcaulk 2022-10-30 10:13:03 +01:00
commit d1a0874683
129 changed files with 2648 additions and 1004 deletions

View File

@ -24,7 +24,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ ubuntu-18.04, ubuntu-20.04, ubuntu-22.04 ] 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: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -74,7 +74,7 @@ jobs:
if: matrix.python-version == '3.9' && matrix.os == 'ubuntu-22.04' if: matrix.python-version == '3.9' && matrix.os == 'ubuntu-22.04'
- name: Coveralls - 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: env:
# Coveralls token. Not used as secret due to github not providing secrets to forked repositories # Coveralls token. Not used as secret due to github not providing secrets to forked repositories
COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu
@ -121,7 +121,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ macos-latest ] os: [ macos-latest ]
python-version: ["3.8", "3.9", "3.10.6"] python-version: ["3.8", "3.9", "3.10"]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -205,7 +205,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ windows-latest ] os: [ windows-latest ]
python-version: ["3.8", "3.9", "3.10.6"] python-version: ["3.8", "3.9", "3.10"]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -441,4 +441,4 @@ jobs:
with: with:
severity: info severity: info
details: Deploy Succeeded! details: Deploy Succeeded!
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}

View File

@ -15,9 +15,9 @@ repos:
additional_dependencies: additional_dependencies:
- types-cachetools==5.2.1 - types-cachetools==5.2.1
- types-filelock==3.2.7 - types-filelock==3.2.7
- types-requests==2.28.11 - types-requests==2.28.11.2
- types-tabulate==0.8.11 - types-tabulate==0.9.0.0
- types-python-dateutil==2.8.19 - types-python-dateutil==2.8.19.2
# stages: [push] # stages: [push]
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort

Binary file not shown.

View File

@ -53,7 +53,7 @@
"XTZ/BTC" "XTZ/BTC"
], ],
"pair_blacklist": [ "pair_blacklist": [
"BNB/BTC" "BNB/.*"
] ]
}, },
"pairlists": [ "pairlists": [

View File

@ -18,13 +18,8 @@
"name": "binance", "name": "binance",
"key": "", "key": "",
"secret": "", "secret": "",
"ccxt_config": { "ccxt_config": {},
"enableRateLimit": true "ccxt_async_config": {},
},
"ccxt_async_config": {
"enableRateLimit": true,
"rateLimit": 200
},
"pair_whitelist": [ "pair_whitelist": [
"1INCH/USDT", "1INCH/USDT",
"ALGO/USDT" "ALGO/USDT"

View File

@ -11,7 +11,7 @@ ENV FT_APP_ENV="docker"
# Prepare environment # Prepare environment
RUN mkdir /freqtrade \ RUN mkdir /freqtrade \
&& apt-get update \ && apt-get update \
&& apt-get -y install sudo libatlas3-base curl sqlite3 libhdf5-dev \ && apt-get -y install sudo libatlas3-base curl sqlite3 libhdf5-dev libutf8proc-dev libsnappy-dev \
&& apt-get clean \ && apt-get clean \
&& useradd -u 1000 -G sudo -U -m ftuser \ && useradd -u 1000 -G sudo -U -m ftuser \
&& chown ftuser:ftuser /freqtrade \ && chown ftuser:ftuser /freqtrade \
@ -37,6 +37,7 @@ ENV LD_LIBRARY_PATH /usr/local/lib
COPY --chown=ftuser:ftuser requirements.txt /freqtrade/ COPY --chown=ftuser:ftuser requirements.txt /freqtrade/
USER ftuser USER ftuser
RUN pip install --user --no-cache-dir numpy \ RUN pip install --user --no-cache-dir numpy \
&& pip install --user /tmp/pyarrow-*.whl \
&& pip install --user --no-cache-dir -r requirements.txt && pip install --user --no-cache-dir -r requirements.txt
# Copy dependencies to runtime-image # Copy dependencies to runtime-image

View File

@ -78,6 +78,8 @@ This function needs to return a floating point number (`float`). Smaller numbers
To override a pre-defined space (`roi_space`, `generate_roi_table`, `stoploss_space`, `trailing_space`), define a nested class called Hyperopt and define the required spaces as follows: To override a pre-defined space (`roi_space`, `generate_roi_table`, `stoploss_space`, `trailing_space`), define a nested class called Hyperopt and define the required spaces as follows:
```python ```python
from freqtrade.optimize.space import Categorical, Dimension, Integer, SKDecimal
class MyAwesomeStrategy(IStrategy): class MyAwesomeStrategy(IStrategy):
class HyperOpt: class HyperOpt:
# Define a custom stoploss space. # Define a custom stoploss space.
@ -94,6 +96,33 @@ class MyAwesomeStrategy(IStrategy):
SKDecimal(0.01, 0.07, decimals=3, name='roi_p2'), SKDecimal(0.01, 0.07, decimals=3, name='roi_p2'),
SKDecimal(0.01, 0.20, decimals=3, name='roi_p3'), SKDecimal(0.01, 0.20, decimals=3, name='roi_p3'),
] ]
def generate_roi_table(params: Dict) -> Dict[int, float]:
roi_table = {}
roi_table[0] = params['roi_p1'] + params['roi_p2'] + params['roi_p3']
roi_table[params['roi_t3']] = params['roi_p1'] + params['roi_p2']
roi_table[params['roi_t3'] + params['roi_t2']] = params['roi_p1']
roi_table[params['roi_t3'] + params['roi_t2'] + params['roi_t1']] = 0
return roi_table
def trailing_space() -> List[Dimension]:
# All parameters here are mandatory, you can only modify their type or the range.
return [
# Fixed to true, if optimizing trailing_stop we assume to use trailing stop at all times.
Categorical([True], name='trailing_stop'),
SKDecimal(0.01, 0.35, decimals=3, name='trailing_stop_positive'),
# 'trailing_stop_positive_offset' should be greater than 'trailing_stop_positive',
# so this intermediate parameter is used as the value of the difference between
# them. The value of the 'trailing_stop_positive_offset' is constructed in the
# generate_trailing_params() method.
# This is similar to the hyperspace dimensions used for constructing the ROI tables.
SKDecimal(0.001, 0.1, decimals=3, name='trailing_stop_positive_offset_p1'),
Categorical([True, False], name='trailing_only_offset_is_reached'),
]
``` ```
!!! Note !!! Note

View File

@ -522,13 +522,13 @@ Since backtesting lacks some detailed information about what happens within a ca
- ROI - ROI
- exits are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the exit will be at 2%) - exits are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the exit will be at 2%)
- exits are never "below the candle", so a ROI of 2% may result in a exit at 2.4% if low was at 2.4% profit - exits are never "below the candle", so a ROI of 2% may result in a exit at 2.4% if low was at 2.4% profit
- Forceexits caused by `<N>=-1` ROI entries use low as exit value, unless N falls on the candle open (e.g. `120: -1` for 1h candles) - Force-exits caused by `<N>=-1` ROI entries use low as exit value, unless N falls on the candle open (e.g. `120: -1` for 1h candles)
- Stoploss exits happen exactly at stoploss price, even if low was lower, but the loss will be `2 * fees` higher than the stoploss price - Stoploss exits happen exactly at stoploss price, even if low was lower, but the loss will be `2 * fees` higher than the stoploss price
- Stoploss is evaluated before ROI within one candle. So you can often see more trades with the `stoploss` exit reason comparing to the results obtained with the same strategy in the Dry Run/Live Trade modes - Stoploss is evaluated before ROI within one candle. So you can often see more trades with the `stoploss` exit reason comparing to the results obtained with the same strategy in the Dry Run/Live Trade modes
- Low happens before high for stoploss, protecting capital first - Low happens before high for stoploss, protecting capital first
- Trailing stoploss - Trailing stoploss
- Trailing Stoploss is only adjusted if it's below the candle's low (otherwise it would be triggered) - Trailing Stoploss is only adjusted if it's below the candle's low (otherwise it would be triggered)
- On trade entry candles that trigger trailing stoploss, the "minimum offset" (`stop_positive_offset`) is assumed (instead of high) - and the stop is calculated from this point - On trade entry candles that trigger trailing stoploss, the "minimum offset" (`stop_positive_offset`) is assumed (instead of high) - and the stop is calculated from this point. This rule is NOT applicable to custom-stoploss scenarios, since there's no information about the stoploss logic available.
- High happens first - adjusting stoploss - High happens first - adjusting stoploss
- Low uses the adjusted stoploss (so exits with large high-low difference are backtested correctly) - Low uses the adjusted stoploss (so exits with large high-low difference are backtested correctly)
- ROI applies before trailing-stop, ensuring profits are "top-capped" at ROI if both ROI and trailing stop applies - ROI applies before trailing-stop, ensuring profits are "top-capped" at ROI if both ROI and trailing stop applies
@ -546,8 +546,8 @@ In addition to the above assumptions, strategy authors should carefully read the
### Trading limits in backtesting ### Trading limits in backtesting
Exchanges have certain trading limits, like minimum base currency, or minimum stake (quote) currency. Exchanges have certain trading limits, like minimum (and maximum) base currency, or minimum/maximum stake (quote) currency.
These limits are usually listed in the exchange documentation as "trading rules" or similar. These limits are usually listed in the exchange documentation as "trading rules" or similar and can be quite different between different pairs.
Backtesting (as well as live and dry-run) does honor these limits, and will ensure that a stoploss can be placed below this value - so the value will be slightly higher than what the exchange specifies. Backtesting (as well as live and dry-run) does honor these limits, and will ensure that a stoploss can be placed below this value - so the value will be slightly higher than what the exchange specifies.
Freqtrade has however no information about historic limits. Freqtrade has however no information about historic limits.

View File

@ -215,16 +215,18 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `telegram.balance_dust_level` | Dust-level (in stake currency) - currencies with a balance below this will not be shown by `/balance`. <br> **Datatype:** float | `telegram.balance_dust_level` | Dust-level (in stake currency) - currencies with a balance below this will not be shown by `/balance`. <br> **Datatype:** float
| `telegram.reload` | Allow "reload" buttons on telegram messages. <br>*Defaults to `True`.<br> **Datatype:** boolean | `telegram.reload` | Allow "reload" buttons on telegram messages. <br>*Defaults to `True`.<br> **Datatype:** boolean
| `telegram.notification_settings.*` | Detailed notification settings. Refer to the [telegram documentation](telegram-usage.md) for details.<br> **Datatype:** dictionary | `telegram.notification_settings.*` | Detailed notification settings. Refer to the [telegram documentation](telegram-usage.md) for details.<br> **Datatype:** dictionary
| `telegram.allow_custom_messages` | Enable the sending of Telegram messages from strategies via the dataprovider.send_msg() function. <br> **Datatype:** Boolean
| | **Webhook** | | **Webhook**
| `webhook.enabled` | Enable usage of Webhook notifications <br> **Datatype:** Boolean | `webhook.enabled` | Enable usage of Webhook notifications <br> **Datatype:** Boolean
| `webhook.url` | URL for the webhook. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String | `webhook.url` | URL for the webhook. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String
| `webhook.webhookentry` | Payload to send on entry. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String | `webhook.entry` | Payload to send on entry. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String
| `webhook.webhookentrycancel` | Payload to send on entry order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String | `webhook.entry_cancel` | Payload to send on entry order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String
| `webhook.webhookentryfill` | Payload to send on entry order filled. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String | `webhook.entry_fill` | Payload to send on entry order filled. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String
| `webhook.webhookexit` | Payload to send on exit. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String | `webhook.exit` | Payload to send on exit. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String
| `webhook.webhookexitcancel` | Payload to send on exit order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String | `webhook.exit_cancel` | Payload to send on exit order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String
| `webhook.webhookexitfill` | Payload to send on exit order filled. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String | `webhook.exit_fill` | Payload to send on exit order filled. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String
| `webhook.webhookstatus` | Payload to send on status calls. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String | `webhook.status` | Payload to send on status calls. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. <br> **Datatype:** String
| `webhook.allow_custom_messages` | Enable the sending of Webhook messages from strategies via the dataprovider.send_msg() function. <br> **Datatype:** Boolean
| | **Rest API / FreqUI / Producer-Consumer** | | **Rest API / FreqUI / Producer-Consumer**
| `api_server.enabled` | Enable usage of API Server. See the [API Server documentation](rest-api.md) for more details. <br> **Datatype:** Boolean | `api_server.enabled` | Enable usage of API Server. See the [API Server documentation](rest-api.md) for more details. <br> **Datatype:** Boolean
| `api_server.listen_ip_address` | Bind IP address. See the [API Server documentation](rest-api.md) for more details. <br> **Datatype:** IPv4 | `api_server.listen_ip_address` | Bind IP address. See the [API Server documentation](rest-api.md) for more details. <br> **Datatype:** IPv4

View File

@ -66,11 +66,11 @@ We will keep a compatibility layer for 1-2 versions (so both `buy_tag` and `ente
#### Naming changes #### Naming changes
Webhook terminology changed from "sell" to "exit", and from "buy" to "entry". Webhook terminology changed from "sell" to "exit", and from "buy" to "entry", removing "webhook" in the process.
* `webhookbuy` -> `webhookentry` * `webhookbuy`, `webhookentry` -> `entry`
* `webhookbuyfill` -> `webhookentryfill` * `webhookbuyfill`, `webhookentryfill` -> `entry_fill`
* `webhookbuycancel` -> `webhookentrycancel` * `webhookbuycancel`, `webhookentrycancel` -> `entry_cancel`
* `webhooksell` -> `webhookexit` * `webhooksell`, `webhookexit` -> `exit`
* `webhooksellfill` -> `webhookexitfill` * `webhooksellfill`, `webhookexitfill` -> `exit_fill`
* `webhooksellcancel` -> `webhookexitcancel` * `webhooksellcancel`, `webhookexitcancel` -> `exit_cancel`

View File

@ -102,6 +102,12 @@ If this happens for all pairs in the pairlist, this might indicate a recent exch
Irrespectively of the reason, Freqtrade will fill up these candles with "empty" candles, where open, high, low and close are set to the previous candle close - and volume is empty. In a chart, this will look like a `_` - and is aligned with how exchanges usually represent 0 volume candles. Irrespectively of the reason, Freqtrade will fill up these candles with "empty" candles, where open, high, low and close are set to the previous candle close - and volume is empty. In a chart, this will look like a `_` - and is aligned with how exchanges usually represent 0 volume candles.
### I'm getting "Price jump between 2 candles detected"
This message is a warning that the candles had a price jump of > 30%.
This might be a sign that the pair stopped trading, and some token exchange took place (e.g. COCOS in 2021 - where price jumped from 0.0000154 to 0.01621).
This message is often accompanied by ["Missing data fillup"](#im-getting-missing-data-fillup-messages-in-the-log) - as trading on such pairs is often stopped for some time.
### I'm getting "Outdated history for pair xxx" in the log ### I'm getting "Outdated history for pair xxx" in the log
The bot is trying to tell you that it got an outdated last candle (not the last complete candle). The bot is trying to tell you that it got an outdated last candle (not the last complete candle).

View File

@ -192,11 +192,11 @@ dataframe["target_roi"] = dataframe["&-s_close_mean"] + dataframe["&-s_close_std
dataframe["sell_roi"] = dataframe["&-s_close_mean"] - dataframe["&-s_close_std"] * 1.25 dataframe["sell_roi"] = dataframe["&-s_close_mean"] - dataframe["&-s_close_std"] * 1.25
``` ```
To consider the population of *historical predictions* for creating the dynamic target instead of information from the training as discussed above, you would set `fit_live_prediction_candles` in the config to the number of historical prediction candles you wish to use to generate target statistics. To consider the population of *historical predictions* for creating the dynamic target instead of information from the training as discussed above, you would set `fit_live_predictions_candles` in the config to the number of historical prediction candles you wish to use to generate target statistics.
```json ```json
"freqai": { "freqai": {
"fit_live_prediction_candles": 300, "fit_live_predictions_candles": 300,
} }
``` ```
@ -204,14 +204,44 @@ If this value is set, FreqAI will initially use the predictions from the trainin
## Using different prediction models ## 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/`.
### Setting classifier targets Regression and classification models differ in what targets they predict - a regression model will predict a target of continuous values, for example what price BTC will be at tomorrow, whilst a classifier will predict a target of discrete values, for example if the price of BTC will go up tomorrow or not. This means that you have to specify your targets differently depending on which model type you are using (see details [below](#setting-model-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: All of the aforementioned model libraries implement gradient boosted decision tree algorithms. They all work on the principle of ensemble learning, where predictions from multiple simple learners are combined to get a final prediction that is more stable and generalized. The simple learners in this case are decision trees. Gradient boosting refers to the method of learning, where each simple learner is built in sequence - the subsequent learner is used to improve on the error from the previous learner. If you want to learn more about the different model libraries you can find the information in their respective docs:
* CatBoost: https://catboost.ai/en/docs/
* LightGBM: https://lightgbm.readthedocs.io/en/v3.3.2/#
* XGBoost: https://xgboost.readthedocs.io/en/stable/#
There are also numerous online articles describing and comparing the algorithms. Some relatively light-weight examples would be [CatBoost vs. LightGBM vs. XGBoost — Which is the best algorithm?](https://towardsdatascience.com/catboost-vs-lightgbm-vs-xgboost-c80f40662924#:~:text=In%20CatBoost%2C%20symmetric%20trees%2C%20or,the%20same%20depth%20can%20differ.) and [XGBoost, LightGBM or CatBoost — which boosting algorithm should I use?](https://medium.com/riskified-technology/xgboost-lightgbm-or-catboost-which-boosting-algorithm-should-i-use-e7fda7bb36bc). Keep in mind that the performance of each model is highly dependent on the application and so any reported metrics might not be true for your particular use of the model.
Apart from the models already available in FreqAI, it is also possible to customize and create your own prediction models using the `IFreqaiModel` class. You are encouraged to inherit `fit()`, `train()`, and `predict()` to customize various aspects of the training procedures. You can place custom FreqAI models in `user_data/freqaimodels` - and freqtrade will pick them up from there based on the provided `--freqaimodel` name - which has to correspond to the class name of your custom model.
Make sure to use unique names to avoid overriding built-in models.
### Setting model targets
#### Regressors
If you are using a regressor, you need to specify a target that has continuous values. FreqAI includes a variety of regressors, such as the `CatboostRegressor`via the flag `--freqaimodel CatboostRegressor`. An example of how you could set a regression target for predicting the price 100 candles into the future would be
```python
df['&s-close_price'] = df['close'].shift(-100)
```
If you want to predict multiple targets, you need to define multiple labels using the same syntax as shown above.
#### Classifiers
If you are using a classifier, you need to specify a target that has discrete values. 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, if you want to predict if the price 100 candles into the future goes up or down you would set
```python ```python
df['&s-up_or_down'] = np.where( df["close"].shift(-100) > df["close"], 'up', 'down') df['&s-up_or_down'] = np.where( df["close"].shift(-100) > df["close"], 'up', 'down')
``` ```
Additionally, the example classifier models do not accommodate multiple labels, but they do allow multi-class classification within a single label column. If you want to predict multiple targets you must specify all labels in the same label column. You could, for example, add the label `same` to define where the price was unchanged by setting
```python
df['&s-up_or_down'] = np.where( df["close"].shift(-100) > df["close"], 'up', 'down')
df['&s-up_or_down'] = np.where( df["close"].shift(-100) == df["close"], 'same', df['&s-up_or_down'])
```

View File

@ -37,12 +37,13 @@ Mandatory parameters are marked as **Required** and have to be set in one of the
| `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). <br> **Datatype:** Integer. <br> 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). <br> **Datatype:** Integer. <br> 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. <br> **Datatype:** Float. <br> Default: `30`. | `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. <br> **Datatype:** Float. <br> 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. <br> **Datatype:** Boolean. <br> Default: `False` (no reversal). | `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. <br> **Datatype:** Boolean. <br> Default: `False` (no reversal).
| `write_metrics_to_disk` | Collect train timings, inference timings and cpu usage in json file. <br> **Datatype:** Boolean. <br> Default: `False`
| | **Data split parameters** | | **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). <br> **Datatype:** Dictionary. | `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). <br> **Datatype:** Dictionary.
| `test_size` | The fraction of data that should be used for testing instead of training. <br> **Datatype:** Positive float < 1. | `test_size` | The fraction of data that should be used for testing instead of training. <br> **Datatype:** Positive float < 1.
| `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`. <br> **Datatype:** Boolean. <br> Defaut: `False`. | `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`. <br> **Datatype:** Boolean. <br> Defaut: `False`.
| | **Model training parameters** | | **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. <br> **Datatype:** Dictionary. | `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. A list of the currently available models can be found [here](freqai-configuration.md#using-different-prediction-models). <br> **Datatype:** Dictionary.
| `n_estimators` | The number of boosted trees to fit in the training of the model. <br> **Datatype:** Integer. | `n_estimators` | The number of boosted trees to fit in the training of the model. <br> **Datatype:** Integer.
| `learning_rate` | Boosting learning rate during training of the model. <br> **Datatype:** Float. | `learning_rate` | Boosting learning rate during training of the model. <br> **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. <br> **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. <br> **Datatype:** Float.

View File

@ -142,15 +142,32 @@ dataframe['outlier'] = np.where(dataframe['DI_values'] > self.di_max.value/10, 1
This specific hyperopt would help you understand the appropriate `DI_values` for your particular parameter space. This specific hyperopt would help you understand the appropriate `DI_values` for your particular parameter space.
## Using Tensorboard
CatBoost models benefit from tracking training metrics via Tensorboard. You can take advantage of the FreqAI integration to track training and evaluation performance across all coins and across all retrainings. Tensorboard is activated via the following command:
```bash
cd freqtrade
tensorboard --logdir user_data/models/unique-id
```
where `unique-id` is the `identifier` set in the `freqai` configuration file. This command must be run in a separate shell if you wish to view the output in your browser at 127.0.0.1:6060 (6060 is the default port used by Tensorboard).
![tensorboard](assets/tensorboard.jpg)
## Setting up a follower ## Setting up a follower
You can indicate to the bot that it should not train models, but instead should look for models trained by a leader with a specific `identifier` by defining: You can indicate to the bot that it should not train models, but instead should look for models trained by a leader with a specific `identifier` by defining:
```json ```json
"freqai": { "freqai": {
"enabled": true,
"follow_mode": true, "follow_mode": true,
"identifier": "example" "identifier": "example",
"feature_parameters": {
// leader bots feature_parameters inserted here
},
} }
``` ```
In this example, the user has a leader bot with the `"identifier": "example"`. The leader bot is already running or is launched simultaneously with the follower. The follower will load models created by the leader and inference them to obtain predictions instead of training its own models. In this example, the user has a leader bot with the `"identifier": "example"`. The leader bot is already running or is launched simultaneously with the follower. The follower will load models created by the leader and inference them to obtain predictions instead of training its own models. The user will also need to duplicate the `feature_parameters` parameters from from the leaders freqai configuration file into the freqai section of the followers config.

View File

@ -1,6 +1,6 @@
markdown==3.3.7 markdown==3.3.7
mkdocs==1.4.0 mkdocs==1.4.1
mkdocs-material==8.5.6 mkdocs-material==8.5.7
mdx_truly_sane_lists==1.3 mdx_truly_sane_lists==1.3
pymdown-extensions==9.6 pymdown-extensions==9.7
jinja2==3.1.2 jinja2==3.1.2

View File

@ -87,7 +87,7 @@ At this stage the bot contains the following stoploss support modes:
2. Trailing stop loss. 2. Trailing stop loss.
3. Trailing stop loss, custom positive loss. 3. Trailing stop loss, custom positive loss.
4. Trailing stop loss only once the trade has reached a certain offset. 4. Trailing stop loss only once the trade has reached a certain offset.
5. [Custom stoploss function](strategy-advanced.md#custom-stoploss) 5. [Custom stoploss function](strategy-callbacks.md#custom-stoploss)
### Static Stop Loss ### Static Stop Loss

View File

@ -159,6 +159,7 @@ The stoploss price can only ever move upwards - if the stoploss value returned f
The method must return a stoploss value (float / number) as a percentage of the current price. The method must return a stoploss value (float / number) as a percentage of the current price.
E.g. If the `current_rate` is 200 USD, then returning `0.02` will set the stoploss price 2% lower, at 196 USD. E.g. If the `current_rate` is 200 USD, then returning `0.02` will set the stoploss price 2% lower, at 196 USD.
During backtesting, `current_rate` (and `current_profit`) are provided against the candle's high (or low for short trades) - while the resulting stoploss is evaluated against the candle's low (or high for short trades).
The absolute value of the return value is used (the sign is ignored), so returning `0.05` or `-0.05` have the same result, a stoploss 5% below the current price. The absolute value of the return value is used (the sign is ignored), so returning `0.05` or `-0.05` have the same result, a stoploss 5% below the current price.

View File

@ -655,13 +655,13 @@ This is where calling `self.dp.current_whitelist()` comes in handy.
# fetch live / historical candle (OHLCV) data for the first informative pair # fetch live / historical candle (OHLCV) data for the first informative pair
inf_pair, inf_timeframe = self.informative_pairs()[0] inf_pair, inf_timeframe = self.informative_pairs()[0]
informative = self.dp.get_pair_dataframe(pair=inf_pair, informative = self.dp.get_pair_dataframe(pair=inf_pair,
timeframe=inf_timeframe) timeframe=inf_timeframe)
``` ```
!!! Warning "Warning about backtesting" !!! Warning "Warning about backtesting"
Be careful when using dataprovider in backtesting. `historic_ohlcv()` (and `get_pair_dataframe()` In backtesting, `dp.get_pair_dataframe()` behavior differs depending on where it's called.
for the backtesting runmode) provides the full time-range in one go, Within `populate_*()` methods, `dp.get_pair_dataframe()` returns the full timerange. Please make sure to not "look into the future" to avoid surprises when running in dry/live mode.
so please be aware of it and make sure to not "look into the future" to avoid surprises when running in dry/live mode. Within [callbacks](strategy-callbacks.md), you'll get the full timerange up to the current (simulated) candle.
### *get_analyzed_dataframe(pair, timeframe)* ### *get_analyzed_dataframe(pair, timeframe)*
@ -670,13 +670,13 @@ It can also be used in specific callbacks to get the signal that caused the acti
``` python ``` python
# fetch current dataframe # fetch current dataframe
if self.dp.runmode.value in ('live', 'dry_run'): dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=metadata['pair'],
dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=metadata['pair'], timeframe=self.timeframe)
timeframe=self.timeframe)
``` ```
!!! Note "No data available" !!! Note "No data available"
Returns an empty dataframe if the requested pair was not cached. Returns an empty dataframe if the requested pair was not cached.
You can check for this with `if dataframe.empty:` and handle this case accordingly.
This should not happen when using whitelisted pairs. This should not happen when using whitelisted pairs.
### *orderbook(pair, maximum)* ### *orderbook(pair, maximum)*

View File

@ -43,19 +43,25 @@ Note : `forcesell`, `forcebuy`, `emergencysell` are changed to `force_exit`, `fo
* `order_time_in_force` buy -> entry, sell -> exit. * `order_time_in_force` buy -> entry, sell -> exit.
* `order_types` buy -> entry, sell -> exit. * `order_types` buy -> entry, sell -> exit.
* `unfilledtimeout` buy -> entry, sell -> exit. * `unfilledtimeout` buy -> entry, sell -> exit.
* `ignore_buying_expired_candle_after` -> moved to root level instead of "ask_strategy/exit_pricing"
* Terminology changes * Terminology changes
* Sell reasons changed to reflect the new naming of "exit" instead of sells. Be careful in your strategy if you're using `exit_reason` checks and eventually update your strategy. * Sell reasons changed to reflect the new naming of "exit" instead of sells. Be careful in your strategy if you're using `exit_reason` checks and eventually update your strategy.
* `sell_signal` -> `exit_signal` * `sell_signal` -> `exit_signal`
* `custom_sell` -> `custom_exit` * `custom_sell` -> `custom_exit`
* `force_sell` -> `force_exit` * `force_sell` -> `force_exit`
* `emergency_sell` -> `emergency_exit` * `emergency_sell` -> `emergency_exit`
* Order pricing
* `bid_strategy` -> `entry_pricing`
* `ask_strategy` -> `exit_pricing`
* `ask_last_balance` -> `price_last_balance`
* `bid_last_balance` -> `price_last_balance`
* Webhook terminology changed from "sell" to "exit", and from "buy" to entry * Webhook terminology changed from "sell" to "exit", and from "buy" to entry
* `webhookbuy` -> `webhookentry` * `webhookbuy` -> `entry`
* `webhookbuyfill` -> `webhookentryfill` * `webhookbuyfill` -> `entry_fill`
* `webhookbuycancel` -> `webhookentrycancel` * `webhookbuycancel` -> `entry_cancel`
* `webhooksell` -> `webhookexit` * `webhooksell` -> `exit`
* `webhooksellfill` -> `webhookexitfill` * `webhooksellfill` -> `exit_fill`
* `webhooksellcancel` -> `webhookexitcancel` * `webhooksellcancel` -> `exit_cancel`
* Telegram notification settings * Telegram notification settings
* `buy` -> `entry` * `buy` -> `entry`
* `buy_fill` -> `entry_fill` * `buy_fill` -> `entry_fill`
@ -443,6 +449,7 @@ Please refer to the [pricing documentation](configuration.md#prices-used-for-ord
"use_order_book": true, "use_order_book": true,
"order_book_top": 1, "order_book_top": 1,
"bid_last_balance": 0.0 "bid_last_balance": 0.0
"ignore_buying_expired_candle_after": 120
} }
} }
``` ```
@ -466,6 +473,7 @@ after:
"use_order_book": true, "use_order_book": true,
"order_book_top": 1, "order_book_top": 1,
"price_last_balance": 0.0 "price_last_balance": 0.0
} },
"ignore_buying_expired_candle_after": 120
} }
``` ```

View File

@ -77,6 +77,7 @@ Example configuration showing the different settings:
"enabled": true, "enabled": true,
"token": "your_telegram_token", "token": "your_telegram_token",
"chat_id": "your_telegram_chat_id", "chat_id": "your_telegram_chat_id",
"allow_custom_messages": true,
"notification_settings": { "notification_settings": {
"status": "silent", "status": "silent",
"warning": "on", "warning": "on",
@ -115,6 +116,7 @@ Example configuration showing the different settings:
`show_candle` - show candle values as part of entry/exit messages. Only possible values are `"ohlc"` or `"off"`. `show_candle` - show candle values as part of entry/exit messages. Only possible values are `"ohlc"` or `"off"`.
`balance_dust_level` will define what the `/balance` command takes as "dust" - Currencies with a balance below this will be shown. `balance_dust_level` will define what the `/balance` command takes as "dust" - Currencies with a balance below this will be shown.
`allow_custom_messages` completely disable strategy messages.
`reload` allows you to disable reload-buttons on selected messages. `reload` allows you to disable reload-buttons on selected messages.
## Create a custom keyboard (command shortcut buttons) ## Create a custom keyboard (command shortcut buttons)

View File

@ -169,6 +169,43 @@ Example: Search dedicated strategy path.
freqtrade list-strategies --strategy-path ~/.freqtrade/strategies/ freqtrade list-strategies --strategy-path ~/.freqtrade/strategies/
``` ```
## List freqAI models
Use the `list-freqaimodels` subcommand to see all freqAI models available.
This subcommand is useful for finding problems in your environment with loading freqAI models: modules with models that contain errors and failed to load are printed in red (LOAD FAILED), while models with duplicate names are printed in yellow (DUPLICATE NAME).
```
usage: freqtrade list-freqaimodels [-h] [-v] [--logfile FILE] [-V] [-c PATH]
[-d PATH] [--userdir PATH]
[--freqaimodel-path PATH] [-1] [--no-color]
optional arguments:
-h, --help show this help message and exit
--freqaimodel-path PATH
Specify additional lookup path for freqaimodels.
-1, --one-column Print output in one column.
--no-color Disable colorization of hyperopt results. May be
useful if you are redirecting output to a file.
Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
--logfile FILE Log to the file specified. Special values are:
'syslog', 'journald'. See the documentation for more
details.
-V, --version show program's version number and exit
-c PATH, --config PATH
Specify configuration file (default:
`userdir/config.json` or `config.json` whichever
exists). Multiple --config options may be used. Can be
set to `-` to read config from stdin.
-d PATH, --datadir PATH, --data-dir PATH
Path to directory with historical backtesting data.
--userdir PATH, --user-data-dir PATH
Path to userdata directory.
```
## List Exchanges ## List Exchanges
Use the `list-exchanges` subcommand to see the exchanges available for the bot. Use the `list-exchanges` subcommand to see the exchanges available for the bot.

View File

@ -10,37 +10,37 @@ Sample configuration (tested using IFTTT).
"webhook": { "webhook": {
"enabled": true, "enabled": true,
"url": "https://maker.ifttt.com/trigger/<YOUREVENT>/with/key/<YOURKEY>/", "url": "https://maker.ifttt.com/trigger/<YOUREVENT>/with/key/<YOURKEY>/",
"webhookentry": { "entry": {
"value1": "Buying {pair}", "value1": "Buying {pair}",
"value2": "limit {limit:8f}", "value2": "limit {limit:8f}",
"value3": "{stake_amount:8f} {stake_currency}" "value3": "{stake_amount:8f} {stake_currency}"
}, },
"webhookentrycancel": { "entry_cancel": {
"value1": "Cancelling Open Buy Order for {pair}", "value1": "Cancelling Open Buy Order for {pair}",
"value2": "limit {limit:8f}", "value2": "limit {limit:8f}",
"value3": "{stake_amount:8f} {stake_currency}" "value3": "{stake_amount:8f} {stake_currency}"
}, },
"webhookentryfill": { "entry_fill": {
"value1": "Buy Order for {pair} filled", "value1": "Buy Order for {pair} filled",
"value2": "at {open_rate:8f}", "value2": "at {open_rate:8f}",
"value3": "" "value3": ""
}, },
"webhookexit": { "exit": {
"value1": "Exiting {pair}", "value1": "Exiting {pair}",
"value2": "limit {limit:8f}", "value2": "limit {limit:8f}",
"value3": "profit: {profit_amount:8f} {stake_currency} ({profit_ratio})" "value3": "profit: {profit_amount:8f} {stake_currency} ({profit_ratio})"
}, },
"webhookexitcancel": { "exit_cancel": {
"value1": "Cancelling Open Exit Order for {pair}", "value1": "Cancelling Open Exit Order for {pair}",
"value2": "limit {limit:8f}", "value2": "limit {limit:8f}",
"value3": "profit: {profit_amount:8f} {stake_currency} ({profit_ratio})" "value3": "profit: {profit_amount:8f} {stake_currency} ({profit_ratio})"
}, },
"webhookexitfill": { "exit_fill": {
"value1": "Exit Order for {pair} filled", "value1": "Exit Order for {pair} filled",
"value2": "at {close_rate:8f}.", "value2": "at {close_rate:8f}.",
"value3": "" "value3": ""
}, },
"webhookstatus": { "status": {
"value1": "Status: {status}", "value1": "Status: {status}",
"value2": "", "value2": "",
"value3": "" "value3": ""
@ -57,7 +57,7 @@ You can set the POST body format to Form-Encoded (default), JSON-Encoded, or raw
"enabled": true, "enabled": true,
"url": "https://<YOURSUBDOMAIN>.cloud.mattermost.com/hooks/<YOURHOOK>", "url": "https://<YOURSUBDOMAIN>.cloud.mattermost.com/hooks/<YOURHOOK>",
"format": "json", "format": "json",
"webhookstatus": { "status": {
"text": "Status: {status}" "text": "Status: {status}"
} }
}, },
@ -88,17 +88,30 @@ Optional parameters are available to enable automatic retries for webhook messag
"url": "https://<YOURHOOKURL>", "url": "https://<YOURHOOKURL>",
"retries": 3, "retries": 3,
"retry_delay": 0.2, "retry_delay": 0.2,
"webhookstatus": { "status": {
"status": "Status: {status}" "status": "Status: {status}"
} }
}, },
``` ```
Custom messages can be sent to Webhook endpoints via the `self.dp.send_msg()` function from within the strategy. To enable this, set the `allow_custom_messages` option to `true`:
```json
"webhook": {
"enabled": true,
"url": "https://<YOURHOOKURL>",
"allow_custom_messages": true,
"strategy_msg": {
"status": "StrategyMessage: {msg}"
}
},
```
Different payloads can be configured for different events. Not all fields are necessary, but you should configure at least one of the dicts, otherwise the webhook will never be called. Different payloads can be configured for different events. Not all fields are necessary, but you should configure at least one of the dicts, otherwise the webhook will never be called.
### Webhookentry ### Entry
The fields in `webhook.webhookentry` are filled when the bot executes a long/short. Parameters are filled using string.format. The fields in `webhook.entry` are filled when the bot executes a long/short. Parameters are filled using string.format.
Possible parameters are: Possible parameters are:
* `trade_id` * `trade_id`
@ -118,9 +131,9 @@ Possible parameters are:
* `current_rate` * `current_rate`
* `enter_tag` * `enter_tag`
### Webhookentrycancel ### Entry cancel
The fields in `webhook.webhookentrycancel` are filled when the bot cancels a long/short order. Parameters are filled using string.format. The fields in `webhook.entry_cancel` are filled when the bot cancels a long/short order. Parameters are filled using string.format.
Possible parameters are: Possible parameters are:
* `trade_id` * `trade_id`
@ -139,9 +152,9 @@ Possible parameters are:
* `current_rate` * `current_rate`
* `enter_tag` * `enter_tag`
### Webhookentryfill ### Entry fill
The fields in `webhook.webhookentryfill` are filled when the bot filled a long/short order. Parameters are filled using string.format. The fields in `webhook.entry_fill` are filled when the bot filled a long/short order. Parameters are filled using string.format.
Possible parameters are: Possible parameters are:
* `trade_id` * `trade_id`
@ -160,9 +173,9 @@ Possible parameters are:
* `current_rate` * `current_rate`
* `enter_tag` * `enter_tag`
### Webhookexit ### Exit
The fields in `webhook.webhookexit` are filled when the bot exits a trade. Parameters are filled using string.format. The fields in `webhook.exit` are filled when the bot exits a trade. Parameters are filled using string.format.
Possible parameters are: Possible parameters are:
* `trade_id` * `trade_id`
@ -184,9 +197,9 @@ Possible parameters are:
* `open_date` * `open_date`
* `close_date` * `close_date`
### Webhookexitfill ### Exit fill
The fields in `webhook.webhookexitfill` are filled when the bot fills a exit order (closes a Trade). Parameters are filled using string.format. The fields in `webhook.exit_fill` are filled when the bot fills a exit order (closes a Trade). Parameters are filled using string.format.
Possible parameters are: Possible parameters are:
* `trade_id` * `trade_id`
@ -209,9 +222,9 @@ Possible parameters are:
* `open_date` * `open_date`
* `close_date` * `close_date`
### Webhookexitcancel ### Exit cancel
The fields in `webhook.webhookexitcancel` are filled when the bot cancels a exit order. Parameters are filled using string.format. The fields in `webhook.exit_cancel` are filled when the bot cancels a exit order. Parameters are filled using string.format.
Possible parameters are: Possible parameters are:
* `trade_id` * `trade_id`
@ -234,9 +247,9 @@ Possible parameters are:
* `open_date` * `open_date`
* `close_date` * `close_date`
### Webhookstatus ### Status
The fields in `webhook.webhookstatus` are used for regular status messages (Started / Stopped / ...). Parameters are filled using string.format. The fields in `webhook.status` are used for regular status messages (Started / Stopped / ...). Parameters are filled using string.format.
The only possible value here is `{status}`. The only possible value here is `{status}`.
@ -280,7 +293,6 @@ You can configure this as follows:
} }
``` ```
The above represents the default (`exit_fill` and `entry_fill` are optional and will default to the above configuration) - modifications are obviously possible. The above represents the default (`exit_fill` and `entry_fill` are optional and will default to the above configuration) - modifications are obviously possible.
Available fields correspond to the fields for webhooks and are documented in the corresponding webhook sections. Available fields correspond to the fields for webhooks and are documented in the corresponding webhook sections.
@ -288,3 +300,13 @@ Available fields correspond to the fields for webhooks and are documented in the
The notifications will look as follows by default. The notifications will look as follows by default.
![discord-notification](assets/discord_notification.png) ![discord-notification](assets/discord_notification.png)
Custom messages can be sent from a strategy to Discord endpoints via the dataprovider.send_msg() function. To enable this, set the `allow_custom_messages` option to `true`:
```json
"discord": {
"enabled": true,
"webhook_url": "https://discord.com/api/webhooks/<Your webhook URL ...>",
"allow_custom_messages": true,
},
```

View File

@ -1,5 +1,5 @@
""" Freqtrade bot """ """ Freqtrade bot """
__version__ = '2022.10.dev' __version__ = '2022.11.dev'
if 'dev' in __version__: if 'dev' in __version__:
try: try:
@ -16,6 +16,6 @@ if 'dev' in __version__:
from pathlib import Path from pathlib import Path
versionfile = Path('./freqtrade_commit') versionfile = Path('./freqtrade_commit')
if versionfile.is_file(): if versionfile.is_file():
__version__ = f"docker-{versionfile.read_text()[:8]}" __version__ = f"docker-{__version__}-{versionfile.read_text()[:8]}"
except Exception: except Exception:
pass pass

View File

@ -15,9 +15,9 @@ from freqtrade.commands.db_commands import start_convert_db
from freqtrade.commands.deploy_commands import (start_create_userdir, start_install_ui, from freqtrade.commands.deploy_commands import (start_create_userdir, start_install_ui,
start_new_strategy) start_new_strategy)
from freqtrade.commands.hyperopt_commands import start_hyperopt_list, start_hyperopt_show from freqtrade.commands.hyperopt_commands import start_hyperopt_list, start_hyperopt_show
from freqtrade.commands.list_commands import (start_list_exchanges, start_list_markets, from freqtrade.commands.list_commands import (start_list_exchanges, start_list_freqAI_models,
start_list_strategies, start_list_timeframes, start_list_markets, start_list_strategies,
start_show_trades) start_list_timeframes, start_show_trades)
from freqtrade.commands.optimize_commands import (start_backtesting, start_backtesting_show, from freqtrade.commands.optimize_commands import (start_backtesting, start_backtesting_show,
start_edge, start_hyperopt) start_edge, start_hyperopt)
from freqtrade.commands.pairlist_commands import start_test_pairlist from freqtrade.commands.pairlist_commands import start_test_pairlist

View File

@ -41,6 +41,8 @@ ARGS_EDGE = ARGS_COMMON_OPTIMIZE + ["stoploss_range"]
ARGS_LIST_STRATEGIES = ["strategy_path", "print_one_column", "print_colorized", ARGS_LIST_STRATEGIES = ["strategy_path", "print_one_column", "print_colorized",
"recursive_strategy_search"] "recursive_strategy_search"]
ARGS_LIST_FREQAIMODELS = ["freqaimodel_path", "print_one_column", "print_colorized"]
ARGS_LIST_HYPEROPTS = ["hyperopt_path", "print_one_column", "print_colorized"] ARGS_LIST_HYPEROPTS = ["hyperopt_path", "print_one_column", "print_colorized"]
ARGS_BACKTEST_SHOW = ["exportfilename", "backtest_show_pair_list"] ARGS_BACKTEST_SHOW = ["exportfilename", "backtest_show_pair_list"]
@ -106,8 +108,8 @@ ARGS_ANALYZE_ENTRIES_EXITS = ["exportfilename", "analysis_groups", "enter_reason
"exit_reason_list", "indicator_list"] "exit_reason_list", "indicator_list"]
NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes", NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes",
"list-markets", "list-pairs", "list-strategies", "list-data", "list-markets", "list-pairs", "list-strategies", "list-freqaimodels",
"hyperopt-list", "hyperopt-show", "backtest-filter", "list-data", "hyperopt-list", "hyperopt-show", "backtest-filter",
"plot-dataframe", "plot-profit", "show-trades", "trades-to-ohlcv"] "plot-dataframe", "plot-profit", "show-trades", "trades-to-ohlcv"]
NO_CONF_ALLOWED = ["create-userdir", "list-exchanges", "new-strategy"] NO_CONF_ALLOWED = ["create-userdir", "list-exchanges", "new-strategy"]
@ -192,10 +194,11 @@ class Arguments:
start_create_userdir, start_download_data, start_edge, start_create_userdir, start_download_data, start_edge,
start_hyperopt, start_hyperopt_list, start_hyperopt_show, start_hyperopt, start_hyperopt_list, start_hyperopt_show,
start_install_ui, start_list_data, start_list_exchanges, start_install_ui, start_list_data, start_list_exchanges,
start_list_markets, start_list_strategies, start_list_freqAI_models, start_list_markets,
start_list_timeframes, start_new_config, start_new_strategy, start_list_strategies, start_list_timeframes,
start_plot_dataframe, start_plot_profit, start_show_trades, start_new_config, start_new_strategy, start_plot_dataframe,
start_test_pairlist, start_trading, start_webserver) start_plot_profit, start_show_trades, start_test_pairlist,
start_trading, start_webserver)
subparsers = self.parser.add_subparsers(dest='command', subparsers = self.parser.add_subparsers(dest='command',
# Use custom message when no subhandler is added # Use custom message when no subhandler is added
@ -362,6 +365,15 @@ class Arguments:
list_strategies_cmd.set_defaults(func=start_list_strategies) list_strategies_cmd.set_defaults(func=start_list_strategies)
self._build_args(optionlist=ARGS_LIST_STRATEGIES, parser=list_strategies_cmd) self._build_args(optionlist=ARGS_LIST_STRATEGIES, parser=list_strategies_cmd)
# Add list-freqAI Models subcommand
list_freqaimodels_cmd = subparsers.add_parser(
'list-freqaimodels',
help='Print available freqAI models.',
parents=[_common_parser],
)
list_freqaimodels_cmd.set_defaults(func=start_list_freqAI_models)
self._build_args(optionlist=ARGS_LIST_FREQAIMODELS, parser=list_freqaimodels_cmd)
# Add list-timeframes subcommand # Add list-timeframes subcommand
list_timeframes_cmd = subparsers.add_parser( list_timeframes_cmd = subparsers.add_parser(
'list-timeframes', 'list-timeframes',

View File

@ -49,7 +49,7 @@ AVAILABLE_CLI_OPTIONS = {
default=0, default=0,
), ),
"logfile": Arg( "logfile": Arg(
'--logfile', '--logfile', '--log-file',
help="Log to the file specified. Special values are: 'syslog', 'journald'. " help="Log to the file specified. Special values are: 'syslog', 'journald'. "
"See the documentation for more details.", "See the documentation for more details.",
metavar='FILE', metavar='FILE',

View File

@ -1,7 +1,6 @@
import csv import csv
import logging import logging
import sys import sys
from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
import rapidjson import rapidjson
@ -10,7 +9,6 @@ from colorama import init as colorama_init
from tabulate import tabulate from tabulate import tabulate
from freqtrade.configuration import setup_utils_configuration from freqtrade.configuration import setup_utils_configuration
from freqtrade.constants import USERPATH_STRATEGIES
from freqtrade.enums import RunMode from freqtrade.enums import RunMode
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange import market_is_active, validate_exchanges from freqtrade.exchange import market_is_active, validate_exchanges
@ -41,7 +39,7 @@ def start_list_exchanges(args: Dict[str, Any]) -> None:
print(tabulate(exchanges, headers=['Exchange name', 'Valid', 'reason'])) print(tabulate(exchanges, headers=['Exchange name', 'Valid', 'reason']))
def _print_objs_tabular(objs: List, print_colorized: bool, base_dir: Path) -> None: def _print_objs_tabular(objs: List, print_colorized: bool) -> None:
if print_colorized: if print_colorized:
colorama_init(autoreset=True) colorama_init(autoreset=True)
red = Fore.RED red = Fore.RED
@ -55,7 +53,7 @@ def _print_objs_tabular(objs: List, print_colorized: bool, base_dir: Path) -> No
names = [s['name'] for s in objs] names = [s['name'] for s in objs]
objs_to_print = [{ objs_to_print = [{
'name': s['name'] if s['name'] else "--", 'name': s['name'] if s['name'] else "--",
'location': s['location'].relative_to(base_dir), 'location': s['location_rel'],
'status': (red + "LOAD FAILED" + reset if s['class'] is None 'status': (red + "LOAD FAILED" + reset if s['class'] is None
else "OK" if names.count(s['name']) == 1 else "OK" if names.count(s['name']) == 1
else yellow + "DUPLICATE NAME" + reset) else yellow + "DUPLICATE NAME" + reset)
@ -76,9 +74,8 @@ def start_list_strategies(args: Dict[str, Any]) -> None:
""" """
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES))
strategy_objs = StrategyResolver.search_all_objects( strategy_objs = StrategyResolver.search_all_objects(
directory, not args['print_one_column'], config.get('recursive_strategy_search', False)) config, not args['print_one_column'], config.get('recursive_strategy_search', False))
# Sort alphabetically # Sort alphabetically
strategy_objs = sorted(strategy_objs, key=lambda x: x['name']) strategy_objs = sorted(strategy_objs, key=lambda x: x['name'])
for obj in strategy_objs: for obj in strategy_objs:
@ -90,7 +87,22 @@ def start_list_strategies(args: Dict[str, Any]) -> None:
if args['print_one_column']: if args['print_one_column']:
print('\n'.join([s['name'] for s in strategy_objs])) print('\n'.join([s['name'] for s in strategy_objs]))
else: else:
_print_objs_tabular(strategy_objs, config.get('print_colorized', False), directory) _print_objs_tabular(strategy_objs, config.get('print_colorized', False))
def start_list_freqAI_models(args: Dict[str, Any]) -> None:
"""
Print files with FreqAI models custom classes available in the directory
"""
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
from freqtrade.resolvers.freqaimodel_resolver import FreqaiModelResolver
model_objs = FreqaiModelResolver.search_all_objects(config, not args['print_one_column'])
# Sort alphabetically
model_objs = sorted(model_objs, key=lambda x: x['name'])
if args['print_one_column']:
print('\n'.join([s['name'] for s in model_objs]))
else:
_print_objs_tabular(model_objs, config.get('print_colorized', False))
def start_list_timeframes(args: Dict[str, Any]) -> None: def start_list_timeframes(args: Dict[str, Any]) -> None:

View File

@ -86,6 +86,7 @@ def validate_config_consistency(conf: Dict[str, Any], preliminary: bool = False)
_validate_unlimited_amount(conf) _validate_unlimited_amount(conf)
_validate_ask_orderbook(conf) _validate_ask_orderbook(conf)
_validate_freqai_hyperopt(conf) _validate_freqai_hyperopt(conf)
_validate_freqai_include_timeframes(conf)
_validate_consumers(conf) _validate_consumers(conf)
validate_migrated_strategy_settings(conf) validate_migrated_strategy_settings(conf)
@ -334,6 +335,26 @@ def _validate_freqai_hyperopt(conf: Dict[str, Any]) -> None:
'Using analyze-per-epoch parameter is not supported with a FreqAI strategy.') '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_consumers(conf: Dict[str, Any]) -> None: def _validate_consumers(conf: Dict[str, Any]) -> None:
emc_conf = conf.get('external_message_consumer', {}) emc_conf = conf.get('external_message_consumer', {})
if emc_conf.get('enabled', False): if emc_conf.get('enabled', False):

View File

@ -3,7 +3,8 @@ import shutil
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from freqtrade.constants import USER_DATA_FILES, Config from freqtrade.constants import (USER_DATA_FILES, USERPATH_FREQAIMODELS, USERPATH_HYPEROPTS,
USERPATH_NOTEBOOKS, USERPATH_STRATEGIES, Config)
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
@ -49,8 +50,8 @@ def create_userdata_dir(directory: str, create_dir: bool = False) -> Path:
:param create_dir: Create directory if it does not exist. :param create_dir: Create directory if it does not exist.
:return: Path object containing the directory :return: Path object containing the directory
""" """
sub_dirs = ["backtest_results", "data", "hyperopts", "hyperopt_results", "logs", sub_dirs = ["backtest_results", "data", USERPATH_HYPEROPTS, "hyperopt_results", "logs",
"notebooks", "plot", "strategies", ] USERPATH_NOTEBOOKS, "plot", USERPATH_STRATEGIES, USERPATH_FREQAIMODELS]
folder = Path(directory) folder = Path(directory)
chown_user_directory(folder) chown_user_directory(folder)
if not folder.is_dir(): if not folder.is_dir():

View File

@ -5,7 +5,7 @@ bot constants
""" """
from typing import Any, Dict, List, Literal, Tuple from typing import Any, Dict, List, Literal, Tuple
from freqtrade.enums import CandleType from freqtrade.enums import CandleType, RPCMessageType
DEFAULT_CONFIG = 'config.json' DEFAULT_CONFIG = 'config.json'
@ -282,6 +282,7 @@ CONF_SCHEMA = {
'enabled': {'type': 'boolean'}, 'enabled': {'type': 'boolean'},
'token': {'type': 'string'}, 'token': {'type': 'string'},
'chat_id': {'type': 'string'}, 'chat_id': {'type': 'string'},
'allow_custom_messages': {'type': 'boolean', 'default': True},
'balance_dust_level': {'type': 'number', 'minimum': 0.0}, 'balance_dust_level': {'type': 'number', 'minimum': 0.0},
'notification_settings': { 'notification_settings': {
'type': 'object', 'type': 'object',
@ -344,6 +345,8 @@ CONF_SCHEMA = {
'format': {'type': 'string', 'enum': WEBHOOK_FORMAT_OPTIONS, 'default': 'form'}, 'format': {'type': 'string', 'enum': WEBHOOK_FORMAT_OPTIONS, 'default': 'form'},
'retries': {'type': 'integer', 'minimum': 0}, 'retries': {'type': 'integer', 'minimum': 0},
'retry_delay': {'type': 'number', 'minimum': 0}, 'retry_delay': {'type': 'number', 'minimum': 0},
**dict([(x, {'type': 'object'}) for x in RPCMessageType]),
# Below -> Deprecated
'webhookentry': {'type': 'object'}, 'webhookentry': {'type': 'object'},
'webhookentrycancel': {'type': 'object'}, 'webhookentrycancel': {'type': 'object'},
'webhookentryfill': {'type': 'object'}, 'webhookentryfill': {'type': 'object'},
@ -537,6 +540,8 @@ CONF_SCHEMA = {
"properties": { "properties": {
"enabled": {"type": "boolean", "default": False}, "enabled": {"type": "boolean", "default": False},
"keras": {"type": "boolean", "default": False}, "keras": {"type": "boolean", "default": False},
"write_metrics_to_disk": {"type": "boolean", "default": False},
"purge_old_models": {"type": "boolean", "default": True},
"conv_width": {"type": "integer", "default": 2}, "conv_width": {"type": "integer", "default": 2},
"train_period_days": {"type": "integer", "default": 0}, "train_period_days": {"type": "integer", "default": 0},
"backtest_period_days": {"type": "number", "default": 7}, "backtest_period_days": {"type": "number", "default": 7},
@ -650,5 +655,6 @@ LongShort = Literal['long', 'short']
EntryExit = Literal['entry', 'exit'] EntryExit = Literal['entry', 'exit']
BuySell = Literal['buy', 'sell'] BuySell = Literal['buy', 'sell']
MakerTaker = Literal['maker', 'taker'] MakerTaker = Literal['maker', 'taker']
BidAsk = Literal['bid', 'ask']
Config = Dict[str, Any] Config = Dict[str, Any]

View File

@ -303,7 +303,7 @@ class IDataHandler(ABC):
timerange=timerange_startup, timerange=timerange_startup,
candle_type=candle_type candle_type=candle_type
) )
if self._check_empty_df(pairdf, pair, timeframe, candle_type, warn_no_data): if self._check_empty_df(pairdf, pair, timeframe, candle_type, warn_no_data, True):
return pairdf return pairdf
else: else:
enddate = pairdf.iloc[-1]['date'] enddate = pairdf.iloc[-1]['date']
@ -323,8 +323,9 @@ class IDataHandler(ABC):
self._check_empty_df(pairdf, pair, timeframe, candle_type, warn_no_data) self._check_empty_df(pairdf, pair, timeframe, candle_type, warn_no_data)
return pairdf return pairdf
def _check_empty_df(self, pairdf: DataFrame, pair: str, timeframe: str, def _check_empty_df(
candle_type: CandleType, warn_no_data: bool): self, pairdf: DataFrame, pair: str, timeframe: str, candle_type: CandleType,
warn_no_data: bool, warn_price: bool = False) -> bool:
""" """
Warn on empty dataframe Warn on empty dataframe
""" """
@ -335,6 +336,20 @@ class IDataHandler(ABC):
"Use `freqtrade download-data` to download the data" "Use `freqtrade download-data` to download the data"
) )
return True return True
elif warn_price:
candle_price_gap = 0
if (candle_type in (CandleType.SPOT, CandleType.FUTURES) and
not pairdf.empty
and 'close' in pairdf.columns and 'open' in pairdf.columns):
# Detect gaps between prior close and open
gaps = ((pairdf['open'] - pairdf['close'].shift(1)) / pairdf['close'].shift(1))
gaps = gaps.dropna()
if len(gaps):
candle_price_gap = max(abs(gaps))
if candle_price_gap > 0.1:
logger.info(f"Price jump in {pair}, {timeframe}, {candle_type} between two candles "
f"of {candle_price_gap:.2%} detected.")
return False return False
def _validate_pairdata(self, pair, pairdata: DataFrame, timeframe: str, def _validate_pairdata(self, pair, pairdata: DataFrame, timeframe: str,

View File

@ -9,14 +9,15 @@ from freqtrade.exchange.bitpanda import Bitpanda
from freqtrade.exchange.bittrex import Bittrex from freqtrade.exchange.bittrex import Bittrex
from freqtrade.exchange.bybit import Bybit from freqtrade.exchange.bybit import Bybit
from freqtrade.exchange.coinbasepro import Coinbasepro from freqtrade.exchange.coinbasepro import Coinbasepro
from freqtrade.exchange.exchange import (amount_to_contract_precision, amount_to_contracts, from freqtrade.exchange.exchange_utils import (amount_to_contract_precision, amount_to_contracts,
amount_to_precision, available_exchanges, ccxt_exchanges, amount_to_precision, available_exchanges,
contracts_to_amount, date_minus_candles, ccxt_exchanges, contracts_to_amount,
is_exchange_known_ccxt, market_is_active, date_minus_candles, is_exchange_known_ccxt,
price_to_precision, timeframe_to_minutes, market_is_active, price_to_precision,
timeframe_to_msecs, timeframe_to_next_date, timeframe_to_minutes, timeframe_to_msecs,
timeframe_to_prev_date, timeframe_to_seconds, timeframe_to_next_date, timeframe_to_prev_date,
validate_exchange, validate_exchanges) timeframe_to_seconds, validate_exchange,
validate_exchanges)
from freqtrade.exchange.ftx import Ftx from freqtrade.exchange.ftx import Ftx
from freqtrade.exchange.gateio import Gateio from freqtrade.exchange.gateio import Gateio
from freqtrade.exchange.hitbtc import Hitbtc from freqtrade.exchange.hitbtc import Hitbtc

View File

@ -11,6 +11,7 @@ from freqtrade.enums import CandleType, MarginMode, TradingMode
from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
from freqtrade.exchange.common import retrier from freqtrade.exchange.common import retrier
from freqtrade.exchange.types import Tickers
from freqtrade.misc import deep_merge_dicts, json_load from freqtrade.misc import deep_merge_dicts, json_load
@ -41,25 +42,7 @@ class Binance(Exchange):
(TradingMode.FUTURES, MarginMode.ISOLATED) (TradingMode.FUTURES, MarginMode.ISOLATED)
] ]
def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool: def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Tickers:
"""
Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary.
:param side: "buy" or "sell"
"""
order_types = ('stop_loss_limit', 'stop', 'stop_market')
return (
order.get('stopPrice', None) is None
or (
order['type'] in order_types
and (
(side == "sell" and stop_loss > float(order['stopPrice'])) or
(side == "buy" and stop_loss < float(order['stopPrice']))
)
))
def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict:
tickers = super().get_tickers(symbols=symbols, cached=cached) tickers = super().get_tickers(symbols=symbols, cached=cached)
if self.trading_mode == TradingMode.FUTURES: if self.trading_mode == TradingMode.FUTURES:
# Binance's future result has no bid/ask values. # Binance's future result has no bid/ask values.

View File

@ -8,7 +8,6 @@ import inspect
import logging import logging
from copy import deepcopy from copy import deepcopy
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import ceil
from threading import Lock from threading import Lock
from typing import Any, Coroutine, Dict, List, Literal, Optional, Tuple, Union from typing import Any, Coroutine, Dict, List, Literal, Optional, Tuple, Union
@ -16,28 +15,31 @@ import arrow
import ccxt import ccxt
import ccxt.async_support as ccxt_async import ccxt.async_support as ccxt_async
from cachetools import TTLCache from cachetools import TTLCache
from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision from ccxt import TICK_SIZE
from dateutil import parser 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, from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, BidAsk,
Config, EntryExit, ListPairsWithTimeframes, MakerTaker, BuySell, Config, EntryExit, ListPairsWithTimeframes, MakerTaker,
PairWithTimeframe) 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.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode
from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError, from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError,
InvalidOrderException, OperationalException, PricingError, InvalidOrderException, OperationalException, PricingError,
RetryableOrderError, TemporaryError) RetryableOrderError, TemporaryError)
from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, BAD_EXCHANGES, from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, remove_credentials, retrier,
EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED, retrier_async)
remove_credentials, retrier, retrier_async) from freqtrade.exchange.exchange_utils import (CcxtModuleType, amount_to_contract_precision,
amount_to_contracts, amount_to_precision,
contracts_to_amount, date_minus_candles,
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)
from freqtrade.exchange.types import Ticker, Tickers
from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_json, from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_json,
safe_value_fallback2) safe_value_fallback2)
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.util import FtPrecise
CcxtModuleType = Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -179,13 +181,14 @@ class Exchange:
exchange_config, ccxt_async, ccxt_kwargs=ccxt_async_config) exchange_config, ccxt_async, ccxt_kwargs=ccxt_async_config)
logger.info(f'Using Exchange "{self.name}"') logger.info(f'Using Exchange "{self.name}"')
self.required_candle_call_count = 1
if validate: if validate:
# Initial markets load # Initial markets load
self._load_markets() self._load_markets()
self.validate_config(config) 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( 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 # Converts the interval provided in minutes in config to seconds
self.markets_refresh_interval: int = exchange_config.get( self.markets_refresh_interval: int = exchange_config.get(
@ -408,11 +411,13 @@ class Exchange:
else: else:
return DataFrame() return DataFrame()
def get_contract_size(self, pair: str) -> float: def get_contract_size(self, pair: str) -> Optional[float]:
if self.trading_mode == TradingMode.FUTURES: if self.trading_mode == TradingMode.FUTURES:
market = self.markets[pair] market = self.markets.get(pair, {})
contract_size: float = 1.0 contract_size: float = 1.0
if market['contractSize'] is not None: if not market:
return None
if market.get('contractSize') is not None:
# ccxt has contractSize in markets as string # ccxt has contractSize in markets as string
contract_size = float(market['contractSize']) contract_size = float(market['contractSize'])
return contract_size return contract_size
@ -1072,7 +1077,14 @@ class Exchange:
Verify stop_loss against stoploss-order value (limit or price) Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary. Returns True if adjustment is necessary.
""" """
raise OperationalException(f"stoploss is not implemented for {self.name}.") if not self._ft_has.get('stoploss_on_exchange'):
raise OperationalException(f"stoploss is not implemented for {self.name}.")
return (
order.get('stopPrice', None) is None
or ((side == "sell" and stop_loss > float(order['stopPrice'])) or
(side == "buy" and stop_loss < float(order['stopPrice'])))
)
def _get_stop_order_type(self, user_order_type) -> Tuple[str, str]: def _get_stop_order_type(self, user_order_type) -> Tuple[str, str]:
@ -1102,7 +1114,7 @@ class Exchange:
'In stoploss limit order, stop price should be more than limit price') 'In stoploss limit order, stop price should be more than limit price')
return limit_rate return limit_rate
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict: def _get_stop_params(self, side: BuySell, ordertype: str, stop_price: float) -> Dict:
params = self._params.copy() params = self._params.copy()
# Verify if stopPrice works for your exchange! # Verify if stopPrice works for your exchange!
params.update({'stopPrice': stop_price}) params.update({'stopPrice': stop_price})
@ -1151,7 +1163,8 @@ class Exchange:
return dry_order return dry_order
try: try:
params = self._get_stop_params(ordertype=ordertype, stop_price=stop_price_norm) params = self._get_stop_params(side=side, ordertype=ordertype,
stop_price=stop_price_norm)
if self.trading_mode == TradingMode.FUTURES: if self.trading_mode == TradingMode.FUTURES:
params['reduceOnly'] = True params['reduceOnly'] = True
@ -1419,14 +1432,17 @@ class Exchange:
raise OperationalException(e) from e raise OperationalException(e) from e
@retrier @retrier
def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict: def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Tickers:
""" """
:param cached: Allow cached result :param cached: Allow cached result
:return: fetch_tickers result :return: fetch_tickers result
""" """
tickers: Tickers
if not self.exchange_has('fetchTickers'):
return {}
if cached: if cached:
with self._cache_lock: with self._cache_lock:
tickers = self._fetch_tickers_cache.get('fetch_tickers') tickers = self._fetch_tickers_cache.get('fetch_tickers') # type: ignore
if tickers: if tickers:
return tickers return tickers
try: try:
@ -1449,12 +1465,12 @@ class Exchange:
# Pricing info # Pricing info
@retrier @retrier
def fetch_ticker(self, pair: str) -> dict: def fetch_ticker(self, pair: str) -> Ticker:
try: try:
if (pair not in self.markets or if (pair not in self.markets or
self.markets[pair].get('active', False) is False): self.markets[pair].get('active', False) is False):
raise ExchangeError(f"Pair {pair} not available") raise ExchangeError(f"Pair {pair} not available")
data = self._api.fetch_ticker(pair) data: Ticker = self._api.fetch_ticker(pair)
return data return data
except ccxt.DDoSProtection as e: except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e raise DDosProtection(e) from e
@ -1505,7 +1521,7 @@ class Exchange:
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(e) from e raise OperationalException(e) from e
def _get_price_side(self, side: str, is_short: bool, conf_strategy: Dict) -> str: def _get_price_side(self, side: str, is_short: bool, conf_strategy: Dict) -> BidAsk:
price_side = conf_strategy['price_side'] price_side = conf_strategy['price_side']
if price_side in ('same', 'other'): if price_side in ('same', 'other'):
@ -1524,7 +1540,7 @@ class Exchange:
def get_rate(self, pair: str, refresh: bool, def get_rate(self, pair: str, refresh: bool,
side: EntryExit, is_short: bool, side: EntryExit, is_short: bool,
order_book: Optional[dict] = None, ticker: Optional[dict] = None) -> float: order_book: Optional[dict] = None, ticker: Optional[Ticker] = None) -> float:
""" """
Calculates bid/ask target Calculates bid/ask target
bid rate - between current ask price and last price bid rate - between current ask price and last price
@ -1850,10 +1866,22 @@ class Exchange:
return pair, timeframe, candle_type, data return pair, timeframe, candle_type, data
def _build_coroutine(self, pair: str, timeframe: str, candle_type: CandleType, 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 if (not since_ms and (self._ft_has["ohlcv_require_since"] or not_all_data)):
and (self._ft_has["ohlcv_require_since"] or self.required_candle_call_count > 1)):
# Multiple calls for one pair - to get more history # Multiple calls for one pair - to get more history
one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit( one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(
timeframe, candle_type, since_ms) timeframe, candle_type, since_ms)
@ -1878,10 +1906,8 @@ class Exchange:
input_coroutines = [] input_coroutines = []
cached_pairs = [] cached_pairs = []
for pair, timeframe, candle_type in set(pair_list): for pair, timeframe, candle_type in set(pair_list):
if ( if (timeframe not in self.timeframes
timeframe not in self.timeframes and candle_type in (CandleType.SPOT, CandleType.FUTURES)):
and candle_type in (CandleType.SPOT, CandleType.FUTURES)
):
logger.warning( logger.warning(
f"Cannot download ({pair}, {timeframe}) combination as this timeframe is " f"Cannot download ({pair}, {timeframe}) combination as this timeframe is "
f"not available on {self.name}. Available timeframes are " f"not available on {self.name}. Available timeframes are "
@ -1890,8 +1916,9 @@ class Exchange:
if ((pair, timeframe, candle_type) not in self._klines or not cache if ((pair, timeframe, candle_type) not in self._klines or not cache
or self._now_is_time_to_refresh(pair, timeframe, candle_type)): 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)) input_coroutines.append(
self._build_coroutine(pair, timeframe, candle_type, since_ms, cache))
else: else:
logger.debug( logger.debug(
@ -1901,6 +1928,29 @@ class Exchange:
return input_coroutines, cached_pairs 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)
ohlcv_df = ohlcv_df.reset_index(drop=True)
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, *, def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *,
since_ms: Optional[int] = None, cache: bool = True, since_ms: Optional[int] = None, cache: bool = True,
drop_incomplete: Optional[bool] = None drop_incomplete: Optional[bool] = None
@ -1937,16 +1987,11 @@ class Exchange:
continue continue
# Deconstruct tuple (has 4 elements) # Deconstruct tuple (has 4 elements)
pair, timeframe, c_type, ticks = res pair, timeframe, c_type, ticks = res
# keeping last candle time as last refreshed time of the pair ohlcv_df = self._process_ohlcv_df(
if ticks: pair, timeframe, c_type, ticks, cache, drop_incomplete)
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)
results_df[(pair, timeframe, c_type)] = ohlcv_df results_df[(pair, timeframe, c_type)] = ohlcv_df
if cache:
self._klines[(pair, timeframe, c_type)] = ohlcv_df
# Return cached klines # Return cached klines
for pair, timeframe, c_type in cached_pairs: for pair, timeframe, c_type in cached_pairs:
results_df[(pair, timeframe, c_type)] = self.klines( results_df[(pair, timeframe, c_type)] = self.klines(
@ -1959,11 +2004,8 @@ class Exchange:
def _now_is_time_to_refresh(self, pair: str, timeframe: str, candle_type: CandleType) -> bool: def _now_is_time_to_refresh(self, pair: str, timeframe: str, candle_type: CandleType) -> bool:
# Timeframe in seconds # Timeframe in seconds
interval_in_sec = timeframe_to_seconds(timeframe) interval_in_sec = timeframe_to_seconds(timeframe)
plr = self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0) + interval_in_sec
return not ( return plr < arrow.utcnow().int_timestamp
(self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0)
+ interval_in_sec) >= arrow.utcnow().int_timestamp
)
@retrier_async @retrier_async
async def _async_get_candle_history( async def _async_get_candle_history(
@ -1989,8 +2031,8 @@ class Exchange:
candle_limit = self.ohlcv_candle_limit( candle_limit = self.ohlcv_candle_limit(
timeframe, candle_type=candle_type, since_ms=since_ms) timeframe, candle_type=candle_type, since_ms=since_ms)
if candle_type != CandleType.SPOT: if candle_type and candle_type != CandleType.SPOT:
params.update({'price': candle_type}) params.update({'price': candle_type.value})
if candle_type != CandleType.FUNDING_RATE: if candle_type != CandleType.FUNDING_RATE:
data = await self._api_async.fetch_ohlcv( data = await self._api_async.fetch_ohlcv(
pair, timeframe=timeframe, since=since_ms, pair, timeframe=timeframe, since=since_ms,
@ -2766,240 +2808,3 @@ class Exchange:
# describes the min amt for a tier, and the lowest tier will always go down to 0 # describes the min amt for a tier, and the lowest tier will always go down to 0
else: else:
raise OperationalException(f"Cannot get maintenance ratio using {self.name}") raise OperationalException(f"Cannot get maintenance ratio using {self.name}")
def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = None) -> bool:
return exchange_name in ccxt_exchanges(ccxt_module)
def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
"""
Return the list of all exchanges known to ccxt
"""
return ccxt_module.exchanges if ccxt_module is not None else ccxt.exchanges
def available_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
"""
Return exchanges available to the bot, i.e. non-bad exchanges in the ccxt list
"""
exchanges = ccxt_exchanges(ccxt_module)
return [x for x in exchanges if validate_exchange(x)[0]]
def validate_exchange(exchange: str) -> Tuple[bool, str]:
ex_mod = getattr(ccxt, exchange.lower())()
if not ex_mod or not ex_mod.has:
return False, ''
missing = [k for k in EXCHANGE_HAS_REQUIRED if ex_mod.has.get(k) is not True]
if missing:
return False, f"missing: {', '.join(missing)}"
missing_opt = [k for k in EXCHANGE_HAS_OPTIONAL if not ex_mod.has.get(k)]
if exchange.lower() in BAD_EXCHANGES:
return False, BAD_EXCHANGES.get(exchange.lower(), '')
if missing_opt:
return True, f"missing opt: {', '.join(missing_opt)}"
return True, ''
def validate_exchanges(all_exchanges: bool) -> List[Tuple[str, bool, str]]:
"""
:return: List of tuples with exchangename, valid, reason.
"""
exchanges = ccxt_exchanges() if all_exchanges else available_exchanges()
exchanges_valid = [
(e, *validate_exchange(e)) for e in exchanges
]
return exchanges_valid
def timeframe_to_seconds(timeframe: str) -> int:
"""
Translates the timeframe interval value written in the human readable
form ('1m', '5m', '1h', '1d', '1w', etc.) to the number
of seconds for one timeframe interval.
"""
return ccxt.Exchange.parse_timeframe(timeframe)
def timeframe_to_minutes(timeframe: str) -> int:
"""
Same as timeframe_to_seconds, but returns minutes.
"""
return ccxt.Exchange.parse_timeframe(timeframe) // 60
def timeframe_to_msecs(timeframe: str) -> int:
"""
Same as timeframe_to_seconds, but returns milliseconds.
"""
return ccxt.Exchange.parse_timeframe(timeframe) * 1000
def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime:
"""
Use Timeframe and determine the candle start date for this date.
Does not round when given a candle start date.
:param timeframe: timeframe in string format (e.g. "5m")
:param date: date to use. Defaults to now(utc)
:returns: date of previous candle (with utc timezone)
"""
if not date:
date = datetime.now(timezone.utc)
new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000,
ROUND_DOWN) // 1000
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime:
"""
Use Timeframe and determine next candle.
:param timeframe: timeframe in string format (e.g. "5m")
:param date: date to use. Defaults to now(utc)
:returns: date of next candle (with utc timezone)
"""
if not date:
date = datetime.now(timezone.utc)
new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000,
ROUND_UP) // 1000
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
def date_minus_candles(
timeframe: str, candle_count: int, date: Optional[datetime] = None) -> datetime:
"""
subtract X candles from a date.
:param timeframe: timeframe in string format (e.g. "5m")
:param candle_count: Amount of candles to subtract.
:param date: date to use. Defaults to now(utc)
"""
if not date:
date = datetime.now(timezone.utc)
tf_min = timeframe_to_minutes(timeframe)
new_date = timeframe_to_prev_date(timeframe, date) - timedelta(minutes=tf_min * candle_count)
return new_date
def market_is_active(market: Dict) -> bool:
"""
Return True if the market is active.
"""
# "It's active, if the active flag isn't explicitly set to false. If it's missing or
# true then it's true. If it's undefined, then it's most likely true, but not 100% )"
# See https://github.com/ccxt/ccxt/issues/4874,
# https://github.com/ccxt/ccxt/issues/4075#issuecomment-434760520
return market.get('active', True) is not False
def amount_to_contracts(amount: float, contract_size: Optional[float]) -> float:
"""
Convert amount to contracts.
:param amount: amount to convert
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
:return: num-contracts
"""
if contract_size and contract_size != 1:
return float(FtPrecise(amount) / FtPrecise(contract_size))
else:
return amount
def contracts_to_amount(num_contracts: float, contract_size: Optional[float]) -> float:
"""
Takes num-contracts and converts it to contract size
:param num_contracts: number of contracts
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
:return: Amount
"""
if contract_size and contract_size != 1:
return float(FtPrecise(num_contracts) * FtPrecise(contract_size))
else:
return num_contracts
def amount_to_precision(amount: float, amount_precision: Optional[float],
precisionMode: Optional[int]) -> float:
"""
Returns the amount to buy or sell to a precision the Exchange accepts
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
based on our definitions.
:param amount: amount to truncate
:param amount_precision: amount precision to use.
should be retrieved from markets[pair]['precision']['amount']
:param precisionMode: precision mode to use. Should be used from precisionMode
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
:return: truncated amount
"""
if amount_precision is not None and precisionMode is not None:
precision = int(amount_precision) if precisionMode != TICK_SIZE else amount_precision
# precision must be an int for non-ticksize inputs.
amount = float(decimal_to_precision(amount, rounding_mode=TRUNCATE,
precision=precision,
counting_mode=precisionMode,
))
return amount
def amount_to_contract_precision(
amount, amount_precision: Optional[float], precisionMode: Optional[int],
contract_size: Optional[float]) -> float:
"""
Returns the amount to buy or sell to a precision the Exchange accepts
including calculation to and from contracts.
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
based on our definitions.
:param amount: amount to truncate
:param amount_precision: amount precision to use.
should be retrieved from markets[pair]['precision']['amount']
:param precisionMode: precision mode to use. Should be used from precisionMode
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
:return: truncated amount
"""
if amount_precision is not None and precisionMode is not None:
contracts = amount_to_contracts(amount, contract_size)
amount_p = amount_to_precision(contracts, amount_precision, precisionMode)
return contracts_to_amount(amount_p, contract_size)
return amount
def price_to_precision(price: float, price_precision: Optional[float],
precisionMode: Optional[int]) -> float:
"""
Returns the price rounded up to the precision the Exchange accepts.
Partial Re-implementation of ccxt internal method decimal_to_precision(),
which does not support rounding up
TODO: If ccxt supports ROUND_UP for decimal_to_precision(), we could remove this and
align with amount_to_precision().
!!! Rounds up
:param price: price to convert
:param price_precision: price precision to use. Used from markets[pair]['precision']['price']
:param precisionMode: precision mode to use. Should be used from precisionMode
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
:return: price rounded up to the precision the Exchange accepts
"""
if price_precision is not None and precisionMode is not None:
# price = float(decimal_to_precision(price, rounding_mode=ROUND,
# precision=price_precision,
# counting_mode=self.precisionMode,
# ))
if precisionMode == TICK_SIZE:
precision = FtPrecise(price_precision)
price_str = FtPrecise(price)
missing = price_str % precision
if not missing == FtPrecise("0"):
price = round(float(str(price_str - missing + precision)), 14)
else:
symbol_prec = price_precision
big_price = price * pow(10, symbol_prec)
price = ceil(big_price) / pow(10, symbol_prec)
return price

View File

@ -0,0 +1,252 @@
"""
Exchange support utils
"""
from datetime import datetime, timedelta, timezone
from math import ceil
from typing import Any, Dict, List, Optional, Tuple
import ccxt
from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision
from freqtrade.exchange.common import BAD_EXCHANGES, EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED
from freqtrade.util import FtPrecise
CcxtModuleType = Any
def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = None) -> bool:
return exchange_name in ccxt_exchanges(ccxt_module)
def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
"""
Return the list of all exchanges known to ccxt
"""
return ccxt_module.exchanges if ccxt_module is not None else ccxt.exchanges
def available_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
"""
Return exchanges available to the bot, i.e. non-bad exchanges in the ccxt list
"""
exchanges = ccxt_exchanges(ccxt_module)
return [x for x in exchanges if validate_exchange(x)[0]]
def validate_exchange(exchange: str) -> Tuple[bool, str]:
ex_mod = getattr(ccxt, exchange.lower())()
if not ex_mod or not ex_mod.has:
return False, ''
missing = [k for k in EXCHANGE_HAS_REQUIRED if ex_mod.has.get(k) is not True]
if missing:
return False, f"missing: {', '.join(missing)}"
missing_opt = [k for k in EXCHANGE_HAS_OPTIONAL if not ex_mod.has.get(k)]
if exchange.lower() in BAD_EXCHANGES:
return False, BAD_EXCHANGES.get(exchange.lower(), '')
if missing_opt:
return True, f"missing opt: {', '.join(missing_opt)}"
return True, ''
def validate_exchanges(all_exchanges: bool) -> List[Tuple[str, bool, str]]:
"""
:return: List of tuples with exchangename, valid, reason.
"""
exchanges = ccxt_exchanges() if all_exchanges else available_exchanges()
exchanges_valid = [
(e, *validate_exchange(e)) for e in exchanges
]
return exchanges_valid
def timeframe_to_seconds(timeframe: str) -> int:
"""
Translates the timeframe interval value written in the human readable
form ('1m', '5m', '1h', '1d', '1w', etc.) to the number
of seconds for one timeframe interval.
"""
return ccxt.Exchange.parse_timeframe(timeframe)
def timeframe_to_minutes(timeframe: str) -> int:
"""
Same as timeframe_to_seconds, but returns minutes.
"""
return ccxt.Exchange.parse_timeframe(timeframe) // 60
def timeframe_to_msecs(timeframe: str) -> int:
"""
Same as timeframe_to_seconds, but returns milliseconds.
"""
return ccxt.Exchange.parse_timeframe(timeframe) * 1000
def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime:
"""
Use Timeframe and determine the candle start date for this date.
Does not round when given a candle start date.
:param timeframe: timeframe in string format (e.g. "5m")
:param date: date to use. Defaults to now(utc)
:returns: date of previous candle (with utc timezone)
"""
if not date:
date = datetime.now(timezone.utc)
new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000,
ROUND_DOWN) // 1000
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime:
"""
Use Timeframe and determine next candle.
:param timeframe: timeframe in string format (e.g. "5m")
:param date: date to use. Defaults to now(utc)
:returns: date of next candle (with utc timezone)
"""
if not date:
date = datetime.now(timezone.utc)
new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000,
ROUND_UP) // 1000
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
def date_minus_candles(
timeframe: str, candle_count: int, date: Optional[datetime] = None) -> datetime:
"""
subtract X candles from a date.
:param timeframe: timeframe in string format (e.g. "5m")
:param candle_count: Amount of candles to subtract.
:param date: date to use. Defaults to now(utc)
"""
if not date:
date = datetime.now(timezone.utc)
tf_min = timeframe_to_minutes(timeframe)
new_date = timeframe_to_prev_date(timeframe, date) - timedelta(minutes=tf_min * candle_count)
return new_date
def market_is_active(market: Dict) -> bool:
"""
Return True if the market is active.
"""
# "It's active, if the active flag isn't explicitly set to false. If it's missing or
# true then it's true. If it's undefined, then it's most likely true, but not 100% )"
# See https://github.com/ccxt/ccxt/issues/4874,
# https://github.com/ccxt/ccxt/issues/4075#issuecomment-434760520
return market.get('active', True) is not False
def amount_to_contracts(amount: float, contract_size: Optional[float]) -> float:
"""
Convert amount to contracts.
:param amount: amount to convert
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
:return: num-contracts
"""
if contract_size and contract_size != 1:
return float(FtPrecise(amount) / FtPrecise(contract_size))
else:
return amount
def contracts_to_amount(num_contracts: float, contract_size: Optional[float]) -> float:
"""
Takes num-contracts and converts it to contract size
:param num_contracts: number of contracts
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
:return: Amount
"""
if contract_size and contract_size != 1:
return float(FtPrecise(num_contracts) * FtPrecise(contract_size))
else:
return num_contracts
def amount_to_precision(amount: float, amount_precision: Optional[float],
precisionMode: Optional[int]) -> float:
"""
Returns the amount to buy or sell to a precision the Exchange accepts
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
based on our definitions.
:param amount: amount to truncate
:param amount_precision: amount precision to use.
should be retrieved from markets[pair]['precision']['amount']
:param precisionMode: precision mode to use. Should be used from precisionMode
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
:return: truncated amount
"""
if amount_precision is not None and precisionMode is not None:
precision = int(amount_precision) if precisionMode != TICK_SIZE else amount_precision
# precision must be an int for non-ticksize inputs.
amount = float(decimal_to_precision(amount, rounding_mode=TRUNCATE,
precision=precision,
counting_mode=precisionMode,
))
return amount
def amount_to_contract_precision(
amount, amount_precision: Optional[float], precisionMode: Optional[int],
contract_size: Optional[float]) -> float:
"""
Returns the amount to buy or sell to a precision the Exchange accepts
including calculation to and from contracts.
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
based on our definitions.
:param amount: amount to truncate
:param amount_precision: amount precision to use.
should be retrieved from markets[pair]['precision']['amount']
:param precisionMode: precision mode to use. Should be used from precisionMode
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
:return: truncated amount
"""
if amount_precision is not None and precisionMode is not None:
contracts = amount_to_contracts(amount, contract_size)
amount_p = amount_to_precision(contracts, amount_precision, precisionMode)
return contracts_to_amount(amount_p, contract_size)
return amount
def price_to_precision(price: float, price_precision: Optional[float],
precisionMode: Optional[int]) -> float:
"""
Returns the price rounded up to the precision the Exchange accepts.
Partial Re-implementation of ccxt internal method decimal_to_precision(),
which does not support rounding up
TODO: If ccxt supports ROUND_UP for decimal_to_precision(), we could remove this and
align with amount_to_precision().
!!! Rounds up
:param price: price to convert
:param price_precision: price precision to use. Used from markets[pair]['precision']['price']
:param precisionMode: precision mode to use. Should be used from precisionMode
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
:return: price rounded up to the precision the Exchange accepts
"""
if price_precision is not None and precisionMode is not None:
# price = float(decimal_to_precision(price, rounding_mode=ROUND,
# precision=price_precision,
# counting_mode=self.precisionMode,
# ))
if precisionMode == TICK_SIZE:
precision = FtPrecise(price_precision)
price_str = FtPrecise(price)
missing = price_str % precision
if not missing == FtPrecise("0"):
price = round(float(str(price_str - missing + precision)), 14)
else:
symbol_prec = price_precision
big_price = price * pow(10, symbol_prec)
price = ceil(big_price) / pow(10, symbol_prec)
return price

View File

@ -126,13 +126,3 @@ class Gateio(Exchange):
pair=pair, pair=pair,
params={'stop': True} params={'stop': True}
) )
def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool:
"""
Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary.
"""
return (order.get('stopPrice', None) is None or (
side == "sell" and stop_loss > float(order['stopPrice'])) or
(side == "buy" and stop_loss < float(order['stopPrice']))
)

View File

@ -2,6 +2,7 @@
import logging import logging
from typing import Dict from typing import Dict
from freqtrade.constants import BuySell
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
@ -22,20 +23,7 @@ class Huobi(Exchange):
"l2_limit_range_required": False, "l2_limit_range_required": False,
} }
def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool: def _get_stop_params(self, side: BuySell, ordertype: str, stop_price: float) -> Dict:
"""
Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary.
"""
return (
order.get('stopPrice', None) is None
or (
order['type'] == 'stop'
and stop_loss > float(order['stopPrice'])
)
)
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
params = self._params.copy() params = self._params.copy()
params.update({ params.update({

View File

@ -12,6 +12,7 @@ from freqtrade.exceptions import (DDosProtection, InsufficientFundsError, Invali
OperationalException, TemporaryError) OperationalException, TemporaryError)
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
from freqtrade.exchange.common import retrier from freqtrade.exchange.common import retrier
from freqtrade.exchange.types import Tickers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,7 +46,7 @@ class Kraken(Exchange):
return (parent_check and return (parent_check and
market.get('darkpool', False) is False) market.get('darkpool', False) is False)
def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Dict: def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Tickers:
# Only fetch tickers for current stake currency # Only fetch tickers for current stake currency
# Otherwise the request for kraken becomes too large. # Otherwise the request for kraken becomes too large.
symbols = list(self.get_markets(quote_currencies=[self._config['stake_currency']])) symbols = list(self.get_markets(quote_currencies=[self._config['stake_currency']]))

View File

@ -2,6 +2,7 @@
import logging import logging
from typing import Dict from typing import Dict
from freqtrade.constants import BuySell
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
@ -27,17 +28,7 @@ class Kucoin(Exchange):
"ohlcv_candle_limit": 1500, "ohlcv_candle_limit": 1500,
} }
def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool: def _get_stop_params(self, side: BuySell, ordertype: str, stop_price: float) -> Dict:
"""
Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary.
"""
return (
order.get('stopPrice', None) is None
or stop_loss > float(order['stopPrice'])
)
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
params = self._params.copy() params = self._params.copy()
params.update({ params.update({

View File

@ -0,0 +1,16 @@
from typing import Dict, Optional, TypedDict
class Ticker(TypedDict):
symbol: str
ask: Optional[float]
askVolume: Optional[float]
bid: Optional[float]
bidVolume: Optional[float]
last: Optional[float]
quoteVolume: Optional[float]
baseVolume: Optional[float]
# Several more - only listing required.
Tickers = Dict[str, Ticker]

View File

@ -51,7 +51,7 @@ class BaseClassifierModel(IFreqaiModel):
f"{end_date} --------------------") f"{end_date} --------------------")
# split data into train/test data. # split data into train/test data.
data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered)
if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live:
dk.fit_labels() dk.fit_labels()
# normalize all data based on train_dataset only # normalize all data based on train_dataset only
data_dictionary = dk.normalize_data(data_dictionary) data_dictionary = dk.normalize_data(data_dictionary)
@ -78,7 +78,7 @@ class BaseClassifierModel(IFreqaiModel):
) -> Tuple[DataFrame, npt.NDArray[np.int_]]: ) -> Tuple[DataFrame, npt.NDArray[np.int_]]:
""" """
Filter the prediction features data and predict with it. 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: :return:
:pred_df: dataframe containing the predictions :pred_df: dataframe containing the predictions
:do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove

View File

@ -50,7 +50,7 @@ class BaseRegressionModel(IFreqaiModel):
f"{end_date} --------------------") f"{end_date} --------------------")
# split data into train/test data. # split data into train/test data.
data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered)
if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live:
dk.fit_labels() dk.fit_labels()
# normalize all data based on train_dataset only # normalize all data based on train_dataset only
data_dictionary = dk.normalize_data(data_dictionary) data_dictionary = dk.normalize_data(data_dictionary)
@ -77,7 +77,7 @@ class BaseRegressionModel(IFreqaiModel):
) -> Tuple[DataFrame, npt.NDArray[np.int_]]: ) -> Tuple[DataFrame, npt.NDArray[np.int_]]:
""" """
Filter the prediction features data and predict with it. 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: :return:
:pred_df: dataframe containing the predictions :pred_df: dataframe containing the predictions
:do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove

View File

@ -47,7 +47,7 @@ class BaseTensorFlowModel(IFreqaiModel):
f"{end_date} --------------------") f"{end_date} --------------------")
# split data into train/test data. # split data into train/test data.
data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered)
if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live:
dk.fit_labels() dk.fit_labels()
# normalize all data based on train_dataset only # normalize all data based on train_dataset only
data_dictionary = dk.normalize_data(data_dictionary) data_dictionary = dk.normalize_data(data_dictionary)

View File

@ -1,14 +1,15 @@
import collections import collections
import json
import logging import logging
import re import re
import shutil import shutil
import threading import threading
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Tuple, TypedDict from typing import Any, Dict, Tuple, TypedDict
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import psutil
import rapidjson import rapidjson
from joblib import dump, load from joblib import dump, load
from joblib.externals import cloudpickle from joblib.externals import cloudpickle
@ -65,6 +66,8 @@ class FreqaiDataDrawer:
self.pair_dict: Dict[str, pair_info] = {} self.pair_dict: Dict[str, pair_info] = {}
# dictionary holding all actively inferenced models in memory given a model filename # dictionary holding all actively inferenced models in memory given a model filename
self.model_dictionary: Dict[str, Any] = {} self.model_dictionary: Dict[str, Any] = {}
# all additional metadata that we want to keep in ram
self.meta_data_dictionary: Dict[str, Dict[str, Any]] = {}
self.model_return_values: Dict[str, DataFrame] = {} self.model_return_values: Dict[str, DataFrame] = {}
self.historic_data: Dict[str, Dict[str, DataFrame]] = {} self.historic_data: Dict[str, Dict[str, DataFrame]] = {}
self.historic_predictions: Dict[str, DataFrame] = {} self.historic_predictions: Dict[str, DataFrame] = {}
@ -78,19 +81,49 @@ class FreqaiDataDrawer:
self.historic_predictions_bkp_path = Path( self.historic_predictions_bkp_path = Path(
self.full_path / "historic_predictions.backup.pkl") self.full_path / "historic_predictions.backup.pkl")
self.pair_dictionary_path = Path(self.full_path / "pair_dictionary.json") self.pair_dictionary_path = Path(self.full_path / "pair_dictionary.json")
self.metric_tracker_path = Path(self.full_path / "metric_tracker.json")
self.follow_mode = follow_mode self.follow_mode = follow_mode
if follow_mode: if follow_mode:
self.create_follower_dict() self.create_follower_dict()
self.load_drawer_from_disk() self.load_drawer_from_disk()
self.load_historic_predictions_from_disk() self.load_historic_predictions_from_disk()
self.load_metric_tracker_from_disk()
self.training_queue: Dict[str, int] = {} self.training_queue: Dict[str, int] = {}
self.history_lock = threading.Lock() self.history_lock = threading.Lock()
self.save_lock = threading.Lock() self.save_lock = threading.Lock()
self.pair_dict_lock = threading.Lock() self.pair_dict_lock = threading.Lock()
self.metric_tracker_lock = threading.Lock()
self.old_DBSCAN_eps: Dict[str, float] = {} self.old_DBSCAN_eps: Dict[str, float] = {}
self.empty_pair_dict: pair_info = { self.empty_pair_dict: pair_info = {
"model_filename": "", "trained_timestamp": 0, "model_filename": "", "trained_timestamp": 0,
"data_path": "", "extras": {}} "data_path": "", "extras": {}}
self.metric_tracker: Dict[str, Dict[str, Dict[str, list]]] = {}
def update_metric_tracker(self, metric: str, value: float, pair: str) -> None:
"""
General utility for adding and updating custom metrics. Typically used
for adding training performance, train timings, inferenc timings, cpu loads etc.
"""
with self.metric_tracker_lock:
if pair not in self.metric_tracker:
self.metric_tracker[pair] = {}
if metric not in self.metric_tracker[pair]:
self.metric_tracker[pair][metric] = {'timestamp': [], 'value': []}
timestamp = int(datetime.now(timezone.utc).timestamp())
self.metric_tracker[pair][metric]['value'].append(value)
self.metric_tracker[pair][metric]['timestamp'].append(timestamp)
def collect_metrics(self, time_spent: float, pair: str):
"""
Add metrics to the metric tracker dictionary
"""
load1, load5, load15 = psutil.getloadavg()
cpus = psutil.cpu_count()
self.update_metric_tracker('train_time', time_spent, pair)
self.update_metric_tracker('cpu_load1min', load1 / cpus, pair)
self.update_metric_tracker('cpu_load5min', load5 / cpus, pair)
self.update_metric_tracker('cpu_load15min', load15 / cpus, pair)
self.limit_ram_use = self.freqai_info.get('limit_ram_usage', False) self.limit_ram_use = self.freqai_info.get('limit_ram_usage', False)
if 'rl_config' in self.freqai_info: if 'rl_config' in self.freqai_info:
self.model_type = 'stable_baselines' self.model_type = 'stable_baselines'
@ -103,12 +136,12 @@ class FreqaiDataDrawer:
""" """
Locate and load a previously saved data drawer full of all pair model metadata in Locate and load a previously saved data drawer full of all pair model metadata in
present model folder. present model folder.
:return: bool - whether or not the drawer was located Load any existing metric tracker that may be present.
""" """
exists = self.pair_dictionary_path.is_file() exists = self.pair_dictionary_path.is_file()
if exists: if exists:
with open(self.pair_dictionary_path, "r") as fp: with open(self.pair_dictionary_path, "r") as fp:
self.pair_dict = json.load(fp) self.pair_dict = rapidjson.load(fp, number_mode=rapidjson.NM_NATIVE)
elif not self.follow_mode: elif not self.follow_mode:
logger.info("Could not find existing datadrawer, starting from scratch") logger.info("Could not find existing datadrawer, starting from scratch")
else: else:
@ -117,7 +150,18 @@ class FreqaiDataDrawer:
"sending null values back to strategy" "sending null values back to strategy"
) )
return exists def load_metric_tracker_from_disk(self):
"""
Tries to load an existing metrics dictionary if the user
wants to collect metrics.
"""
if self.freqai_info.get('write_metrics_to_disk', False):
exists = self.metric_tracker_path.is_file()
if exists:
with open(self.metric_tracker_path, "r") as fp:
self.metric_tracker = rapidjson.load(fp, number_mode=rapidjson.NM_NATIVE)
else:
logger.info("Could not find existing metric tracker, starting from scratch")
def load_historic_predictions_from_disk(self): def load_historic_predictions_from_disk(self):
""" """
@ -153,7 +197,7 @@ class FreqaiDataDrawer:
def save_historic_predictions_to_disk(self): def save_historic_predictions_to_disk(self):
""" """
Save data drawer full of all pair model metadata in present model folder. Save historic predictions pickle to disk
""" """
with open(self.historic_predictions_path, "wb") as fp: with open(self.historic_predictions_path, "wb") as fp:
cloudpickle.dump(self.historic_predictions, fp, protocol=cloudpickle.DEFAULT_PROTOCOL) cloudpickle.dump(self.historic_predictions, fp, protocol=cloudpickle.DEFAULT_PROTOCOL)
@ -161,6 +205,15 @@ class FreqaiDataDrawer:
# create a backup # create a backup
shutil.copy(self.historic_predictions_path, self.historic_predictions_bkp_path) shutil.copy(self.historic_predictions_path, self.historic_predictions_bkp_path)
def save_metric_tracker_to_disk(self):
"""
Save metric tracker of all pair metrics collected.
"""
with self.save_lock:
with open(self.metric_tracker_path, 'w') as fp:
rapidjson.dump(self.metric_tracker, fp, default=self.np_encoder,
number_mode=rapidjson.NM_NATIVE)
def save_drawer_to_disk(self): def save_drawer_to_disk(self):
""" """
Save data drawer full of all pair model metadata in present model folder. Save data drawer full of all pair model metadata in present model folder.
@ -419,9 +472,8 @@ class FreqaiDataDrawer:
def save_data(self, model: Any, coin: str, dk: FreqaiDataKitchen) -> None: 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 Saves all data associated with a model for a single sub-train time range
:params: :param model: User trained model which can be reused for inferencing to generate
:model: User trained model which can be reused for inferencing to generate predictions
predictions
""" """
if not dk.data_path.is_dir(): if not dk.data_path.is_dir():
@ -466,6 +518,10 @@ class FreqaiDataDrawer:
self.model_dictionary[coin] = model self.model_dictionary[coin] = model
self.pair_dict[coin]["model_filename"] = dk.model_filename self.pair_dict[coin]["model_filename"] = dk.model_filename
self.pair_dict[coin]["data_path"] = str(dk.data_path) self.pair_dict[coin]["data_path"] = str(dk.data_path)
if coin not in self.meta_data_dictionary:
self.meta_data_dictionary[coin] = {}
self.meta_data_dictionary[coin]["train_df"] = dk.data_dictionary["train_features"]
self.meta_data_dictionary[coin]["meta_data"] = dk.data
self.save_drawer_to_disk() self.save_drawer_to_disk()
return return
@ -476,7 +532,7 @@ class FreqaiDataDrawer:
presaved backtesting (prediction file loading). presaved backtesting (prediction file loading).
""" """
with open(dk.data_path / f"{dk.model_filename}_metadata.json", "r") as fp: with open(dk.data_path / f"{dk.model_filename}_metadata.json", "r") as fp:
dk.data = json.load(fp) dk.data = rapidjson.load(fp, number_mode=rapidjson.NM_NATIVE)
dk.training_features_list = dk.data["training_features_list"] dk.training_features_list = dk.data["training_features_list"]
dk.label_list = dk.data["label_list"] dk.label_list = dk.data["label_list"]
@ -502,14 +558,19 @@ class FreqaiDataDrawer:
/ dk.data_path.parts[-1] / dk.data_path.parts[-1]
) )
with open(dk.data_path / f"{dk.model_filename}_metadata.json", "r") as fp: if coin in self.meta_data_dictionary:
dk.data = json.load(fp) dk.data = self.meta_data_dictionary[coin]["meta_data"]
dk.training_features_list = dk.data["training_features_list"] dk.data_dictionary["train_features"] = self.meta_data_dictionary[coin]["train_df"]
dk.label_list = dk.data["label_list"] else:
with open(dk.data_path / f"{dk.model_filename}_metadata.json", "r") as fp:
dk.data = rapidjson.load(fp, number_mode=rapidjson.NM_NATIVE)
dk.data_dictionary["train_features"] = pd.read_pickle( dk.data_dictionary["train_features"] = pd.read_pickle(
dk.data_path / f"{dk.model_filename}_trained_df.pkl" dk.data_path / f"{dk.model_filename}_trained_df.pkl"
) )
dk.training_features_list = dk.data["training_features_list"]
dk.label_list = dk.data["label_list"]
# try to access model in memory instead of loading object from disk to save time # try to access model in memory instead of loading object from disk to save time
if dk.live and coin in self.model_dictionary and not self.limit_ram_use: if dk.live and coin in self.model_dictionary and not self.limit_ram_use:
@ -549,8 +610,7 @@ class FreqaiDataDrawer:
Append new candles to our stores historic data (in memory) so that 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 we do not need to load candle history from disk and we dont need to
pinging exchange multiple times for the same candle. pinging exchange multiple times for the same candle.
:params: :param dataframe: DataFrame = strategy provided dataframe
dataframe: DataFrame = strategy provided dataframe
""" """
feat_params = self.freqai_info["feature_parameters"] feat_params = self.freqai_info["feature_parameters"]
with self.history_lock: with self.history_lock:
@ -596,9 +656,8 @@ class FreqaiDataDrawer:
""" """
Load pair histories for all whitelist and corr_pairlist pairs. Load pair histories for all whitelist and corr_pairlist pairs.
Only called once upon startup of bot. Only called once upon startup of bot.
:params: :param timerange: TimeRange = full timerange required to populate all indicators
timerange: TimeRange = full timerange required to populate all indicators for training according to user defined train_period_days
for training according to user defined train_period_days
""" """
history_data = self.historic_data history_data = self.historic_data
@ -621,10 +680,9 @@ class FreqaiDataDrawer:
""" """
Searches through our historic_data in memory and returns the dataframes relevant Searches through our historic_data in memory and returns the dataframes relevant
to the present pair. to the present pair.
:params: :param timerange: TimeRange = full timerange required to populate all indicators
timerange: TimeRange = full timerange required to populate all indicators for training according to user defined train_period_days
for training according to user defined train_period_days :param metadata: dict = strategy furnished pair metadata
metadata: dict = strategy furnished pair metadata
""" """
with self.history_lock: with self.history_lock:
corr_dataframes: Dict[Any, Any] = {} corr_dataframes: Dict[Any, Any] = {}
@ -635,7 +693,8 @@ class FreqaiDataDrawer:
) )
for tf in self.freqai_info["feature_parameters"].get("include_timeframes"): 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: if pairs:
for p in pairs: for p in pairs:
if pair in p: if pair in p:
@ -644,6 +703,6 @@ class FreqaiDataDrawer:
corr_dataframes[p] = {} corr_dataframes[p] = {}
corr_dataframes[p][tf] = dk.slice_dataframe( corr_dataframes[p][tf] = dk.slice_dataframe(
timerange, historic_data[p][tf] timerange, historic_data[p][tf]
) ).reset_index(drop=True)
return corr_dataframes, base_dataframes return corr_dataframes, base_dataframes

View File

@ -111,9 +111,8 @@ class FreqaiDataKitchen:
) -> None: ) -> None:
""" """
Set the paths to the data for the present coin/botloop Set the paths to the data for the present coin/botloop
:params: :param metadata: dict = strategy furnished pair metadata
metadata: dict = strategy furnished pair metadata :param trained_timestamp: int = timestamp of most recent training
trained_timestamp: int = timestamp of most recent training
""" """
self.full_path = Path( self.full_path = Path(
self.config["user_data_dir"] / "models" / str(self.freqai_config.get("identifier")) self.config["user_data_dir"] / "models" / str(self.freqai_config.get("identifier"))
@ -133,8 +132,8 @@ class FreqaiDataKitchen:
Given the dataframe for the full history for training, split the data into Given the dataframe for the full history for training, split the data into
training and test data according to user specified parameters in configuration training and test data according to user specified parameters in configuration
file. file.
:filtered_dataframe: cleaned dataframe ready to be split. :param filtered_dataframe: cleaned dataframe ready to be split.
:labels: cleaned labels ready to be split. :param labels: cleaned labels ready to be split.
""" """
feat_dict = self.freqai_config["feature_parameters"] feat_dict = self.freqai_config["feature_parameters"]
@ -193,13 +192,14 @@ class FreqaiDataKitchen:
remove all NaNs. Any row with a NaN is removed from training dataset or replaced with 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 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. row that had a NaN and will shield user from that prediction.
:params:
:unfiltered_df: the full dataframe for the present training period :param unfiltered_df: the full dataframe for the present training period
:training_feature_list: list, the training feature list constructed by :param training_feature_list: list, the training feature list constructed by
self.build_feature_list() according to user specified parameters in the configuration file. self.build_feature_list() according to user specified
:labels: the labels for the dataset parameters in the configuration file.
:training_filter: boolean which lets the function know if it is training data or :param labels: the labels for the dataset
prediction data to be filtered. :param training_filter: boolean which lets the function know if it is training data or
prediction data to be filtered.
:returns: :returns:
:filtered_df: dataframe cleaned of NaNs and only containing the user :filtered_df: dataframe cleaned of NaNs and only containing the user
requested feature set. requested feature set.
@ -214,7 +214,10 @@ class FreqaiDataKitchen:
const_cols = list((filtered_df.nunique() == 1).loc[lambda x: x].index) const_cols = list((filtered_df.nunique() == 1).loc[lambda x: x].index)
if const_cols: if const_cols:
filtered_df = filtered_df.filter(filtered_df.columns.difference(const_cols)) filtered_df = filtered_df.filter(filtered_df.columns.difference(const_cols))
self.data['constant_features_list'] = const_cols
logger.warning(f"Removed features {const_cols} with constant values.") logger.warning(f"Removed features {const_cols} with constant values.")
else:
self.data['constant_features_list'] = []
# we don't care about total row number (total no. datapoints) in training, we only care # we don't care about total row number (total no. datapoints) in training, we only care
# about removing any row with NaNs # about removing any row with NaNs
# if labels has multiple columns (user wants to train multiple modelEs), we detect here # if labels has multiple columns (user wants to train multiple modelEs), we detect here
@ -245,6 +248,8 @@ class FreqaiDataKitchen:
self.data["filter_drop_index_training"] = drop_index self.data["filter_drop_index_training"] = drop_index
else: else:
if len(self.data['constant_features_list']):
filtered_df = self.check_pred_labels(filtered_df)
# we are backtesting so we need to preserve row number to send back to strategy, # 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 # so now we use do_predict to avoid any prediction based on a NaN
drop_index = pd.isnull(filtered_df).any(axis=1) drop_index = pd.isnull(filtered_df).any(axis=1)
@ -289,8 +294,8 @@ class FreqaiDataKitchen:
def normalize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: def normalize_data(self, data_dictionary: Dict) -> Dict[Any, Any]:
""" """
Normalize all data in the data_dictionary according to the training dataset Normalize all data in the data_dictionary according to the training dataset
:params: :param data_dictionary: dictionary containing the cleaned and
:data_dictionary: dictionary containing the cleaned and split training/test data/labels split training/test data/labels
:returns: :returns:
:data_dictionary: updated dictionary with standardized values. :data_dictionary: updated dictionary with standardized values.
""" """
@ -464,6 +469,22 @@ class FreqaiDataKitchen:
return df return df
def check_pred_labels(self, df_predictions: DataFrame) -> DataFrame:
"""
Check that prediction feature labels match training feature labels.
:param df_predictions: incoming predictions
"""
constant_labels = self.data['constant_features_list']
df_predictions = df_predictions.filter(
df_predictions.columns.difference(constant_labels)
)
logger.warning(
f"Removed {len(constant_labels)} features from prediction features, "
f"these were considered constant values during most recent training."
)
return df_predictions
def principal_component_analysis(self) -> None: def principal_component_analysis(self) -> None:
""" """
Performs Principal Component Analysis on the data for dimensionality reduction Performs Principal Component Analysis on the data for dimensionality reduction
@ -520,8 +541,7 @@ class FreqaiDataKitchen:
def pca_transform(self, filtered_dataframe: DataFrame) -> None: def pca_transform(self, filtered_dataframe: DataFrame) -> None:
""" """
Use an existing pca transform to transform data into components Use an existing pca transform to transform data into components
:params: :param filtered_dataframe: DataFrame = the cleaned dataframe
filtered_dataframe: DataFrame = the cleaned dataframe
""" """
pca_components = self.pca.transform(filtered_dataframe) pca_components = self.pca.transform(filtered_dataframe)
self.data_dictionary["prediction_features"] = pd.DataFrame( self.data_dictionary["prediction_features"] = pd.DataFrame(
@ -565,8 +585,7 @@ class FreqaiDataKitchen:
""" """
Build/inference a Support Vector Machine to detect outliers Build/inference a Support Vector Machine to detect outliers
in training data and prediction in training data and prediction
:params: :param predict: bool = If true, inference an existing SVM model, else construct one
predict: bool = If true, inference an existing SVM model, else construct one
""" """
if self.keras: if self.keras:
@ -651,11 +670,11 @@ class FreqaiDataKitchen:
Use DBSCAN to cluster training data and remove "noisy" data (read outliers). 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 User controls this via the config param `DBSCAN_outlier_pct` which indicates the
pct of training data that they want to be considered outliers. pct of training data that they want to be considered outliers.
:params: :param predict: bool = If False (training), iterate to find the best hyper parameters
predict: bool = If False (training), iterate to find the best hyper parameters to match to match user requested outlier percent target.
user requested outlier percent target. If True (prediction), use the parameters If True (prediction), use the parameters determined from
determined from the previous training to estimate if the current prediction point the previous training to estimate if the current prediction point
is an outlier. is an outlier.
""" """
if predict: if predict:
@ -944,6 +963,9 @@ class FreqaiDataKitchen:
append_df[f"{label}_mean"] = self.data["labels_mean"][label] append_df[f"{label}_mean"] = self.data["labels_mean"][label]
append_df[f"{label}_std"] = self.data["labels_std"][label] append_df[f"{label}_std"] = self.data["labels_std"][label]
for extra_col in self.data["extra_returns_per_train"]:
append_df["{extra_col}"] = self.data["extra_returns_per_train"][extra_col]
append_df["do_predict"] = do_predict append_df["do_predict"] = do_predict
if self.freqai_config["feature_parameters"].get("DI_threshold", 0) > 0: if self.freqai_config["feature_parameters"].get("DI_threshold", 0) > 0:
append_df["DI_values"] = self.DI_values append_df["DI_values"] = self.DI_values
@ -1122,15 +1144,13 @@ class FreqaiDataKitchen:
prediction_dataframe: DataFrame = pd.DataFrame(), prediction_dataframe: DataFrame = pd.DataFrame(),
) -> DataFrame: ) -> DataFrame:
""" """
Use the user defined strategy for populating indicators during Use the user defined strategy for populating indicators during retrain
retrain :param strategy: IStrategy = user defined strategy object
:params: :param corr_dataframes: dict = dict containing the informative pair dataframes
strategy: IStrategy = user defined strategy object (for user defined timeframes)
corr_dataframes: dict = dict containing the informative pair dataframes :param base_dataframes: dict = dict containing the current pair dataframes
(for user defined timeframes) (for user defined timeframes)
base_dataframes: dict = dict containing the current pair dataframes :param metadata: dict = strategy furnished pair metadata
(for user defined timeframes)
metadata: dict = strategy furnished pair metadata
:returns: :returns:
dataframe: DataFrame = dataframe containing populated indicators dataframe: DataFrame = dataframe containing populated indicators
""" """

View File

@ -1,5 +1,4 @@
import logging import logging
import shutil
import threading import threading
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@ -7,7 +6,7 @@ from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Literal, Tuple
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -22,7 +21,7 @@ from freqtrade.exceptions import OperationalException
from freqtrade.exchange import timeframe_to_seconds from freqtrade.exchange import timeframe_to_seconds
from freqtrade.freqai.data_drawer import FreqaiDataDrawer from freqtrade.freqai.data_drawer import FreqaiDataDrawer
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from freqtrade.freqai.utils import plot_feature_importance from freqtrade.freqai.utils import plot_feature_importance, record_params
from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.interface import IStrategy
@ -62,6 +61,7 @@ class IFreqaiModel(ABC):
"data_split_parameters", {}) "data_split_parameters", {})
self.model_training_parameters: Dict[str, Any] = config.get("freqai", {}).get( self.model_training_parameters: Dict[str, Any] = config.get("freqai", {}).get(
"model_training_parameters", {}) "model_training_parameters", {})
self.identifier: str = self.freqai_info.get("identifier", "no_id_provided")
self.retrain = False self.retrain = False
self.first = True self.first = True
self.set_full_path() self.set_full_path()
@ -70,7 +70,6 @@ class IFreqaiModel(ABC):
if self.save_backtest_models: if self.save_backtest_models:
logger.info('Backtesting module configured to save all models.') logger.info('Backtesting module configured to save all models.')
self.dd = FreqaiDataDrawer(Path(self.full_path), self.config, self.follow_mode) self.dd = FreqaiDataDrawer(Path(self.full_path), self.config, self.follow_mode)
self.identifier: str = self.freqai_info.get("identifier", "no_id_provided")
self.scanning = False self.scanning = False
self.ft_params = self.freqai_info["feature_parameters"] self.ft_params = self.freqai_info["feature_parameters"]
self.keras: bool = self.freqai_info.get("keras", False) self.keras: bool = self.freqai_info.get("keras", False)
@ -100,12 +99,13 @@ class IFreqaiModel(ABC):
self.strategy: Optional[IStrategy] = None self.strategy: Optional[IStrategy] = None
self.max_system_threads = max(int(psutil.cpu_count() * 2 - 2), 1) self.max_system_threads = max(int(psutil.cpu_count() * 2 - 2), 1)
record_params(config, self.full_path)
def __getstate__(self): def __getstate__(self):
""" """
Return an empty state to be pickled in hyperopt Return an empty state to be pickled in hyperopt
""" """
return ({}) return ({})
self.strategy: Optional[IStrategy] = None
def assert_config(self, config: Config) -> None: def assert_config(self, config: Config) -> None:
@ -149,7 +149,7 @@ class IFreqaiModel(ABC):
dataframe = dk.remove_features_from_df(dk.return_dataframe) dataframe = dk.remove_features_from_df(dk.return_dataframe)
self.clean_up() self.clean_up()
if self.live: if self.live:
self.inference_timer('stop') self.inference_timer('stop', metadata["pair"])
return dataframe return dataframe
def clean_up(self): def clean_up(self):
@ -210,29 +210,31 @@ class IFreqaiModel(ABC):
(_, trained_timestamp, _) = self.dd.get_pair_dict_info(pair) (_, trained_timestamp, _) = self.dd.get_pair_dict_info(pair)
dk = FreqaiDataKitchen(self.config, self.live, pair) dk = FreqaiDataKitchen(self.config, self.live, pair)
dk.set_paths(pair, trained_timestamp)
( (
retrain, retrain,
new_trained_timerange, new_trained_timerange,
data_load_timerange, data_load_timerange,
) = dk.check_if_new_training_required(trained_timestamp) ) = dk.check_if_new_training_required(trained_timestamp)
dk.set_paths(pair, new_trained_timerange.stopts)
if retrain: if retrain:
self.train_timer('start') self.train_timer('start')
dk.set_paths(pair, new_trained_timerange.stopts)
try: try:
self.extract_data_and_train_model( self.extract_data_and_train_model(
new_trained_timerange, pair, strategy, dk, data_load_timerange new_trained_timerange, pair, strategy, dk, data_load_timerange
) )
except Exception as msg: 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') self.train_timer('stop', pair)
# only rotate the queue after the first has been trained. # only rotate the queue after the first has been trained.
self.train_queue.rotate(-1) self.train_queue.rotate(-1)
self.dd.save_historic_predictions_to_disk() self.dd.save_historic_predictions_to_disk()
if self.freqai_info.get('write_metrics_to_disk', False):
self.dd.save_metric_tracker_to_disk()
def start_backtesting( def start_backtesting(
self, dataframe: DataFrame, metadata: dict, dk: FreqaiDataKitchen self, dataframe: DataFrame, metadata: dict, dk: FreqaiDataKitchen
@ -281,9 +283,7 @@ class IFreqaiModel(ABC):
) )
trained_timestamp_int = int(trained_timestamp.stopts) trained_timestamp_int = int(trained_timestamp.stopts)
dk.data_path = Path( dk.set_paths(pair, trained_timestamp_int)
dk.full_path / f"sub-train-{pair.split('/')[0]}_{trained_timestamp_int}"
)
dk.set_new_model_names(pair, trained_timestamp) dk.set_new_model_names(pair, trained_timestamp)
@ -540,14 +540,13 @@ class IFreqaiModel(ABC):
return file_exists return file_exists
def set_full_path(self) -> None: def set_full_path(self) -> None:
"""
Creates and sets the full path for the identifier
"""
self.full_path = Path( self.full_path = Path(
self.config["user_data_dir"] / "models" / f"{self.freqai_info['identifier']}" self.config["user_data_dir"] / "models" / f"{self.identifier}"
) )
self.full_path.mkdir(parents=True, exist_ok=True) self.full_path.mkdir(parents=True, exist_ok=True)
shutil.copy(
self.config["config_files"][0],
Path(self.full_path, Path(self.config["config_files"][0]).name),
)
def extract_data_and_train_model( def extract_data_and_train_model(
self, self,
@ -616,11 +615,11 @@ class IFreqaiModel(ABC):
If the user reuses an identifier on a subsequent instance, If the user reuses an identifier on a subsequent instance,
this function will not be called. In that case, "real" predictions this function will not be called. In that case, "real" predictions
will be appended to the loaded set of historic predictions. will be appended to the loaded set of historic predictions.
:param: df: DataFrame = the dataframe containing the training feature data :param df: DataFrame = the dataframe containing the training feature data
:param: model: Any = A model which was `fit` using a common library such as :param model: Any = A model which was `fit` using a common library such as
catboost or lightgbm catboost or lightgbm
:param: dk: FreqaiDataKitchen = object containing methods for data analysis :param dk: FreqaiDataKitchen = object containing methods for data analysis
:param: pair: str = current pair :param pair: str = current pair
""" """
self.dd.historic_predictions[pair] = pred_df self.dd.historic_predictions[pair] = pred_df
@ -671,7 +670,7 @@ class IFreqaiModel(ABC):
return return
def inference_timer(self, do='start'): def inference_timer(self, do: Literal['start', 'stop'] = 'start', pair: str = ''):
""" """
Timer designed to track the cumulative time spent in FreqAI for one pass through Timer designed to track the cumulative time spent in FreqAI for one pass through
the whitelist. This will check if the time spent is more than 1/4 the time the whitelist. This will check if the time spent is more than 1/4 the time
@ -682,7 +681,10 @@ class IFreqaiModel(ABC):
self.begin_time = time.time() self.begin_time = time.time()
elif do == 'stop': elif do == 'stop':
end = time.time() end = time.time()
self.inference_time += (end - self.begin_time) time_spent = (end - self.begin_time)
if self.freqai_info.get('write_metrics_to_disk', False):
self.dd.update_metric_tracker('inference_time', time_spent, pair)
self.inference_time += time_spent
if self.pair_it == self.total_pairs: if self.pair_it == self.total_pairs:
logger.info( logger.info(
f'Total time spent inferencing pairlist {self.inference_time:.2f} seconds') f'Total time spent inferencing pairlist {self.inference_time:.2f} seconds')
@ -693,7 +695,7 @@ class IFreqaiModel(ABC):
self.inference_time = 0 self.inference_time = 0
return return
def train_timer(self, do='start'): def train_timer(self, do: Literal['start', 'stop'] = 'start', pair: str = ''):
""" """
Timer designed to track the cumulative time spent training the full pairlist in Timer designed to track the cumulative time spent training the full pairlist in
FreqAI. FreqAI.
@ -703,7 +705,11 @@ class IFreqaiModel(ABC):
self.begin_time_train = time.time() self.begin_time_train = time.time()
elif do == 'stop': elif do == 'stop':
end = time.time() end = time.time()
self.train_time += (end - self.begin_time_train) time_spent = (end - self.begin_time_train)
if self.freqai_info.get('write_metrics_to_disk', False):
self.dd.collect_metrics(time_spent, pair)
self.train_time += time_spent
if self.pair_it_train == self.total_pairs: if self.pair_it_train == self.total_pairs:
logger.info( logger.info(
f'Total time spent training pairlist {self.train_time:.2f} seconds') f'Total time spent training pairlist {self.train_time:.2f} seconds')

View File

@ -1,4 +1,6 @@
import logging import logging
import sys
from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from catboost import CatBoostClassifier, Pool from catboost import CatBoostClassifier, Pool
@ -20,9 +22,8 @@ class CatboostClassifier(BaseClassifierModel):
def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
""" """
User sets up the training and test data to fit their desired model here User sets up the training and test data to fit their desired model here
:params: :param data_dictionary: the dictionary constructed by DataHandler to hold
:data_dictionary: the dictionary constructed by DataHandler to hold all the training and test data/labels.
all the training and test data/labels.
""" """
train_data = Pool( train_data = Pool(
@ -30,15 +31,25 @@ class CatboostClassifier(BaseClassifierModel):
label=data_dictionary["train_labels"], label=data_dictionary["train_labels"],
weight=data_dictionary["train_weights"], weight=data_dictionary["train_weights"],
) )
if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) == 0:
test_data = None
else:
test_data = Pool(
data=data_dictionary["test_features"],
label=data_dictionary["test_labels"],
weight=data_dictionary["test_weights"],
)
cbr = CatBoostClassifier( cbr = CatBoostClassifier(
allow_writing_files=False, allow_writing_files=True,
loss_function='MultiClass', loss_function='MultiClass',
train_dir=Path(dk.data_path),
**self.model_training_parameters, **self.model_training_parameters,
) )
init_model = self.get_init_model(dk.pair) init_model = self.get_init_model(dk.pair)
cbr.fit(train_data, init_model=init_model) cbr.fit(X=train_data, eval_set=test_data, init_model=init_model,
log_cout=sys.stdout, log_cerr=sys.stderr)
return cbr return cbr

View File

@ -1,4 +1,6 @@
import logging import logging
import sys
from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from catboost import CatBoostRegressor, Pool from catboost import CatBoostRegressor, Pool
@ -41,10 +43,12 @@ class CatboostRegressor(BaseRegressionModel):
init_model = self.get_init_model(dk.pair) init_model = self.get_init_model(dk.pair)
model = CatBoostRegressor( model = CatBoostRegressor(
allow_writing_files=False, allow_writing_files=True,
train_dir=Path(dk.data_path),
**self.model_training_parameters, **self.model_training_parameters,
) )
model.fit(X=train_data, eval_set=test_data, init_model=init_model) model.fit(X=train_data, eval_set=test_data, init_model=init_model,
log_cout=sys.stdout, log_cerr=sys.stderr)
return model return model

View File

@ -1,4 +1,6 @@
import logging import logging
import sys
from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from catboost import CatBoostRegressor, Pool from catboost import CatBoostRegressor, Pool
@ -26,7 +28,8 @@ class CatboostRegressorMultiTarget(BaseRegressionModel):
""" """
cbr = CatBoostRegressor( cbr = CatBoostRegressor(
allow_writing_files=False, allow_writing_files=True,
train_dir=Path(dk.data_path),
**self.model_training_parameters, **self.model_training_parameters,
) )
@ -56,8 +59,10 @@ class CatboostRegressorMultiTarget(BaseRegressionModel):
fit_params = [] fit_params = []
for i in range(len(eval_sets)): for i in range(len(eval_sets)):
fit_params.append( fit_params.append({
{'eval_set': eval_sets[i], 'init_model': init_models[i]}) 'eval_set': eval_sets[i], 'init_model': init_models[i],
'log_cout': sys.stdout, 'log_cerr': sys.stderr,
})
model = FreqaiMultiOutputRegressor(estimator=cbr) model = FreqaiMultiOutputRegressor(estimator=cbr)
thread_training = self.freqai_info.get('multitarget_parallel_training', False) thread_training = self.freqai_info.get('multitarget_parallel_training', False)

View File

@ -20,9 +20,8 @@ class LightGBMClassifier(BaseClassifierModel):
def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
""" """
User sets up the training and test data to fit their desired model here User sets up the training and test data to fit their desired model here
:params: :param data_dictionary: the dictionary constructed by DataHandler to hold
:data_dictionary: the dictionary constructed by DataHandler to hold all the training and test data/labels.
all the training and test data/labels.
""" """
if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) == 0: if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) == 0:

View File

@ -26,9 +26,8 @@ class XGBoostClassifier(BaseClassifierModel):
def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
""" """
User sets up the training and test data to fit their desired model here User sets up the training and test data to fit their desired model here
:params: :param data_dictionary: the dictionary constructed by DataHandler to hold
:data_dictionary: the dictionary constructed by DataHandler to hold all the training and test data/labels.
all the training and test data/labels.
""" """
X = data_dictionary["train_features"].to_numpy() X = data_dictionary["train_features"].to_numpy()
@ -65,7 +64,7 @@ class XGBoostClassifier(BaseClassifierModel):
) -> Tuple[DataFrame, npt.NDArray[np.int_]]: ) -> Tuple[DataFrame, npt.NDArray[np.int_]]:
""" """
Filter the prediction features data and predict with it. 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: :return:
:pred_df: dataframe containing the predictions :pred_df: dataframe containing the predictions
:do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove

View File

@ -0,0 +1,84 @@
import logging
from typing import Any, Dict, Tuple
import numpy as np
import numpy.typing as npt
import pandas as pd
from pandas import DataFrame
from pandas.api.types import is_integer_dtype
from sklearn.preprocessing import LabelEncoder
from xgboost import XGBRFClassifier
from freqtrade.freqai.base_models.BaseClassifierModel import BaseClassifierModel
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
logger = logging.getLogger(__name__)
class XGBoostRFClassifier(BaseClassifierModel):
"""
User created prediction model. The class needs to override three necessary
functions, predict(), train(), fit(). The class inherits ModelHandler which
has its own DataHandler where data is held, saved, loaded, and managed.
"""
def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
"""
User sets up the training and test data to fit their desired model here
:param data_dictionary: the dictionary constructed by DataHandler to hold
all the training and test data/labels.
"""
X = data_dictionary["train_features"].to_numpy()
y = data_dictionary["train_labels"].to_numpy()[:, 0]
le = LabelEncoder()
if not is_integer_dtype(y):
y = pd.Series(le.fit_transform(y), dtype="int64")
if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) == 0:
eval_set = None
else:
test_features = data_dictionary["test_features"].to_numpy()
test_labels = data_dictionary["test_labels"].to_numpy()[:, 0]
if not is_integer_dtype(test_labels):
test_labels = pd.Series(le.transform(test_labels), dtype="int64")
eval_set = [(test_features, test_labels)]
train_weights = data_dictionary["train_weights"]
init_model = self.get_init_model(dk.pair)
model = XGBRFClassifier(**self.model_training_parameters)
model.fit(X=X, y=y, eval_set=eval_set, sample_weight=train_weights,
xgb_model=init_model)
return model
def predict(
self, unfiltered_df: DataFrame, dk: FreqaiDataKitchen, **kwargs
) -> 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.
:return:
:pred_df: dataframe containing the predictions
:do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove
data (NaNs) or felt uncertain about data (PCA and DI index)
"""
(pred_df, dk.do_predict) = super().predict(unfiltered_df, dk, **kwargs)
le = LabelEncoder()
label = dk.label_list[0]
labels_before = list(dk.data['labels_std'].keys())
labels_after = le.fit_transform(labels_before).tolist()
pred_df[label] = le.inverse_transform(pred_df[label])
pred_df = pred_df.rename(
columns={labels_after[i]: labels_before[i] for i in range(len(labels_before))})
return (pred_df, dk.do_predict)

View File

@ -0,0 +1,46 @@
import logging
from typing import Any, Dict
from xgboost import XGBRFRegressor
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
logger = logging.getLogger(__name__)
class XGBoostRFRegressor(BaseRegressionModel):
"""
User created prediction model. The class needs to override three necessary
functions, predict(), train(), fit(). The class inherits ModelHandler which
has its own DataHandler where data is held, saved, loaded, and managed.
"""
def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any:
"""
User sets up the training and test data to fit their desired model here
:param data_dictionary: the dictionary constructed by DataHandler to hold
all the training and test data/labels.
"""
X = data_dictionary["train_features"]
y = data_dictionary["train_labels"]
if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) == 0:
eval_set = None
eval_weights = None
else:
eval_set = [(data_dictionary["test_features"], data_dictionary["test_labels"])]
eval_weights = [data_dictionary['test_weights']]
sample_weight = data_dictionary["train_weights"]
xgb_model = self.get_init_model(dk.pair)
model = XGBRFRegressor(**self.model_training_parameters)
model.fit(X=X, y=y, sample_weight=sample_weight, eval_set=eval_set,
sample_weight_eval_set=eval_weights, xgb_model=xgb_model)
return model

View File

@ -29,6 +29,7 @@ class XGBoostRegressor(BaseRegressionModel):
if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) == 0: if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) == 0:
eval_set = None eval_set = None
eval_weights = None
else: else:
eval_set = [(data_dictionary["test_features"], data_dictionary["test_labels"])] eval_set = [(data_dictionary["test_features"], data_dictionary["test_labels"])]
eval_weights = [data_dictionary['test_weights']] eval_weights = [data_dictionary['test_weights']]

View File

@ -1,9 +1,11 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from pathlib import Path
from typing import Any, Dict
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import rapidjson
from freqtrade.configuration import TimeRange from freqtrade.configuration import TimeRange
from freqtrade.constants import Config from freqtrade.constants import Config
@ -191,3 +193,28 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen,
fig.update_layout(title_text=f"Best and worst features by importance {pair}") fig.update_layout(title_text=f"Best and worst features by importance {pair}")
label = label.replace('&', '').replace('%', '') # escape two FreqAI specific characters label = label.replace('&', '').replace('%', '') # escape two FreqAI specific characters
store_plot_file(fig, f"{dk.model_filename}-{label}.html", dk.data_path) store_plot_file(fig, f"{dk.model_filename}-{label}.html", dk.data_path)
def record_params(config: Dict[str, Any], full_path: Path) -> None:
"""
Records run params in the full path for reproducibility
"""
params_record_path = full_path / "run_params.json"
run_params = {
"freqai": config.get('freqai', {}),
"timeframe": config.get('timeframe'),
"stake_amount": config.get('stake_amount'),
"stake_currency": config.get('stake_currency'),
"max_open_trades": config.get('max_open_trades'),
"pairs": config.get('exchange', {}).get('pair_whitelist')
}
with open(params_record_path, "w") as handle:
rapidjson.dump(
run_params,
handle,
indent=4,
default=str,
number_mode=rapidjson.NM_NATIVE | rapidjson.NM_NAN
)

View File

@ -1471,12 +1471,13 @@ class FreqtradeBot(LoggingMixin):
) )
return cancelled return cancelled
def _safe_exit_amount(self, pair: str, amount: float) -> float: def _safe_exit_amount(self, trade: Trade, pair: str, amount: float) -> float:
""" """
Get sellable amount. Get sellable amount.
Should be trade.amount - but will fall back to the available amount if necessary. Should be trade.amount - but will fall back to the available amount if necessary.
This should cover cases where get_real_amount() was not able to update the amount This should cover cases where get_real_amount() was not able to update the amount
for whatever reason. for whatever reason.
:param trade: Trade we're working with
:param pair: Pair we're trying to sell :param pair: Pair we're trying to sell
:param amount: amount we expect to be available :param amount: amount we expect to be available
:return: amount to sell :return: amount to sell
@ -1495,6 +1496,7 @@ class FreqtradeBot(LoggingMixin):
return amount return amount
elif wallet_amount > amount * 0.98: elif wallet_amount > amount * 0.98:
logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.") logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.")
trade.amount = wallet_amount
return wallet_amount return wallet_amount
else: else:
raise DependencyException( raise DependencyException(
@ -1553,7 +1555,7 @@ class FreqtradeBot(LoggingMixin):
# Emergency sells (default to market!) # Emergency sells (default to market!)
order_type = self.strategy.order_types.get("emergency_exit", "market") order_type = self.strategy.order_types.get("emergency_exit", "market")
amount = self._safe_exit_amount(trade.pair, sub_trade_amt or trade.amount) amount = self._safe_exit_amount(trade, trade.pair, sub_trade_amt or trade.amount)
time_in_force = self.strategy.order_time_in_force['exit'] time_in_force = self.strategy.order_time_in_force['exit']
if (exit_check.exit_type != ExitType.LIQUIDATION if (exit_check.exit_type != ExitType.LIQUIDATION
@ -1828,7 +1830,7 @@ class FreqtradeBot(LoggingMixin):
never in base currency. never in base currency.
""" """
self.wallets.update() self.wallets.update()
amount_ = amount amount_ = trade.amount
if order_obj.ft_order_side == trade.exit_side or order_obj.ft_order_side == 'stoploss': if order_obj.ft_order_side == trade.exit_side or order_obj.ft_order_side == 'stoploss':
# check against remaining amount! # check against remaining amount!
amount_ = trade.amount - amount amount_ = trade.amount - amount

View File

@ -6,7 +6,7 @@ import logging
import re import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Iterator, List from typing import Any, Dict, Iterator, List, Mapping, Union
from typing.io import IO from typing.io import IO
from urllib.parse import urlparse from urllib.parse import urlparse
@ -186,7 +186,10 @@ def safe_value_fallback(obj: dict, key1: str, key2: str, default_value=None):
return default_value return default_value
def safe_value_fallback2(dict1: dict, dict2: dict, key1: str, key2: str, default_value=None): dictMap = Union[Dict[str, Any], Mapping[str, Any]]
def safe_value_fallback2(dict1: dictMap, dict2: dictMap, key1: str, key2: str, default_value=None):
""" """
Search a value in dict1, return this if it's not None. Search a value in dict1, return this if it's not None.
Fall back to dict2 - return key2 from dict2 if it's not None. Fall back to dict2 - return key2 from dict2 if it's not None.

View File

@ -151,6 +151,8 @@ class Backtesting:
self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT) self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT)
# strategies which define "can_short=True" will fail to load in Spot mode. # strategies which define "can_short=True" will fail to load in Spot mode.
self._can_short = self.trading_mode != TradingMode.SPOT self._can_short = self.trading_mode != TradingMode.SPOT
self._position_stacking: bool = self.config.get('position_stacking', False)
self.enable_protections: bool = self.config.get('enable_protections', False)
self.init_backtest() self.init_backtest()
@ -617,13 +619,16 @@ class Backtesting:
exit_reason = row[EXIT_TAG_IDX] exit_reason = row[EXIT_TAG_IDX]
# Custom exit pricing only for exit-signals # Custom exit pricing only for exit-signals
if order_type == 'limit': if order_type == 'limit':
close_rate = strategy_safe_wrapper(self.strategy.custom_exit_price, rate = strategy_safe_wrapper(self.strategy.custom_exit_price,
default_retval=close_rate)( default_retval=close_rate)(
pair=trade.pair, pair=trade.pair,
trade=trade, # type: ignore[arg-type] trade=trade, # type: ignore[arg-type]
current_time=exit_candle_time, current_time=exit_candle_time,
proposed_rate=close_rate, current_profit=current_profit, proposed_rate=close_rate, current_profit=current_profit,
exit_tag=exit_reason) exit_tag=exit_reason)
if rate != close_rate:
close_rate = price_to_precision(rate, trade.price_precision,
self.precision_mode)
# We can't place orders lower than current low. # We can't place orders lower than current low.
# freqtrade does not support this in live, and the order would fill immediately # freqtrade does not support this in live, and the order would fill immediately
if trade.is_short: if trade.is_short:
@ -660,7 +665,6 @@ class Backtesting:
# amount = amount or trade.amount # amount = amount or trade.amount
amount = amount_to_contract_precision(amount or trade.amount, trade.amount_precision, amount = amount_to_contract_precision(amount or trade.amount, trade.amount_precision,
self.precision_mode, trade.contract_size) self.precision_mode, trade.contract_size)
rate = price_to_precision(close_rate, trade.price_precision, self.precision_mode)
order = Order( order = Order(
id=self.order_id_counter, id=self.order_id_counter,
ft_trade_id=trade.id, ft_trade_id=trade.id,
@ -674,12 +678,12 @@ class Backtesting:
side=trade.exit_side, side=trade.exit_side,
order_type=order_type, order_type=order_type,
status="open", status="open",
price=rate, price=close_rate,
average=rate, average=close_rate,
amount=amount, amount=amount,
filled=0, filled=0,
remaining=amount, remaining=amount,
cost=amount * rate, cost=amount * close_rate,
) )
trade.orders.append(order) trade.orders.append(order)
return trade return trade
@ -726,18 +730,21 @@ class Backtesting:
def get_valid_price_and_stake( def get_valid_price_and_stake(
self, pair: str, row: Tuple, propose_rate: float, stake_amount: float, self, pair: str, row: Tuple, propose_rate: float, stake_amount: float,
direction: LongShort, current_time: datetime, entry_tag: Optional[str], direction: LongShort, current_time: datetime, entry_tag: Optional[str],
trade: Optional[LocalTrade], order_type: str trade: Optional[LocalTrade], order_type: str, price_precision: Optional[float]
) -> Tuple[float, float, float, float]: ) -> Tuple[float, float, float, float]:
if order_type == 'limit': if order_type == 'limit':
propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, new_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=propose_rate)( default_retval=propose_rate)(
pair=pair, current_time=current_time, pair=pair, current_time=current_time,
proposed_rate=propose_rate, entry_tag=entry_tag, proposed_rate=propose_rate, entry_tag=entry_tag,
side=direction, side=direction,
) # default value is the open rate ) # default value is the open rate
# We can't place orders higher than current high (otherwise it'd be a stop limit entry) # We can't place orders higher than current high (otherwise it'd be a stop limit entry)
# which freqtrade does not support in live. # which freqtrade does not support in live.
if new_rate != propose_rate:
propose_rate = price_to_precision(new_rate, price_precision,
self.precision_mode)
if direction == "short": if direction == "short":
propose_rate = max(propose_rate, row[LOW_IDX]) propose_rate = max(propose_rate, row[LOW_IDX])
else: else:
@ -799,9 +806,11 @@ class Backtesting:
pos_adjust = trade is not None and requested_rate is None pos_adjust = trade is not None and requested_rate is None
stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0) stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0)
precision_price = self.exchange.get_precision_price(pair)
propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake( propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake(
pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade, pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade,
order_type order_type, precision_price,
) )
# replace proposed rate if another rate was requested # replace proposed rate if another rate was requested
@ -817,8 +826,6 @@ class Backtesting:
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount): if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
self.order_id_counter += 1 self.order_id_counter += 1
base_currency = self.exchange.get_pair_base_currency(pair) base_currency = self.exchange.get_pair_base_currency(pair)
precision_price = self.exchange.get_precision_price(pair)
propose_rate = price_to_precision(propose_rate, precision_price, self.precision_mode)
amount_p = (stake_amount / propose_rate) * leverage amount_p = (stake_amount / propose_rate) * leverage
contract_size = self.exchange.get_contract_size(pair) contract_size = self.exchange.get_contract_size(pair)
@ -914,30 +921,23 @@ class Backtesting:
return trade return trade
def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]], def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
data: Dict[str, List[Tuple]]) -> List[LocalTrade]: data: Dict[str, List[Tuple]]) -> None:
""" """
Handling of left open trades at the end of backtesting Handling of left open trades at the end of backtesting
""" """
trades = []
for pair in open_trades.keys(): for pair in open_trades.keys():
if len(open_trades[pair]) > 0: for trade in list(open_trades[pair]):
for trade in open_trades[pair]: if trade.open_order_id and trade.nr_of_successful_entries == 0:
if trade.open_order_id and trade.nr_of_successful_entries == 0: # Ignore trade if entry-order did not fill yet
# Ignore trade if entry-order did not fill yet continue
continue exit_row = data[pair][-1]
exit_row = data[pair][-1] self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount)
self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount) trade.orders[-1].close_bt_order(exit_row[DATE_IDX].to_pydatetime(), trade)
trade.orders[-1].close_bt_order(exit_row[DATE_IDX].to_pydatetime(), trade)
trade.close_date = exit_row[DATE_IDX].to_pydatetime() trade.close_date = exit_row[DATE_IDX].to_pydatetime()
trade.exit_reason = ExitType.FORCE_EXIT.value trade.exit_reason = ExitType.FORCE_EXIT.value
trade.close(exit_row[OPEN_IDX], show_msg=False) trade.close(exit_row[OPEN_IDX], show_msg=False)
LocalTrade.close_bt_trade(trade) LocalTrade.close_bt_trade(trade)
# Deepcopy object to have wallets update correctly
trade1 = deepcopy(trade)
trade1.is_open = True
trades.append(trade1)
return trades
def trade_slot_available(self, max_open_trades: int, open_trade_count: int) -> bool: def trade_slot_available(self, max_open_trades: int, open_trade_count: int) -> bool:
# Always allow trades when max_open_trades is enabled. # Always allow trades when max_open_trades is enabled.
@ -961,9 +961,8 @@ class Backtesting:
return 'short' return 'short'
return None return None
def run_protections( def run_protections(self, pair: str, current_time: datetime, side: LongShort):
self, enable_protections, pair: str, current_time: datetime, side: LongShort): if self.enable_protections:
if enable_protections:
self.protections.stop_per_pair(pair, current_time, side) self.protections.stop_per_pair(pair, current_time, side)
self.protections.global_stop(current_time, side) self.protections.global_stop(current_time, side)
@ -1069,10 +1068,78 @@ class Backtesting:
return None return None
return row return row
def backtest(self, processed: Dict, # noqa: max-complexity: 13 def backtest_loop(
self, row: Tuple, pair: str, current_time: datetime, end_date: datetime,
max_open_trades: int, open_trade_count_start: int) -> int:
"""
NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
Backtesting processing for one candle/pair.
"""
for t in list(LocalTrade.bt_trades_open_pp[pair]):
# 1. Manage currently open orders of active trades
if self.manage_open_orders(t, current_time, row):
# Close trade
open_trade_count_start -= 1
LocalTrade.remove_bt_trade(t)
self.wallets.update()
# 2. Process entries.
# without positionstacking, we can only have one open trade per pair.
# max_open_trades must be respected
# don't open on the last row
trade_dir = self.check_for_trade_entry(row)
if (
(self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0)
and self.trade_slot_available(max_open_trades, open_trade_count_start)
and current_time != end_date
and trade_dir is not None
and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
):
trade = self._enter_trade(pair, row, trade_dir)
if trade:
# TODO: hacky workaround to avoid opening > max_open_trades
# This emulates previous behavior - not sure if this is correct
# Prevents entering if the trade-slot was freed in this candle
open_trade_count_start += 1
# logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
LocalTrade.add_bt_trade(trade)
self.wallets.update()
for trade in list(LocalTrade.bt_trades_open_pp[pair]):
# 3. Process entry orders.
order = trade.select_order(trade.entry_side, is_open=True)
if order and self._get_order_filled(order.price, row):
order.close_bt_order(current_time, trade)
trade.open_order_id = None
self.wallets.update()
# 4. Create exit orders (if any)
if not trade.open_order_id:
self._get_exit_trade_entry(trade, row) # Place exit order if necessary
# 5. Process exit orders.
order = trade.select_order(trade.exit_side, is_open=True)
if order and self._get_order_filled(order.price, row):
order.close_bt_order(current_time, trade)
trade.open_order_id = None
sub_trade = order.safe_amount_after_fee != trade.amount
if sub_trade:
order.close_bt_order(current_time, trade)
trade.recalc_trade_from_orders()
else:
trade.close_date = current_time
trade.close(order.price, show_msg=False)
# logger.debug(f"{pair} - Backtesting exit {trade}")
LocalTrade.close_bt_trade(trade)
self.wallets.update()
self.run_protections(pair, current_time, trade.trade_direction)
return open_trade_count_start
def backtest(self, processed: Dict,
start_date: datetime, end_date: datetime, start_date: datetime, end_date: datetime,
max_open_trades: int = 0, position_stacking: bool = False, max_open_trades: int = 0) -> Dict[str, Any]:
enable_protections: bool = False) -> Dict[str, Any]:
""" """
Implement backtesting functionality Implement backtesting functionality
@ -1085,12 +1152,9 @@ class Backtesting:
:param start_date: backtesting timerange start datetime :param start_date: backtesting timerange start datetime
:param end_date: backtesting timerange end datetime :param end_date: backtesting timerange end datetime
:param max_open_trades: maximum number of concurrent trades, <= 0 means unlimited :param max_open_trades: maximum number of concurrent trades, <= 0 means unlimited
:param position_stacking: do we allow position stacking?
:param enable_protections: Should protections be enabled?
:return: DataFrame with trades (results of backtesting) :return: DataFrame with trades (results of backtesting)
""" """
trades: List[LocalTrade] = [] self.prepare_backtest(self.enable_protections)
self.prepare_backtest(enable_protections)
# Ensure wallets are uptodate (important for --strategy-list) # Ensure wallets are uptodate (important for --strategy-list)
self.wallets.update() self.wallets.update()
# Use dict of lists with data for performance # Use dict of lists with data for performance
@ -1101,15 +1165,12 @@ class Backtesting:
indexes: Dict = defaultdict(int) indexes: Dict = defaultdict(int)
current_time = start_date + timedelta(minutes=self.timeframe_min) current_time = start_date + timedelta(minutes=self.timeframe_min)
open_trades: Dict[str, List[LocalTrade]] = defaultdict(list)
open_trade_count = 0
self.progress.init_step(BacktestState.BACKTEST, int( self.progress.init_step(BacktestState.BACKTEST, int(
(end_date - start_date) / timedelta(minutes=self.timeframe_min))) (end_date - start_date) / timedelta(minutes=self.timeframe_min)))
# Loop timerange and get candle for each pair at that point in time # Loop timerange and get candle for each pair at that point in time
while current_time <= end_date: while current_time <= end_date:
open_trade_count_start = open_trade_count open_trade_count_start = LocalTrade.bt_open_open_trade_count
self.check_abort() self.check_abort()
for i, pair in enumerate(data): for i, pair in enumerate(data):
row_index = indexes[pair] row_index = indexes[pair]
@ -1121,81 +1182,17 @@ class Backtesting:
indexes[pair] = row_index indexes[pair] = row_index
self.dataprovider._set_dataframe_max_index(row_index) self.dataprovider._set_dataframe_max_index(row_index)
for t in list(open_trades[pair]): open_trade_count_start = self.backtest_loop(
# 1. Manage currently open orders of active trades row, pair, current_time, end_date, max_open_trades, open_trade_count_start)
if self.manage_open_orders(t, current_time, row):
# Close trade
open_trade_count -= 1
open_trades[pair].remove(t)
LocalTrade.trades_open.remove(t)
self.wallets.update()
# 2. Process entries.
# without positionstacking, we can only have one open trade per pair.
# max_open_trades must be respected
# don't open on the last row
trade_dir = self.check_for_trade_entry(row)
if (
(position_stacking or len(open_trades[pair]) == 0)
and self.trade_slot_available(max_open_trades, open_trade_count_start)
and current_time != end_date
and trade_dir is not None
and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir)
):
trade = self._enter_trade(pair, row, trade_dir)
if trade:
# TODO: hacky workaround to avoid opening > max_open_trades
# This emulates previous behavior - not sure if this is correct
# Prevents entering if the trade-slot was freed in this candle
open_trade_count_start += 1
open_trade_count += 1
# logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
open_trades[pair].append(trade)
LocalTrade.add_bt_trade(trade)
self.wallets.update()
for trade in list(open_trades[pair]):
# 3. Process entry orders.
order = trade.select_order(trade.entry_side, is_open=True)
if order and self._get_order_filled(order.price, row):
order.close_bt_order(current_time, trade)
trade.open_order_id = None
self.wallets.update()
# 4. Create exit orders (if any)
if not trade.open_order_id:
self._get_exit_trade_entry(trade, row) # Place exit order if necessary
# 5. Process exit orders.
order = trade.select_order(trade.exit_side, is_open=True)
if order and self._get_order_filled(order.price, row):
order.close_bt_order(current_time, trade)
trade.open_order_id = None
sub_trade = order.safe_amount_after_fee != trade.amount
if sub_trade:
order.close_bt_order(current_time, trade)
trade.recalc_trade_from_orders()
else:
trade.close_date = current_time
trade.close(order.price, show_msg=False)
# logger.debug(f"{pair} - Backtesting exit {trade}")
open_trade_count -= 1
open_trades[pair].remove(trade)
LocalTrade.close_bt_trade(trade)
trades.append(trade)
self.wallets.update()
self.run_protections(
enable_protections, pair, current_time, trade.trade_direction)
# Move time one configured time_interval ahead. # Move time one configured time_interval ahead.
self.progress.increment() self.progress.increment()
current_time += timedelta(minutes=self.timeframe_min) current_time += timedelta(minutes=self.timeframe_min)
trades += self.handle_left_open(open_trades, data=data) self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data)
self.wallets.update() self.wallets.update()
results = trade_list_to_dataframe(trades) results = trade_list_to_dataframe(LocalTrade.trades)
return { return {
'results': results, 'results': results,
'config': self.strategy.config, 'config': self.strategy.config,
@ -1248,8 +1245,6 @@ class Backtesting:
start_date=min_date, start_date=min_date,
end_date=max_date, end_date=max_date,
max_open_trades=max_open_trades, max_open_trades=max_open_trades,
position_stacking=self.config.get('position_stacking', False),
enable_protections=self.config.get('enable_protections', False),
) )
backtest_end_time = datetime.now(timezone.utc) backtest_end_time = datetime.now(timezone.utc)
results.update({ results.update({

View File

@ -122,7 +122,6 @@ class Hyperopt:
else: else:
logger.debug('Ignoring max_open_trades (--disable-max-market-positions was used) ...') logger.debug('Ignoring max_open_trades (--disable-max-market-positions was used) ...')
self.max_open_trades = 0 self.max_open_trades = 0
self.position_stacking = self.config.get('position_stacking', False)
if HyperoptTools.has_space(self.config, 'sell'): if HyperoptTools.has_space(self.config, 'sell'):
# Make sure use_exit_signal is enabled # Make sure use_exit_signal is enabled
@ -258,6 +257,7 @@ class Hyperopt:
logger.debug("Hyperopt has 'protection' space") logger.debug("Hyperopt has 'protection' space")
# Enable Protections if protection space is selected. # Enable Protections if protection space is selected.
self.config['enable_protections'] = True self.config['enable_protections'] = True
self.backtesting.enable_protections = True
self.protection_space = self.custom_hyperopt.protection_space() self.protection_space = self.custom_hyperopt.protection_space()
if HyperoptTools.has_space(self.config, 'buy'): if HyperoptTools.has_space(self.config, 'buy'):
@ -339,8 +339,6 @@ class Hyperopt:
start_date=self.min_date, start_date=self.min_date,
end_date=self.max_date, end_date=self.max_date,
max_open_trades=self.max_open_trades, max_open_trades=self.max_open_trades,
position_stacking=self.position_stacking,
enable_protections=self.config.get('enable_protections', False),
) )
backtest_end_time = datetime.now(timezone.utc) backtest_end_time = datetime.now(timezone.utc)
bt_results.update({ bt_results.update({

View File

@ -12,7 +12,7 @@ import tabulate
from colorama import Fore, Style from colorama import Fore, Style
from pandas import isna, json_normalize from pandas import isna, json_normalize
from freqtrade.constants import FTHYPT_FILEVERSION, USERPATH_STRATEGIES, Config from freqtrade.constants import FTHYPT_FILEVERSION, Config
from freqtrade.enums import HyperoptState from freqtrade.enums import HyperoptState
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.misc import deep_merge_dicts, round_coin_value, round_dict, safe_value_fallback2 from freqtrade.misc import deep_merge_dicts, round_coin_value, round_dict, safe_value_fallback2
@ -50,9 +50,8 @@ class HyperoptTools():
Get Strategy-location (filename) from strategy_name Get Strategy-location (filename) from strategy_name
""" """
from freqtrade.resolvers.strategy_resolver import StrategyResolver from freqtrade.resolvers.strategy_resolver import StrategyResolver
directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES))
strategy_objs = StrategyResolver.search_all_objects( strategy_objs = StrategyResolver.search_all_objects(
directory, False, config.get('recursive_strategy_search', False)) config, False, config.get('recursive_strategy_search', False))
strategies = [s for s in strategy_objs if s['name'] == strategy_name] strategies = [s for s in strategy_objs if s['name'] == strategy_name]
if strategies: if strategies:
strategy = strategies[0] strategy = strategies[0]

View File

@ -408,10 +408,10 @@ def generate_strategy_stats(pairlist: List[str],
exit_reason_stats = generate_exit_reason_stats(max_open_trades=max_open_trades, exit_reason_stats = generate_exit_reason_stats(max_open_trades=max_open_trades,
results=results) results=results)
left_open_results = generate_pair_metrics(pairlist, stake_currency=stake_currency, left_open_results = generate_pair_metrics(
starting_balance=start_balance, pairlist, stake_currency=stake_currency, starting_balance=start_balance,
results=results.loc[results['is_open']], results=results.loc[results['exit_reason'] == 'force_exit'], skip_nan=True)
skip_nan=True)
daily_stats = generate_daily_stats(results) daily_stats = generate_daily_stats(results)
trade_stats = generate_trading_stats(results) trade_stats = generate_trading_stats(results)
best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'], best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'],

View File

@ -2,6 +2,7 @@
This module contains the class to persist trades into SQLite This module contains the class to persist trades into SQLite
""" """
import logging import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import isclose from math import isclose
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -255,6 +256,9 @@ class LocalTrade():
# Trades container for backtesting # Trades container for backtesting
trades: List['LocalTrade'] = [] trades: List['LocalTrade'] = []
trades_open: List['LocalTrade'] = [] trades_open: List['LocalTrade'] = []
# Copy of trades_open - but indexed by pair
bt_trades_open_pp: Dict[str, List['LocalTrade']] = defaultdict(list)
bt_open_open_trade_count: int = 0
total_profit: float = 0 total_profit: float = 0
realized_profit: float = 0 realized_profit: float = 0
@ -538,6 +542,8 @@ class LocalTrade():
""" """
LocalTrade.trades = [] LocalTrade.trades = []
LocalTrade.trades_open = [] LocalTrade.trades_open = []
LocalTrade.bt_trades_open_pp = defaultdict(list)
LocalTrade.bt_open_open_trade_count = 0
LocalTrade.total_profit = 0 LocalTrade.total_profit = 0
def adjust_min_max_rates(self, current_price: float, current_price_low: float) -> None: def adjust_min_max_rates(self, current_price: float, current_price_low: float) -> None:
@ -1067,6 +1073,8 @@ class LocalTrade():
@staticmethod @staticmethod
def close_bt_trade(trade): def close_bt_trade(trade):
LocalTrade.trades_open.remove(trade) LocalTrade.trades_open.remove(trade)
LocalTrade.bt_trades_open_pp[trade.pair].remove(trade)
LocalTrade.bt_open_open_trade_count -= 1
LocalTrade.trades.append(trade) LocalTrade.trades.append(trade)
LocalTrade.total_profit += trade.close_profit_abs LocalTrade.total_profit += trade.close_profit_abs
@ -1074,9 +1082,17 @@ class LocalTrade():
def add_bt_trade(trade): def add_bt_trade(trade):
if trade.is_open: if trade.is_open:
LocalTrade.trades_open.append(trade) LocalTrade.trades_open.append(trade)
LocalTrade.bt_trades_open_pp[trade.pair].append(trade)
LocalTrade.bt_open_open_trade_count += 1
else: else:
LocalTrade.trades.append(trade) LocalTrade.trades.append(trade)
@staticmethod
def remove_bt_trade(trade):
LocalTrade.trades_open.remove(trade)
LocalTrade.bt_trades_open_pp[trade.pair].remove(trade)
LocalTrade.bt_open_open_trade_count -= 1
@staticmethod @staticmethod
def get_open_trades() -> List[Any]: def get_open_trades() -> List[Any]:
""" """
@ -1092,7 +1108,7 @@ class LocalTrade():
if Trade.use_db: if Trade.use_db:
return Trade.query.filter(Trade.is_open.is_(True)).count() return Trade.query.filter(Trade.is_open.is_(True)).count()
else: else:
return len(LocalTrade.trades_open) return LocalTrade.bt_open_open_trade_count
@staticmethod @staticmethod
def stoploss_reinitialization(desired_stoploss): def stoploss_reinitialization(desired_stoploss):
@ -1504,3 +1520,87 @@ class Trade(_DECL_BASE, LocalTrade):
Order.status == 'closed' Order.status == 'closed'
).scalar() ).scalar()
return trading_volume return trading_volume
@staticmethod
def from_json(json_str: str) -> 'Trade':
"""
Create a Trade instance from a json string.
Used for debugging purposes - please keep.
:param json_str: json string to parse
:return: Trade instance
"""
import rapidjson
data = rapidjson.loads(json_str)
trade = Trade(
id=data["trade_id"],
pair=data["pair"],
base_currency=data["base_currency"],
stake_currency=data["quote_currency"],
is_open=data["is_open"],
exchange=data["exchange"],
amount=data["amount"],
amount_requested=data["amount_requested"],
stake_amount=data["stake_amount"],
strategy=data["strategy"],
enter_tag=data["enter_tag"],
timeframe=data["timeframe"],
fee_open=data["fee_open"],
fee_open_cost=data["fee_open_cost"],
fee_open_currency=data["fee_open_currency"],
fee_close=data["fee_close"],
fee_close_cost=data["fee_close_cost"],
fee_close_currency=data["fee_close_currency"],
open_date=datetime.fromtimestamp(data["open_timestamp"] // 1000, tz=timezone.utc),
open_rate=data["open_rate"],
open_rate_requested=data["open_rate_requested"],
open_trade_value=data["open_trade_value"],
close_date=(datetime.fromtimestamp(data["close_timestamp"] // 1000, tz=timezone.utc)
if data["close_timestamp"] else None),
realized_profit=data["realized_profit"],
close_rate=data["close_rate"],
close_rate_requested=data["close_rate_requested"],
close_profit=data["close_profit"],
close_profit_abs=data["close_profit_abs"],
exit_reason=data["exit_reason"],
exit_order_status=data["exit_order_status"],
stop_loss=data["stop_loss_abs"],
stop_loss_pct=data["stop_loss_ratio"],
stoploss_order_id=data["stoploss_order_id"],
stoploss_last_update=(datetime.fromtimestamp(data["stoploss_last_update"] // 1000,
tz=timezone.utc) if data["stoploss_last_update"] else None),
initial_stop_loss=data["initial_stop_loss_abs"],
initial_stop_loss_pct=data["initial_stop_loss_ratio"],
min_rate=data["min_rate"],
max_rate=data["max_rate"],
leverage=data["leverage"],
interest_rate=data["interest_rate"],
liquidation_price=data["liquidation_price"],
is_short=data["is_short"],
trading_mode=data["trading_mode"],
funding_fees=data["funding_fees"],
open_order_id=data["open_order_id"],
)
for order in data["orders"]:
order_obj = Order(
amount=order["amount"],
ft_order_side=order["ft_order_side"],
ft_pair=order["pair"],
ft_is_open=order["is_open"],
order_id=order["order_id"],
status=order["status"],
average=order["average"],
cost=order["cost"],
filled=order["filled"],
order_date=datetime.strptime(order["order_date"], DATETIME_PRINT_FORMAT),
order_filled_date=(datetime.fromtimestamp(
order["order_filled_timestamp"] // 1000, tz=timezone.utc)
if order["order_filled_timestamp"] else None),
order_type=order["order_type"],
price=order["price"],
remaining=order["remaining"],
)
trade.orders.append(order_obj)
return trade

View File

@ -10,6 +10,7 @@ from pandas import DataFrame
from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.constants import Config, ListPairsWithTimeframes
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange.types import Tickers
from freqtrade.misc import plural from freqtrade.misc import plural
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
from freqtrade.util import PeriodicCache from freqtrade.util import PeriodicCache
@ -67,10 +68,10 @@ class AgeFilter(IPairList):
f"{self._max_days_listed} {plural(self._max_days_listed, 'day')}" f"{self._max_days_listed} {plural(self._max_days_listed, 'day')}"
) if self._max_days_listed else '') ) if self._max_days_listed else '')
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
:param pairlist: pairlist to filter or sort :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 :return: new allowlist
""" """
needed_pairs: ListPairsWithTimeframes = [ needed_pairs: ListPairsWithTimeframes = [

View File

@ -4,11 +4,12 @@ PairList Handler base class
import logging import logging
from abc import ABC, abstractmethod, abstractproperty from abc import ABC, abstractmethod, abstractproperty
from copy import deepcopy from copy import deepcopy
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange import Exchange, market_is_active from freqtrade.exchange import Exchange, market_is_active
from freqtrade.exchange.types import Ticker, Tickers
from freqtrade.mixins import LoggingMixin from freqtrade.mixins import LoggingMixin
@ -61,7 +62,7 @@ class IPairList(LoggingMixin, ABC):
-> Please overwrite in subclasses -> Please overwrite in subclasses
""" """
def _validate_pair(self, pair: str, ticker: Dict[str, Any]) -> bool: def _validate_pair(self, pair: str, ticker: Optional[Ticker]) -> bool:
""" """
Check one pair against Pairlist Handler's specific conditions. Check one pair against Pairlist Handler's specific conditions.
@ -69,12 +70,12 @@ class IPairList(LoggingMixin, ABC):
filter_pairlist() method. filter_pairlist() method.
:param pair: Pair that's currently validated :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 :return: True if the pair can stay, false if it should be removed
""" """
raise NotImplementedError() raise NotImplementedError()
def gen_pairlist(self, tickers: Dict) -> List[str]: def gen_pairlist(self, tickers: Tickers) -> List[str]:
""" """
Generate the pairlist. Generate the pairlist.
@ -85,13 +86,13 @@ class IPairList(LoggingMixin, ABC):
it will raise the exception if a Pairlist Handler is used at the first it will raise the exception if a Pairlist Handler is used at the first
position in the chain. 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 :return: List of pairs
""" """
raise OperationalException("This Pairlist Handler should not be used " raise OperationalException("This Pairlist Handler should not be used "
"at the first position in the list of Pairlist Handlers.") "at the first position in the list of Pairlist Handlers.")
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
Filters and sorts pairlist and returns the whitelist again. Filters and sorts pairlist and returns the whitelist again.
@ -103,14 +104,14 @@ class IPairList(LoggingMixin, ABC):
own filtration. own filtration.
:param pairlist: pairlist to filter or sort :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 :return: new whitelist
""" """
if self._enabled: if self._enabled:
# Copy list since we're modifying this list # Copy list since we're modifying this list
for p in deepcopy(pairlist): for p in deepcopy(pairlist):
# Filter out assets # Filter out assets
if not self._validate_pair(p, tickers[p] if p in tickers else {}): if not self._validate_pair(p, tickers[p] if p in tickers else None):
pairlist.remove(p) pairlist.remove(p)
return pairlist return pairlist

View File

@ -6,6 +6,7 @@ from typing import Any, Dict, List
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange.types import Tickers
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -42,12 +43,12 @@ class OffsetFilter(IPairList):
return f"{self.name} - Taking {self._number_pairs} Pairs, starting from {self._offset}." return f"{self.name} - Taking {self._number_pairs} Pairs, starting from {self._offset}."
return f"{self.name} - Offsetting pairs by {self._offset}." return f"{self.name} - Offsetting pairs by {self._offset}."
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
Filters and sorts pairlist and returns the whitelist again. Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort :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 :return: new whitelist
""" """
if self._offset > len(pairlist): if self._offset > len(pairlist):

View File

@ -7,6 +7,7 @@ from typing import Any, Dict, List
import pandas as pd import pandas as pd
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.exchange.types import Tickers
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -39,12 +40,12 @@ class PerformanceFilter(IPairList):
""" """
return f"{self.name} - Sorting pairs by performance." return f"{self.name} - Sorting pairs by performance."
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
Filters and sorts pairlist and returns the allowlist again. Filters and sorts pairlist and returns the allowlist again.
Called on each bot iteration - please use internal caching if necessary Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort :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 :return: new allowlist
""" """
# Get the trading performance for pairs from database # Get the trading performance for pairs from database

View File

@ -2,10 +2,11 @@
Precision pair list filter Precision pair list filter
""" """
import logging import logging
from typing import Any, Dict from typing import Any, Dict, Optional
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange.types import Ticker
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -44,15 +45,15 @@ class PrecisionFilter(IPairList):
""" """
return f"{self.name} - Filtering untradable pairs." return f"{self.name} - Filtering untradable pairs."
def _validate_pair(self, pair: str, ticker: Dict[str, Any]) -> bool: def _validate_pair(self, pair: str, ticker: Optional[Ticker]) -> bool:
""" """
Check if pair has enough room to add a stoploss to avoid "unsellable" buys of very Check if pair has enough room to add a stoploss to avoid "unsellable" buys of very
low value pairs. low value pairs.
:param pair: Pair that's currently validated :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 :return: True if the pair can stay, false if it should be removed
""" """
if ticker.get('last', None) is None: if not ticker or ticker.get('last', None) is None:
self.log_once(f"Removed {pair} from whitelist, because " self.log_once(f"Removed {pair} from whitelist, because "
"ticker['last'] is empty (Usually no trade in the last 24h).", "ticker['last'] is empty (Usually no trade in the last 24h).",
logger.info) logger.info)

View File

@ -2,10 +2,11 @@
Price pair list filter Price pair list filter
""" """
import logging import logging
from typing import Any, Dict from typing import Any, Dict, Optional
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange.types import Ticker
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -64,14 +65,16 @@ class PriceFilter(IPairList):
return f"{self.name} - No price filters configured." return f"{self.name} - No price filters configured."
def _validate_pair(self, pair: str, ticker: Dict[str, Any]) -> bool: def _validate_pair(self, pair: str, ticker: Optional[Ticker]) -> bool:
""" """
Check if if one price-step (pip) is > than a certain barrier. Check if if one price-step (pip) is > than a certain barrier.
:param pair: Pair that's currently validated :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 :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: if ticker and 'last' in ticker and ticker['last'] is not None and ticker.get('last') != 0:
price: float = ticker['last']
else:
self.log_once(f"Removed {pair} from whitelist, because " self.log_once(f"Removed {pair} from whitelist, because "
"ticker['last'] is empty (Usually no trade in the last 24h).", "ticker['last'] is empty (Usually no trade in the last 24h).",
logger.info) logger.info)
@ -79,8 +82,8 @@ class PriceFilter(IPairList):
# Perform low_price_ratio check. # Perform low_price_ratio check.
if self._low_price_ratio != 0: if self._low_price_ratio != 0:
compare = self._exchange.price_get_one_pip(pair, ticker['last']) compare = self._exchange.price_get_one_pip(pair, price)
changeperc = compare / ticker['last'] changeperc = compare / price
if changeperc > self._low_price_ratio: if changeperc > self._low_price_ratio:
self.log_once(f"Removed {pair} from whitelist, " self.log_once(f"Removed {pair} from whitelist, "
f"because 1 unit is {changeperc:.3%}", logger.info) f"because 1 unit is {changeperc:.3%}", logger.info)
@ -88,7 +91,6 @@ class PriceFilter(IPairList):
# Perform low_amount check # Perform low_amount check
if self._max_value != 0: if self._max_value != 0:
price = ticker['last']
market = self._exchange.markets[pair] market = self._exchange.markets[pair]
limits = market['limits'] limits = market['limits']
if (limits['amount']['min'] is not None): if (limits['amount']['min'] is not None):
@ -113,14 +115,14 @@ class PriceFilter(IPairList):
# Perform min_price check. # Perform min_price check.
if self._min_price != 0: if self._min_price != 0:
if ticker['last'] < self._min_price: if price < self._min_price:
self.log_once(f"Removed {pair} from whitelist, " self.log_once(f"Removed {pair} from whitelist, "
f"because last price < {self._min_price:.8f}", logger.info) f"because last price < {self._min_price:.8f}", logger.info)
return False return False
# Perform max_price check. # Perform max_price check.
if self._max_price != 0: if self._max_price != 0:
if ticker['last'] > self._max_price: if price > self._max_price:
self.log_once(f"Removed {pair} from whitelist, " self.log_once(f"Removed {pair} from whitelist, "
f"because last price > {self._max_price:.8f}", logger.info) f"because last price > {self._max_price:.8f}", logger.info)
return False return False

View File

@ -7,6 +7,7 @@ import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange.types import Tickers
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -68,10 +69,10 @@ class ProducerPairList(IPairList):
return pairs return pairs
def gen_pairlist(self, tickers: Dict) -> List[str]: def gen_pairlist(self, tickers: Tickers) -> List[str]:
""" """
Generate the pairlist 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 :return: List of pairs
""" """
pairs = self._filter_pairlist(None) pairs = self._filter_pairlist(None)
@ -79,12 +80,12 @@ class ProducerPairList(IPairList):
pairs = self._whitelist_for_active_markets(self.verify_whitelist(pairs, logger.info)) pairs = self._whitelist_for_active_markets(self.verify_whitelist(pairs, logger.info))
return pairs return pairs
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
Filters and sorts pairlist and returns the whitelist again. Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort :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 :return: new whitelist
""" """
return self._filter_pairlist(pairlist) return self._filter_pairlist(pairlist)

View File

@ -7,6 +7,7 @@ from typing import Any, Dict, List
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.enums import RunMode from freqtrade.enums import RunMode
from freqtrade.exchange.types import Tickers
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -47,12 +48,12 @@ class ShuffleFilter(IPairList):
return (f"{self.name} - Shuffling pairs" + return (f"{self.name} - Shuffling pairs" +
(f", seed = {self._seed}." if self._seed is not None else ".")) (f", seed = {self._seed}." if self._seed is not None else "."))
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
Filters and sorts pairlist and returns the whitelist again. Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort :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 :return: new whitelist
""" """
# Shuffle is done inplace # Shuffle is done inplace

View File

@ -2,10 +2,10 @@
Spread pair list filter Spread pair list filter
""" """
import logging import logging
from typing import Any, Dict from typing import Any, Dict, Optional
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Ticker
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -22,12 +22,6 @@ class SpreadFilter(IPairList):
self._max_spread_ratio = pairlistconfig.get('max_spread_ratio', 0.005) self._max_spread_ratio = pairlistconfig.get('max_spread_ratio', 0.005)
self._enabled = self._max_spread_ratio != 0 self._enabled = self._max_spread_ratio != 0
if not self._exchange.exchange_has('fetchTickers'):
raise OperationalException(
'Exchange does not support fetchTickers, therefore SpreadFilter cannot be used.'
'Please edit your config and restart the bot.'
)
@property @property
def needstickers(self) -> bool: def needstickers(self) -> bool:
""" """
@ -44,14 +38,14 @@ class SpreadFilter(IPairList):
return (f"{self.name} - Filtering pairs with ask/bid diff above " return (f"{self.name} - Filtering pairs with ask/bid diff above "
f"{self._max_spread_ratio:.2%}.") f"{self._max_spread_ratio:.2%}.")
def _validate_pair(self, pair: str, ticker: Dict[str, Any]) -> bool: def _validate_pair(self, pair: str, ticker: Optional[Ticker]) -> bool:
""" """
Validate spread for the ticker Validate spread for the ticker
:param pair: Pair that's currently validated :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 :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']: if ticker and 'bid' in ticker and 'ask' in ticker and ticker['ask'] and ticker['bid']:
spread = 1 - ticker['bid'] / ticker['ask'] spread = 1 - ticker['bid'] / ticker['ask']
if spread > self._max_spread_ratio: if spread > self._max_spread_ratio:
self.log_once(f"Removed {pair} from whitelist, because spread " self.log_once(f"Removed {pair} from whitelist, because spread "

View File

@ -8,6 +8,7 @@ from copy import deepcopy
from typing import Any, Dict, List from typing import Any, Dict, List
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.exchange.types import Tickers
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -39,10 +40,10 @@ class StaticPairList(IPairList):
""" """
return f"{self.name}" return f"{self.name}"
def gen_pairlist(self, tickers: Dict) -> List[str]: def gen_pairlist(self, tickers: Tickers) -> List[str]:
""" """
Generate the pairlist 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 :return: List of pairs
""" """
if self._allow_inactive: if self._allow_inactive:
@ -53,12 +54,12 @@ class StaticPairList(IPairList):
return self._whitelist_for_active_markets( return self._whitelist_for_active_markets(
self.verify_whitelist(self._config['exchange']['pair_whitelist'], logger.info)) self.verify_whitelist(self._config['exchange']['pair_whitelist'], logger.info))
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
Filters and sorts pairlist and returns the whitelist again. Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort :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 :return: new whitelist
""" """
pairlist_ = deepcopy(pairlist) pairlist_ = deepcopy(pairlist)

View File

@ -13,6 +13,7 @@ from pandas import DataFrame
from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.constants import Config, ListPairsWithTimeframes
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange.types import Tickers
from freqtrade.misc import plural from freqtrade.misc import plural
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -62,11 +63,11 @@ class VolatilityFilter(IPairList):
f"{self._min_volatility}-{self._max_volatility} " f"{self._min_volatility}-{self._max_volatility} "
f" the last {self._days} {plural(self._days, 'day')}.") f" the last {self._days} {plural(self._days, 'day')}.")
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
Validate trading range Validate trading range
:param pairlist: pairlist to filter or sort :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 :return: new allowlist
""" """
needed_pairs: ListPairsWithTimeframes = [ needed_pairs: ListPairsWithTimeframes = [

View File

@ -5,13 +5,14 @@ Provides dynamic pair list based on trade volumes
""" """
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List from typing import Any, Dict, List, Literal
from cachetools import TTLCache from cachetools import TTLCache
from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.constants import Config, ListPairsWithTimeframes
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
from freqtrade.exchange.types import Tickers
from freqtrade.misc import format_ms_time from freqtrade.misc import format_ms_time
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -36,7 +37,7 @@ class VolumePairList(IPairList):
self._stake_currency = config['stake_currency'] self._stake_currency = config['stake_currency']
self._number_pairs = self._pairlistconfig['number_assets'] self._number_pairs = self._pairlistconfig['number_assets']
self._sort_key = self._pairlistconfig.get('sort_key', 'quoteVolume') self._sort_key: Literal['quoteVolume'] = self._pairlistconfig.get('sort_key', 'quoteVolume')
self._min_value = self._pairlistconfig.get('min_value', 0) self._min_value = self._pairlistconfig.get('min_value', 0)
self._refresh_period = self._pairlistconfig.get('refresh_period', 1800) self._refresh_period = self._pairlistconfig.get('refresh_period', 1800)
self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period) self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
@ -110,10 +111,10 @@ class VolumePairList(IPairList):
""" """
return f"{self.name} - top {self._pairlistconfig['number_assets']} volume pairs." return f"{self.name} - top {self._pairlistconfig['number_assets']} volume pairs."
def gen_pairlist(self, tickers: Dict) -> List[str]: def gen_pairlist(self, tickers: Tickers) -> List[str]:
""" """
Generate the pairlist 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 :return: List of pairs
""" """
# Generate dynamic whitelist # Generate dynamic whitelist
@ -150,7 +151,7 @@ class VolumePairList(IPairList):
Filters and sorts pairlist and returns the whitelist again. Filters and sorts pairlist and returns the whitelist again.
Called on each bot iteration - please use internal caching if necessary Called on each bot iteration - please use internal caching if necessary
:param pairlist: pairlist to filter or sort :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 :return: new whitelist
""" """
if self._use_range: if self._use_range:

View File

@ -12,7 +12,7 @@ def expand_pairlist(wildcardpl: List[str], available_pairs: List[str],
:param wildcardpl: List of Pairlists, which may contain regex :param wildcardpl: List of Pairlists, which may contain regex
:param available_pairs: List of all available pairs (`exchange.get_markets().keys()`) :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 :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`) :raises: ValueError if a wildcard is invalid (like '*/BTC' - which should be `.*/BTC`)
""" """
result = [] result = []

View File

@ -11,6 +11,7 @@ from pandas import DataFrame
from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.constants import Config, ListPairsWithTimeframes
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange.types import Tickers
from freqtrade.misc import plural from freqtrade.misc import plural
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -60,11 +61,11 @@ class RangeStabilityFilter(IPairList):
f"{self._min_rate_of_change}{max_rate_desc} over the " f"{self._min_rate_of_change}{max_rate_desc} over the "
f"last {plural(self._days, 'day')}.") f"last {plural(self._days, 'day')}.")
def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
""" """
Validate trading range Validate trading range
:param pairlist: pairlist to filter or sort :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 :return: new allowlist
""" """
needed_pairs: ListPairsWithTimeframes = [ needed_pairs: ListPairsWithTimeframes = [

View File

@ -11,6 +11,7 @@ from freqtrade.constants import Config, ListPairsWithTimeframes
from freqtrade.data.dataprovider import DataProvider from freqtrade.data.dataprovider import DataProvider
from freqtrade.enums import CandleType from freqtrade.enums import CandleType
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange.types import Tickers
from freqtrade.mixins import LoggingMixin from freqtrade.mixins import LoggingMixin
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
@ -45,6 +46,15 @@ class PairListManager(LoggingMixin):
if not self._pairlist_handlers: if not self._pairlist_handlers:
raise OperationalException("No Pairlist Handlers defined") raise OperationalException("No Pairlist Handlers defined")
if self._tickers_needed and not self._exchange.exchange_has('fetchTickers'):
invalid = ". ".join([p.name for p in self._pairlist_handlers if p.needstickers])
raise OperationalException(
"Exchange does not support fetchTickers, therefore the following pairlists "
"cannot be used. Please edit your config and restart the bot.\n"
f"{invalid}."
)
refresh_period = config.get('pairlist_refresh_period', 3600) refresh_period = config.get('pairlist_refresh_period', 3600)
LoggingMixin.__init__(self, logger, refresh_period) LoggingMixin.__init__(self, logger, refresh_period)
@ -76,7 +86,7 @@ class PairListManager(LoggingMixin):
return [{p.name: p.short_desc()} for p in self._pairlist_handlers] return [{p.name: p.short_desc()} for p in self._pairlist_handlers]
@cached(TTLCache(maxsize=1, ttl=1800)) @cached(TTLCache(maxsize=1, ttl=1800))
def _get_cached_tickers(self): def _get_cached_tickers(self) -> Tickers:
return self._exchange.get_tickers() return self._exchange.get_tickers()
def refresh_pairlist(self) -> None: def refresh_pairlist(self) -> None:

View File

@ -26,6 +26,7 @@ class FreqaiModelResolver(IResolver):
initial_search_path = ( initial_search_path = (
Path(__file__).parent.parent.joinpath("freqai/prediction_models").resolve() Path(__file__).parent.parent.joinpath("freqai/prediction_models").resolve()
) )
extra_path = "freqaimodel_path"
@staticmethod @staticmethod
def load_freqaimodel(config: Config) -> IFreqaiModel: def load_freqaimodel(config: Config) -> IFreqaiModel:
@ -50,7 +51,6 @@ class FreqaiModelResolver(IResolver):
freqaimodel_name, freqaimodel_name,
config, config,
kwargs={"config": config}, kwargs={"config": config},
extra_dir=config.get("freqaimodel_path"),
) )
return freqaimodel return freqaimodel

View File

@ -42,6 +42,8 @@ class IResolver:
object_type_str: str object_type_str: str
user_subdir: Optional[str] = None user_subdir: Optional[str] = None
initial_search_path: Optional[Path] initial_search_path: Optional[Path]
# Optional config setting containing a path (strategy_path, freqaimodel_path)
extra_path: Optional[str] = None
@classmethod @classmethod
def build_search_paths(cls, config: Config, user_subdir: Optional[str] = None, def build_search_paths(cls, config: Config, user_subdir: Optional[str] = None,
@ -58,6 +60,9 @@ class IResolver:
for dir in extra_dirs: for dir in extra_dirs:
abs_paths.insert(0, Path(dir).resolve()) abs_paths.insert(0, Path(dir).resolve())
if cls.extra_path and (extra := config.get(cls.extra_path)):
abs_paths.insert(0, Path(extra).resolve())
return abs_paths return abs_paths
@classmethod @classmethod
@ -183,9 +188,35 @@ class IResolver:
) )
@classmethod @classmethod
def search_all_objects(cls, directory: Path, enum_failed: bool, def search_all_objects(cls, config: Config, enum_failed: bool,
recursive: bool = False) -> List[Dict[str, Any]]: recursive: bool = False) -> List[Dict[str, Any]]:
""" """
Searches for valid objects
:param config: Config object
:param enum_failed: If True, will return None for modules which fail.
Otherwise, failing modules are skipped.
:param recursive: Recursively walk directory tree searching for strategies
:return: List of dicts containing 'name', 'class' and 'location' entries
"""
result = []
abs_paths = cls.build_search_paths(config, user_subdir=cls.user_subdir)
for path in abs_paths:
result.extend(cls._search_all_objects(path, enum_failed, recursive))
return result
@classmethod
def _build_rel_location(cls, directory: Path, entry: Path) -> str:
builtin = cls.initial_search_path == directory
return f"<builtin>/{entry.relative_to(directory)}" if builtin else str(
entry.relative_to(directory))
@classmethod
def _search_all_objects(
cls, directory: Path, enum_failed: bool, recursive: bool = False,
basedir: Optional[Path] = None) -> List[Dict[str, Any]]:
"""
Searches a directory for valid objects Searches a directory for valid objects
:param directory: Path to search :param directory: Path to search
:param enum_failed: If True, will return None for modules which fail. :param enum_failed: If True, will return None for modules which fail.
@ -204,7 +235,8 @@ class IResolver:
and not entry.name.startswith('__') and not entry.name.startswith('__')
and not entry.name.startswith('.') and not entry.name.startswith('.')
): ):
objects.extend(cls.search_all_objects(entry, enum_failed, recursive=recursive)) objects.extend(cls._search_all_objects(
entry, enum_failed, recursive, basedir or directory))
# Only consider python files # Only consider python files
if entry.suffix != '.py': if entry.suffix != '.py':
logger.debug('Ignoring %s', entry) logger.debug('Ignoring %s', entry)
@ -217,5 +249,6 @@ class IResolver:
{'name': obj[0].__name__ if obj is not None else '', {'name': obj[0].__name__ if obj is not None else '',
'class': obj[0] if obj is not None else None, 'class': obj[0] if obj is not None else None,
'location': entry, 'location': entry,
'location_rel': cls._build_rel_location(basedir or directory, entry),
}) })
return objects return objects

View File

@ -30,6 +30,7 @@ class StrategyResolver(IResolver):
object_type_str = "Strategy" object_type_str = "Strategy"
user_subdir = USERPATH_STRATEGIES user_subdir = USERPATH_STRATEGIES
initial_search_path = None initial_search_path = None
extra_path = "strategy_path"
@staticmethod @staticmethod
def load_strategy(config: Config = None) -> IStrategy: def load_strategy(config: Config = None) -> IStrategy:

View File

@ -89,6 +89,7 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
lastconfig['enable_protections'] = btconfig.get('enable_protections') lastconfig['enable_protections'] = btconfig.get('enable_protections')
lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet') lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet')
ApiServer._bt.enable_protections = btconfig.get('enable_protections', False)
ApiServer._bt.strategylist = [strat] ApiServer._bt.strategylist = [strat]
ApiServer._bt.results = {} ApiServer._bt.results = {}
ApiServer._bt.load_prior_backtest() ApiServer._bt.load_prior_backtest()

View File

@ -1,13 +1,11 @@
import logging import logging
from copy import deepcopy from copy import deepcopy
from pathlib import Path
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from freqtrade import __version__ from freqtrade import __version__
from freqtrade.constants import USERPATH_STRATEGIES
from freqtrade.data.history import get_datahandler from freqtrade.data.history import get_datahandler
from freqtrade.enums import CandleType, TradingMode from freqtrade.enums import CandleType, TradingMode
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
@ -253,11 +251,9 @@ def plot_config(rpc: RPC = Depends(get_rpc)):
@router.get('/strategies', response_model=StrategyListResponse, tags=['strategy']) @router.get('/strategies', response_model=StrategyListResponse, tags=['strategy'])
def list_strategies(config=Depends(get_config)): def list_strategies(config=Depends(get_config)):
directory = Path(config.get(
'strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES))
from freqtrade.resolvers.strategy_resolver import StrategyResolver from freqtrade.resolvers.strategy_resolver import StrategyResolver
strategies = StrategyResolver.search_all_objects( strategies = StrategyResolver.search_all_objects(
directory, False, config.get('recursive_strategy_search', False)) config, False, config.get('recursive_strategy_search', False))
strategies = sorted(strategies, key=lambda x: x['name']) strategies = sorted(strategies, key=lambda x: x['name'])
return {'strategies': [x['name'] for x in strategies]} return {'strategies': [x['name'] for x in strategies]}

View File

@ -4,11 +4,13 @@ from typing import Any, Dict
from fastapi import APIRouter, Depends, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocketDisconnect
from fastapi.websockets import WebSocket, WebSocketState from fastapi.websockets import WebSocket, WebSocketState
from pydantic import ValidationError from pydantic import ValidationError
from websockets.exceptions import WebSocketException
from freqtrade.enums import RPCMessageType, RPCRequestType from freqtrade.enums import RPCMessageType, RPCRequestType
from freqtrade.rpc.api_server.api_auth import validate_ws_token from freqtrade.rpc.api_server.api_auth import validate_ws_token
from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc
from freqtrade.rpc.api_server.ws import WebSocketChannel from freqtrade.rpc.api_server.ws import WebSocketChannel
from freqtrade.rpc.api_server.ws.channel import ChannelManager
from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSMessageSchema, from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSMessageSchema,
WSRequestSchema, WSWhitelistMessage) WSRequestSchema, WSWhitelistMessage)
from freqtrade.rpc.rpc import RPC from freqtrade.rpc.rpc import RPC
@ -35,7 +37,8 @@ async def is_websocket_alive(ws: WebSocket) -> bool:
async def _process_consumer_request( async def _process_consumer_request(
request: Dict[str, Any], request: Dict[str, Any],
channel: WebSocketChannel, channel: WebSocketChannel,
rpc: RPC rpc: RPC,
channel_manager: ChannelManager
): ):
""" """
Validate and handle a request from a websocket consumer Validate and handle a request from a websocket consumer
@ -72,7 +75,7 @@ async def _process_consumer_request(
# Format response # Format response
response = WSWhitelistMessage(data=whitelist) response = WSWhitelistMessage(data=whitelist)
# Send it back # Send it back
await channel.send(response.dict(exclude_none=True)) await channel_manager.send_direct(channel, response.dict(exclude_none=True))
elif type == RPCRequestType.ANALYZED_DF: elif type == RPCRequestType.ANALYZED_DF:
limit = None limit = None
@ -87,7 +90,7 @@ async def _process_consumer_request(
# For every dataframe, send as a separate message # For every dataframe, send as a separate message
for _, message in analyzed_df.items(): for _, message in analyzed_df.items():
response = WSAnalyzedDFMessage(data=message) response = WSAnalyzedDFMessage(data=message)
await channel.send(response.dict(exclude_none=True)) await channel_manager.send_direct(channel, response.dict(exclude_none=True))
@router.websocket("/message/ws") @router.websocket("/message/ws")
@ -102,7 +105,6 @@ async def message_endpoint(
""" """
try: try:
channel = await channel_manager.on_connect(ws) channel = await channel_manager.on_connect(ws)
if await is_websocket_alive(ws): if await is_websocket_alive(ws):
logger.info(f"Consumer connected - {channel}") logger.info(f"Consumer connected - {channel}")
@ -113,28 +115,33 @@ async def message_endpoint(
request = await channel.recv() request = await channel.recv()
# Process the request here # Process the request here
await _process_consumer_request(request, channel, rpc) await _process_consumer_request(request, channel, rpc, channel_manager)
except WebSocketDisconnect: except (WebSocketDisconnect, WebSocketException):
# Handle client disconnects # Handle client disconnects
logger.info(f"Consumer disconnected - {channel}") logger.info(f"Consumer disconnected - {channel}")
await channel_manager.on_disconnect(ws) except RuntimeError:
except Exception as e:
logger.info(f"Consumer connection failed - {channel}")
logger.exception(e)
# Handle cases like - # Handle cases like -
# RuntimeError('Cannot call "send" once a closed message has been sent') # RuntimeError('Cannot call "send" once a closed message has been sent')
pass
except Exception as e:
logger.info(f"Consumer connection failed - {channel}: {e}")
logger.debug(e, exc_info=e)
finally:
await channel_manager.on_disconnect(ws) await channel_manager.on_disconnect(ws)
else: else:
if channel:
await channel_manager.on_disconnect(ws)
await ws.close() await ws.close()
except RuntimeError: except RuntimeError:
# WebSocket was closed # WebSocket was closed
await channel_manager.on_disconnect(ws) # Do nothing
pass
except Exception as e: except Exception as e:
logger.error(f"Failed to serve - {ws.client}") logger.error(f"Failed to serve - {ws.client}")
# Log tracebacks to keep track of what errors are happening # Log tracebacks to keep track of what errors are happening
logger.exception(e) logger.exception(e)
finally:
await channel_manager.on_disconnect(ws) await channel_manager.on_disconnect(ws)

View File

@ -16,6 +16,7 @@ from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer
from freqtrade.rpc.api_server.ws import ChannelManager from freqtrade.rpc.api_server.ws import ChannelManager
from freqtrade.rpc.api_server.ws_schemas import WSMessageSchemaType
from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler
@ -127,7 +128,7 @@ class ApiServer(RPCHandler):
cls._has_rpc = False cls._has_rpc = False
cls._rpc = None cls._rpc = None
def send_msg(self, msg: Dict[str, str]) -> None: def send_msg(self, msg: Dict[str, Any]) -> None:
if self._ws_queue: if self._ws_queue:
sync_q = self._ws_queue.sync_q sync_q = self._ws_queue.sync_q
sync_q.put(msg) sync_q.put(msg)
@ -194,14 +195,10 @@ class ApiServer(RPCHandler):
while True: while True:
logger.debug("Getting queue messages...") logger.debug("Getting queue messages...")
# Get data from queue # Get data from queue
message = await async_queue.get() message: WSMessageSchemaType = await async_queue.get()
logger.debug(f"Found message of type: {message.get('type')}") logger.debug(f"Found message of type: {message.get('type')}")
# Broadcast it # Broadcast it
await self._ws_channel_manager.broadcast(message) await self._ws_channel_manager.broadcast(message)
# 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: except asyncio.CancelledError:
pass pass
@ -245,6 +242,7 @@ class ApiServer(RPCHandler):
use_colors=False, use_colors=False,
log_config=None, log_config=None,
access_log=True if verbosity != 'error' else False, access_log=True if verbosity != 'error' else False,
ws_ping_interval=None # We do this explicitly ourselves
) )
try: try:
self._server = UvicornServer(uvconfig) self._server = UvicornServer(uvconfig)

View File

@ -1,6 +1,7 @@
import asyncio
import logging import logging
from threading import RLock from threading import RLock
from typing import List, Optional, Type from typing import Any, Dict, List, Optional, Type, Union
from uuid import uuid4 from uuid import uuid4
from fastapi import WebSocket as FastAPIWebSocket from fastapi import WebSocket as FastAPIWebSocket
@ -9,6 +10,7 @@ from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy
from freqtrade.rpc.api_server.ws.serializer import (HybridJSONWebSocketSerializer, from freqtrade.rpc.api_server.ws.serializer import (HybridJSONWebSocketSerializer,
WebSocketSerializer) WebSocketSerializer)
from freqtrade.rpc.api_server.ws.types import WebSocketType from freqtrade.rpc.api_server.ws.types import WebSocketType
from freqtrade.rpc.api_server.ws_schemas import WSMessageSchemaType
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,6 +25,8 @@ class WebSocketChannel:
self, self,
websocket: WebSocketType, websocket: WebSocketType,
channel_id: Optional[str] = None, channel_id: Optional[str] = None,
drain_timeout: int = 3,
throttle: float = 0.01,
serializer_cls: Type[WebSocketSerializer] = HybridJSONWebSocketSerializer serializer_cls: Type[WebSocketSerializer] = HybridJSONWebSocketSerializer
): ):
@ -33,7 +37,13 @@ class WebSocketChannel:
# The Serializing class for the WebSocket object # The Serializing class for the WebSocket object
self._serializer_cls = serializer_cls self._serializer_cls = serializer_cls
self.drain_timeout = drain_timeout
self.throttle = throttle
self._subscriptions: List[str] = [] self._subscriptions: List[str] = []
# 32 is the size of the receiving queue in websockets package
self.queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=32)
self._relay_task = asyncio.create_task(self.relay())
# Internal event to signify a closed websocket # Internal event to signify a closed websocket
self._closed = False self._closed = False
@ -44,16 +54,34 @@ class WebSocketChannel:
def __repr__(self): def __repr__(self):
return f"WebSocketChannel({self.channel_id}, {self.remote_addr})" return f"WebSocketChannel({self.channel_id}, {self.remote_addr})"
@property
def raw_websocket(self):
return self._websocket.raw_websocket
@property @property
def remote_addr(self): def remote_addr(self):
return self._websocket.remote_addr return self._websocket.remote_addr
async def send(self, data): async def _send(self, data):
""" """
Send data on the wrapped websocket Send data on the wrapped websocket
""" """
await self._wrapped_ws.send(data) await self._wrapped_ws.send(data)
async def send(self, data) -> bool:
"""
Add the data to the queue to be sent.
:returns: True if data added to queue, False otherwise
"""
try:
await asyncio.wait_for(
self.queue.put(data),
timeout=self.drain_timeout
)
return True
except asyncio.TimeoutError:
return False
async def recv(self): async def recv(self):
""" """
Receive data on the wrapped websocket Receive data on the wrapped websocket
@ -72,6 +100,7 @@ class WebSocketChannel:
""" """
self._closed = True self._closed = True
self._relay_task.cancel()
def is_closed(self) -> bool: def is_closed(self) -> bool:
""" """
@ -95,6 +124,26 @@ class WebSocketChannel:
""" """
return message_type in self._subscriptions return message_type in self._subscriptions
async def relay(self):
"""
Relay messages from the channel's queue and send them out. This is started
as a task.
"""
while True:
message = await self.queue.get()
try:
await self._send(message)
self.queue.task_done()
# Limit messages per sec.
# Could cause problems with queue size if too low, and
# problems with network traffik if too high.
# 0.01 = 100/s
await asyncio.sleep(self.throttle)
except RuntimeError:
# The connection was closed, just exit the task
return
class ChannelManager: class ChannelManager:
def __init__(self): def __init__(self):
@ -130,6 +179,7 @@ class ChannelManager:
with self._lock: with self._lock:
channel = self.channels.get(websocket) channel = self.channels.get(websocket)
if channel: if channel:
logger.info(f"Disconnecting channel {channel}")
if not channel.is_closed(): if not channel.is_closed():
await channel.close() await channel.close()
@ -140,36 +190,30 @@ class ChannelManager:
Disconnect all Channels Disconnect all Channels
""" """
with self._lock: with self._lock:
for websocket, channel in self.channels.copy().items(): for websocket in self.channels.copy().keys():
if not channel.is_closed(): await self.on_disconnect(websocket)
await channel.close()
self.channels = dict() async def broadcast(self, message: WSMessageSchemaType):
async def broadcast(self, data):
""" """
Broadcast data on all Channels Broadcast a message on all Channels
:param data: The data to send :param message: The message to send
""" """
with self._lock: with self._lock:
message_type = data.get('type') for channel in self.channels.copy().values():
for websocket, channel in self.channels.copy().items(): if channel.subscribed_to(message.get('type')):
try: await self.send_direct(channel, message)
if channel.subscribed_to(message_type):
await channel.send(data)
except RuntimeError:
# Handle cannot send after close cases
await self.on_disconnect(websocket)
async def send_direct(self, channel, data): async def send_direct(
self, channel: WebSocketChannel, message: Union[WSMessageSchemaType, Dict[str, Any]]):
""" """
Send data directly through direct_channel only Send a message directly through direct_channel only
:param direct_channel: The WebSocketChannel object to send data through :param direct_channel: The WebSocketChannel object to send the message through
:param data: The data to send :param message: The message to send
""" """
await channel.send(data) if not await channel.send(message):
await self.on_disconnect(channel.raw_websocket)
def has_channels(self): def has_channels(self):
""" """

View File

@ -15,6 +15,10 @@ class WebSocketProxy:
def __init__(self, websocket: WebSocketType): def __init__(self, websocket: WebSocketType):
self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket
@property
def raw_websocket(self):
return self._websocket
@property @property
def remote_addr(self) -> Tuple[Any, ...]: def remote_addr(self) -> Tuple[Any, ...]:
if isinstance(self._websocket, WebSocket): if isinstance(self._websocket, WebSocket):

View File

@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, TypedDict
from pandas import DataFrame from pandas import DataFrame
from pydantic import BaseModel from pydantic import BaseModel
@ -18,6 +18,12 @@ class WSRequestSchema(BaseArbitraryModel):
data: Optional[Any] = None data: Optional[Any] = None
class WSMessageSchemaType(TypedDict):
# Type for typing to avoid doing pydantic typechecks.
type: RPCMessageType
data: Optional[Dict[str, Any]]
class WSMessageSchema(BaseArbitraryModel): class WSMessageSchema(BaseArbitraryModel):
type: RPCMessageType type: RPCMessageType
data: Optional[Any] = None data: Optional[Any] = None

View File

@ -11,13 +11,12 @@ logger = logging.getLogger(__name__)
class Discord(Webhook): class Discord(Webhook):
def __init__(self, rpc: 'RPC', config: Config): def __init__(self, rpc: 'RPC', config: Config):
# super().__init__(rpc, config) self._config = config
self.rpc = rpc self.rpc = rpc
self.config = config
self.strategy = config.get('strategy', '') self.strategy = config.get('strategy', '')
self.timeframe = config.get('timeframe', '') self.timeframe = config.get('timeframe', '')
self._url = self.config['discord']['webhook_url'] self._url = config['discord']['webhook_url']
self._format = 'json' self._format = 'json'
self._retries = 1 self._retries = 1
self._retry_delay = 0.1 self._retry_delay = 0.1
@ -31,19 +30,21 @@ class Discord(Webhook):
def send_msg(self, msg) -> None: def send_msg(self, msg) -> None:
if msg['type'].value in self.config['discord']: if msg['type'].value in self._config['discord']:
logger.info(f"Sending discord message: {msg}") logger.info(f"Sending discord message: {msg}")
msg['strategy'] = self.strategy msg['strategy'] = self.strategy
msg['timeframe'] = self.timeframe msg['timeframe'] = self.timeframe
fields = self.config['discord'].get(msg['type'].value) fields = self._config['discord'].get(msg['type'].value)
color = 0x0000FF color = 0x0000FF
if msg['type'] in (RPCMessageType.EXIT, RPCMessageType.EXIT_FILL): if msg['type'] in (RPCMessageType.EXIT, RPCMessageType.EXIT_FILL):
profit_ratio = msg.get('profit_ratio') profit_ratio = msg.get('profit_ratio')
color = (0x00FF00 if profit_ratio > 0 else 0xFF0000) color = (0x00FF00 if profit_ratio > 0 else 0xFF0000)
title = msg['type'].value
if 'pair' in msg:
title = f"Trade: {msg['pair']} {msg['type'].value}"
embeds = [{ embeds = [{
'title': f"Trade: {msg['pair']} {msg['type'].value}", 'title': title,
'color': color, 'color': color,
'fields': [], 'fields': [],
@ -51,7 +52,7 @@ class Discord(Webhook):
for f in fields: for f in fields:
for k, v in f.items(): for k, v in f.items():
v = v.format(**msg) v = v.format(**msg)
embeds[0]['fields'].append( # type: ignore embeds[0]['fields'].append(
{'name': k, 'value': v, 'inline': True}) {'name': k, 'value': v, 'inline': True})
# Send the message to discord channel # Send the message to discord channel

View File

@ -62,7 +62,7 @@ class ExternalMessageConsumer:
self.enabled = self._emc_config.get('enabled', False) self.enabled = self._emc_config.get('enabled', False)
self.producers: List[Producer] = self._emc_config.get('producers', []) self.producers: List[Producer] = self._emc_config.get('producers', [])
self.wait_timeout = self._emc_config.get('wait_timeout', 300) # in seconds self.wait_timeout = self._emc_config.get('wait_timeout', 30) # in seconds
self.ping_timeout = self._emc_config.get('ping_timeout', 10) # in seconds self.ping_timeout = self._emc_config.get('ping_timeout', 10) # in seconds
self.sleep_time = self._emc_config.get('sleep_time', 10) # in seconds self.sleep_time = self._emc_config.get('sleep_time', 10) # in seconds
@ -174,6 +174,7 @@ class ExternalMessageConsumer:
:param producer: Dictionary containing producer info :param producer: Dictionary containing producer info
:param lock: An asyncio Lock :param lock: An asyncio Lock
""" """
channel = None
while self._running: while self._running:
try: try:
host, port = producer['host'], producer['port'] host, port = producer['host'], producer['port']
@ -182,7 +183,11 @@ class ExternalMessageConsumer:
ws_url = f"ws://{host}:{port}/api/v1/message/ws?token={token}" ws_url = f"ws://{host}:{port}/api/v1/message/ws?token={token}"
# This will raise InvalidURI if the url is bad # This will raise InvalidURI if the url is bad
async with websockets.connect(ws_url, max_size=self.message_size_limit) as ws: async with websockets.connect(
ws_url,
max_size=self.message_size_limit,
ping_interval=None
) as ws:
channel = WebSocketChannel(ws, channel_id=name) channel = WebSocketChannel(ws, channel_id=name)
logger.info(f"Producer connection success - {channel}") logger.info(f"Producer connection success - {channel}")
@ -224,6 +229,10 @@ class ExternalMessageConsumer:
logger.exception(e) logger.exception(e)
continue continue
finally:
if channel:
await channel.close()
async def _receive_messages( async def _receive_messages(
self, self,
channel: WebSocketChannel, channel: WebSocketChannel,
@ -261,6 +270,11 @@ class ExternalMessageConsumer:
logger.debug(f"Connection to {channel} still alive...") logger.debug(f"Connection to {channel} still alive...")
continue continue
except (websockets.exceptions.ConnectionClosed):
# Just eat the error and continue reconnecting
logger.warning(f"Disconnection in {channel} - retrying in {self.sleep_time}s")
await asyncio.sleep(self.sleep_time)
break
except Exception as e: except Exception as e:
logger.warning(f"Ping error {channel} - retrying in {self.sleep_time}s") logger.warning(f"Ping error {channel} - retrying in {self.sleep_time}s")
logger.debug(e, exc_info=e) logger.debug(e, exc_info=e)

View File

@ -3,8 +3,8 @@ Module that define classes to convert Crypto-currency to FIAT
e.g BTC to USD e.g BTC to USD
""" """
import datetime
import logging import logging
from datetime import datetime
from typing import Dict, List from typing import Dict, List
from cachetools import TTLCache from cachetools import TTLCache
@ -46,7 +46,9 @@ class CryptoToFiatConverter(LoggingMixin):
if CryptoToFiatConverter.__instance is None: if CryptoToFiatConverter.__instance is None:
CryptoToFiatConverter.__instance = object.__new__(cls) CryptoToFiatConverter.__instance = object.__new__(cls)
try: 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: except BaseException:
CryptoToFiatConverter._coingekko = None CryptoToFiatConverter._coingekko = None
return CryptoToFiatConverter.__instance return CryptoToFiatConverter.__instance
@ -67,7 +69,7 @@ class CryptoToFiatConverter(LoggingMixin):
logger.warning( logger.warning(
"Too many requests for CoinGecko API, backing off and trying again later.") "Too many requests for CoinGecko API, backing off and trying again later.")
# Set backoff timestamp to 60 seconds in the future # Set backoff timestamp to 60 seconds in the future
self._backoff = datetime.datetime.now().timestamp() + 60 self._backoff = datetime.now().timestamp() + 60
return return
# If the request is not a 429 error we want to raise the normal error # If the request is not a 429 error we want to raise the normal error
logger.error( logger.error(
@ -81,7 +83,7 @@ class CryptoToFiatConverter(LoggingMixin):
def _get_gekko_id(self, crypto_symbol): def _get_gekko_id(self, crypto_symbol):
if not self._coinlistings: if not self._coinlistings:
if self._backoff <= datetime.datetime.now().timestamp(): if self._backoff <= datetime.now().timestamp():
self._load_cryptomap() self._load_cryptomap()
# Still not loaded. # Still not loaded.
if not self._coinlistings: if not self._coinlistings:

View File

@ -88,10 +88,13 @@ class RPCManager:
""" """
while queue: while queue:
msg = queue.popleft() msg = queue.popleft()
self.send_msg({ logger.info('Sending rpc strategy_msg: %s', msg)
'type': RPCMessageType.STRATEGY_MSG, for mod in self.registered_modules:
'msg': msg, if mod._config.get(mod.name, {}).get('allow_custom_messages', False):
}) mod.send_msg({
'type': RPCMessageType.STRATEGY_MSG,
'msg': msg,
})
def startup_messages(self, config: Config, pairlist, protections) -> None: def startup_messages(self, config: Config, pairlist, protections) -> None:
if config['dry_run']: if config['dry_run']:

View File

@ -3,7 +3,7 @@ This module manages webhook communication
""" """
import logging import logging
import time import time
from typing import Any, Dict from typing import Any, Dict, Optional
from requests import RequestException, post from requests import RequestException, post
@ -41,36 +41,44 @@ class Webhook(RPCHandler):
""" """
pass pass
def _get_value_dict(self, msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
whconfig = self._config['webhook']
# Deprecated 2022.10 - only keep generic method.
if msg['type'] in [RPCMessageType.ENTRY]:
valuedict = whconfig.get('webhookentry')
elif msg['type'] in [RPCMessageType.ENTRY_CANCEL]:
valuedict = whconfig.get('webhookentrycancel')
elif msg['type'] in [RPCMessageType.ENTRY_FILL]:
valuedict = whconfig.get('webhookentryfill')
elif msg['type'] == RPCMessageType.EXIT:
valuedict = whconfig.get('webhookexit')
elif msg['type'] == RPCMessageType.EXIT_FILL:
valuedict = whconfig.get('webhookexitfill')
elif msg['type'] == RPCMessageType.EXIT_CANCEL:
valuedict = whconfig.get('webhookexitcancel')
elif msg['type'] in (RPCMessageType.STATUS,
RPCMessageType.STARTUP,
RPCMessageType.WARNING):
valuedict = whconfig.get('webhookstatus')
elif msg['type'].value in whconfig:
# Allow all types ...
valuedict = whconfig.get(msg['type'].value)
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 None
return valuedict
def send_msg(self, msg: Dict[str, Any]) -> None: def send_msg(self, msg: Dict[str, Any]) -> None:
""" Send a message to telegram channel """ """ Send a message to telegram channel """
try: try:
whconfig = self._config['webhook']
if msg['type'] in [RPCMessageType.ENTRY]: valuedict = self._get_value_dict(msg)
valuedict = whconfig.get('webhookentry')
elif msg['type'] in [RPCMessageType.ENTRY_CANCEL]:
valuedict = whconfig.get('webhookentrycancel')
elif msg['type'] in [RPCMessageType.ENTRY_FILL]:
valuedict = whconfig.get('webhookentryfill')
elif msg['type'] == RPCMessageType.EXIT:
valuedict = whconfig.get('webhookexit')
elif msg['type'] == RPCMessageType.EXIT_FILL:
valuedict = whconfig.get('webhookexitfill')
elif msg['type'] == RPCMessageType.EXIT_CANCEL:
valuedict = whconfig.get('webhookexitcancel')
elif msg['type'] in (RPCMessageType.STATUS,
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: if not valuedict:
logger.info("Message type '%s' not configured for webhooks", msg['type']) logger.info("Message type '%s' not configured for webhooks", msg['type'])
return return

View File

@ -49,7 +49,7 @@ class IStrategy(ABC, HyperStrategyMixin):
_ft_params_from_file: Dict _ft_params_from_file: Dict
# associated minimal roi # associated minimal roi
minimal_roi: Dict = {} minimal_roi: Dict = {"0": 10.0}
# associated stoploss # associated stoploss
stoploss: float stoploss: float
@ -1072,28 +1072,26 @@ class IStrategy(ABC, HyperStrategyMixin):
trade.stop_loss > (high or current_rate) trade.stop_loss > (high or current_rate)
) )
# Make sure current_profit is calculated using high for backtesting.
bound = (low if trade.is_short else high)
bound_profit = current_profit if not bound else trade.calc_profit_ratio(bound)
if self.use_custom_stoploss and dir_correct: if self.use_custom_stoploss and dir_correct:
stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None
)(pair=trade.pair, trade=trade, )(pair=trade.pair, trade=trade,
current_time=current_time, current_time=current_time,
current_rate=current_rate, current_rate=(bound or current_rate),
current_profit=current_profit) current_profit=bound_profit)
# Sanity check - error cases will return None # Sanity check - error cases will return None
if stop_loss_value: if stop_loss_value:
# logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}") # logger.info(f"{trade.pair} {stop_loss_value=} {bound_profit=}")
trade.adjust_stop_loss(current_rate, stop_loss_value) trade.adjust_stop_loss(bound or current_rate, stop_loss_value)
else: else:
logger.warning("CustomStoploss function did not return valid stoploss") logger.warning("CustomStoploss function did not return valid stoploss")
sl_lower_long = (trade.stop_loss < (low or current_rate) and not trade.is_short) if self.trailing_stop and dir_correct:
sl_higher_short = (trade.stop_loss > (high or current_rate) and trade.is_short)
if self.trailing_stop and (sl_lower_long or sl_higher_short):
# trailing stoploss handling # trailing stoploss handling
sl_offset = self.trailing_stop_positive_offset sl_offset = self.trailing_stop_positive_offset
# Make sure current_profit is calculated using high for backtesting. # Make sure current_profit is calculated using high for backtesting.
bound = low if trade.is_short else high
bound_profit = current_profit if not bound else trade.calc_profit_ratio(bound)
# Don't update stoploss if trailing_only_offset_is_reached is true. # Don't update stoploss if trailing_only_offset_is_reached is true.
if not (self.trailing_only_offset_is_reached and bound_profit < sl_offset): if not (self.trailing_only_offset_is_reached and bound_profit < sl_offset):
@ -1101,7 +1099,7 @@ class IStrategy(ABC, HyperStrategyMixin):
if self.trailing_stop_positive is not None and bound_profit > sl_offset: if self.trailing_stop_positive is not None and bound_profit > sl_offset:
stop_loss_value = self.trailing_stop_positive stop_loss_value = self.trailing_stop_positive
logger.debug(f"{trade.pair} - Using positive stoploss: {stop_loss_value} " logger.debug(f"{trade.pair} - Using positive stoploss: {stop_loss_value} "
f"offset: {sl_offset:.4g} profit: {current_profit:.2%}") f"offset: {sl_offset:.4g} profit: {bound_profit:.2%}")
trade.adjust_stop_loss(bound or current_rate, stop_loss_value) trade.adjust_stop_loss(bound or current_rate, stop_loss_value)

View File

@ -5,6 +5,7 @@
import numpy as np # noqa import numpy as np # noqa
import pandas as pd # noqa import pandas as pd # noqa
from pandas import DataFrame from pandas import DataFrame
from typing import Optional, Union
from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter, from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter,
IStrategy, IntParameter) IStrategy, IntParameter)

View File

@ -14,6 +14,7 @@ from freqtrade.configuration import Configuration
from freqtrade.constants import PROCESS_THROTTLE_SECS, RETRY_TIMEOUT, Config from freqtrade.constants import PROCESS_THROTTLE_SECS, RETRY_TIMEOUT, Config
from freqtrade.enums import State from freqtrade.enums import State
from freqtrade.exceptions import OperationalException, TemporaryError from freqtrade.exceptions import OperationalException, TemporaryError
from freqtrade.exchange import timeframe_to_next_date
from freqtrade.freqtradebot import FreqtradeBot from freqtrade.freqtradebot import FreqtradeBot
@ -35,7 +36,6 @@ class Worker:
self._config = config self._config = config
self._init(False) self._init(False)
self.last_throttle_start_time: float = 0
self._heartbeat_msg: float = 0 self._heartbeat_msg: float = 0
# Tell systemd that we completed initialization phase # Tell systemd that we completed initialization phase
@ -112,7 +112,10 @@ class Worker:
# Ping systemd watchdog before throttling # Ping systemd watchdog before throttling
self._notify("WATCHDOG=1\nSTATUS=State: RUNNING.") self._notify("WATCHDOG=1\nSTATUS=State: RUNNING.")
self._throttle(func=self._process_running, throttle_secs=self._throttle_secs) # Use an offset of 1s to ensure a new candle has been issued
self._throttle(func=self._process_running, throttle_secs=self._throttle_secs,
timeframe=self._config['timeframe'] if self._config else None,
timeframe_offset=1)
if self._heartbeat_interval: if self._heartbeat_interval:
now = time.time() now = time.time()
@ -127,24 +130,42 @@ class Worker:
return state return state
def _throttle(self, func: Callable[..., Any], throttle_secs: float, *args, **kwargs) -> Any: def _throttle(self, func: Callable[..., Any], throttle_secs: float,
timeframe: Optional[str] = None, timeframe_offset: float = 1.0,
*args, **kwargs) -> Any:
""" """
Throttles the given callable that it Throttles the given callable that it
takes at least `min_secs` to finish execution. takes at least `min_secs` to finish execution.
:param func: Any callable :param func: Any callable
:param throttle_secs: throttling interation execution time limit in seconds :param throttle_secs: throttling interation execution time limit in seconds
:param timeframe: ensure iteration is executed at the beginning of the next candle.
:param timeframe_offset: offset in seconds to apply to the next candle time.
:return: Any (result of execution of func) :return: Any (result of execution of func)
""" """
self.last_throttle_start_time = time.time() last_throttle_start_time = time.time()
logger.debug("========================================") logger.debug("========================================")
result = func(*args, **kwargs) result = func(*args, **kwargs)
time_passed = time.time() - self.last_throttle_start_time time_passed = time.time() - last_throttle_start_time
sleep_duration = max(throttle_secs - time_passed, 0.0) sleep_duration = throttle_secs - time_passed
if timeframe:
next_tf = timeframe_to_next_date(timeframe)
# Maximum throttling should be until new candle arrives
# Offset of 0.2s is added to ensure a new candle has been issued.
next_tf_with_offset = next_tf.timestamp() - time.time() + timeframe_offset
sleep_duration = min(sleep_duration, next_tf_with_offset)
sleep_duration = max(sleep_duration, 0.0)
# next_iter = datetime.now(timezone.utc) + timedelta(seconds=sleep_duration)
logger.debug(f"Throttling with '{func.__name__}()': sleep for {sleep_duration:.2f} s, " logger.debug(f"Throttling with '{func.__name__}()': sleep for {sleep_duration:.2f} s, "
f"last iteration took {time_passed:.2f} s.") f"last iteration took {time_passed:.2f} s.")
time.sleep(sleep_duration) self._sleep(sleep_duration)
return result return result
@staticmethod
def _sleep(sleep_duration: float) -> None:
"""Local sleep method - to improve testability"""
time.sleep(sleep_duration)
def _process_stopped(self) -> None: def _process_stopped(self) -> None:
self.freqtrade.process_stopped() self.freqtrade.process_stopped()

View File

@ -9,23 +9,23 @@
coveralls==3.3.1 coveralls==3.3.1
flake8==5.0.4 flake8==5.0.4
flake8-tidy-imports==4.8.0 flake8-tidy-imports==4.8.0
mypy==0.981 mypy==0.982
pre-commit==2.20.0 pre-commit==2.20.0
pytest==7.1.3 pytest==7.1.3
pytest-asyncio==0.19.0 pytest-asyncio==0.20.1
pytest-cov==4.0.0 pytest-cov==4.0.0
pytest-mock==3.9.0 pytest-mock==3.10.0
pytest-random-order==1.0.4 pytest-random-order==1.0.4
isort==5.10.1 isort==5.10.1
# For datetime mocking # For datetime mocking
time-machine==2.8.2 time-machine==2.8.2
# Convert jupyter notebooks to markdown documents # Convert jupyter notebooks to markdown documents
nbconvert==7.0.0 nbconvert==7.2.1
# mypy types # mypy types
types-cachetools==5.2.1 types-cachetools==5.2.1
types-filelock==3.2.7 types-filelock==3.2.7
types-requests==2.28.11 types-requests==2.28.11.2
types-tabulate==0.8.11 types-tabulate==0.9.0.0
types-python-dateutil==2.8.19 types-python-dateutil==2.8.19.2

View File

@ -5,5 +5,6 @@
scikit-learn==1.1.2 scikit-learn==1.1.2
joblib==1.2.0 joblib==1.2.0
catboost==1.1; platform_machine != 'aarch64' catboost==1.1; platform_machine != 'aarch64'
lightgbm==3.3.2 lightgbm==3.3.3
xgboost==1.6.2 xgboost==1.6.2
tensorboard==2.10.1

View File

@ -2,8 +2,8 @@
-r requirements.txt -r requirements.txt
# Required for hyperopt # Required for hyperopt
scipy==1.9.1 scipy==1.9.3
scikit-learn==1.1.2 scikit-learn==1.1.2
scikit-optimize==0.9.0 scikit-optimize==0.9.0
filelock==3.8.0 filelock==3.8.0
progressbar2==4.0.0 progressbar2==4.1.1

View File

@ -1,14 +1,14 @@
numpy==1.23.3 numpy==1.23.4
pandas==1.5.0; platform_machine != 'armv7l' pandas==1.5.1; platform_machine != 'armv7l'
# Piwheels doesn't have 1.5.0 yet. # Piwheels doesn't have 1.5.0 yet.
pandas==1.4.3; platform_machine == 'armv7l' pandas==1.4.3; platform_machine == 'armv7l'
pandas-ta==0.3.14b pandas-ta==0.3.14b
ccxt==1.95.2 ccxt==2.0.58
# Pin cryptography for now due to rust build errors with piwheels # Pin cryptography for now due to rust build errors with piwheels
cryptography==38.0.1 cryptography==38.0.1
aiohttp==3.8.3 aiohttp==3.8.3
SQLAlchemy==1.4.41 SQLAlchemy==1.4.42
python-telegram-bot==13.14 python-telegram-bot==13.14
arrow==1.2.3 arrow==1.2.3
cachetools==4.2.2 cachetools==4.2.2
@ -17,7 +17,7 @@ urllib3==1.26.12
jsonschema==4.16.0 jsonschema==4.16.0
TA-Lib==0.4.25 TA-Lib==0.4.25
technical==1.3.0 technical==1.3.0
tabulate==0.8.10 tabulate==0.9.0
pycoingecko==3.0.0 pycoingecko==3.0.0
jinja2==3.1.2 jinja2==3.1.2
tables==3.7.0 tables==3.7.0
@ -29,7 +29,7 @@ pyarrow==9.0.0; platform_machine != 'armv7l'
py_find_1st==1.1.5 py_find_1st==1.1.5
# Load ticker files 30% faster # Load ticker files 30% faster
python-rapidjson==1.8 python-rapidjson==1.9
# Properly format api responses # Properly format api responses
orjson==3.8.0 orjson==3.8.0
@ -37,10 +37,10 @@ orjson==3.8.0
sdnotify==0.3.2 sdnotify==0.3.2
# API Server # API Server
fastapi==0.85.0 fastapi==0.85.1
pydantic>=1.8.0 pydantic==1.10.2
uvicorn==0.18.3 uvicorn==0.18.3
pyjwt==2.5.0 pyjwt==2.6.0
aiofiles==22.1.0 aiofiles==22.1.0
psutil==5.9.2 psutil==5.9.2

Some files were not shown because too many files have changed in this diff Show More