mirror of
https://github.com/freqtrade/freqtrade.git
synced 2024-11-11 18:53:54 +00:00
Merge pull request #7657 from freqtrade/new_release
New release 2022.10
This commit is contained in:
commit
9adce8d167
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
@ -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 }}
|
||||||
|
|
|
@ -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
|
||||||
|
|
BIN
build_helpers/pyarrow-9.0.0-cp39-cp39-linux_armv7l.whl
Normal file
BIN
build_helpers/pyarrow-9.0.0-cp39-cp39-linux_armv7l.whl
Normal file
Binary file not shown.
|
@ -53,7 +53,7 @@
|
||||||
"XTZ/BTC"
|
"XTZ/BTC"
|
||||||
],
|
],
|
||||||
"pair_blacklist": [
|
"pair_blacklist": [
|
||||||
"BNB/BTC"
|
"BNB/.*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pairlists": [
|
"pairlists": [
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
BIN
docs/assets/binance_futures_settings.png
Normal file
BIN
docs/assets/binance_futures_settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 80 KiB |
BIN
docs/assets/tensorboard.jpg
Normal file
BIN
docs/assets/tensorboard.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 362 KiB |
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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`
|
||||||
|
|
|
@ -60,11 +60,18 @@ Binance supports [time_in_force](configuration.md#understand-order_time_in_force
|
||||||
Binance supports `stoploss_on_exchange` and uses `stop-loss-limit` orders. It provides great advantages, so we recommend to benefit from it by enabling stoploss on exchange.
|
Binance supports `stoploss_on_exchange` and uses `stop-loss-limit` orders. It provides great advantages, so we recommend to benefit from it by enabling stoploss on exchange.
|
||||||
On futures, Binance supports both `stop-limit` as well as `stop-market` orders. You can use either `"limit"` or `"market"` in the `order_types.stoploss` configuration setting to decide which type to use.
|
On futures, Binance supports both `stop-limit` as well as `stop-market` orders. You can use either `"limit"` or `"market"` in the `order_types.stoploss` configuration setting to decide which type to use.
|
||||||
|
|
||||||
### Binance Blacklist
|
### Binance Blacklist recommendation
|
||||||
|
|
||||||
For Binance, it is suggested to add `"BNB/<STAKE>"` to your blacklist to avoid issues, unless you are willing to maintain enough extra `BNB` on the account or unless you're willing to disable using `BNB` for fees.
|
For Binance, it is suggested to add `"BNB/<STAKE>"` to your blacklist to avoid issues, unless you are willing to maintain enough extra `BNB` on the account or unless you're willing to disable using `BNB` for fees.
|
||||||
Binance accounts may use `BNB` for fees, and if a trade happens to be on `BNB`, further trades may consume this position and make the initial BNB trade unsellable as the expected amount is not there anymore.
|
Binance accounts may use `BNB` for fees, and if a trade happens to be on `BNB`, further trades may consume this position and make the initial BNB trade unsellable as the expected amount is not there anymore.
|
||||||
|
|
||||||
|
### Binance sites
|
||||||
|
|
||||||
|
Binance has been split into 2, and users must use the correct ccxt exchange ID for their exchange, otherwise API keys are not recognized.
|
||||||
|
|
||||||
|
* [binance.com](https://www.binance.com/) - International users. Use exchange id: `binance`.
|
||||||
|
* [binance.us](https://www.binance.us/) - US based users. Use exchange id: `binanceus`.
|
||||||
|
|
||||||
### Binance Futures
|
### Binance Futures
|
||||||
|
|
||||||
Binance has specific (unfortunately complex) [Futures Trading Quantitative Rules](https://www.binance.com/en/support/faq/4f462ebe6ff445d4a170be7d9e897272) which need to be followed, and which prohibit a too low stake-amount (among others) for too many orders.
|
Binance has specific (unfortunately complex) [Futures Trading Quantitative Rules](https://www.binance.com/en/support/faq/4f462ebe6ff445d4a170be7d9e897272) which need to be followed, and which prohibit a too low stake-amount (among others) for too many orders.
|
||||||
|
@ -87,12 +94,14 @@ When trading on Binance Futures market, orderbook must be used because there is
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
### Binance sites
|
#### Binance futures settings
|
||||||
|
|
||||||
Binance has been split into 2, and users must use the correct ccxt exchange ID for their exchange, otherwise API keys are not recognized.
|
Users will also have to have the futures-setting "Position Mode" set to "One-way Mode", and "Asset Mode" set to "Single-Asset Mode".
|
||||||
|
These settings will be checked on startup, and freqtrade will show an error if this setting is wrong.
|
||||||
|
|
||||||
* [binance.com](https://www.binance.com/) - International users. Use exchange id: `binance`.
|
![Binance futures settings](assets/binance_futures_settings.png)
|
||||||
* [binance.us](https://www.binance.us/) - US based users. Use exchange id: `binanceus`.
|
|
||||||
|
Freqtrade will not attempt to change these settings.
|
||||||
|
|
||||||
## Kraken
|
## Kraken
|
||||||
|
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
`FreqAI` is configured through the typical [Freqtrade config file](configuration.md) and the standard [Freqtrade strategy](strategy-customization.md). Examples of `FreqAI` config and strategy files can be found in `config_examples/config_freqai.example.json` and `freqtrade/templates/FreqaiExampleStrategy.py`, respectively.
|
FreqAI is configured through the typical [Freqtrade config file](configuration.md) and the standard [Freqtrade strategy](strategy-customization.md). Examples of FreqAI config and strategy files can be found in `config_examples/config_freqai.example.json` and `freqtrade/templates/FreqaiExampleStrategy.py`, respectively.
|
||||||
|
|
||||||
## Setting up the configuration file
|
## Setting up the configuration file
|
||||||
|
|
||||||
Although there are plenty of additional parameters to choose from, as highlighted in the [parameter table](freqai-parameter-table.md#parameter-table), a `FreqAI` config must at minimum include the following parameters (the parameter values are only examples):
|
Although there are plenty of additional parameters to choose from, as highlighted in the [parameter table](freqai-parameter-table.md#parameter-table), a FreqAI config must at minimum include the following parameters (the parameter values are only examples):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"freqai": {
|
"freqai": {
|
||||||
|
@ -35,9 +35,9 @@
|
||||||
|
|
||||||
A full example config is available in `config_examples/config_freqai.example.json`.
|
A full example config is available in `config_examples/config_freqai.example.json`.
|
||||||
|
|
||||||
## Building a `FreqAI` strategy
|
## Building a FreqAI strategy
|
||||||
|
|
||||||
The `FreqAI` strategy requires including the following lines of code in the standard [Freqtrade strategy](strategy-customization.md):
|
The FreqAI strategy requires including the following lines of code in the standard [Freqtrade strategy](strategy-customization.md):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# user should define the maximum startup candle count (the largest number of candles
|
# user should define the maximum startup candle count (the largest number of candles
|
||||||
|
@ -129,7 +129,7 @@ Notice also the location of the labels under `if set_generalized_indicators:` at
|
||||||
The `self.freqai.start()` function cannot be called outside the `populate_indicators()`.
|
The `self.freqai.start()` function cannot be called outside the `populate_indicators()`.
|
||||||
|
|
||||||
!!! Note
|
!!! Note
|
||||||
Features **must** be defined in `populate_any_indicators()`. Defining `FreqAI` features in `populate_indicators()`
|
Features **must** be defined in `populate_any_indicators()`. Defining FreqAI features in `populate_indicators()`
|
||||||
will cause the algorithm to fail in live/dry mode. In order to add generalized features that are not associated with a specific pair or timeframe, the following structure inside `populate_any_indicators()` should be used
|
will cause the algorithm to fail in live/dry mode. In order to add generalized features that are not associated with a specific pair or timeframe, the following structure inside `populate_any_indicators()` should be used
|
||||||
(as exemplified in `freqtrade/templates/FreqaiExampleStrategy.py`):
|
(as exemplified in `freqtrade/templates/FreqaiExampleStrategy.py`):
|
||||||
|
|
||||||
|
@ -166,15 +166,15 @@ Below are the values you can expect to include/use inside a typical strategy dat
|
||||||
|
|
||||||
| DataFrame Key | Description |
|
| DataFrame Key | Description |
|
||||||
|------------|-------------|
|
|------------|-------------|
|
||||||
| `df['&*']` | Any dataframe column prepended with `&` in `populate_any_indicators()` is treated as a training target (label) inside `FreqAI` (typically following the naming convention `&-s*`). The names of these dataframe columns are fed back as the predictions. For example, to predict the price change in the next 40 candles (similar to `templates/FreqaiExampleStrategy.py`), you would set `df['&-s_close']`. `FreqAI` makes the predictions and gives them back under the same key (`df['&-s_close']`) to be used in `populate_entry/exit_trend()`. <br> **Datatype:** Depends on the output of the model.
|
| `df['&*']` | Any dataframe column prepended with `&` in `populate_any_indicators()` is treated as a training target (label) inside FreqAI (typically following the naming convention `&-s*`). For example, to predict the close price 40 candles into the future, you would set `df['&-s_close'] = df['close'].shift(-self.freqai_info["feature_parameters"]["label_period_candles"])` with `"label_period_candles": 40` in the config. FreqAI makes the predictions and gives them back under the same key (`df['&-s_close']`) to be used in `populate_entry/exit_trend()`. <br> **Datatype:** Depends on the output of the model.
|
||||||
| `df['&*_std/mean']` | Standard deviation and mean values of the defined labels during training (or live tracking with `fit_live_predictions_candles`). Commonly used to understand the rarity of a prediction (use the z-score as shown in `templates/FreqaiExampleStrategy.py` and explained [here](#creating-a-dynamic-target-threshold) to evaluate how often a particular prediction was observed during training or historically with `fit_live_predictions_candles`). <br> **Datatype:** Float.
|
| `df['&*_std/mean']` | Standard deviation and mean values of the defined labels during training (or live tracking with `fit_live_predictions_candles`). Commonly used to understand the rarity of a prediction (use the z-score as shown in `templates/FreqaiExampleStrategy.py` and explained [here](#creating-a-dynamic-target-threshold) to evaluate how often a particular prediction was observed during training or historically with `fit_live_predictions_candles`). <br> **Datatype:** Float.
|
||||||
| `df['do_predict']` | Indication of an outlier data point. The return value is integer between -1 and 2, which lets you know if the prediction is trustworthy or not. `do_predict==1` means that the prediction is trustworthy. If the Dissimilarity Index (DI, see details [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di)) of the input data point is above the threshold defined in the config, `FreqAI` will subtract 1 from `do_predict`, resulting in `do_predict==0`. If `use_SVM_to_remove_outliers()` is active, the Support Vector Machine (SVM, see details [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm)) may also detect outliers in training and prediction data. In this case, the SVM will also subtract 1 from `do_predict`. If the input data point was considered an outlier by the SVM but not by the DI, or vice versa, the result will be `do_predict==0`. If both the DI and the SVM considers the input data point to be an outlier, the result will be `do_predict==-1`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`. <br> **Datatype:** Integer between -1 and 2.
|
| `df['do_predict']` | Indication of an outlier data point. The return value is integer between -2 and 2, which lets you know if the prediction is trustworthy or not. `do_predict==1` means that the prediction is trustworthy. If the Dissimilarity Index (DI, see details [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di)) of the input data point is above the threshold defined in the config, FreqAI will subtract 1 from `do_predict`, resulting in `do_predict==0`. If `use_SVM_to_remove_outliers()` is active, the Support Vector Machine (SVM, see details [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm)) may also detect outliers in training and prediction data. In this case, the SVM will also subtract 1 from `do_predict`. If the input data point was considered an outlier by the SVM but not by the DI, or vice versa, the result will be `do_predict==0`. If both the DI and the SVM considers the input data point to be an outlier, the result will be `do_predict==-1`. As with the SVM, if `use_DBSCAN_to_remove_outliers` is active, DBSCAN (see details [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan)) may also detect outliers and subtract 1 from `do_predict`. Hence, if both the SVM and DBSCAN are active and identify a datapoint that was above the DI threshold as an outlier, the result will be `do_predict==-2`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`. <br> **Datatype:** Integer between -2 and 2.
|
||||||
| `df['DI_values']` | Dissimilarity Index (DI) values are proxies for the level of confidence `FreqAI` has in the prediction. A lower DI means the prediction is close to the training data, i.e., higher prediction confidence. See details about the DI [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di). <br> **Datatype:** Float.
|
| `df['DI_values']` | Dissimilarity Index (DI) values are proxies for the level of confidence FreqAI has in the prediction. A lower DI means the prediction is close to the training data, i.e., higher prediction confidence. See details about the DI [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di). <br> **Datatype:** Float.
|
||||||
| `df['%*']` | Any dataframe column prepended with `%` in `populate_any_indicators()` is treated as a training feature. For example, you can include the RSI in the training feature set (similar to in `templates/FreqaiExampleStrategy.py`) by setting `df['%-rsi']`. See more details on how this is done [here](freqai-feature-engineering.md). <br> **Note:** Since the number of features prepended with `%` can multiply very quickly (10s of thousands of features is easily engineered using the multiplictative functionality described in the `feature_parameters` table shown above), these features are removed from the dataframe upon return from `FreqAI`. To keep a particular type of feature for plotting purposes, you would prepend it with `%%`. <br> **Datatype:** Depends on the output of the model.
|
| `df['%*']` | Any dataframe column prepended with `%` in `populate_any_indicators()` is treated as a training feature. For example, you can include the RSI in the training feature set (similar to in `templates/FreqaiExampleStrategy.py`) by setting `df['%-rsi']`. See more details on how this is done [here](freqai-feature-engineering.md). <br> **Note:** Since the number of features prepended with `%` can multiply very quickly (10s of thousands of features are easily engineered using the multiplictative functionality of, e.g., `include_shifted_candles` and `include_timeframes` as described in the [parameter table](freqai-parameter-table.md)), these features are removed from the dataframe that is returned from FreqAI to the strategy. To keep a particular type of feature for plotting purposes, you would prepend it with `%%`. <br> **Datatype:** Depends on the output of the model.
|
||||||
|
|
||||||
## Setting the `startup_candle_count`
|
## Setting the `startup_candle_count`
|
||||||
|
|
||||||
The `startup_candle_count` in the `FreqAI` strategy needs to be set up in the same way as in the standard Freqtrade strategy (see details [here](strategy-customization.md#strategy-startup-period)). This value is used by Freqtrade to ensure that a sufficient amount of data is provided when calling the `dataprovider`, to avoid any NaNs at the beginning of the first training. You can easily set this value by identifying the longest period (in candle units) which is passed to the indicator creation functions (e.g., Ta-Lib functions). In the presented example, `startup_candle_count` is 20 since this is the maximum value in `indicators_periods_candles`.
|
The `startup_candle_count` in the FreqAI strategy needs to be set up in the same way as in the standard Freqtrade strategy (see details [here](strategy-customization.md#strategy-startup-period)). This value is used by Freqtrade to ensure that a sufficient amount of data is provided when calling the `dataprovider`, to avoid any NaNs at the beginning of the first training. You can easily set this value by identifying the longest period (in candle units) which is passed to the indicator creation functions (e.g., Ta-Lib functions). In the presented example, `startup_candle_count` is 20 since this is the maximum value in `indicators_periods_candles`.
|
||||||
|
|
||||||
!!! Note
|
!!! Note
|
||||||
There are instances where the Ta-Lib functions actually require more data than just the passed `period` or else the feature dataset gets populated with NaNs. Anecdotally, multiplying the `startup_candle_count` by 2 always leads to a fully NaN free training dataset. Hence, it is typically safest to multiply the expected `startup_candle_count` by 2. Look out for this log message to confirm that the data is clean:
|
There are instances where the Ta-Lib functions actually require more data than just the passed `period` or else the feature dataset gets populated with NaNs. Anecdotally, multiplying the `startup_candle_count` by 2 always leads to a fully NaN free training dataset. Hence, it is typically safest to multiply the expected `startup_candle_count` by 2. Look out for this log message to confirm that the data is clean:
|
||||||
|
@ -185,33 +185,63 @@ The `startup_candle_count` in the `FreqAI` strategy needs to be set up in the sa
|
||||||
|
|
||||||
## Creating a dynamic target threshold
|
## Creating a dynamic target threshold
|
||||||
|
|
||||||
Deciding when to enter or exit a trade can be done in a dynamic way to reflect current market conditions. `FreqAI` allows you to return additional information from the training of a model (more info [here](freqai-feature-engineering.md#returning-additional-info-from-training)). For example, the `&*_std/mean` return values describe the statistical distribution of the target/label *during the most recent training*. Comparing a given prediction to these values allows you to know the rarity of the prediction. In `templates/FreqaiExampleStrategy.py`, the `target_roi` and `sell_roi` are defined to be 1.25 z-scores away from the mean which causes predictions that are closer to the mean to be filtered out.
|
Deciding when to enter or exit a trade can be done in a dynamic way to reflect current market conditions. FreqAI allows you to return additional information from the training of a model (more info [here](freqai-feature-engineering.md#returning-additional-info-from-training)). For example, the `&*_std/mean` return values describe the statistical distribution of the target/label *during the most recent training*. Comparing a given prediction to these values allows you to know the rarity of the prediction. In `templates/FreqaiExampleStrategy.py`, the `target_roi` and `sell_roi` are defined to be 1.25 z-scores away from the mean which causes predictions that are closer to the mean to be filtered out.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
dataframe["target_roi"] = dataframe["&-s_close_mean"] + dataframe["&-s_close_std"] * 1.25
|
dataframe["target_roi"] = dataframe["&-s_close_mean"] + dataframe["&-s_close_std"] * 1.25
|
||||||
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,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If this value is set, `FreqAI` will initially use the predictions from the training data and subsequently begin introducing real prediction data as it is generated. `FreqAI` will save this historical data to be reloaded if you stop and restart a model with the same `identifier`.
|
If this value is set, FreqAI will initially use the predictions from the training data and subsequently begin introducing real prediction data as it is generated. FreqAI will save this historical data to be reloaded if you stop and restart a model with the same `identifier`.
|
||||||
|
|
||||||
## Using different prediction models
|
## 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'])
|
||||||
|
```
|
||||||
|
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
## Project architecture
|
## Project architecture
|
||||||
|
|
||||||
The architecture and functions of `FreqAI` are generalized to encourages development of unique features, functions, models, etc.
|
The architecture and functions of FreqAI are generalized to encourages development of unique features, functions, models, etc.
|
||||||
|
|
||||||
The class structure and a detailed algorithmic overview is depicted in the following diagram:
|
The class structure and a detailed algorithmic overview is depicted in the following diagram:
|
||||||
|
|
||||||
![image](assets/freqai_algorithm-diagram.jpg)
|
![image](assets/freqai_algorithm-diagram.jpg)
|
||||||
|
|
||||||
As shown, there are three distinct objects comprising `FreqAI`:
|
As shown, there are three distinct objects comprising FreqAI:
|
||||||
|
|
||||||
* **IFreqaiModel** - A singular persistent object containing all the necessary logic to collect, store, and process data, engineer features, run training, and inference models.
|
* **IFreqaiModel** - A singular persistent object containing all the necessary logic to collect, store, and process data, engineer features, run training, and inference models.
|
||||||
* **FreqaiDataKitchen** - A non-persistent object which is created uniquely for each unique asset/model. Beyond metadata, it also contains a variety of data processing tools.
|
* **FreqaiDataKitchen** - A non-persistent object which is created uniquely for each unique asset/model. Beyond metadata, it also contains a variety of data processing tools.
|
||||||
|
@ -18,7 +18,7 @@ There are a variety of built-in [prediction models](freqai-configuration.md#usin
|
||||||
|
|
||||||
## Data handling
|
## Data handling
|
||||||
|
|
||||||
`FreqAI` aims to organize model files, prediction data, and meta data in a way that simplifies post-processing and enhances crash resilience by automatic data reloading. The data is saved in a file structure,`user_data_dir/models/`, which contains all the data associated with the trainings and backtests. The `FreqaiDataKitchen()` relies heavily on the file structure for proper training and inferencing and should therefore not be manually modified.
|
FreqAI aims to organize model files, prediction data, and meta data in a way that simplifies post-processing and enhances crash resilience by automatic data reloading. The data is saved in a file structure,`user_data_dir/models/`, which contains all the data associated with the trainings and backtests. The `FreqaiDataKitchen()` relies heavily on the file structure for proper training and inferencing and should therefore not be manually modified.
|
||||||
|
|
||||||
### File structure
|
### File structure
|
||||||
|
|
||||||
|
@ -27,13 +27,13 @@ The file structure is automatically generated based on the model `identifier` se
|
||||||
| Structure | Description |
|
| Structure | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `config_*.json` | A copy of the model specific configuration file. |
|
| `config_*.json` | A copy of the model specific configuration file. |
|
||||||
| `historic_predictions.pkl` | A file containing all historic predictions generated during the lifetime of the `identifier` model during live deployment. `historic_predictions.pkl` is used to reload the model after a crash or a config change. A backup file is always held incase of corruption on the main file. **`FreqAI` automatically detects corruption and replaces the corrupted file with the backup**. |
|
| `historic_predictions.pkl` | A file containing all historic predictions generated during the lifetime of the `identifier` model during live deployment. `historic_predictions.pkl` is used to reload the model after a crash or a config change. A backup file is always held in case of corruption on the main file. FreqAI **automatically** detects corruption and replaces the corrupted file with the backup. |
|
||||||
| `pair_dictionary.json` | A file containing the training queue as well as the on disk location of the most recently trained model. |
|
| `pair_dictionary.json` | A file containing the training queue as well as the on disk location of the most recently trained model. |
|
||||||
| `sub-train-*_TIMESTAMP` | A folder containing all the files associated with a single model, such as: <br>
|
| `sub-train-*_TIMESTAMP` | A folder containing all the files associated with a single model, such as: <br>
|
||||||
|| `*_metadata.json` - Metadata for the model, such as normalization max/mins, expected training feature list, etc. <br>
|
|| `*_metadata.json` - Metadata for the model, such as normalization max/min, expected training feature list, etc. <br>
|
||||||
|| `*_model.*` - The model file saved to disk for reloading from a crash. Can be `joblib` (typical boosting libs), `zip` (stable_baselines), `hd5` (keras type), etc. <br>
|
|| `*_model.*` - The model file saved to disk for reloading from a crash. Can be `joblib` (typical boosting libs), `zip` (stable_baselines), `hd5` (keras type), etc. <br>
|
||||||
|| `*_pca_object.pkl` - The [Principal component analysis (PCA)](freqai-feature-engineering.md#data-dimensionality-reduction-with-principal-component-analysis) transform (if `principal_component_analysis: true` is set in the config) which will be used to transform unseen prediction features. <br>
|
|| `*_pca_object.pkl` - The [Principal component analysis (PCA)](freqai-feature-engineering.md#data-dimensionality-reduction-with-principal-component-analysis) transform (if `principal_component_analysis: True` is set in the config) which will be used to transform unseen prediction features. <br>
|
||||||
|| `*_svm_model.pkl` - The [Support Vector Machine (SVM)](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm) model which is used to detect outliers in unseen prediction features. <br>
|
|| `*_svm_model.pkl` - The [Support Vector Machine (SVM)](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm) model (if `use_SVM_to_remove_outliers: True` is set in the config) which is used to detect outliers in unseen prediction features. <br>
|
||||||
|| `*_trained_df.pkl` - The dataframe containing all the training features used to train the `identifier` model. This is used for computing the [Dissimilarity Index (DI)](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di) and can also be used for post-processing. <br>
|
|| `*_trained_df.pkl` - The dataframe containing all the training features used to train the `identifier` model. This is used for computing the [Dissimilarity Index (DI)](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di) and can also be used for post-processing. <br>
|
||||||
|| `*_trained_dates.df.pkl` - The dates associated with the `trained_df.pkl`, which is useful for post-processing. |
|
|| `*_trained_dates.df.pkl` - The dates associated with the `trained_df.pkl`, which is useful for post-processing. |
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
Low level feature engineering is performed in the user strategy within a function called `populate_any_indicators()`. That function sets the `base features` such as, `RSI`, `MFI`, `EMA`, `SMA`, time of day, volume, etc. The `base features` can be custom indicators or they can be imported from any technical-analysis library that you can find. One important syntax rule is that all `base features` string names are prepended with `%`, while labels/targets are prepended with `&`.
|
Low level feature engineering is performed in the user strategy within a function called `populate_any_indicators()`. That function sets the `base features` such as, `RSI`, `MFI`, `EMA`, `SMA`, time of day, volume, etc. The `base features` can be custom indicators or they can be imported from any technical-analysis library that you can find. One important syntax rule is that all `base features` string names are prepended with `%`, while labels/targets are prepended with `&`.
|
||||||
|
|
||||||
Meanwhile, high level feature engineering is handled within `"feature_parameters":{}` in the `FreqAI` config. Within this file, it is possible to decide large scale feature expansions on top of the `base_features` such as "including correlated pairs" or "including informative timeframes" or even "including recent candles."
|
Meanwhile, high level feature engineering is handled within `"feature_parameters":{}` in the FreqAI config. Within this file, it is possible to decide large scale feature expansions on top of the `base_features` such as "including correlated pairs" or "including informative timeframes" or even "including recent candles."
|
||||||
|
|
||||||
It is advisable to start from the template `populate_any_indicators()` in the source provided example strategy (found in `templates/FreqaiExampleStrategy.py`) to ensure that the feature definitions are following the correct conventions. Here is an example of how to set the indicators and labels in the strategy:
|
It is advisable to start from the template `populate_any_indicators()` in the source provided example strategy (found in `templates/FreqaiExampleStrategy.py`) to ensure that the feature definitions are following the correct conventions. Here is an example of how to set the indicators and labels in the strategy:
|
||||||
|
|
||||||
|
@ -122,7 +122,7 @@ The `include_timeframes` in the config above are the timeframes (`tf`) of each c
|
||||||
|
|
||||||
You can ask for each of the defined features to be included also for informative pairs using the `include_corr_pairlist`. This means that the feature set will include all the features from `populate_any_indicators` on all the `include_timeframes` for each of the correlated pairs defined in the config (`ETH/USD`, `LINK/USD`, and `BNB/USD` in the presented example).
|
You can ask for each of the defined features to be included also for informative pairs using the `include_corr_pairlist`. This means that the feature set will include all the features from `populate_any_indicators` on all the `include_timeframes` for each of the correlated pairs defined in the config (`ETH/USD`, `LINK/USD`, and `BNB/USD` in the presented example).
|
||||||
|
|
||||||
`include_shifted_candles` indicates the number of previous candles to include in the feature set. For example, `include_shifted_candles: 2` tells `FreqAI` to include the past 2 candles for each of the features in the feature set.
|
`include_shifted_candles` indicates the number of previous candles to include in the feature set. For example, `include_shifted_candles: 2` tells FreqAI to include the past 2 candles for each of the features in the feature set.
|
||||||
|
|
||||||
In total, the number of features the user of the presented example strat has created is: length of `include_timeframes` * no. features in `populate_any_indicators()` * length of `include_corr_pairlist` * no. `include_shifted_candles` * length of `indicator_periods_candles`
|
In total, the number of features the user of the presented example strat has created is: length of `include_timeframes` * no. features in `populate_any_indicators()` * length of `include_corr_pairlist` * no. `include_shifted_candles` * length of `indicator_periods_candles`
|
||||||
$= 3 * 3 * 3 * 2 * 2 = 108$.
|
$= 3 * 3 * 3 * 2 * 2 = 108$.
|
||||||
|
@ -131,7 +131,7 @@ In total, the number of features the user of the presented example strat has cre
|
||||||
|
|
||||||
Important metrics can be returned to the strategy at the end of each model training by assigning them to `dk.data['extra_returns_per_train']['my_new_value'] = XYZ` inside the custom prediction model class.
|
Important metrics can be returned to the strategy at the end of each model training by assigning them to `dk.data['extra_returns_per_train']['my_new_value'] = XYZ` inside the custom prediction model class.
|
||||||
|
|
||||||
`FreqAI` takes the `my_new_value` assigned in this dictionary and expands it to fit the dataframe that is returned to the strategy. You can then use the returned metrics in your strategy through `dataframe['my_new_value']`. An example of how return values can be used in `FreqAI` are the `&*_mean` and `&*_std` values that are used to [created a dynamic target threshold](freqai-configuration.md#creating-a-dynamic-target-threshold).
|
FreqAI takes the `my_new_value` assigned in this dictionary and expands it to fit the dataframe that is returned to the strategy. You can then use the returned metrics in your strategy through `dataframe['my_new_value']`. An example of how return values can be used in FreqAI are the `&*_mean` and `&*_std` values that are used to [created a dynamic target threshold](freqai-configuration.md#creating-a-dynamic-target-threshold).
|
||||||
|
|
||||||
Another example, where the user wants to use live metrics from the trade database, is shown below:
|
Another example, where the user wants to use live metrics from the trade database, is shown below:
|
||||||
|
|
||||||
|
@ -141,15 +141,15 @@ Another example, where the user wants to use live metrics from the trade databas
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
You need to set the standard dictionary in the config so that `FreqAI` can return proper dataframe shapes. These values will likely be overridden by the prediction model, but in the case where the model has yet to set them, or needs a default initial value, the preset values are what will be returned.
|
You need to set the standard dictionary in the config so that FreqAI can return proper dataframe shapes. These values will likely be overridden by the prediction model, but in the case where the model has yet to set them, or needs a default initial value, the pre-set values are what will be returned.
|
||||||
|
|
||||||
## Feature normalization
|
## Feature normalization
|
||||||
|
|
||||||
`FreqAI` is strict when it comes to data normalization. The train features, $X^{train}$, are always normalized to [-1, 1] using a shifted min-max normalization:
|
FreqAI is strict when it comes to data normalization. The train features, $X^{train}$, are always normalized to [-1, 1] using a shifted min-max normalization:
|
||||||
|
|
||||||
$$X^{train}_{norm} = 2 * \frac{X^{train} - X^{train}.min()}{X^{train}.max() - X^{train}.min()} - 1$$
|
$$X^{train}_{norm} = 2 * \frac{X^{train} - X^{train}.min()}{X^{train}.max() - X^{train}.min()} - 1$$
|
||||||
|
|
||||||
All other data (test data and unseen prediction data in dry/live/backtest) is always automatically normalized to the training feature space according to industry standards. `FreqAI` stores all the metadata required to ensure that test and prediction features will be properly normalized and that predictions are properly denormalized. For this reason, it is not recommended to eschew industry standards and modify `FreqAI` internals - however - advanced users can do so by inheriting `train()` in their custom `IFreqaiModel` and using their own normalization functions.
|
All other data (test data and unseen prediction data in dry/live/backtest) is always automatically normalized to the training feature space according to industry standards. FreqAI stores all the metadata required to ensure that test and prediction features will be properly normalized and that predictions are properly denormalized. For this reason, it is not recommended to eschew industry standards and modify FreqAI internals - however - advanced users can do so by inheriting `train()` in their custom `IFreqaiModel` and using their own normalization functions.
|
||||||
|
|
||||||
## Data dimensionality reduction with Principal Component Analysis
|
## Data dimensionality reduction with Principal Component Analysis
|
||||||
|
|
||||||
|
@ -169,17 +169,17 @@ This will perform PCA on the features and reduce their dimensionality so that th
|
||||||
|
|
||||||
The `inlier_metric` is a metric aimed at quantifying how similar a the features of a data point are to the most recent historic data points.
|
The `inlier_metric` is a metric aimed at quantifying how similar a the features of a data point are to the most recent historic data points.
|
||||||
|
|
||||||
You define the lookback window by setting `inlier_metric_window` and `FreqAI` computes the distance between the present time point and each of the previous `inlier_metric_window` lookback points. A Weibull function is fit to each of the lookback distributions and its cumulative distribution function (CDF) is used to produce a quantile for each lookback point. The `inlier_metric` is then computed for each time point as the average of the corresponding lookback quantiles. The figure below explains the concept for an `inlier_metric_window` of 5.
|
You define the lookback window by setting `inlier_metric_window` and FreqAI computes the distance between the present time point and each of the previous `inlier_metric_window` lookback points. A Weibull function is fit to each of the lookback distributions and its cumulative distribution function (CDF) is used to produce a quantile for each lookback point. The `inlier_metric` is then computed for each time point as the average of the corresponding lookback quantiles. The figure below explains the concept for an `inlier_metric_window` of 5.
|
||||||
|
|
||||||
![inlier-metric](assets/freqai_inlier-metric.jpg)
|
![inlier-metric](assets/freqai_inlier-metric.jpg)
|
||||||
|
|
||||||
`FreqAI` adds the `inlier_metric` to the training features and hence gives the model access to a novel type of temporal information.
|
FreqAI adds the `inlier_metric` to the training features and hence gives the model access to a novel type of temporal information.
|
||||||
|
|
||||||
This function does **not** remove outliers from the data set.
|
This function does **not** remove outliers from the data set.
|
||||||
|
|
||||||
## Weighting features for temporal importance
|
## Weighting features for temporal importance
|
||||||
|
|
||||||
`FreqAI` allows you to set a `weight_factor` to weight recent data more strongly than past data via an exponential function:
|
FreqAI allows you to set a `weight_factor` to weight recent data more strongly than past data via an exponential function:
|
||||||
|
|
||||||
$$ W_i = \exp(\frac{-i}{\alpha*n}) $$
|
$$ W_i = \exp(\frac{-i}{\alpha*n}) $$
|
||||||
|
|
||||||
|
@ -189,13 +189,13 @@ where $W_i$ is the weight of data point $i$ in a total set of $n$ data points. B
|
||||||
|
|
||||||
## Outlier detection
|
## Outlier detection
|
||||||
|
|
||||||
Equity and crypto markets suffer from a high level of non-patterned noise in the form of outlier data points. `FreqAI` implements a variety of methods to identify such outliers and hence mitigate risk.
|
Equity and crypto markets suffer from a high level of non-patterned noise in the form of outlier data points. FreqAI implements a variety of methods to identify such outliers and hence mitigate risk.
|
||||||
|
|
||||||
### Identifying outliers with the Dissimilarity Index (DI)
|
### Identifying outliers with the Dissimilarity Index (DI)
|
||||||
|
|
||||||
The Dissimilarity Index (DI) aims to quantify the uncertainty associated with each prediction made by the model.
|
The Dissimilarity Index (DI) aims to quantify the uncertainty associated with each prediction made by the model.
|
||||||
|
|
||||||
You can tell `FreqAI` to remove outlier data points from the training/test data sets using the DI by including the following statement in the config:
|
You can tell FreqAI to remove outlier data points from the training/test data sets using the DI by including the following statement in the config:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"freqai": {
|
"freqai": {
|
||||||
|
@ -205,7 +205,7 @@ You can tell `FreqAI` to remove outlier data points from the training/test data
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The DI allows predictions which are outliers (not existent in the model feature space) to be thrown out due to low levels of certainty. To do so, `FreqAI` measures the distance between each training data point (feature vector), $X_{a}$, and all other training data points:
|
The DI allows predictions which are outliers (not existent in the model feature space) to be thrown out due to low levels of certainty. To do so, FreqAI measures the distance between each training data point (feature vector), $X_{a}$, and all other training data points:
|
||||||
|
|
||||||
$$ d_{ab} = \sqrt{\sum_{j=1}^p(X_{a,j}-X_{b,j})^2} $$
|
$$ d_{ab} = \sqrt{\sum_{j=1}^p(X_{a,j}-X_{b,j})^2} $$
|
||||||
|
|
||||||
|
@ -229,7 +229,7 @@ Below is a figure that describes the DI for a 3D data set.
|
||||||
|
|
||||||
### Identifying outliers using a Support Vector Machine (SVM)
|
### Identifying outliers using a Support Vector Machine (SVM)
|
||||||
|
|
||||||
You can tell `FreqAI` to remove outlier data points from the training/test data sets using a Support Vector Machine (SVM) by including the following statement in the config:
|
You can tell FreqAI to remove outlier data points from the training/test data sets using a Support Vector Machine (SVM) by including the following statement in the config:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"freqai": {
|
"freqai": {
|
||||||
|
@ -241,7 +241,7 @@ You can tell `FreqAI` to remove outlier data points from the training/test data
|
||||||
|
|
||||||
The SVM will be trained on the training data and any data point that the SVM deems to be beyond the feature space will be removed.
|
The SVM will be trained on the training data and any data point that the SVM deems to be beyond the feature space will be removed.
|
||||||
|
|
||||||
`FreqAI` uses `sklearn.linear_model.SGDOneClassSVM` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDOneClassSVM.html) (external website)) and you can elect to provide additional parameters for the SVM, such as `shuffle`, and `nu`.
|
FreqAI uses `sklearn.linear_model.SGDOneClassSVM` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDOneClassSVM.html) (external website)) and you can elect to provide additional parameters for the SVM, such as `shuffle`, and `nu`.
|
||||||
|
|
||||||
The parameter `shuffle` is by default set to `False` to ensure consistent results. If it is set to `True`, running the SVM multiple times on the same data set might result in different outcomes due to `max_iter` being to low for the algorithm to reach the demanded `tol`. Increasing `max_iter` solves this issue but causes the procedure to take longer time.
|
The parameter `shuffle` is by default set to `False` to ensure consistent results. If it is set to `True`, running the SVM multiple times on the same data set might result in different outcomes due to `max_iter` being to low for the algorithm to reach the demanded `tol`. Increasing `max_iter` solves this issue but causes the procedure to take longer time.
|
||||||
|
|
||||||
|
@ -249,7 +249,7 @@ The parameter `nu`, *very* broadly, is the amount of data points that should be
|
||||||
|
|
||||||
### Identifying outliers with DBSCAN
|
### Identifying outliers with DBSCAN
|
||||||
|
|
||||||
You can configure `FreqAI` to use DBSCAN to cluster and remove outliers from the training/test data set or incoming outliers from predictions, by activating `use_DBSCAN_to_remove_outliers` in the config:
|
You can configure FreqAI to use DBSCAN to cluster and remove outliers from the training/test data set or incoming outliers from predictions, by activating `use_DBSCAN_to_remove_outliers` in the config:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"freqai": {
|
"freqai": {
|
||||||
|
@ -265,4 +265,4 @@ Given a number of data points $N$, and a distance $\varepsilon$, DBSCAN clusters
|
||||||
|
|
||||||
![dbscan](assets/freqai_dbscan.jpg)
|
![dbscan](assets/freqai_dbscan.jpg)
|
||||||
|
|
||||||
`FreqAI` uses `sklearn.cluster.DBSCAN` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html) (external website)) with `min_samples` ($N$) taken as 1/4 of the no. of time points in the feature set. `eps` ($\varepsilon$) is computed automatically as the elbow point in the *k-distance graph* computed from the nearest neighbors in the pairwise distances of all data points in the feature set.
|
FreqAI uses `sklearn.cluster.DBSCAN` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html) (external website)) with `min_samples` ($N$) taken as 1/4 of the no. of time points (candles) in the feature set. `eps` ($\varepsilon$) is computed automatically as the elbow point in the *k-distance graph* computed from the nearest neighbors in the pairwise distances of all data points in the feature set.
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
# Parameter table
|
# Parameter table
|
||||||
|
|
||||||
The table below will list all configuration parameters available for `FreqAI`. Some of the parameters are exemplified in `config_examples/config_freqai.example.json`.
|
The table below will list all configuration parameters available for FreqAI. Some of the parameters are exemplified in `config_examples/config_freqai.example.json`.
|
||||||
|
|
||||||
Mandatory parameters are marked as **Required** and have to be set in one of the suggested ways.
|
Mandatory parameters are marked as **Required** and have to be set in one of the suggested ways.
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
|------------|-------------|
|
|------------|-------------|
|
||||||
| | **General configuration parameters**
|
| | **General configuration parameters**
|
||||||
| `freqai` | **Required.** <br> The parent dictionary containing all the parameters for controlling `FreqAI`. <br> **Datatype:** Dictionary.
|
| `freqai` | **Required.** <br> The parent dictionary containing all the parameters for controlling FreqAI. <br> **Datatype:** Dictionary.
|
||||||
| `train_period_days` | **Required.** <br> Number of days to use for the training data (width of the sliding window). <br> **Datatype:** Positive integer.
|
| `train_period_days` | **Required.** <br> Number of days to use for the training data (width of the sliding window). <br> **Datatype:** Positive integer.
|
||||||
| `backtest_period_days` | **Required.** <br> Number of days to inference from the trained model before sliding the `train_period_days` window defined above, and retraining the model during backtesting (more info [here](freqai-running.md#backtesting)). This can be fractional days, but beware that the provided `timerange` will be divided by this number to yield the number of trainings necessary to complete the backtest. <br> **Datatype:** Float.
|
| `backtest_period_days` | **Required.** <br> Number of days to inference from the trained model before sliding the `train_period_days` window defined above, and retraining the model during backtesting (more info [here](freqai-running.md#backtesting)). This can be fractional days, but beware that the provided `timerange` will be divided by this number to yield the number of trainings necessary to complete the backtest. <br> **Datatype:** Float.
|
||||||
| `identifier` | **Required.** <br> A unique ID for the current model. If models are saved to disk, the `identifier` allows for reloading specific pre-trained models/data. <br> **Datatype:** String.
|
| `identifier` | **Required.** <br> A unique ID for the current model. If models are saved to disk, the `identifier` allows for reloading specific pre-trained models/data. <br> **Datatype:** String.
|
||||||
| `live_retrain_hours` | Frequency of retraining during dry/live runs. <br> **Datatype:** Float > 0. <br> Default: 0 (models retrain as often as possible).
|
| `live_retrain_hours` | Frequency of retraining during dry/live runs. <br> **Datatype:** Float > 0. <br> Default: `0` (models retrain as often as possible).
|
||||||
| `expiration_hours` | Avoid making predictions if a model is more than `expiration_hours` old. <br> **Datatype:** Positive integer. <br> Default: 0 (models never expire).
|
| `expiration_hours` | Avoid making predictions if a model is more than `expiration_hours` old. <br> **Datatype:** Positive integer. <br> Default: `0` (models never expire).
|
||||||
| `purge_old_models` | Delete obsolete models. <br> **Datatype:** Boolean. <br> Default: `False` (all historic models remain on disk).
|
| `purge_old_models` | Delete obsolete models. <br> **Datatype:** Boolean. <br> Default: `False` (all historic models remain on disk).
|
||||||
| `save_backtest_models` | Save models to disk when running backtesting. Backtesting operates most efficiently by saving the prediction data and reusing them directly for subsequent runs (when you wish to tune entry/exit parameters). Saving backtesting models to disk also allows to use the same model files for starting a dry/live instance with the same model `identifier`. <br> **Datatype:** Boolean. <br> Default: `False` (no models are saved).
|
| `save_backtest_models` | Save models to disk when running backtesting. Backtesting operates most efficiently by saving the prediction data and reusing them directly for subsequent runs (when you wish to tune entry/exit parameters). Saving backtesting models to disk also allows to use the same model files for starting a dry/live instance with the same model `identifier`. <br> **Datatype:** Boolean. <br> Default: `False` (no models are saved).
|
||||||
| `fit_live_predictions_candles` | Number of historical candles to use for computing target (label) statistics from prediction data, instead of from the training dataset (more information can be found [here](freqai-configuration.md#creating-a-dynamic-target-threshold)). <br> **Datatype:** Positive integer.
|
| `fit_live_predictions_candles` | Number of historical candles to use for computing target (label) statistics from prediction data, instead of from the training dataset (more information can be found [here](freqai-configuration.md#creating-a-dynamic-target-threshold)). <br> **Datatype:** Positive integer.
|
||||||
|
@ -21,32 +21,32 @@ Mandatory parameters are marked as **Required** and have to be set in one of the
|
||||||
| | **Feature parameters**
|
| | **Feature parameters**
|
||||||
| `feature_parameters` | A dictionary containing the parameters used to engineer the feature set. Details and examples are shown [here](freqai-feature-engineering.md). <br> **Datatype:** Dictionary.
|
| `feature_parameters` | A dictionary containing the parameters used to engineer the feature set. Details and examples are shown [here](freqai-feature-engineering.md). <br> **Datatype:** Dictionary.
|
||||||
| `include_timeframes` | A list of timeframes that all indicators in `populate_any_indicators` will be created for. The list is added as features to the base indicators dataset. <br> **Datatype:** List of timeframes (strings).
|
| `include_timeframes` | A list of timeframes that all indicators in `populate_any_indicators` will be created for. The list is added as features to the base indicators dataset. <br> **Datatype:** List of timeframes (strings).
|
||||||
| `include_corr_pairlist` | A list of correlated coins that `FreqAI` will add as additional features to all `pair_whitelist` coins. All indicators set in `populate_any_indicators` during feature engineering (see details [here](freqai-feature-engineering.md)) will be created for each correlated coin. The correlated coins features are added to the base indicators dataset. <br> **Datatype:** List of assets (strings).
|
| `include_corr_pairlist` | A list of correlated coins that FreqAI will add as additional features to all `pair_whitelist` coins. All indicators set in `populate_any_indicators` during feature engineering (see details [here](freqai-feature-engineering.md)) will be created for each correlated coin. The correlated coins features are added to the base indicators dataset. <br> **Datatype:** List of assets (strings).
|
||||||
| `label_period_candles` | Number of candles into the future that the labels are created for. This is used in `populate_any_indicators` (see `templates/FreqaiExampleStrategy.py` for detailed usage). You can create custom labels and choose whether to make use of this parameter or not. <br> **Datatype:** Positive integer.
|
| `label_period_candles` | Number of candles into the future that the labels are created for. This is used in `populate_any_indicators` (see `templates/FreqaiExampleStrategy.py` for detailed usage). You can create custom labels and choose whether to make use of this parameter or not. <br> **Datatype:** Positive integer.
|
||||||
| `include_shifted_candles` | Add features from previous candles to subsequent candles with the intent of adding historical information. If used, `FreqAI` will duplicate and shift all features from the `include_shifted_candles` previous candles so that the information is available for the subsequent candle. <br> **Datatype:** Positive integer.
|
| `include_shifted_candles` | Add features from previous candles to subsequent candles with the intent of adding historical information. If used, FreqAI will duplicate and shift all features from the `include_shifted_candles` previous candles so that the information is available for the subsequent candle. <br> **Datatype:** Positive integer.
|
||||||
| `weight_factor` | Weight training data points according to their recency (see details [here](freqai-feature-engineering.md#weighting-features-for-temporal-importance)). <br> **Datatype:** Positive float (typically < 1).
|
| `weight_factor` | Weight training data points according to their recency (see details [here](freqai-feature-engineering.md#weighting-features-for-temporal-importance)). <br> **Datatype:** Positive float (typically < 1).
|
||||||
| `indicator_max_period_candles` | **No longer used (#7325)**. Replaced by `startup_candle_count` which is set in the [strategy](freqai-configuration.md#building-a-freqai-strategy). `startup_candle_count` is timeframe independent and defines the maximum *period* used in `populate_any_indicators()` for indicator creation. `FreqAI` uses this parameter together with the maximum timeframe in `include_time_frames` to calculate how many data points to download such that the first data point does not include a NaN <br> **Datatype:** Positive integer.
|
| `indicator_max_period_candles` | **No longer used (#7325)**. Replaced by `startup_candle_count` which is set in the [strategy](freqai-configuration.md#building-a-freqai-strategy). `startup_candle_count` is timeframe independent and defines the maximum *period* used in `populate_any_indicators()` for indicator creation. FreqAI uses this parameter together with the maximum timeframe in `include_time_frames` to calculate how many data points to download such that the first data point does not include a NaN. <br> **Datatype:** Positive integer.
|
||||||
| `indicator_periods_candles` | Time periods to calculate indicators for. The indicators are added to the base indicator dataset. <br> **Datatype:** List of positive integers.
|
| `indicator_periods_candles` | Time periods to calculate indicators for. The indicators are added to the base indicator dataset. <br> **Datatype:** List of positive integers.
|
||||||
| `stratify_training_data` | Split the feature set into training and testing datasets. For example, `stratify_training_data: 2` would set every 2nd data point into a separate dataset to be pulled from during training/testing. See details about how it works [here](freqai-running.md#data-stratification-for-training-and-testing-the-model). <br> **Datatype:** Positive integer.
|
| `principal_component_analysis` | Automatically reduce the dimensionality of the data set using Principal Component Analysis. See details about how it works [here](#reducing-data-dimensionality-with-principal-component-analysis) <br> **Datatype:** Boolean. <br> Default: `False`.
|
||||||
| `principal_component_analysis` | Automatically reduce the dimensionality of the data set using Principal Component Analysis. See details about how it works [here](#reducing-data-dimensionality-with-principal-component-analysis) <br> **Datatype:** Boolean. defaults to `false`.
|
| `plot_feature_importances` | Create a feature importance plot for each model for the top/bottom `plot_feature_importances` number of features. <br> **Datatype:** Integer. <br> Default: `0`.
|
||||||
| `plot_feature_importances` | Create a feature importance plot for each model for the top/bottom `plot_feature_importances` number of features.<br> **Datatype:** Integer, defaults to `0`.
|
|
||||||
| `DI_threshold` | Activates the use of the Dissimilarity Index for outlier detection when set to > 0. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di). <br> **Datatype:** Positive float (typically < 1).
|
| `DI_threshold` | Activates the use of the Dissimilarity Index for outlier detection when set to > 0. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di). <br> **Datatype:** Positive float (typically < 1).
|
||||||
| `use_SVM_to_remove_outliers` | Train a support vector machine to detect and remove outliers from the training dataset, as well as from incoming data points. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm). <br> **Datatype:** Boolean.
|
| `use_SVM_to_remove_outliers` | Train a support vector machine to detect and remove outliers from the training dataset, as well as from incoming data points. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm). <br> **Datatype:** Boolean.
|
||||||
| `svm_params` | All parameters available in Sklearn's `SGDOneClassSVM()`. See details about some select parameters [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm). <br> **Datatype:** Dictionary.
|
| `svm_params` | All parameters available in Sklearn's `SGDOneClassSVM()`. See details about some select parameters [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm). <br> **Datatype:** Dictionary.
|
||||||
| `use_DBSCAN_to_remove_outliers` | Cluster data using the DBSCAN algorithm to identify and remove outliers from training and prediction data. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan). <br> **Datatype:** Boolean.
|
| `use_DBSCAN_to_remove_outliers` | Cluster data using the DBSCAN algorithm to identify and remove outliers from training and prediction data. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan). <br> **Datatype:** Boolean.
|
||||||
| `inlier_metric_window` | If set, `FreqAI` adds an `inlier_metric` to the training feature set and set the lookback to be the `inlier_metric_window`, i.e., the number of previous time points to compare the current candle to. Details of how the `inlier_metric` is computed can be found [here](freqai-feature-engineering.md#inlier-metric). <br> **Datatype:** Integer. <br> Default: 0.
|
| `inlier_metric_window` | If set, FreqAI adds an `inlier_metric` to the training feature set and set the lookback to be the `inlier_metric_window`, i.e., the number of previous time points to compare the current candle to. Details of how the `inlier_metric` is computed can be found [here](freqai-feature-engineering.md#inlier-metric). <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.
|
| `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, for time-series forecasting, this is set to `False`. <br> **Datatype:** Boolean.
|
| `shuffle` | Shuffle the training data points during training. Typically, to not remove the chronological order of data in time-series forecasting, this is set to `False`. <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 regression. <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 regression. <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.
|
||||||
| | **Extraneous parameters**
|
| | **Extraneous parameters**
|
||||||
| `keras` | If the selected model makes use of Keras (typical for Tensorflow-based prediction models), this flag needs to be activated so that the model save/loading follows Keras standards. <br> **Datatype:** Boolean. <br> Default: `False`.
|
| `keras` | If the selected model makes use of Keras (typical for Tensorflow-based prediction models), this flag needs to be activated so that the model save/loading follows Keras standards. <br> **Datatype:** Boolean. <br> Default: `False`.
|
||||||
| `conv_width` | The width of a convolutional neural network input tensor. This replaces the need for shifting candles (`include_shifted_candles`) by feeding in historical data points as the second dimension of the tensor. Technically, this parameter can also be used for regressors, but it only adds computational overhead and does not change the model training/prediction. <br> **Datatype:** Integer. <br> Default: 2.
|
| `conv_width` | The width of a convolutional neural network input tensor. This replaces the need for shifting candles (`include_shifted_candles`) by feeding in historical data points as the second dimension of the tensor. Technically, this parameter can also be used for regressors, but it only adds computational overhead and does not change the model training/prediction. <br> **Datatype:** Integer. <br> Default: `2`.
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# Running FreqAI
|
# Running FreqAI
|
||||||
|
|
||||||
There are two ways to train and deploy an adaptive machine learning model - live deployment and historical backtesting. In both cases, `FreqAI` runs/simulates periodic retraining of models as shown in the following figure:
|
There are two ways to train and deploy an adaptive machine learning model - live deployment and historical backtesting. In both cases, FreqAI runs/simulates periodic retraining of models as shown in the following figure:
|
||||||
|
|
||||||
![freqai-window](assets/freqai_moving-window.jpg)
|
![freqai-window](assets/freqai_moving-window.jpg)
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ FreqAI automatically downloads the proper amount of data needed to ensure traini
|
||||||
|
|
||||||
### Saving prediction data
|
### Saving prediction data
|
||||||
|
|
||||||
All predictions made during the lifetime of a specific `identifier` model are stored in `historical_predictions.pkl` to allow for reloading after a crash or changes made to the config.
|
All predictions made during the lifetime of a specific `identifier` model are stored in `historic_predictions.pkl` to allow for reloading after a crash or changes made to the config.
|
||||||
|
|
||||||
### Purging old model data
|
### Purging old model data
|
||||||
|
|
||||||
|
@ -75,19 +75,19 @@ To allow for tweaking your strategy (**not** the features!), FreqAI will automat
|
||||||
|
|
||||||
An additional directory called `predictions`, which contains all the predictions stored in `hdf` format, will be created in the `unique-id` folder.
|
An additional directory called `predictions`, which contains all the predictions stored in `hdf` format, will be created in the `unique-id` folder.
|
||||||
|
|
||||||
To change your **features**, you **must** set a new `identifier` in the config to signal to `FreqAI` to train new models.
|
To change your **features**, you **must** set a new `identifier` in the config to signal to FreqAI to train new models.
|
||||||
|
|
||||||
To save the models generated during a particular backtest so that you can start a live deployment from one of them instead of training a new model, you must set `save_backtest_models` to `True` in the config.
|
To save the models generated during a particular backtest so that you can start a live deployment from one of them instead of training a new model, you must set `save_backtest_models` to `True` in the config.
|
||||||
|
|
||||||
### Downloading data to cover the full backtest period
|
### Downloading data to cover the full backtest period
|
||||||
|
|
||||||
For live/dry deployments, FreqAI will download the necessary data automatically. However, to use backtesting functionality, you need to download the necessary data using `download-data` (details [here](data-download.md#data-downloading)). You need to pay careful attention to understanding how much *additional* data needs to be downloaded to ensure that there is a sufficient amount of training data *before* the start of the backtesting timerange. The amount of additional data can be roughly estimated by moving the start date of the timerange backwards by `train_period_days` and the `startup_candle_count` (see the [parameter table](freqai-parameter-table.md) for detailed descriptions of these parameters) from the beginning of the desired backtesting timerange.
|
For live/dry deployments, FreqAI will download the necessary data automatically. However, to use backtesting functionality, you need to download the necessary data using `download-data` (details [here](data-download.md#data-downloading)). You need to pay careful attention to understanding how much *additional* data needs to be downloaded to ensure that there is a sufficient amount of training data *before* the start of the backtesting time range. The amount of additional data can be roughly estimated by moving the start date of the time range backwards by `train_period_days` and the `startup_candle_count` (see the [parameter table](freqai-parameter-table.md) for detailed descriptions of these parameters) from the beginning of the desired backtesting time range.
|
||||||
|
|
||||||
As an example, to backtest the `--timerange 20210501-20210701` using the [example config](freqai-configuration.md#setting-up-the-configuration-file) which sets `train_period_days` to 30, together with `startup_candle_count: 40` on a maximum `include_timeframes` of 1h, the start date for the downloaded data needs to be `20210501` - 30 days - 40 * 1h / 24 hours = 20210330 (31.7 days earlier than the start of the desired training timerange).
|
As an example, to backtest the `--timerange 20210501-20210701` using the [example config](freqai-configuration.md#setting-up-the-configuration-file) which sets `train_period_days` to 30, together with `startup_candle_count: 40` on a maximum `include_timeframes` of 1h, the start date for the downloaded data needs to be `20210501` - 30 days - 40 * 1h / 24 hours = 20210330 (31.7 days earlier than the start of the desired training time range).
|
||||||
|
|
||||||
### Deciding the size of the sliding training window and backtesting duration
|
### Deciding the size of the sliding training window and backtesting duration
|
||||||
|
|
||||||
The backtesting timerange is defined with the typical `--timerange` parameter in the configuration file. The duration of the sliding training window is set by `train_period_days`, whilst `backtest_period_days` is the sliding backtesting window, both in number of days (`backtest_period_days` can be
|
The backtesting time range is defined with the typical `--timerange` parameter in the configuration file. The duration of the sliding training window is set by `train_period_days`, whilst `backtest_period_days` is the sliding backtesting window, both in number of days (`backtest_period_days` can be
|
||||||
a float to indicate sub-daily retraining in live/dry mode). In the presented [example config](freqai-configuration.md#setting-up-the-configuration-file) (found in `config_examples/config_freqai.example.json`), the user is asking FreqAI to use a training period of 30 days and backtest on the subsequent 7 days. After the training of the model, FreqAI will backtest the subsequent 7 days. The "sliding window" then moves one week forward (emulating FreqAI retraining once per week in live mode) and the new model uses the previous 30 days (including the 7 days used for backtesting by the previous model) to train. This is repeated until the end of `--timerange`. This means that if you set `--timerange 20210501-20210701`, FreqAI will have trained 8 separate models at the end of `--timerange` (because the full range comprises 8 weeks).
|
a float to indicate sub-daily retraining in live/dry mode). In the presented [example config](freqai-configuration.md#setting-up-the-configuration-file) (found in `config_examples/config_freqai.example.json`), the user is asking FreqAI to use a training period of 30 days and backtest on the subsequent 7 days. After the training of the model, FreqAI will backtest the subsequent 7 days. The "sliding window" then moves one week forward (emulating FreqAI retraining once per week in live mode) and the new model uses the previous 30 days (including the 7 days used for backtesting by the previous model) to train. This is repeated until the end of `--timerange`. This means that if you set `--timerange 20210501-20210701`, FreqAI will have trained 8 separate models at the end of `--timerange` (because the full range comprises 8 weeks).
|
||||||
|
|
||||||
!!! Note
|
!!! Note
|
||||||
|
@ -105,23 +105,6 @@ During dry/live mode, FreqAI trains each coin pair sequentially (on separate thr
|
||||||
|
|
||||||
In the presented example config, the user will only allow predictions on models that are less than 1/2 hours old.
|
In the presented example config, the user will only allow predictions on models that are less than 1/2 hours old.
|
||||||
|
|
||||||
## Data stratification for training and testing the model
|
|
||||||
|
|
||||||
You can stratify (group) the training/testing data using:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"freqai": {
|
|
||||||
"feature_parameters" : {
|
|
||||||
"stratify_training_data": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This will split the data chronologically so that every Xth data point is used to test the model after training. In the example above, the user is asking for every third data point in the dataframe to be used for
|
|
||||||
testing; the other points are used for training.
|
|
||||||
|
|
||||||
The test data is used to evaluate the performance of the model after training. If the test score is high, the model is able to capture the behavior of the data well. If the test score is low, either the model does not capture the complexity of the data, the test data is significantly different from the train data, or a different type of model should be used.
|
|
||||||
|
|
||||||
## Controlling the model learning process
|
## Controlling the model learning process
|
||||||
|
|
||||||
Model training parameters are unique to the selected machine learning library. FreqAI allows you to set any parameter for any library using the `model_training_parameters` dictionary in the config. The example config (found in `config_examples/config_freqai.example.json`) shows some of the example parameters associated with `Catboost` and `LightGBM`, but you can add any parameters available in those libraries or any other machine learning library you choose to implement.
|
Model training parameters are unique to the selected machine learning library. FreqAI allows you to set any parameter for any library using the `model_training_parameters` dictionary in the config. The example config (found in `config_examples/config_freqai.example.json`) shows some of the example parameters associated with `Catboost` and `LightGBM`, but you can add any parameters available in those libraries or any other machine learning library you choose to implement.
|
||||||
|
@ -132,7 +115,7 @@ The FreqAI specific parameter `label_period_candles` defines the offset (number
|
||||||
|
|
||||||
## Continual learning
|
## Continual learning
|
||||||
|
|
||||||
You can choose to adopt a continual learning scheme by setting `"continual_learning": true` in the config. By enabling `continual_learning`, after training an initial model from scratch, subsequent trainings will start from the final model state of the preceding training. This gives the new model a "memory" of the previous state. By default, this is set to `false` which means that all new models are trained from scratch, without input from previous models.
|
You can choose to adopt a continual learning scheme by setting `"continual_learning": true` in the config. By enabling `continual_learning`, after training an initial model from scratch, subsequent trainings will start from the final model state of the preceding training. This gives the new model a "memory" of the previous state. By default, this is set to `False` which means that all new models are trained from scratch, without input from previous models.
|
||||||
|
|
||||||
## Hyperopt
|
## Hyperopt
|
||||||
|
|
||||||
|
@ -159,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.
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
![freqai-logo](assets/freqai_doc_logo.svg)
|
![freqai-logo](assets/freqai_doc_logo.svg)
|
||||||
|
|
||||||
# `FreqAI`
|
# FreqAI
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
`FreqAI` is a software designed to automate a variety of tasks associated with training a predictive machine learning model to generate market forecasts given a set of input features.
|
FreqAI is a software designed to automate a variety of tasks associated with training a predictive machine learning model to generate market forecasts given a set of input features.
|
||||||
|
|
||||||
Features include:
|
Features include:
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ Features include:
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
The easiest way to quickly test `FreqAI` is to run it in dry mode with the following command:
|
The easiest way to quickly test FreqAI is to run it in dry mode with the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
freqtrade trade --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel LightGBMRegressor --strategy-path freqtrade/templates
|
freqtrade trade --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel LightGBMRegressor --strategy-path freqtrade/templates
|
||||||
|
@ -37,7 +37,7 @@ An example strategy, prediction model, and config to use as a starting points ca
|
||||||
|
|
||||||
## General approach
|
## General approach
|
||||||
|
|
||||||
You provide `FreqAI` with a set of custom *base indicators* (the same way as in a [typical Freqtrade strategy](strategy-customization.md)) as well as target values (*labels*). For each pair in the whitelist, `FreqAI` trains a model to predict the target values based on the input of custom indicators. The models are then consistently retrained, with a predetermined frequency, to adapt to market conditions. `FreqAI` offers the ability to both backtest strategies (emulating reality with periodic retraining on historic data) and deploy dry/live runs. In dry/live conditions, `FreqAI` can be set to constant retraining in a background thread to keep models as up to date as possible.
|
You provide FreqAI with a set of custom *base indicators* (the same way as in a [typical Freqtrade strategy](strategy-customization.md)) as well as target values (*labels*). For each pair in the whitelist, FreqAI trains a model to predict the target values based on the input of custom indicators. The models are then consistently retrained, with a predetermined frequency, to adapt to market conditions. FreqAI offers the ability to both backtest strategies (emulating reality with periodic retraining on historic data) and deploy dry/live runs. In dry/live conditions, FreqAI can be set to constant retraining in a background thread to keep models as up to date as possible.
|
||||||
|
|
||||||
An overview of the algorithm, explaining the data processing pipeline and model usage, is shown below.
|
An overview of the algorithm, explaining the data processing pipeline and model usage, is shown below.
|
||||||
|
|
||||||
|
@ -45,21 +45,21 @@ An overview of the algorithm, explaining the data processing pipeline and model
|
||||||
|
|
||||||
### Important machine learning vocabulary
|
### Important machine learning vocabulary
|
||||||
|
|
||||||
**Features** - the parameters, based on historic data, on which a model is trained. All features for a single candle is stored as a vector. In `FreqAI`, you build a feature data sets from anything you can construct in the strategy.
|
**Features** - the parameters, based on historic data, on which a model is trained. All features for a single candle are stored as a vector. In FreqAI, you build a feature data set from anything you can construct in the strategy.
|
||||||
|
|
||||||
**Labels** - the target values that a model is trained toward. Each feature vector is associated with a single label that is defined by you within the strategy. These labels intentionally look into the future, and are not available to the model during dry/live/backtesting.
|
**Labels** - the target values that the model is trained toward. Each feature vector is associated with a single label that is defined by you within the strategy. These labels intentionally look into the future and are what you are training the model to be able to predict.
|
||||||
|
|
||||||
**Training** - the process of "teaching" the model to match the feature sets to the associated labels. Different types of models "learn" in different ways. More information about the different models can be found [here](freqai-configuration.md#using-different-prediction-models).
|
**Training** - the process of "teaching" the model to match the feature sets to the associated labels. Different types of models "learn" in different ways which means that one might be better than another for a specific application. More information about the different models that are already implemented in FreqAI can be found [here](freqai-configuration.md#using-different-prediction-models).
|
||||||
|
|
||||||
**Train data** - a subset of the feature data set that is fed to the model during training. This data directly influences weight connections in the model.
|
**Train data** - a subset of the feature data set that is fed to the model during training to "teach" the model how to predict the targets. This data directly influences weight connections in the model.
|
||||||
|
|
||||||
**Test data** - a subset of the feature data set that is used to evaluate the performance of the model after training. This data does not influence nodal weights within the model.
|
**Test data** - a subset of the feature data set that is used to evaluate the performance of the model after training. This data does not influence nodal weights within the model.
|
||||||
|
|
||||||
**Inferencing** - the process of feeding a trained model new data on which it will make a prediction.
|
**Inferencing** - the process of feeding a trained model new unseen data on which it will make a prediction.
|
||||||
|
|
||||||
## Install prerequisites
|
## Install prerequisites
|
||||||
|
|
||||||
The normal Freqtrade install process will ask if you wish to install `FreqAI` dependencies. You should reply "yes" to this question if you wish to use `FreqAI`. If you did not reply yes, you can manually install these dependencies after the install with:
|
The normal Freqtrade install process will ask if you wish to install FreqAI dependencies. You should reply "yes" to this question if you wish to use FreqAI. If you did not reply yes, you can manually install these dependencies after the install with:
|
||||||
|
|
||||||
``` bash
|
``` bash
|
||||||
pip install -r requirements-freqai.txt
|
pip install -r requirements-freqai.txt
|
||||||
|
@ -70,18 +70,18 @@ pip install -r requirements-freqai.txt
|
||||||
|
|
||||||
### Usage with docker
|
### Usage with docker
|
||||||
|
|
||||||
If you are using docker, a dedicated tag with `FreqAI` dependencies is available as `:freqai`. As such - you can replace the image line in your docker-compose file with `image: freqtradeorg/freqtrade:develop_freqai`. This image contains the regular `FreqAI` dependencies. Similar to native installs, Catboost will not be available on ARM based devices.
|
If you are using docker, a dedicated tag with FreqAI dependencies is available as `:freqai`. As such - you can replace the image line in your docker-compose file with `image: freqtradeorg/freqtrade:develop_freqai`. This image contains the regular FreqAI dependencies. Similar to native installs, Catboost will not be available on ARM based devices.
|
||||||
|
|
||||||
## Common pitfalls
|
## Common pitfalls
|
||||||
|
|
||||||
`FreqAI` cannot be combined with dynamic `VolumePairlists` (or any pairlist filter that adds and removes pairs dynamically).
|
FreqAI cannot be combined with dynamic `VolumePairlists` (or any pairlist filter that adds and removes pairs dynamically).
|
||||||
This is for performance reasons - `FreqAI` relies on making quick predictions/retrains. To do this effectively,
|
This is for performance reasons - FreqAI relies on making quick predictions/retrains. To do this effectively,
|
||||||
it needs to download all the training data at the beginning of a dry/live instance. `FreqAI` stores and appends
|
it needs to download all the training data at the beginning of a dry/live instance. FreqAI stores and appends
|
||||||
new candles automatically for future retrains. This means that if new pairs arrive later in the dry run due to a volume pairlist, it will not have the data ready. However, `FreqAI` does work with the `ShufflePairlist` or a `VolumePairlist` which keeps the total pairlist constant (but reorders the pairs according to volume).
|
new candles automatically for future retrains. This means that if new pairs arrive later in the dry run due to a volume pairlist, it will not have the data ready. However, FreqAI does work with the `ShufflePairlist` or a `VolumePairlist` which keeps the total pairlist constant (but reorders the pairs according to volume).
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
`FreqAI` is developed by a group of individuals who all contribute specific skillsets to the project.
|
FreqAI is developed by a group of individuals who all contribute specific skillsets to the project.
|
||||||
|
|
||||||
Conception and software development:
|
Conception and software development:
|
||||||
Robert Caulk @robcaulk
|
Robert Caulk @robcaulk
|
||||||
|
@ -96,5 +96,4 @@ Software development:
|
||||||
Wagner Costa @wagnercosta
|
Wagner Costa @wagnercosta
|
||||||
|
|
||||||
Beta testing and bug reporting:
|
Beta testing and bug reporting:
|
||||||
Stefan Gehring @bloodhunter4rc, @longyu, Andrew Robert Lawless @paranoidandy, Pascal Schmidt @smidelis, Ryan McMullan @smarmau,
|
Stefan Gehring @bloodhunter4rc, @longyu, Andrew Lawless @paranoidandy, Pascal Schmidt @smidelis, Ryan McMullan @smarmau, Juha Nykänen @suikula, Johan van der Vlugt @jooopiert, Richárd Józsa @richardjosza, Timothy Pogue @wizrds
|
||||||
Juha Nykänen @suikula, Johan van der Vlugt @jooopiert, Richárd Józsa @richardjosza
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged
|
||||||
|
|
||||||
* [`StaticPairList`](#static-pair-list) (default, if not configured differently)
|
* [`StaticPairList`](#static-pair-list) (default, if not configured differently)
|
||||||
* [`VolumePairList`](#volume-pair-list)
|
* [`VolumePairList`](#volume-pair-list)
|
||||||
|
* [`ProducerPairList`](#producerpairlist)
|
||||||
* [`AgeFilter`](#agefilter)
|
* [`AgeFilter`](#agefilter)
|
||||||
* [`OffsetFilter`](#offsetfilter)
|
* [`OffsetFilter`](#offsetfilter)
|
||||||
* [`PerformanceFilter`](#performancefilter)
|
* [`PerformanceFilter`](#performancefilter)
|
||||||
|
@ -84,7 +85,7 @@ Filtering instances (not the first position in the list) will not apply any cach
|
||||||
|
|
||||||
You can define a minimum volume with `min_value` - which will filter out pairs with a volume lower than the specified value in the specified timerange.
|
You can define a minimum volume with `min_value` - which will filter out pairs with a volume lower than the specified value in the specified timerange.
|
||||||
|
|
||||||
### VolumePairList Advanced mode
|
##### VolumePairList Advanced mode
|
||||||
|
|
||||||
`VolumePairList` can also operate in an advanced mode to build volume over a given timerange of specified candle size. It utilizes exchange historical candle data, builds a typical price (calculated by (open+high+low)/3) and multiplies the typical price with every candle's volume. The sum is the `quoteVolume` over the given range. This allows different scenarios, for a more smoothened volume, when using longer ranges with larger candle sizes, or the opposite when using a short range with small candles.
|
`VolumePairList` can also operate in an advanced mode to build volume over a given timerange of specified candle size. It utilizes exchange historical candle data, builds a typical price (calculated by (open+high+low)/3) and multiplies the typical price with every candle's volume. The sum is the `quoteVolume` over the given range. This allows different scenarios, for a more smoothened volume, when using longer ranges with larger candle sizes, or the opposite when using a short range with small candles.
|
||||||
|
|
||||||
|
@ -146,6 +147,32 @@ More sophisticated approach can be used, by using `lookback_timeframe` for candl
|
||||||
!!! Note
|
!!! Note
|
||||||
`VolumePairList` does not support backtesting mode.
|
`VolumePairList` does not support backtesting mode.
|
||||||
|
|
||||||
|
#### ProducerPairList
|
||||||
|
|
||||||
|
With `ProducerPairList`, you can reuse the pairlist from a [Producer](producer-consumer.md) without explicitly defining the pairlist on each consumer.
|
||||||
|
|
||||||
|
[Consumer mode](producer-consumer.md) is required for this pairlist to work.
|
||||||
|
|
||||||
|
The pairlist will perform a check on active pairs against the current exchange configuration to avoid attempting to trade on invalid markets.
|
||||||
|
|
||||||
|
You can limit the length of the pairlist with the optional parameter `number_assets`. Using `"number_assets"=0` or omitting this key will result in the reuse of all producer pairs valid for the current setup.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "ProducerPairList",
|
||||||
|
"number_assets": 5,
|
||||||
|
"producer_name": "default",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
!!! Tip "Combining pairlists"
|
||||||
|
This pairlist can be combined with all other pairlists and filters for further pairlist reduction, and can also act as an "additional" pairlist, on top of already defined pairs.
|
||||||
|
`ProducerPairList` can also be used multiple times in sequence, combining the pairs from multiple producers.
|
||||||
|
Obviously in complex such configurations, the Producer may not provide data for all pairs, so the strategy must be fit for this.
|
||||||
|
|
||||||
#### AgeFilter
|
#### AgeFilter
|
||||||
|
|
||||||
Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`) or more than `max_days_listed` days (defaults `None` mean infinity).
|
Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`) or more than `max_days_listed` days (defaults `None` mean infinity).
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
markdown==3.3.7
|
markdown==3.3.7
|
||||||
mkdocs==1.3.1
|
mkdocs==1.4.1
|
||||||
mkdocs-material==8.5.3
|
mkdocs-material==8.5.7
|
||||||
mdx_truly_sane_lists==1.3
|
mdx_truly_sane_lists==1.3
|
||||||
pymdown-extensions==9.5
|
pymdown-extensions==9.7
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
@ -643,7 +644,7 @@ This callback is **not** called when there is an open order (either buy or sell)
|
||||||
|
|
||||||
Additional Buys are ignored once you have reached the maximum amount of extra buys that you have set on `max_entry_position_adjustment`, but the callback is called anyway looking for partial exits.
|
Additional Buys are ignored once you have reached the maximum amount of extra buys that you have set on `max_entry_position_adjustment`, but the callback is called anyway looking for partial exits.
|
||||||
|
|
||||||
Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position (negative values will decrease your position), no matter if it's a long or short trade. Modifications to leverage are not possible.
|
Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position (negative values will decrease your position), no matter if it's a long or short trade. Modifications to leverage are not possible, and the stake-amount is assumed to be before applying leverage.
|
||||||
|
|
||||||
!!! Note "About stake size"
|
!!! Note "About stake size"
|
||||||
Using fixed stake size means it will be the amount used for the first order, just like without position adjustment.
|
Using fixed stake size means it will be the amount used for the first order, just like without position adjustment.
|
||||||
|
|
|
@ -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)*
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -37,3 +37,12 @@ pip install -e .
|
||||||
# Ensure freqUI is at the latest version
|
# Ensure freqUI is at the latest version
|
||||||
freqtrade install-ui
|
freqtrade install-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Problems updating
|
||||||
|
|
||||||
|
Update-problems usually come missing dependencies (you didn't follow the above instructions) - or from updated dependencies, which fail to install (for example TA-lib).
|
||||||
|
Please refer to the corresponding installation sections (common problems linked below)
|
||||||
|
|
||||||
|
Common problems and their solutions:
|
||||||
|
|
||||||
|
* [ta-lib update on windows](windows_installation.md#2-install-ta-lib)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
|
@ -34,7 +34,7 @@ python -m venv .env
|
||||||
.env\Scripts\activate.ps1
|
.env\Scripts\activate.ps1
|
||||||
# optionally install ta-lib from wheel
|
# optionally install ta-lib from wheel
|
||||||
# Eventually adjust the below filename to match the downloaded wheel
|
# Eventually adjust the below filename to match the downloaded wheel
|
||||||
pip install --find-links build_helpers\ TA-Lib
|
pip install --find-links build_helpers\ TA-Lib -U
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install -e .
|
pip install -e .
|
||||||
freqtrade
|
freqtrade
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
""" Freqtrade bot """
|
""" Freqtrade bot """
|
||||||
__version__ = '2022.9.1'
|
__version__ = '2022.10'
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
# flake8: noqa: F401
|
# flake8: noqa: F401
|
||||||
|
|
||||||
from freqtrade.configuration.check_exchange import check_exchange
|
|
||||||
from freqtrade.configuration.config_setup import setup_utils_configuration
|
from freqtrade.configuration.config_setup import setup_utils_configuration
|
||||||
from freqtrade.configuration.config_validation import validate_config_consistency
|
from freqtrade.configuration.config_validation import validate_config_consistency
|
||||||
from freqtrade.configuration.configuration import Configuration
|
from freqtrade.configuration.configuration import Configuration
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -8,7 +8,6 @@ from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from freqtrade import constants
|
from freqtrade import constants
|
||||||
from freqtrade.configuration.check_exchange import check_exchange
|
|
||||||
from freqtrade.configuration.deprecated_settings import process_temporary_deprecated_settings
|
from freqtrade.configuration.deprecated_settings import process_temporary_deprecated_settings
|
||||||
from freqtrade.configuration.directory_operations import create_datadir, create_userdata_dir
|
from freqtrade.configuration.directory_operations import create_datadir, create_userdata_dir
|
||||||
from freqtrade.configuration.environment_vars import enironment_vars_to_dict
|
from freqtrade.configuration.environment_vars import enironment_vars_to_dict
|
||||||
|
@ -100,6 +99,9 @@ class Configuration:
|
||||||
|
|
||||||
self._process_freqai_options(config)
|
self._process_freqai_options(config)
|
||||||
|
|
||||||
|
# Import check_exchange here to avoid import cycle problems
|
||||||
|
from freqtrade.exchange.check_exchange import check_exchange
|
||||||
|
|
||||||
# Check if the exchange set by the user is supported
|
# Check if the exchange set by the user is supported
|
||||||
check_exchange(config, config.get('experimental', {}).get('block_bad_exchanges', True))
|
check_exchange(config, config.get('experimental', {}).get('block_bad_exchanges', True))
|
||||||
|
|
||||||
|
|
|
@ -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():
|
||||||
|
|
|
@ -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'
|
||||||
|
@ -31,7 +31,7 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss',
|
||||||
'CalmarHyperOptLoss',
|
'CalmarHyperOptLoss',
|
||||||
'MaxDrawDownHyperOptLoss', 'MaxDrawDownRelativeHyperOptLoss',
|
'MaxDrawDownHyperOptLoss', 'MaxDrawDownRelativeHyperOptLoss',
|
||||||
'ProfitDrawDownHyperOptLoss']
|
'ProfitDrawDownHyperOptLoss']
|
||||||
AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
|
AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'ProducerPairList',
|
||||||
'AgeFilter', 'OffsetFilter', 'PerformanceFilter',
|
'AgeFilter', 'OffsetFilter', 'PerformanceFilter',
|
||||||
'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter',
|
'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter',
|
||||||
'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter']
|
'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter']
|
||||||
|
@ -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},
|
||||||
|
@ -567,6 +572,7 @@ CONF_SCHEMA = {
|
||||||
"properties": {
|
"properties": {
|
||||||
"test_size": {"type": "number"},
|
"test_size": {"type": "number"},
|
||||||
"random_state": {"type": "integer"},
|
"random_state": {"type": "integer"},
|
||||||
|
"shuffle": {"type": "boolean", "default": False}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"model_training_parameters": {
|
"model_training_parameters": {
|
||||||
|
@ -652,5 +658,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]
|
||||||
|
|
|
@ -47,8 +47,7 @@ def ohlcv_to_dataframe(ohlcv: list, timeframe: str, pair: str, *,
|
||||||
|
|
||||||
|
|
||||||
def clean_ohlcv_dataframe(data: DataFrame, timeframe: str, pair: str, *,
|
def clean_ohlcv_dataframe(data: DataFrame, timeframe: str, pair: str, *,
|
||||||
fill_missing: bool = True,
|
fill_missing: bool, drop_incomplete: bool) -> DataFrame:
|
||||||
drop_incomplete: bool = True) -> DataFrame:
|
|
||||||
"""
|
"""
|
||||||
Cleanse a OHLCV dataframe by
|
Cleanse a OHLCV dataframe by
|
||||||
* Grouping it by date (removes duplicate tics)
|
* Grouping it by date (removes duplicate tics)
|
||||||
|
|
|
@ -26,7 +26,7 @@ def load_pair_history(pair: str,
|
||||||
datadir: Path, *,
|
datadir: Path, *,
|
||||||
timerange: Optional[TimeRange] = None,
|
timerange: Optional[TimeRange] = None,
|
||||||
fill_up_missing: bool = True,
|
fill_up_missing: bool = True,
|
||||||
drop_incomplete: bool = True,
|
drop_incomplete: bool = False,
|
||||||
startup_candles: int = 0,
|
startup_candles: int = 0,
|
||||||
data_format: str = None,
|
data_format: str = None,
|
||||||
data_handler: IDataHandler = None,
|
data_handler: IDataHandler = None,
|
||||||
|
|
|
@ -275,7 +275,7 @@ class IDataHandler(ABC):
|
||||||
candle_type: CandleType, *,
|
candle_type: CandleType, *,
|
||||||
timerange: Optional[TimeRange] = None,
|
timerange: Optional[TimeRange] = None,
|
||||||
fill_missing: bool = True,
|
fill_missing: bool = True,
|
||||||
drop_incomplete: bool = True,
|
drop_incomplete: bool = False,
|
||||||
startup_candles: int = 0,
|
startup_candles: int = 0,
|
||||||
warn_no_data: bool = True,
|
warn_no_data: bool = True,
|
||||||
) -> DataFrame:
|
) -> DataFrame:
|
||||||
|
@ -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,
|
||||||
|
|
|
@ -12,8 +12,8 @@ from freqtrade.exchange.coinbasepro import Coinbasepro
|
||||||
from freqtrade.exchange.exchange import (amount_to_contract_precision, amount_to_contracts,
|
from freqtrade.exchange.exchange import (amount_to_contract_precision, amount_to_contracts,
|
||||||
amount_to_precision, available_exchanges, ccxt_exchanges,
|
amount_to_precision, available_exchanges, ccxt_exchanges,
|
||||||
contracts_to_amount, date_minus_candles,
|
contracts_to_amount, date_minus_candles,
|
||||||
is_exchange_known_ccxt, is_exchange_officially_supported,
|
is_exchange_known_ccxt, market_is_active,
|
||||||
market_is_active, price_to_precision, timeframe_to_minutes,
|
price_to_precision, timeframe_to_minutes,
|
||||||
timeframe_to_msecs, timeframe_to_next_date,
|
timeframe_to_msecs, timeframe_to_next_date,
|
||||||
timeframe_to_prev_date, timeframe_to_seconds,
|
timeframe_to_prev_date, timeframe_to_seconds,
|
||||||
validate_exchange, validate_exchanges)
|
validate_exchange, validate_exchanges)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -59,7 +60,7 @@ class Binance(Exchange):
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
|
|
||||||
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:
|
||||||
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.
|
||||||
|
@ -68,6 +69,37 @@ class Binance(Exchange):
|
||||||
tickers = deep_merge_dicts(bidsasks, tickers, allow_null_overrides=False)
|
tickers = deep_merge_dicts(bidsasks, tickers, allow_null_overrides=False)
|
||||||
return tickers
|
return tickers
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def additional_exchange_init(self) -> None:
|
||||||
|
"""
|
||||||
|
Additional exchange initialization logic.
|
||||||
|
.api will be available at this point.
|
||||||
|
Must be overridden in child methods if required.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']:
|
||||||
|
position_side = self._api.fapiPrivateGetPositionsideDual()
|
||||||
|
self._log_exchange_response('position_side_setting', position_side)
|
||||||
|
assets_margin = self._api.fapiPrivateGetMultiAssetsMargin()
|
||||||
|
self._log_exchange_response('multi_asset_margin', assets_margin)
|
||||||
|
msg = ""
|
||||||
|
if position_side.get('dualSidePosition') is True:
|
||||||
|
msg += (
|
||||||
|
"\nHedge Mode is not supported by freqtrade. "
|
||||||
|
"Please change 'Position Mode' on your binance futures account.")
|
||||||
|
if assets_margin.get('multiAssetsMargin') is True:
|
||||||
|
msg += ("\nMulti-Asset Mode is not supported by freqtrade. "
|
||||||
|
"Please change 'Asset Mode' on your binance futures account.")
|
||||||
|
if msg:
|
||||||
|
raise OperationalException(msg)
|
||||||
|
except ccxt.DDoSProtection as e:
|
||||||
|
raise DDosProtection(e) from e
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e) from e
|
||||||
|
|
||||||
@retrier
|
@retrier
|
||||||
def _set_leverage(
|
def _set_leverage(
|
||||||
self,
|
self,
|
||||||
|
|
|
@ -3,8 +3,8 @@ import logging
|
||||||
from freqtrade.constants import Config
|
from freqtrade.constants import Config
|
||||||
from freqtrade.enums import RunMode
|
from freqtrade.enums import RunMode
|
||||||
from freqtrade.exceptions import OperationalException
|
from freqtrade.exceptions import OperationalException
|
||||||
from freqtrade.exchange import (available_exchanges, is_exchange_known_ccxt,
|
from freqtrade.exchange import available_exchanges, is_exchange_known_ccxt, validate_exchange
|
||||||
is_exchange_officially_supported, validate_exchange)
|
from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS, SUPPORTED_EXCHANGES
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -52,7 +52,7 @@ def check_exchange(config: Config, check_for_bad: bool = True) -> bool:
|
||||||
else:
|
else:
|
||||||
logger.warning(f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}')
|
logger.warning(f'Exchange "{exchange}" will not work with Freqtrade. Reason: {reason}')
|
||||||
|
|
||||||
if is_exchange_officially_supported(exchange):
|
if MAP_EXCHANGE_CHILDCLASS.get(exchange, exchange) in SUPPORTED_EXCHANGES:
|
||||||
logger.info(f'Exchange "{exchange}" is officially supported '
|
logger.info(f'Exchange "{exchange}" is officially supported '
|
||||||
f'by the Freqtrade development team.')
|
f'by the Freqtrade development team.')
|
||||||
else:
|
else:
|
|
@ -18,20 +18,20 @@ 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 ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision
|
||||||
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, BAD_EXCHANGES,
|
||||||
EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED,
|
EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED,
|
||||||
SUPPORTED_EXCHANGES, remove_credentials, retrier,
|
remove_credentials, retrier, retrier_async)
|
||||||
retrier_async)
|
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
|
||||||
|
@ -180,13 +180,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(
|
||||||
|
@ -409,11 +410,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
|
||||||
|
@ -1292,7 +1295,14 @@ class Exchange:
|
||||||
order = self.fetch_order(order_id, pair)
|
order = self.fetch_order(order_id, pair)
|
||||||
except InvalidOrderException:
|
except InvalidOrderException:
|
||||||
logger.warning(f"Could not fetch cancelled order {order_id}.")
|
logger.warning(f"Could not fetch cancelled order {order_id}.")
|
||||||
order = {'fee': {}, 'status': 'canceled', 'amount': amount, 'info': {}}
|
order = {
|
||||||
|
'id': order_id,
|
||||||
|
'status': 'canceled',
|
||||||
|
'amount': amount,
|
||||||
|
'filled': 0.0,
|
||||||
|
'fee': {},
|
||||||
|
'info': {}
|
||||||
|
}
|
||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
@ -1413,14 +1423,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:
|
||||||
|
@ -1443,12 +1456,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
|
||||||
|
@ -1499,7 +1512,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'):
|
||||||
|
@ -1518,7 +1531,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
|
||||||
|
@ -1844,10 +1857,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)
|
||||||
|
@ -1863,6 +1888,60 @@ class Exchange:
|
||||||
return self._async_get_candle_history(
|
return self._async_get_candle_history(
|
||||||
pair, timeframe, since_ms=since_ms, candle_type=candle_type)
|
pair, timeframe, since_ms=since_ms, candle_type=candle_type)
|
||||||
|
|
||||||
|
def _build_ohlcv_dl_jobs(
|
||||||
|
self, pair_list: ListPairsWithTimeframes, since_ms: Optional[int],
|
||||||
|
cache: bool) -> Tuple[List[Coroutine], List[Tuple[str, str, CandleType]]]:
|
||||||
|
"""
|
||||||
|
Build Coroutines to execute as part of refresh_latest_ohlcv
|
||||||
|
"""
|
||||||
|
input_coroutines = []
|
||||||
|
cached_pairs = []
|
||||||
|
for pair, timeframe, candle_type in set(pair_list):
|
||||||
|
if (timeframe not in self.timeframes
|
||||||
|
and candle_type in (CandleType.SPOT, CandleType.FUTURES)):
|
||||||
|
logger.warning(
|
||||||
|
f"Cannot download ({pair}, {timeframe}) combination as this timeframe is "
|
||||||
|
f"not available on {self.name}. Available timeframes are "
|
||||||
|
f"{', '.join(self.timeframes)}.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ((pair, timeframe, candle_type) not in self._klines or not cache
|
||||||
|
or self._now_is_time_to_refresh(pair, timeframe, candle_type)):
|
||||||
|
|
||||||
|
input_coroutines.append(
|
||||||
|
self._build_coroutine(pair, timeframe, candle_type, since_ms, cache))
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"Using cached candle (OHLCV) data for {pair}, {timeframe}, {candle_type} ..."
|
||||||
|
)
|
||||||
|
cached_pairs.append((pair, timeframe, candle_type))
|
||||||
|
|
||||||
|
return input_coroutines, cached_pairs
|
||||||
|
|
||||||
|
def _process_ohlcv_df(self, pair: str, timeframe: str, c_type: CandleType, ticks: List[List],
|
||||||
|
cache: bool, drop_incomplete: bool) -> DataFrame:
|
||||||
|
# keeping last candle time as last refreshed time of the pair
|
||||||
|
if ticks and cache:
|
||||||
|
self._pairs_last_refresh_time[(pair, timeframe, c_type)] = ticks[-1][0] // 1000
|
||||||
|
# keeping parsed dataframe in cache
|
||||||
|
ohlcv_df = ohlcv_to_dataframe(ticks, timeframe, pair=pair, fill_missing=True,
|
||||||
|
drop_incomplete=drop_incomplete)
|
||||||
|
if cache:
|
||||||
|
if (pair, timeframe, c_type) in self._klines:
|
||||||
|
old = self._klines[(pair, timeframe, c_type)]
|
||||||
|
# Reassign so we return the updated, combined df
|
||||||
|
ohlcv_df = clean_ohlcv_dataframe(concat([old, ohlcv_df], axis=0), timeframe, pair,
|
||||||
|
fill_missing=True, drop_incomplete=False)
|
||||||
|
candle_limit = self.ohlcv_candle_limit(timeframe, self._config['candle_type_def'])
|
||||||
|
# Age out old candles
|
||||||
|
ohlcv_df = ohlcv_df.tail(candle_limit + self._startup_candle_count)
|
||||||
|
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
|
||||||
|
@ -1880,27 +1959,9 @@ class Exchange:
|
||||||
"""
|
"""
|
||||||
logger.debug("Refreshing candle (OHLCV) data for %d pairs", len(pair_list))
|
logger.debug("Refreshing candle (OHLCV) data for %d pairs", len(pair_list))
|
||||||
drop_incomplete = self._ohlcv_partial_candle if drop_incomplete is None else drop_incomplete
|
drop_incomplete = self._ohlcv_partial_candle if drop_incomplete is None else drop_incomplete
|
||||||
input_coroutines = []
|
|
||||||
cached_pairs = []
|
|
||||||
# Gather coroutines to run
|
|
||||||
for pair, timeframe, candle_type in set(pair_list):
|
|
||||||
if (timeframe not in self.timeframes
|
|
||||||
and candle_type in (CandleType.SPOT, CandleType.FUTURES)):
|
|
||||||
logger.warning(
|
|
||||||
f"Cannot download ({pair}, {timeframe}) combination as this timeframe is "
|
|
||||||
f"not available on {self.name}. Available timeframes are "
|
|
||||||
f"{', '.join(self.timeframes)}.")
|
|
||||||
continue
|
|
||||||
if ((pair, timeframe, candle_type) not in self._klines or not cache
|
|
||||||
or self._now_is_time_to_refresh(pair, timeframe, candle_type)):
|
|
||||||
input_coroutines.append(self._build_coroutine(
|
|
||||||
pair, timeframe, candle_type=candle_type, since_ms=since_ms))
|
|
||||||
|
|
||||||
else:
|
# Gather coroutines to run
|
||||||
logger.debug(
|
input_coroutines, cached_pairs = self._build_ohlcv_dl_jobs(pair_list, since_ms, cache)
|
||||||
f"Using cached candle (OHLCV) data for {pair}, {timeframe}, {candle_type} ..."
|
|
||||||
)
|
|
||||||
cached_pairs.append((pair, timeframe, candle_type))
|
|
||||||
|
|
||||||
results_df = {}
|
results_df = {}
|
||||||
# Chunk requests into batches of 100 to avoid overwelming ccxt Throttling
|
# Chunk requests into batches of 100 to avoid overwelming ccxt Throttling
|
||||||
|
@ -1917,16 +1978,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(
|
||||||
|
@ -1940,11 +1996,9 @@ class Exchange:
|
||||||
# Timeframe in seconds
|
# Timeframe in seconds
|
||||||
interval_in_sec = timeframe_to_seconds(timeframe)
|
interval_in_sec = timeframe_to_seconds(timeframe)
|
||||||
|
|
||||||
return not (
|
return (
|
||||||
(self._pairs_last_refresh_time.get(
|
(self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0)
|
||||||
(pair, timeframe, candle_type),
|
+ interval_in_sec) < arrow.utcnow().int_timestamp
|
||||||
0
|
|
||||||
) + interval_in_sec) >= arrow.utcnow().int_timestamp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@retrier_async
|
@retrier_async
|
||||||
|
@ -1971,8 +2025,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,
|
||||||
|
@ -2754,10 +2808,6 @@ def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = Non
|
||||||
return exchange_name in ccxt_exchanges(ccxt_module)
|
return exchange_name in ccxt_exchanges(ccxt_module)
|
||||||
|
|
||||||
|
|
||||||
def is_exchange_officially_supported(exchange_name: str) -> bool:
|
|
||||||
return exchange_name in SUPPORTED_EXCHANGES
|
|
||||||
|
|
||||||
|
|
||||||
def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
|
def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Return the list of all exchanges known to ccxt
|
Return the list of all exchanges known to ccxt
|
||||||
|
|
|
@ -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']]))
|
||||||
|
|
|
@ -78,7 +78,8 @@ class Okx(Exchange):
|
||||||
raise DDosProtection(e) from e
|
raise DDosProtection(e) from e
|
||||||
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
raise TemporaryError(
|
raise TemporaryError(
|
||||||
f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e
|
f'Error in additional_exchange_init due to {e.__class__.__name__}. Message: {e}'
|
||||||
|
) from e
|
||||||
except ccxt.BaseError as e:
|
except ccxt.BaseError as e:
|
||||||
raise OperationalException(e) from e
|
raise OperationalException(e) from e
|
||||||
|
|
||||||
|
|
16
freqtrade/exchange/types.py
Normal file
16
freqtrade/exchange/types.py
Normal 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]
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,30 +81,60 @@ 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)
|
||||||
|
|
||||||
def load_drawer_from_disk(self):
|
def load_drawer_from_disk(self):
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
|
@ -110,7 +143,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):
|
||||||
"""
|
"""
|
||||||
|
@ -146,7 +190,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)
|
||||||
|
@ -154,6 +198,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.
|
||||||
|
@ -257,7 +310,7 @@ class FreqaiDataDrawer:
|
||||||
|
|
||||||
def append_model_predictions(self, pair: str, predictions: DataFrame,
|
def append_model_predictions(self, pair: str, predictions: DataFrame,
|
||||||
do_preds: NDArray[np.int_],
|
do_preds: NDArray[np.int_],
|
||||||
dk: FreqaiDataKitchen, len_df: int) -> None:
|
dk: FreqaiDataKitchen, strat_df: DataFrame) -> None:
|
||||||
"""
|
"""
|
||||||
Append model predictions to historic predictions dataframe, then set the
|
Append model predictions to historic predictions dataframe, then set the
|
||||||
strategy return dataframe to the tail of the historic predictions. The length of
|
strategy return dataframe to the tail of the historic predictions. The length of
|
||||||
|
@ -266,6 +319,7 @@ class FreqaiDataDrawer:
|
||||||
historic predictions.
|
historic predictions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
len_df = len(strat_df)
|
||||||
index = self.historic_predictions[pair].index[-1:]
|
index = self.historic_predictions[pair].index[-1:]
|
||||||
columns = self.historic_predictions[pair].columns
|
columns = self.historic_predictions[pair].columns
|
||||||
|
|
||||||
|
@ -293,6 +347,15 @@ class FreqaiDataDrawer:
|
||||||
for return_str in rets:
|
for return_str in rets:
|
||||||
df[return_str].iloc[-1] = rets[return_str]
|
df[return_str].iloc[-1] = rets[return_str]
|
||||||
|
|
||||||
|
# this logic carries users between version without needing to
|
||||||
|
# change their identifier
|
||||||
|
if 'close_price' not in df.columns:
|
||||||
|
df['close_price'] = np.nan
|
||||||
|
df['date_pred'] = np.nan
|
||||||
|
|
||||||
|
df['close_price'].iloc[-1] = strat_df['close'].iloc[-1]
|
||||||
|
df['date_pred'].iloc[-1] = strat_df['date'].iloc[-1]
|
||||||
|
|
||||||
self.model_return_values[pair] = df.tail(len_df).reset_index(drop=True)
|
self.model_return_values[pair] = df.tail(len_df).reset_index(drop=True)
|
||||||
|
|
||||||
def attach_return_values_to_return_dataframe(
|
def attach_return_values_to_return_dataframe(
|
||||||
|
@ -402,9 +465,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():
|
||||||
|
@ -444,9 +506,14 @@ class FreqaiDataDrawer:
|
||||||
)
|
)
|
||||||
|
|
||||||
# if self.live:
|
# if self.live:
|
||||||
|
# store as much in ram as possible to increase performance
|
||||||
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
|
||||||
|
@ -457,7 +524,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"]
|
||||||
|
|
||||||
|
@ -483,14 +550,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:
|
if dk.live and coin in self.model_dictionary:
|
||||||
|
@ -522,8 +594,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:
|
||||||
|
@ -569,9 +640,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
|
||||||
|
|
||||||
|
@ -594,10 +664,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] = {}
|
||||||
|
@ -608,7 +677,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:
|
||||||
|
@ -617,25 +687,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
|
||||||
|
|
||||||
# to be used if we want to send predictions directly to the follower instead of forcing
|
|
||||||
# follower to load models and inference
|
|
||||||
# def save_model_return_values_to_disk(self) -> None:
|
|
||||||
# with open(self.full_path / str('model_return_values.json'), "w") as fp:
|
|
||||||
# json.dump(self.model_return_values, fp, default=self.np_encoder)
|
|
||||||
|
|
||||||
# def load_model_return_values_from_disk(self, dk: FreqaiDataKitchen) -> FreqaiDataKitchen:
|
|
||||||
# exists = Path(self.full_path / str('model_return_values.json')).resolve().exists()
|
|
||||||
# if exists:
|
|
||||||
# with open(self.full_path / str('model_return_values.json'), "r") as fp:
|
|
||||||
# self.model_return_values = json.load(fp)
|
|
||||||
# elif not self.follow_mode:
|
|
||||||
# logger.info("Could not find existing datadrawer, starting from scratch")
|
|
||||||
# else:
|
|
||||||
# logger.warning(f'Follower could not find pair_dictionary at {self.full_path} '
|
|
||||||
# 'sending null values back to strategy')
|
|
||||||
|
|
||||||
# return exists, dk
|
|
||||||
|
|
|
@ -107,9 +107,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"))
|
||||||
|
@ -129,25 +128,20 @@ 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"]
|
||||||
|
|
||||||
|
if 'shuffle' not in self.freqai_config['data_split_parameters']:
|
||||||
|
self.freqai_config["data_split_parameters"].update({'shuffle': False})
|
||||||
|
|
||||||
weights: npt.ArrayLike
|
weights: npt.ArrayLike
|
||||||
if feat_dict.get("weight_factor", 0) > 0:
|
if feat_dict.get("weight_factor", 0) > 0:
|
||||||
weights = self.set_weights_higher_recent(len(filtered_dataframe))
|
weights = self.set_weights_higher_recent(len(filtered_dataframe))
|
||||||
else:
|
else:
|
||||||
weights = np.ones(len(filtered_dataframe))
|
weights = np.ones(len(filtered_dataframe))
|
||||||
|
|
||||||
if feat_dict.get("stratify_training_data", 0) > 0:
|
|
||||||
stratification = np.zeros(len(filtered_dataframe))
|
|
||||||
for i in range(1, len(stratification)):
|
|
||||||
if i % feat_dict.get("stratify_training_data", 0) == 0:
|
|
||||||
stratification[i] = 1
|
|
||||||
else:
|
|
||||||
stratification = None
|
|
||||||
|
|
||||||
if self.freqai_config.get('data_split_parameters', {}).get('test_size', 0.1) != 0:
|
if self.freqai_config.get('data_split_parameters', {}).get('test_size', 0.1) != 0:
|
||||||
(
|
(
|
||||||
train_features,
|
train_features,
|
||||||
|
@ -160,7 +154,6 @@ class FreqaiDataKitchen:
|
||||||
filtered_dataframe[: filtered_dataframe.shape[0]],
|
filtered_dataframe[: filtered_dataframe.shape[0]],
|
||||||
labels,
|
labels,
|
||||||
weights,
|
weights,
|
||||||
stratify=stratification,
|
|
||||||
**self.config["freqai"]["data_split_parameters"],
|
**self.config["freqai"]["data_split_parameters"],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
@ -195,13 +188,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.
|
||||||
|
@ -216,7 +210,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
|
||||||
|
@ -247,6 +244,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)
|
||||||
|
@ -291,8 +290,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.
|
||||||
"""
|
"""
|
||||||
|
@ -466,6 +465,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
|
||||||
|
@ -522,8 +537,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(
|
||||||
|
@ -567,8 +581,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:
|
||||||
|
@ -653,11 +666,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:
|
||||||
|
@ -946,6 +959,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
|
||||||
|
@ -1124,15 +1140,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
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -7,7 +7,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, Tuple
|
from typing import Any, Dict, List, Literal, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
@ -144,7 +144,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):
|
||||||
|
@ -196,29 +196,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
|
||||||
|
@ -267,9 +269,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)
|
||||||
|
|
||||||
|
@ -393,7 +393,7 @@ class IFreqaiModel(ABC):
|
||||||
# allows FreqUI to show full return values.
|
# allows FreqUI to show full return values.
|
||||||
pred_df, do_preds = self.predict(dataframe, dk)
|
pred_df, do_preds = self.predict(dataframe, dk)
|
||||||
if pair not in self.dd.historic_predictions:
|
if pair not in self.dd.historic_predictions:
|
||||||
self.set_initial_historic_predictions(pred_df, dk, pair)
|
self.set_initial_historic_predictions(pred_df, dk, pair, dataframe)
|
||||||
self.dd.set_initial_return_values(pair, pred_df)
|
self.dd.set_initial_return_values(pair, pred_df)
|
||||||
|
|
||||||
dk.return_dataframe = self.dd.attach_return_values_to_return_dataframe(pair, dataframe)
|
dk.return_dataframe = self.dd.attach_return_values_to_return_dataframe(pair, dataframe)
|
||||||
|
@ -414,7 +414,7 @@ class IFreqaiModel(ABC):
|
||||||
|
|
||||||
if self.freqai_info.get('fit_live_predictions_candles', 0) and self.live:
|
if self.freqai_info.get('fit_live_predictions_candles', 0) and self.live:
|
||||||
self.fit_live_predictions(dk, pair)
|
self.fit_live_predictions(dk, pair)
|
||||||
self.dd.append_model_predictions(pair, pred_df, do_preds, dk, len(dataframe))
|
self.dd.append_model_predictions(pair, pred_df, do_preds, dk, dataframe)
|
||||||
dk.return_dataframe = self.dd.attach_return_values_to_return_dataframe(pair, dataframe)
|
dk.return_dataframe = self.dd.attach_return_values_to_return_dataframe(pair, dataframe)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -583,7 +583,7 @@ class IFreqaiModel(ABC):
|
||||||
self.dd.purge_old_models()
|
self.dd.purge_old_models()
|
||||||
|
|
||||||
def set_initial_historic_predictions(
|
def set_initial_historic_predictions(
|
||||||
self, pred_df: DataFrame, dk: FreqaiDataKitchen, pair: str
|
self, pred_df: DataFrame, dk: FreqaiDataKitchen, pair: str, strat_df: DataFrame
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
This function is called only if the datadrawer failed to load an
|
This function is called only if the datadrawer failed to load an
|
||||||
|
@ -602,11 +602,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
|
||||||
|
@ -626,6 +626,9 @@ class IFreqaiModel(ABC):
|
||||||
for return_str in dk.data['extra_returns_per_train']:
|
for return_str in dk.data['extra_returns_per_train']:
|
||||||
hist_preds_df[return_str] = 0
|
hist_preds_df[return_str] = 0
|
||||||
|
|
||||||
|
hist_preds_df['close_price'] = strat_df['close']
|
||||||
|
hist_preds_df['date_pred'] = strat_df['date']
|
||||||
|
|
||||||
# # for keras type models, the conv_window needs to be prepended so
|
# # for keras type models, the conv_window needs to be prepended so
|
||||||
# # viewing is correct in frequi
|
# # viewing is correct in frequi
|
||||||
if self.freqai_info.get('keras', False) or self.ft_params.get('inlier_metric_window', 0):
|
if self.freqai_info.get('keras', False) or self.ft_params.get('inlier_metric_window', 0):
|
||||||
|
@ -654,7 +657,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
|
||||||
|
@ -665,7 +668,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')
|
||||||
|
@ -676,7 +682,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.
|
||||||
|
@ -686,7 +692,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')
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
84
freqtrade/freqai/prediction_models/XGBoostRFClassifier.py
Normal file
84
freqtrade/freqai/prediction_models/XGBoostRFClassifier.py
Normal 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)
|
46
freqtrade/freqai/prediction_models/XGBoostRFRegressor.py
Normal file
46
freqtrade/freqai/prediction_models/XGBoostRFRegressor.py
Normal 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
|
|
@ -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']]
|
||||||
|
|
|
@ -82,7 +82,10 @@ class FreqtradeBot(LoggingMixin):
|
||||||
# Keep this at the end of this initialization method.
|
# Keep this at the end of this initialization method.
|
||||||
self.rpc: RPCManager = RPCManager(self)
|
self.rpc: RPCManager = RPCManager(self)
|
||||||
|
|
||||||
self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists, self.rpc)
|
self.dataprovider = DataProvider(self.config, self.exchange, rpc=self.rpc)
|
||||||
|
self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
|
||||||
|
|
||||||
|
self.dataprovider.add_pairlisthandler(self.pairlists)
|
||||||
|
|
||||||
# Attach Dataprovider to strategy instance
|
# Attach Dataprovider to strategy instance
|
||||||
self.strategy.dp = self.dataprovider
|
self.strategy.dp = self.dataprovider
|
||||||
|
@ -597,7 +600,7 @@ class FreqtradeBot(LoggingMixin):
|
||||||
# We should decrease our position
|
# We should decrease our position
|
||||||
amount = self.exchange.amount_to_contract_precision(
|
amount = self.exchange.amount_to_contract_precision(
|
||||||
trade.pair,
|
trade.pair,
|
||||||
abs(float(FtPrecise(stake_amount) / FtPrecise(current_exit_rate))))
|
abs(float(FtPrecise(stake_amount * trade.leverage) / FtPrecise(current_exit_rate))))
|
||||||
if amount > trade.amount:
|
if amount > trade.amount:
|
||||||
# This is currently ineffective as remaining would become < min tradable
|
# This is currently ineffective as remaining would become < min tradable
|
||||||
# Fixing this would require checking for 0.0 there -
|
# Fixing this would require checking for 0.0 there -
|
||||||
|
@ -1308,7 +1311,7 @@ class FreqtradeBot(LoggingMixin):
|
||||||
# place new order only if new price is supplied
|
# place new order only if new price is supplied
|
||||||
self.execute_entry(
|
self.execute_entry(
|
||||||
pair=trade.pair,
|
pair=trade.pair,
|
||||||
stake_amount=(order_obj.remaining * order_obj.price),
|
stake_amount=(order_obj.remaining * order_obj.price / trade.leverage),
|
||||||
price=adjusted_entry_price,
|
price=adjusted_entry_price,
|
||||||
trade=trade,
|
trade=trade,
|
||||||
is_short=trade.is_short,
|
is_short=trade.is_short,
|
||||||
|
@ -1340,11 +1343,12 @@ class FreqtradeBot(LoggingMixin):
|
||||||
replacing: Optional[bool] = False
|
replacing: Optional[bool] = False
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Buy cancel - cancel order
|
entry cancel - cancel order
|
||||||
:param replacing: Replacing order - prevent trade deletion.
|
:param replacing: Replacing order - prevent trade deletion.
|
||||||
:return: True if order was fully cancelled
|
:return: True if trade was fully cancelled
|
||||||
"""
|
"""
|
||||||
was_trade_fully_canceled = False
|
was_trade_fully_canceled = False
|
||||||
|
side = trade.entry_side.capitalize()
|
||||||
|
|
||||||
# Cancelled orders may have the status of 'canceled' or 'closed'
|
# Cancelled orders may have the status of 'canceled' or 'closed'
|
||||||
if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES:
|
if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES:
|
||||||
|
@ -1371,7 +1375,6 @@ class FreqtradeBot(LoggingMixin):
|
||||||
corder = order
|
corder = order
|
||||||
reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
|
reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
|
||||||
|
|
||||||
side = trade.entry_side.capitalize()
|
|
||||||
logger.info('%s order %s for %s.', side, reason, trade)
|
logger.info('%s order %s for %s.', side, reason, trade)
|
||||||
|
|
||||||
# Using filled to determine the filled amount
|
# Using filled to determine the filled amount
|
||||||
|
@ -1385,24 +1388,15 @@ class FreqtradeBot(LoggingMixin):
|
||||||
was_trade_fully_canceled = True
|
was_trade_fully_canceled = True
|
||||||
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
|
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
|
||||||
else:
|
else:
|
||||||
# FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below.
|
|
||||||
self.update_trade_state(trade, trade.open_order_id, corder)
|
self.update_trade_state(trade, trade.open_order_id, corder)
|
||||||
trade.open_order_id = None
|
trade.open_order_id = None
|
||||||
logger.info(f'{side} Order timeout for {trade}.')
|
logger.info(f'{side} Order timeout for {trade}.')
|
||||||
else:
|
else:
|
||||||
# if trade is partially complete, edit the stake details for the trade
|
# update_trade_state (and subsequently recalc_trade_from_orders) will handle updates
|
||||||
# and close the order
|
# to the trade object
|
||||||
# cancel_order may not contain the full order dict, so we need to fallback
|
|
||||||
# to the order dict acquired before cancelling.
|
|
||||||
# we need to fall back to the values from order if corder does not contain these keys.
|
|
||||||
trade.amount = filled_amount
|
|
||||||
# * Check edge cases, we don't want to make leverage > 1.0 if we don't have to
|
|
||||||
# * (for leverage modes which aren't isolated futures)
|
|
||||||
|
|
||||||
trade.stake_amount = trade.amount * trade.open_rate / trade.leverage
|
|
||||||
self.update_trade_state(trade, trade.open_order_id, corder)
|
self.update_trade_state(trade, trade.open_order_id, corder)
|
||||||
|
|
||||||
trade.open_order_id = None
|
trade.open_order_id = None
|
||||||
|
|
||||||
logger.info(f'Partial {trade.entry_side} order timeout for {trade}.')
|
logger.info(f'Partial {trade.entry_side} order timeout for {trade}.')
|
||||||
reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}"
|
reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}"
|
||||||
|
|
||||||
|
@ -1417,58 +1411,73 @@ class FreqtradeBot(LoggingMixin):
|
||||||
:return: True if exit order was cancelled, false otherwise
|
:return: True if exit order was cancelled, false otherwise
|
||||||
"""
|
"""
|
||||||
cancelled = False
|
cancelled = False
|
||||||
# if trade is not partially completed, just cancel the order
|
# Cancelled orders may have the status of 'canceled' or 'closed'
|
||||||
if order['remaining'] == order['amount'] or order.get('filled') == 0.0:
|
if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES:
|
||||||
if not self.exchange.check_order_canceled_empty(order):
|
filled_val: float = order.get('filled', 0.0) or 0.0
|
||||||
try:
|
filled_rem_stake = trade.stake_amount - filled_val * trade.open_rate
|
||||||
# if trade is not partially completed, just delete the order
|
minstake = self.exchange.get_min_pair_stake_amount(
|
||||||
co = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair,
|
trade.pair, trade.open_rate, self.strategy.stoploss)
|
||||||
trade.amount)
|
# Double-check remaining amount
|
||||||
trade.update_order(co)
|
if filled_val > 0:
|
||||||
except InvalidOrderException:
|
reason = constants.CANCEL_REASON['PARTIALLY_FILLED']
|
||||||
logger.exception(
|
if minstake and filled_rem_stake < minstake:
|
||||||
f"Could not cancel {trade.exit_side} order {trade.open_order_id}")
|
logger.warning(
|
||||||
return False
|
f"Order {trade.open_order_id} for {trade.pair} not cancelled, as "
|
||||||
logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade)
|
f"the filled amount of {filled_val} would result in an unexitable trade.")
|
||||||
else:
|
reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN']
|
||||||
reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
|
|
||||||
logger.info('%s order %s for %s.', trade.exit_side.capitalize(), reason, trade)
|
|
||||||
trade.update_order(order)
|
|
||||||
|
|
||||||
|
self._notify_exit_cancel(
|
||||||
|
trade,
|
||||||
|
order_type=self.strategy.order_types['exit'],
|
||||||
|
reason=reason, order_id=order['id'],
|
||||||
|
sub_trade=trade.amount != order['amount']
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
co = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair,
|
||||||
|
trade.amount)
|
||||||
|
except InvalidOrderException:
|
||||||
|
logger.exception(
|
||||||
|
f"Could not cancel {trade.exit_side} order {trade.open_order_id}")
|
||||||
|
return False
|
||||||
trade.close_rate = None
|
trade.close_rate = None
|
||||||
trade.close_rate_requested = None
|
trade.close_rate_requested = None
|
||||||
trade.close_profit = None
|
trade.close_profit = None
|
||||||
trade.close_profit_abs = None
|
trade.close_profit_abs = None
|
||||||
trade.close_date = None
|
# Set exit_reason for fill message
|
||||||
trade.is_open = True
|
exit_reason_prev = trade.exit_reason
|
||||||
trade.open_order_id = None
|
trade.exit_reason = trade.exit_reason + f", {reason}" if trade.exit_reason else reason
|
||||||
trade.exit_reason = None
|
self.update_trade_state(trade, trade.open_order_id, co)
|
||||||
|
# Order might be filled above in odd timing issues.
|
||||||
|
if co.get('status') in ('canceled', 'cancelled'):
|
||||||
|
trade.exit_reason = None
|
||||||
|
trade.open_order_id = None
|
||||||
|
else:
|
||||||
|
trade.exit_reason = exit_reason_prev
|
||||||
|
|
||||||
|
logger.info(f'{trade.exit_side.capitalize()} order {reason} for {trade}.')
|
||||||
cancelled = True
|
cancelled = True
|
||||||
self.wallets.update()
|
|
||||||
else:
|
else:
|
||||||
# TODO: figure out how to handle partially complete sell orders
|
reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
|
||||||
reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN']
|
logger.info(f'{trade.exit_side.capitalize()} order {reason} for {trade}.')
|
||||||
cancelled = False
|
self.update_trade_state(trade, trade.open_order_id, order)
|
||||||
|
trade.open_order_id = None
|
||||||
|
|
||||||
order_obj = trade.select_order_by_order_id(order['id'])
|
|
||||||
if not order_obj:
|
|
||||||
raise DependencyException(
|
|
||||||
f"Order_obj not found for {order['id']}. This should not have happened.")
|
|
||||||
|
|
||||||
sub_trade = order_obj.amount != trade.amount
|
|
||||||
self._notify_exit_cancel(
|
self._notify_exit_cancel(
|
||||||
trade,
|
trade,
|
||||||
order_type=self.strategy.order_types['exit'],
|
order_type=self.strategy.order_types['exit'],
|
||||||
reason=reason, order=order_obj, sub_trade=sub_trade
|
reason=reason, order_id=order['id'], sub_trade=trade.amount != order['amount']
|
||||||
)
|
)
|
||||||
return cancelled
|
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
|
||||||
|
@ -1487,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(
|
||||||
|
@ -1545,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
|
||||||
|
@ -1656,7 +1666,7 @@ class FreqtradeBot(LoggingMixin):
|
||||||
self.rpc.send_msg(msg)
|
self.rpc.send_msg(msg)
|
||||||
|
|
||||||
def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str,
|
def _notify_exit_cancel(self, trade: Trade, order_type: str, reason: str,
|
||||||
order: Order, sub_trade: bool = False) -> None:
|
order_id: str, sub_trade: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Sends rpc notification when a sell cancel occurred.
|
Sends rpc notification when a sell cancel occurred.
|
||||||
"""
|
"""
|
||||||
|
@ -1665,6 +1675,11 @@ class FreqtradeBot(LoggingMixin):
|
||||||
else:
|
else:
|
||||||
trade.exit_order_status = reason
|
trade.exit_order_status = reason
|
||||||
|
|
||||||
|
order = trade.select_order_by_order_id(order_id)
|
||||||
|
if not order:
|
||||||
|
raise DependencyException(
|
||||||
|
f"Order_obj not found for {order_id}. This should not have happened.")
|
||||||
|
|
||||||
profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested
|
profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested
|
||||||
profit_trade = trade.calc_profit(rate=profit_rate)
|
profit_trade = trade.calc_profit(rate=profit_rate)
|
||||||
current_rate = self.exchange.get_rate(
|
current_rate = self.exchange.get_rate(
|
||||||
|
@ -1700,11 +1715,6 @@ class FreqtradeBot(LoggingMixin):
|
||||||
'stake_amount': trade.stake_amount,
|
'stake_amount': trade.stake_amount,
|
||||||
}
|
}
|
||||||
|
|
||||||
if 'fiat_display_currency' in self.config:
|
|
||||||
msg.update({
|
|
||||||
'fiat_currency': self.config['fiat_display_currency'],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Send the message
|
# Send the message
|
||||||
self.rpc.send_msg(msg)
|
self.rpc.send_msg(msg)
|
||||||
|
|
||||||
|
@ -1820,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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -110,7 +110,7 @@ class Backtesting:
|
||||||
self.timeframe = str(self.config.get('timeframe'))
|
self.timeframe = str(self.config.get('timeframe'))
|
||||||
self.timeframe_min = timeframe_to_minutes(self.timeframe)
|
self.timeframe_min = timeframe_to_minutes(self.timeframe)
|
||||||
self.init_backtest_detail()
|
self.init_backtest_detail()
|
||||||
self.pairlists = PairListManager(self.exchange, self.config)
|
self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider)
|
||||||
if 'VolumePairList' in self.pairlists.name_list:
|
if 'VolumePairList' in self.pairlists.name_list:
|
||||||
raise OperationalException("VolumePairList not allowed for backtesting. "
|
raise OperationalException("VolumePairList not allowed for backtesting. "
|
||||||
"Please use StaticPairList instead.")
|
"Please use StaticPairList instead.")
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -540,7 +542,7 @@ class Backtesting:
|
||||||
|
|
||||||
if stake_amount is not None and stake_amount < 0.0:
|
if stake_amount is not None and stake_amount < 0.0:
|
||||||
amount = amount_to_contract_precision(
|
amount = amount_to_contract_precision(
|
||||||
abs(stake_amount) / current_rate, trade.amount_precision,
|
abs(stake_amount * trade.leverage) / current_rate, trade.amount_precision,
|
||||||
self.precision_mode, trade.contract_size)
|
self.precision_mode, trade.contract_size)
|
||||||
if amount == 0.0:
|
if amount == 0.0:
|
||||||
return trade
|
return trade
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -1045,7 +1044,7 @@ class Backtesting:
|
||||||
if requested_rate:
|
if requested_rate:
|
||||||
self._enter_trade(pair=trade.pair, row=row, trade=trade,
|
self._enter_trade(pair=trade.pair, row=row, trade=trade,
|
||||||
requested_rate=requested_rate,
|
requested_rate=requested_rate,
|
||||||
requested_stake=(order.remaining * order.price),
|
requested_stake=(order.remaining * order.price / trade.leverage),
|
||||||
direction='short' if trade.is_short else 'long')
|
direction='short' if trade.is_short else 'long')
|
||||||
self.replaced_entry_orders += 1
|
self.replaced_entry_orders += 1
|
||||||
else:
|
else:
|
||||||
|
@ -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({
|
||||||
|
|
|
@ -24,6 +24,7 @@ from pandas import DataFrame
|
||||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, FTHYPT_FILEVERSION, LAST_BT_RESULT_FN, Config
|
from freqtrade.constants import DATETIME_PRINT_FORMAT, FTHYPT_FILEVERSION, LAST_BT_RESULT_FN, Config
|
||||||
from freqtrade.data.converter import trim_dataframes
|
from freqtrade.data.converter import trim_dataframes
|
||||||
from freqtrade.data.history import get_timerange
|
from freqtrade.data.history import get_timerange
|
||||||
|
from freqtrade.data.metrics import calculate_market_change
|
||||||
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, file_dump_json, plural
|
from freqtrade.misc import deep_merge_dicts, file_dump_json, plural
|
||||||
|
@ -111,6 +112,7 @@ class Hyperopt:
|
||||||
|
|
||||||
self.clean_hyperopt()
|
self.clean_hyperopt()
|
||||||
|
|
||||||
|
self.market_change = 0.0
|
||||||
self.num_epochs_saved = 0
|
self.num_epochs_saved = 0
|
||||||
self.current_best_epoch: Optional[Dict[str, Any]] = None
|
self.current_best_epoch: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
@ -120,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
|
||||||
|
@ -256,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'):
|
||||||
|
@ -337,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({
|
||||||
|
@ -357,7 +357,7 @@ class Hyperopt:
|
||||||
|
|
||||||
strat_stats = generate_strategy_stats(
|
strat_stats = generate_strategy_stats(
|
||||||
self.pairlist, self.backtesting.strategy.get_strategy_name(),
|
self.pairlist, self.backtesting.strategy.get_strategy_name(),
|
||||||
backtesting_results, min_date, max_date, market_change=0
|
backtesting_results, min_date, max_date, market_change=self.market_change
|
||||||
)
|
)
|
||||||
results_explanation = HyperoptTools.format_results_explanation_string(
|
results_explanation = HyperoptTools.format_results_explanation_string(
|
||||||
strat_stats, self.config['stake_currency'])
|
strat_stats, self.config['stake_currency'])
|
||||||
|
@ -425,6 +425,9 @@ class Hyperopt:
|
||||||
# Trim startup period from analyzed dataframe to get correct dates for output.
|
# Trim startup period from analyzed dataframe to get correct dates for output.
|
||||||
trimmed = trim_dataframes(preprocessed, self.timerange, self.backtesting.required_startup)
|
trimmed = trim_dataframes(preprocessed, self.timerange, self.backtesting.required_startup)
|
||||||
self.min_date, self.max_date = get_timerange(trimmed)
|
self.min_date, self.max_date = get_timerange(trimmed)
|
||||||
|
if not self.market_change:
|
||||||
|
self.market_change = calculate_market_change(trimmed, 'close')
|
||||||
|
|
||||||
# Real trimming will happen as part of backtesting.
|
# Real trimming will happen as part of backtesting.
|
||||||
return preprocessed
|
return preprocessed
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
91
freqtrade/plugins/pairlist/ProducerPairList.py
Normal file
91
freqtrade/plugins/pairlist/ProducerPairList.py
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
"""
|
||||||
|
External Pair List provider
|
||||||
|
|
||||||
|
Provides pair list from Leader data
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from freqtrade.exceptions import OperationalException
|
||||||
|
from freqtrade.exchange.types import Tickers
|
||||||
|
from freqtrade.plugins.pairlist.IPairList import IPairList
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProducerPairList(IPairList):
|
||||||
|
"""
|
||||||
|
PairList plugin for use with external_message_consumer.
|
||||||
|
Will use pairs given from leader data.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
"pairlists": [
|
||||||
|
{
|
||||||
|
"method": "ProducerPairList",
|
||||||
|
"number_assets": 5,
|
||||||
|
"producer_name": "default",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, exchange, pairlistmanager,
|
||||||
|
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
|
||||||
|
pairlist_pos: int) -> None:
|
||||||
|
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
|
||||||
|
|
||||||
|
self._num_assets: int = self._pairlistconfig.get('number_assets', 0)
|
||||||
|
self._producer_name = self._pairlistconfig.get('producer_name', 'default')
|
||||||
|
if not config.get('external_message_consumer', {}).get('enabled'):
|
||||||
|
raise OperationalException(
|
||||||
|
"ProducerPairList requires external_message_consumer to be enabled.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def needstickers(self) -> bool:
|
||||||
|
"""
|
||||||
|
Boolean property defining if tickers are necessary.
|
||||||
|
If no Pairlist requires tickers, an empty Dict is passed
|
||||||
|
as tickers argument to filter_pairlist
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def short_desc(self) -> str:
|
||||||
|
"""
|
||||||
|
Short whitelist method description - used for startup-messages
|
||||||
|
-> Please overwrite in subclasses
|
||||||
|
"""
|
||||||
|
return f"{self.name} - {self._producer_name}"
|
||||||
|
|
||||||
|
def _filter_pairlist(self, pairlist: Optional[List[str]]):
|
||||||
|
upstream_pairlist = self._pairlistmanager._dataprovider.get_producer_pairs(
|
||||||
|
self._producer_name)
|
||||||
|
|
||||||
|
if pairlist is None:
|
||||||
|
pairlist = self._pairlistmanager._dataprovider.get_producer_pairs(self._producer_name)
|
||||||
|
|
||||||
|
pairs = list(dict.fromkeys(pairlist + upstream_pairlist))
|
||||||
|
if self._num_assets:
|
||||||
|
pairs = pairs[:self._num_assets]
|
||||||
|
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
def gen_pairlist(self, tickers: Tickers) -> List[str]:
|
||||||
|
"""
|
||||||
|
Generate the pairlist
|
||||||
|
:param tickers: Tickers (from exchange.get_tickers). May be cached.
|
||||||
|
:return: List of pairs
|
||||||
|
"""
|
||||||
|
pairs = self._filter_pairlist(None)
|
||||||
|
self.log_once(f"Received pairs: {pairs}", logger.debug)
|
||||||
|
pairs = self._whitelist_for_active_markets(self.verify_whitelist(pairs, logger.info))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]:
|
||||||
|
"""
|
||||||
|
Filters and sorts pairlist and returns the whitelist again.
|
||||||
|
Called on each bot iteration - please use internal caching if necessary
|
||||||
|
:param pairlist: pairlist to filter or sort
|
||||||
|
:param tickers: Tickers (from exchange.get_tickers). May be cached.
|
||||||
|
:return: new whitelist
|
||||||
|
"""
|
||||||
|
return self._filter_pairlist(pairlist)
|
|
@ -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
|
||||||
|
|
|
@ -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 "
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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:
|
||||||
|
@ -232,6 +233,4 @@ class VolumePairList(IPairList):
|
||||||
# Limit pairlist to the requested number of pairs
|
# Limit pairlist to the requested number of pairs
|
||||||
pairs = pairs[:self._number_pairs]
|
pairs = pairs[:self._number_pairs]
|
||||||
|
|
||||||
self.log_once(f"Searching {self._number_pairs} pairs: {pairs}", logger.info)
|
|
||||||
|
|
||||||
return pairs
|
return pairs
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -3,13 +3,15 @@ PairList manager class
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from cachetools import TTLCache, cached
|
||||||
|
|
||||||
from freqtrade.constants import Config, ListPairsWithTimeframes
|
from freqtrade.constants import Config, ListPairsWithTimeframes
|
||||||
|
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
|
||||||
|
@ -21,13 +23,14 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class PairListManager(LoggingMixin):
|
class PairListManager(LoggingMixin):
|
||||||
|
|
||||||
def __init__(self, exchange, config: Config) -> None:
|
def __init__(self, exchange, config: Config, dataprovider: DataProvider = None) -> None:
|
||||||
self._exchange = exchange
|
self._exchange = exchange
|
||||||
self._config = config
|
self._config = config
|
||||||
self._whitelist = self._config['exchange'].get('pair_whitelist')
|
self._whitelist = self._config['exchange'].get('pair_whitelist')
|
||||||
self._blacklist = self._config['exchange'].get('pair_blacklist', [])
|
self._blacklist = self._config['exchange'].get('pair_blacklist', [])
|
||||||
self._pairlist_handlers: List[IPairList] = []
|
self._pairlist_handlers: List[IPairList] = []
|
||||||
self._tickers_needed = False
|
self._tickers_needed = False
|
||||||
|
self._dataprovider: Optional[DataProvider] = dataprovider
|
||||||
for pairlist_handler_config in self._config.get('pairlists', []):
|
for pairlist_handler_config in self._config.get('pairlists', []):
|
||||||
pairlist_handler = PairListResolver.load_pairlist(
|
pairlist_handler = PairListResolver.load_pairlist(
|
||||||
pairlist_handler_config['method'],
|
pairlist_handler_config['method'],
|
||||||
|
@ -43,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)
|
||||||
|
|
||||||
|
@ -74,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:
|
||||||
|
@ -96,6 +108,8 @@ class PairListManager(LoggingMixin):
|
||||||
# to ensure blacklist is respected.
|
# to ensure blacklist is respected.
|
||||||
pairlist = self.verify_blacklist(pairlist, logger.warning)
|
pairlist = self.verify_blacklist(pairlist, logger.warning)
|
||||||
|
|
||||||
|
self.log_once(f"Whitelist with {len(pairlist)} pairs: {pairlist}", logger.info)
|
||||||
|
|
||||||
self._whitelist = pairlist
|
self._whitelist = pairlist
|
||||||
|
|
||||||
def verify_blacklist(self, pairlist: List[str], logmethod) -> List[str]:
|
def verify_blacklist(self, pairlist: List[str], logmethod) -> List[str]:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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]}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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']:
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user