Merge remote-tracking branch 'origin/develop' into reduce-indicator-population
This commit is contained in:
commit
66d8ed6c0b
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -258,7 +258,7 @@ jobs:
|
|||||||
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|
||||||
mypy_version_check:
|
mypy_version_check:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
@ -283,7 +283,7 @@ jobs:
|
|||||||
- uses: pre-commit/action@v3.0.0
|
- uses: pre-commit/action@v3.0.0
|
||||||
|
|
||||||
docs_check:
|
docs_check:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
@ -313,7 +313,7 @@ jobs:
|
|||||||
# Notify only once - when CI completes (and after deploy) in case it's successfull
|
# Notify only once - when CI completes (and after deploy) in case it's successfull
|
||||||
notify-complete:
|
notify-complete:
|
||||||
needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check, pre-commit ]
|
needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check, pre-commit ]
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
# Discord notification can't handle schedule events
|
# Discord notification can't handle schedule events
|
||||||
if: (github.event_name != 'schedule')
|
if: (github.event_name != 'schedule')
|
||||||
permissions:
|
permissions:
|
||||||
@ -338,7 +338,7 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check, pre-commit ]
|
needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check, pre-commit ]
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade'
|
if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade'
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ repos:
|
|||||||
- types-filelock==3.2.7
|
- types-filelock==3.2.7
|
||||||
- types-requests==2.28.11.2
|
- types-requests==2.28.11.2
|
||||||
- types-tabulate==0.9.0.0
|
- types-tabulate==0.9.0.0
|
||||||
- types-python-dateutil==2.8.19.1
|
- 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
|
||||||
|
@ -522,13 +522,13 @@ Since backtesting lacks some detailed information about what happens within a ca
|
|||||||
- ROI
|
- ROI
|
||||||
- exits are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the exit will be at 2%)
|
- exits are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the exit will be at 2%)
|
||||||
- exits are never "below the candle", so a ROI of 2% may result in a exit at 2.4% if low was at 2.4% profit
|
- exits are never "below the candle", so a ROI of 2% may result in a exit at 2.4% if low was at 2.4% profit
|
||||||
- Forceexits caused by `<N>=-1` ROI entries use low as exit value, unless N falls on the candle open (e.g. `120: -1` for 1h candles)
|
- Force-exits caused by `<N>=-1` ROI entries use low as exit value, unless N falls on the candle open (e.g. `120: -1` for 1h candles)
|
||||||
- Stoploss exits happen exactly at stoploss price, even if low was lower, but the loss will be `2 * fees` higher than the stoploss price
|
- Stoploss exits happen exactly at stoploss price, even if low was lower, but the loss will be `2 * fees` higher than the stoploss price
|
||||||
- Stoploss is evaluated before ROI within one candle. So you can often see more trades with the `stoploss` exit reason comparing to the results obtained with the same strategy in the Dry Run/Live Trade modes
|
- Stoploss is evaluated before ROI within one candle. So you can often see more trades with the `stoploss` exit reason comparing to the results obtained with the same strategy in the Dry Run/Live Trade modes
|
||||||
- Low happens before high for stoploss, protecting capital first
|
- Low happens before high for stoploss, protecting capital first
|
||||||
- Trailing stoploss
|
- Trailing stoploss
|
||||||
- Trailing Stoploss is only adjusted if it's below the candle's low (otherwise it would be triggered)
|
- Trailing Stoploss is only adjusted if it's below the candle's low (otherwise it would be triggered)
|
||||||
- On trade entry candles that trigger trailing stoploss, the "minimum offset" (`stop_positive_offset`) is assumed (instead of high) - and the stop is calculated from this point
|
- On trade entry candles that trigger trailing stoploss, the "minimum offset" (`stop_positive_offset`) is assumed (instead of high) - and the stop is calculated from this point. This rule is NOT applicable to custom-stoploss scenarios, since there's no information about the stoploss logic available.
|
||||||
- High happens first - adjusting stoploss
|
- High happens first - adjusting stoploss
|
||||||
- Low uses the adjusted stoploss (so exits with large high-low difference are backtested correctly)
|
- Low uses the adjusted stoploss (so exits with large high-low difference are backtested correctly)
|
||||||
- ROI applies before trailing-stop, ensuring profits are "top-capped" at ROI if both ROI and trailing stop applies
|
- ROI applies before trailing-stop, ensuring profits are "top-capped" at ROI if both ROI and trailing stop applies
|
||||||
@ -546,8 +546,8 @@ In addition to the above assumptions, strategy authors should carefully read the
|
|||||||
|
|
||||||
### Trading limits in backtesting
|
### Trading limits in backtesting
|
||||||
|
|
||||||
Exchanges have certain trading limits, like minimum base currency, or minimum stake (quote) currency.
|
Exchanges have certain trading limits, like minimum (and maximum) base currency, or minimum/maximum stake (quote) currency.
|
||||||
These limits are usually listed in the exchange documentation as "trading rules" or similar.
|
These limits are usually listed in the exchange documentation as "trading rules" or similar and can be quite different between different pairs.
|
||||||
|
|
||||||
Backtesting (as well as live and dry-run) does honor these limits, and will ensure that a stoploss can be placed below this value - so the value will be slightly higher than what the exchange specifies.
|
Backtesting (as well as live and dry-run) does honor these limits, and will ensure that a stoploss can be placed below this value - so the value will be slightly higher than what the exchange specifies.
|
||||||
Freqtrade has however no information about historic limits.
|
Freqtrade has however no information about historic limits.
|
||||||
|
@ -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).
|
||||||
|
@ -189,11 +189,11 @@ dataframe["target_roi"] = dataframe["&-s_close_mean"] + dataframe["&-s_close_std
|
|||||||
dataframe["sell_roi"] = dataframe["&-s_close_mean"] - dataframe["&-s_close_std"] * 1.25
|
dataframe["sell_roi"] = dataframe["&-s_close_mean"] - dataframe["&-s_close_std"] * 1.25
|
||||||
```
|
```
|
||||||
|
|
||||||
To consider the population of *historical predictions* for creating the dynamic target instead of information from the training as discussed above, you would set `fit_live_prediction_candles` in the config to the number of historical prediction candles you wish to use to generate target statistics.
|
To consider the population of *historical predictions* for creating the dynamic target instead of information from the training as discussed above, you would set `fit_live_predictions_candles` in the config to the number of historical prediction candles you wish to use to generate target statistics.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"freqai": {
|
"freqai": {
|
||||||
"fit_live_prediction_candles": 300,
|
"fit_live_predictions_candles": 300,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -161,9 +161,13 @@ You can indicate to the bot that it should not train models, but instead should
|
|||||||
|
|
||||||
```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,6 +1,6 @@
|
|||||||
markdown==3.3.7
|
markdown==3.3.7
|
||||||
mkdocs==1.4.1
|
mkdocs==1.4.1
|
||||||
mkdocs-material==8.5.6
|
mkdocs-material==8.5.7
|
||||||
mdx_truly_sane_lists==1.3
|
mdx_truly_sane_lists==1.3
|
||||||
pymdown-extensions==9.6
|
pymdown-extensions==9.7
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
""" Freqtrade bot """
|
""" Freqtrade bot """
|
||||||
__version__ = '2022.10.dev'
|
__version__ = '2022.11.dev'
|
||||||
|
|
||||||
if 'dev' in __version__:
|
if 'dev' in __version__:
|
||||||
try:
|
try:
|
||||||
@ -16,6 +16,6 @@ if 'dev' in __version__:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
versionfile = Path('./freqtrade_commit')
|
versionfile = Path('./freqtrade_commit')
|
||||||
if versionfile.is_file():
|
if versionfile.is_file():
|
||||||
__version__ = f"docker-{versionfile.read_text()[:8]}"
|
__version__ = f"docker-{__version__}-{versionfile.read_text()[:8]}"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
@ -49,7 +49,7 @@ AVAILABLE_CLI_OPTIONS = {
|
|||||||
default=0,
|
default=0,
|
||||||
),
|
),
|
||||||
"logfile": Arg(
|
"logfile": Arg(
|
||||||
'--logfile',
|
'--logfile', '--log-file',
|
||||||
help="Log to the file specified. Special values are: 'syslog', 'journald'. "
|
help="Log to the file specified. Special values are: 'syslog', 'journald'. "
|
||||||
"See the documentation for more details.",
|
"See the documentation for more details.",
|
||||||
metavar='FILE',
|
metavar='FILE',
|
||||||
|
@ -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,
|
||||||
|
@ -9,14 +9,15 @@ from freqtrade.exchange.bitpanda import Bitpanda
|
|||||||
from freqtrade.exchange.bittrex import Bittrex
|
from freqtrade.exchange.bittrex import Bittrex
|
||||||
from freqtrade.exchange.bybit import Bybit
|
from freqtrade.exchange.bybit import Bybit
|
||||||
from freqtrade.exchange.coinbasepro import Coinbasepro
|
from freqtrade.exchange.coinbasepro import Coinbasepro
|
||||||
from freqtrade.exchange.exchange import (amount_to_contract_precision, amount_to_contracts,
|
from freqtrade.exchange.exchange_utils import (amount_to_contract_precision, amount_to_contracts,
|
||||||
amount_to_precision, available_exchanges, ccxt_exchanges,
|
amount_to_precision, available_exchanges,
|
||||||
contracts_to_amount, date_minus_candles,
|
ccxt_exchanges, contracts_to_amount,
|
||||||
is_exchange_known_ccxt, market_is_active,
|
date_minus_candles, is_exchange_known_ccxt,
|
||||||
price_to_precision, timeframe_to_minutes,
|
market_is_active, price_to_precision,
|
||||||
timeframe_to_msecs, timeframe_to_next_date,
|
timeframe_to_minutes, timeframe_to_msecs,
|
||||||
timeframe_to_prev_date, timeframe_to_seconds,
|
timeframe_to_next_date, timeframe_to_prev_date,
|
||||||
validate_exchange, validate_exchanges)
|
timeframe_to_seconds, validate_exchange,
|
||||||
|
validate_exchanges)
|
||||||
from freqtrade.exchange.ftx import Ftx
|
from freqtrade.exchange.ftx import Ftx
|
||||||
from freqtrade.exchange.gateio import Gateio
|
from freqtrade.exchange.gateio import Gateio
|
||||||
from freqtrade.exchange.hitbtc import Hitbtc
|
from freqtrade.exchange.hitbtc import Hitbtc
|
||||||
|
@ -42,24 +42,6 @@ class Binance(Exchange):
|
|||||||
(TradingMode.FUTURES, MarginMode.ISOLATED)
|
(TradingMode.FUTURES, MarginMode.ISOLATED)
|
||||||
]
|
]
|
||||||
|
|
||||||
def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool:
|
|
||||||
"""
|
|
||||||
Verify stop_loss against stoploss-order value (limit or price)
|
|
||||||
Returns True if adjustment is necessary.
|
|
||||||
:param side: "buy" or "sell"
|
|
||||||
"""
|
|
||||||
order_types = ('stop_loss_limit', 'stop', 'stop_market')
|
|
||||||
|
|
||||||
return (
|
|
||||||
order.get('stopPrice', None) is None
|
|
||||||
or (
|
|
||||||
order['type'] in order_types
|
|
||||||
and (
|
|
||||||
(side == "sell" and stop_loss > float(order['stopPrice'])) or
|
|
||||||
(side == "buy" and stop_loss < float(order['stopPrice']))
|
|
||||||
)
|
|
||||||
))
|
|
||||||
|
|
||||||
def get_tickers(self, symbols: Optional[List[str]] = None, cached: bool = False) -> Tickers:
|
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:
|
||||||
|
@ -8,7 +8,6 @@ import inspect
|
|||||||
import logging
|
import logging
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from math import ceil
|
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Any, Coroutine, Dict, List, Literal, Optional, Tuple, Union
|
from typing import Any, Coroutine, Dict, List, Literal, Optional, Tuple, Union
|
||||||
|
|
||||||
@ -16,7 +15,7 @@ import arrow
|
|||||||
import ccxt
|
import ccxt
|
||||||
import ccxt.async_support as ccxt_async
|
import ccxt.async_support as ccxt_async
|
||||||
from cachetools import TTLCache
|
from cachetools import TTLCache
|
||||||
from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision
|
from ccxt import TICK_SIZE
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
from pandas import DataFrame, concat
|
from pandas import DataFrame, concat
|
||||||
|
|
||||||
@ -28,17 +27,19 @@ from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode
|
|||||||
from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError,
|
from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError,
|
||||||
InvalidOrderException, OperationalException, PricingError,
|
InvalidOrderException, OperationalException, PricingError,
|
||||||
RetryableOrderError, TemporaryError)
|
RetryableOrderError, TemporaryError)
|
||||||
from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, BAD_EXCHANGES,
|
from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, remove_credentials, retrier,
|
||||||
EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED,
|
retrier_async)
|
||||||
remove_credentials, retrier, retrier_async)
|
from freqtrade.exchange.exchange_utils import (CcxtModuleType, amount_to_contract_precision,
|
||||||
|
amount_to_contracts, amount_to_precision,
|
||||||
|
contracts_to_amount, date_minus_candles,
|
||||||
|
is_exchange_known_ccxt, market_is_active,
|
||||||
|
price_to_precision, timeframe_to_minutes,
|
||||||
|
timeframe_to_msecs, timeframe_to_next_date,
|
||||||
|
timeframe_to_prev_date, timeframe_to_seconds)
|
||||||
from freqtrade.exchange.types import Ticker, Tickers
|
from freqtrade.exchange.types import Ticker, Tickers
|
||||||
from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_json,
|
from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_json,
|
||||||
safe_value_fallback2)
|
safe_value_fallback2)
|
||||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||||
from freqtrade.util import FtPrecise
|
|
||||||
|
|
||||||
|
|
||||||
CcxtModuleType = Any
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -1076,7 +1077,14 @@ class Exchange:
|
|||||||
Verify stop_loss against stoploss-order value (limit or price)
|
Verify stop_loss against stoploss-order value (limit or price)
|
||||||
Returns True if adjustment is necessary.
|
Returns True if adjustment is necessary.
|
||||||
"""
|
"""
|
||||||
raise OperationalException(f"stoploss is not implemented for {self.name}.")
|
if not self._ft_has.get('stoploss_on_exchange'):
|
||||||
|
raise OperationalException(f"stoploss is not implemented for {self.name}.")
|
||||||
|
|
||||||
|
return (
|
||||||
|
order.get('stopPrice', None) is None
|
||||||
|
or ((side == "sell" and stop_loss > float(order['stopPrice'])) or
|
||||||
|
(side == "buy" and stop_loss < float(order['stopPrice'])))
|
||||||
|
)
|
||||||
|
|
||||||
def _get_stop_order_type(self, user_order_type) -> Tuple[str, str]:
|
def _get_stop_order_type(self, user_order_type) -> Tuple[str, str]:
|
||||||
|
|
||||||
@ -1106,7 +1114,7 @@ class Exchange:
|
|||||||
'In stoploss limit order, stop price should be more than limit price')
|
'In stoploss limit order, stop price should be more than limit price')
|
||||||
return limit_rate
|
return limit_rate
|
||||||
|
|
||||||
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
|
def _get_stop_params(self, side: BuySell, ordertype: str, stop_price: float) -> Dict:
|
||||||
params = self._params.copy()
|
params = self._params.copy()
|
||||||
# Verify if stopPrice works for your exchange!
|
# Verify if stopPrice works for your exchange!
|
||||||
params.update({'stopPrice': stop_price})
|
params.update({'stopPrice': stop_price})
|
||||||
@ -1155,7 +1163,8 @@ class Exchange:
|
|||||||
return dry_order
|
return dry_order
|
||||||
|
|
||||||
try:
|
try:
|
||||||
params = self._get_stop_params(ordertype=ordertype, stop_price=stop_price_norm)
|
params = self._get_stop_params(side=side, ordertype=ordertype,
|
||||||
|
stop_price=stop_price_norm)
|
||||||
if self.trading_mode == TradingMode.FUTURES:
|
if self.trading_mode == TradingMode.FUTURES:
|
||||||
params['reduceOnly'] = True
|
params['reduceOnly'] = True
|
||||||
|
|
||||||
@ -1995,11 +2004,8 @@ class Exchange:
|
|||||||
def _now_is_time_to_refresh(self, pair: str, timeframe: str, candle_type: CandleType) -> bool:
|
def _now_is_time_to_refresh(self, pair: str, timeframe: str, candle_type: CandleType) -> bool:
|
||||||
# Timeframe in seconds
|
# Timeframe in seconds
|
||||||
interval_in_sec = timeframe_to_seconds(timeframe)
|
interval_in_sec = timeframe_to_seconds(timeframe)
|
||||||
|
plr = self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0) + interval_in_sec
|
||||||
return not (
|
return plr < arrow.utcnow().int_timestamp
|
||||||
(self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0)
|
|
||||||
+ interval_in_sec) >= arrow.utcnow().int_timestamp
|
|
||||||
)
|
|
||||||
|
|
||||||
@retrier_async
|
@retrier_async
|
||||||
async def _async_get_candle_history(
|
async def _async_get_candle_history(
|
||||||
@ -2802,240 +2808,3 @@ class Exchange:
|
|||||||
# describes the min amt for a tier, and the lowest tier will always go down to 0
|
# describes the min amt for a tier, and the lowest tier will always go down to 0
|
||||||
else:
|
else:
|
||||||
raise OperationalException(f"Cannot get maintenance ratio using {self.name}")
|
raise OperationalException(f"Cannot get maintenance ratio using {self.name}")
|
||||||
|
|
||||||
|
|
||||||
def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = None) -> bool:
|
|
||||||
return exchange_name in ccxt_exchanges(ccxt_module)
|
|
||||||
|
|
||||||
|
|
||||||
def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
|
|
||||||
"""
|
|
||||||
Return the list of all exchanges known to ccxt
|
|
||||||
"""
|
|
||||||
return ccxt_module.exchanges if ccxt_module is not None else ccxt.exchanges
|
|
||||||
|
|
||||||
|
|
||||||
def available_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
|
|
||||||
"""
|
|
||||||
Return exchanges available to the bot, i.e. non-bad exchanges in the ccxt list
|
|
||||||
"""
|
|
||||||
exchanges = ccxt_exchanges(ccxt_module)
|
|
||||||
return [x for x in exchanges if validate_exchange(x)[0]]
|
|
||||||
|
|
||||||
|
|
||||||
def validate_exchange(exchange: str) -> Tuple[bool, str]:
|
|
||||||
ex_mod = getattr(ccxt, exchange.lower())()
|
|
||||||
if not ex_mod or not ex_mod.has:
|
|
||||||
return False, ''
|
|
||||||
missing = [k for k in EXCHANGE_HAS_REQUIRED if ex_mod.has.get(k) is not True]
|
|
||||||
if missing:
|
|
||||||
return False, f"missing: {', '.join(missing)}"
|
|
||||||
|
|
||||||
missing_opt = [k for k in EXCHANGE_HAS_OPTIONAL if not ex_mod.has.get(k)]
|
|
||||||
|
|
||||||
if exchange.lower() in BAD_EXCHANGES:
|
|
||||||
return False, BAD_EXCHANGES.get(exchange.lower(), '')
|
|
||||||
if missing_opt:
|
|
||||||
return True, f"missing opt: {', '.join(missing_opt)}"
|
|
||||||
|
|
||||||
return True, ''
|
|
||||||
|
|
||||||
|
|
||||||
def validate_exchanges(all_exchanges: bool) -> List[Tuple[str, bool, str]]:
|
|
||||||
"""
|
|
||||||
:return: List of tuples with exchangename, valid, reason.
|
|
||||||
"""
|
|
||||||
exchanges = ccxt_exchanges() if all_exchanges else available_exchanges()
|
|
||||||
exchanges_valid = [
|
|
||||||
(e, *validate_exchange(e)) for e in exchanges
|
|
||||||
]
|
|
||||||
return exchanges_valid
|
|
||||||
|
|
||||||
|
|
||||||
def timeframe_to_seconds(timeframe: str) -> int:
|
|
||||||
"""
|
|
||||||
Translates the timeframe interval value written in the human readable
|
|
||||||
form ('1m', '5m', '1h', '1d', '1w', etc.) to the number
|
|
||||||
of seconds for one timeframe interval.
|
|
||||||
"""
|
|
||||||
return ccxt.Exchange.parse_timeframe(timeframe)
|
|
||||||
|
|
||||||
|
|
||||||
def timeframe_to_minutes(timeframe: str) -> int:
|
|
||||||
"""
|
|
||||||
Same as timeframe_to_seconds, but returns minutes.
|
|
||||||
"""
|
|
||||||
return ccxt.Exchange.parse_timeframe(timeframe) // 60
|
|
||||||
|
|
||||||
|
|
||||||
def timeframe_to_msecs(timeframe: str) -> int:
|
|
||||||
"""
|
|
||||||
Same as timeframe_to_seconds, but returns milliseconds.
|
|
||||||
"""
|
|
||||||
return ccxt.Exchange.parse_timeframe(timeframe) * 1000
|
|
||||||
|
|
||||||
|
|
||||||
def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime:
|
|
||||||
"""
|
|
||||||
Use Timeframe and determine the candle start date for this date.
|
|
||||||
Does not round when given a candle start date.
|
|
||||||
:param timeframe: timeframe in string format (e.g. "5m")
|
|
||||||
:param date: date to use. Defaults to now(utc)
|
|
||||||
:returns: date of previous candle (with utc timezone)
|
|
||||||
"""
|
|
||||||
if not date:
|
|
||||||
date = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000,
|
|
||||||
ROUND_DOWN) // 1000
|
|
||||||
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime:
|
|
||||||
"""
|
|
||||||
Use Timeframe and determine next candle.
|
|
||||||
:param timeframe: timeframe in string format (e.g. "5m")
|
|
||||||
:param date: date to use. Defaults to now(utc)
|
|
||||||
:returns: date of next candle (with utc timezone)
|
|
||||||
"""
|
|
||||||
if not date:
|
|
||||||
date = datetime.now(timezone.utc)
|
|
||||||
new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000,
|
|
||||||
ROUND_UP) // 1000
|
|
||||||
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def date_minus_candles(
|
|
||||||
timeframe: str, candle_count: int, date: Optional[datetime] = None) -> datetime:
|
|
||||||
"""
|
|
||||||
subtract X candles from a date.
|
|
||||||
:param timeframe: timeframe in string format (e.g. "5m")
|
|
||||||
:param candle_count: Amount of candles to subtract.
|
|
||||||
:param date: date to use. Defaults to now(utc)
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not date:
|
|
||||||
date = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
tf_min = timeframe_to_minutes(timeframe)
|
|
||||||
new_date = timeframe_to_prev_date(timeframe, date) - timedelta(minutes=tf_min * candle_count)
|
|
||||||
return new_date
|
|
||||||
|
|
||||||
|
|
||||||
def market_is_active(market: Dict) -> bool:
|
|
||||||
"""
|
|
||||||
Return True if the market is active.
|
|
||||||
"""
|
|
||||||
# "It's active, if the active flag isn't explicitly set to false. If it's missing or
|
|
||||||
# true then it's true. If it's undefined, then it's most likely true, but not 100% )"
|
|
||||||
# See https://github.com/ccxt/ccxt/issues/4874,
|
|
||||||
# https://github.com/ccxt/ccxt/issues/4075#issuecomment-434760520
|
|
||||||
return market.get('active', True) is not False
|
|
||||||
|
|
||||||
|
|
||||||
def amount_to_contracts(amount: float, contract_size: Optional[float]) -> float:
|
|
||||||
"""
|
|
||||||
Convert amount to contracts.
|
|
||||||
:param amount: amount to convert
|
|
||||||
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
|
|
||||||
:return: num-contracts
|
|
||||||
"""
|
|
||||||
if contract_size and contract_size != 1:
|
|
||||||
return float(FtPrecise(amount) / FtPrecise(contract_size))
|
|
||||||
else:
|
|
||||||
return amount
|
|
||||||
|
|
||||||
|
|
||||||
def contracts_to_amount(num_contracts: float, contract_size: Optional[float]) -> float:
|
|
||||||
"""
|
|
||||||
Takes num-contracts and converts it to contract size
|
|
||||||
:param num_contracts: number of contracts
|
|
||||||
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
|
|
||||||
:return: Amount
|
|
||||||
"""
|
|
||||||
|
|
||||||
if contract_size and contract_size != 1:
|
|
||||||
return float(FtPrecise(num_contracts) * FtPrecise(contract_size))
|
|
||||||
else:
|
|
||||||
return num_contracts
|
|
||||||
|
|
||||||
|
|
||||||
def amount_to_precision(amount: float, amount_precision: Optional[float],
|
|
||||||
precisionMode: Optional[int]) -> float:
|
|
||||||
"""
|
|
||||||
Returns the amount to buy or sell to a precision the Exchange accepts
|
|
||||||
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
|
|
||||||
based on our definitions.
|
|
||||||
:param amount: amount to truncate
|
|
||||||
:param amount_precision: amount precision to use.
|
|
||||||
should be retrieved from markets[pair]['precision']['amount']
|
|
||||||
:param precisionMode: precision mode to use. Should be used from precisionMode
|
|
||||||
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
|
|
||||||
:return: truncated amount
|
|
||||||
"""
|
|
||||||
if amount_precision is not None and precisionMode is not None:
|
|
||||||
precision = int(amount_precision) if precisionMode != TICK_SIZE else amount_precision
|
|
||||||
# precision must be an int for non-ticksize inputs.
|
|
||||||
amount = float(decimal_to_precision(amount, rounding_mode=TRUNCATE,
|
|
||||||
precision=precision,
|
|
||||||
counting_mode=precisionMode,
|
|
||||||
))
|
|
||||||
|
|
||||||
return amount
|
|
||||||
|
|
||||||
|
|
||||||
def amount_to_contract_precision(
|
|
||||||
amount, amount_precision: Optional[float], precisionMode: Optional[int],
|
|
||||||
contract_size: Optional[float]) -> float:
|
|
||||||
"""
|
|
||||||
Returns the amount to buy or sell to a precision the Exchange accepts
|
|
||||||
including calculation to and from contracts.
|
|
||||||
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
|
|
||||||
based on our definitions.
|
|
||||||
:param amount: amount to truncate
|
|
||||||
:param amount_precision: amount precision to use.
|
|
||||||
should be retrieved from markets[pair]['precision']['amount']
|
|
||||||
:param precisionMode: precision mode to use. Should be used from precisionMode
|
|
||||||
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
|
|
||||||
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
|
|
||||||
:return: truncated amount
|
|
||||||
"""
|
|
||||||
if amount_precision is not None and precisionMode is not None:
|
|
||||||
contracts = amount_to_contracts(amount, contract_size)
|
|
||||||
amount_p = amount_to_precision(contracts, amount_precision, precisionMode)
|
|
||||||
return contracts_to_amount(amount_p, contract_size)
|
|
||||||
return amount
|
|
||||||
|
|
||||||
|
|
||||||
def price_to_precision(price: float, price_precision: Optional[float],
|
|
||||||
precisionMode: Optional[int]) -> float:
|
|
||||||
"""
|
|
||||||
Returns the price rounded up to the precision the Exchange accepts.
|
|
||||||
Partial Re-implementation of ccxt internal method decimal_to_precision(),
|
|
||||||
which does not support rounding up
|
|
||||||
TODO: If ccxt supports ROUND_UP for decimal_to_precision(), we could remove this and
|
|
||||||
align with amount_to_precision().
|
|
||||||
!!! Rounds up
|
|
||||||
:param price: price to convert
|
|
||||||
:param price_precision: price precision to use. Used from markets[pair]['precision']['price']
|
|
||||||
:param precisionMode: precision mode to use. Should be used from precisionMode
|
|
||||||
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
|
|
||||||
:return: price rounded up to the precision the Exchange accepts
|
|
||||||
|
|
||||||
"""
|
|
||||||
if price_precision is not None and precisionMode is not None:
|
|
||||||
# price = float(decimal_to_precision(price, rounding_mode=ROUND,
|
|
||||||
# precision=price_precision,
|
|
||||||
# counting_mode=self.precisionMode,
|
|
||||||
# ))
|
|
||||||
if precisionMode == TICK_SIZE:
|
|
||||||
precision = FtPrecise(price_precision)
|
|
||||||
price_str = FtPrecise(price)
|
|
||||||
missing = price_str % precision
|
|
||||||
if not missing == FtPrecise("0"):
|
|
||||||
price = round(float(str(price_str - missing + precision)), 14)
|
|
||||||
else:
|
|
||||||
symbol_prec = price_precision
|
|
||||||
big_price = price * pow(10, symbol_prec)
|
|
||||||
price = ceil(big_price) / pow(10, symbol_prec)
|
|
||||||
return price
|
|
||||||
|
252
freqtrade/exchange/exchange_utils.py
Normal file
252
freqtrade/exchange/exchange_utils.py
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Exchange support utils
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from math import ceil
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import ccxt
|
||||||
|
from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision
|
||||||
|
|
||||||
|
from freqtrade.exchange.common import BAD_EXCHANGES, EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED
|
||||||
|
from freqtrade.util import FtPrecise
|
||||||
|
|
||||||
|
|
||||||
|
CcxtModuleType = Any
|
||||||
|
|
||||||
|
|
||||||
|
def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = None) -> bool:
|
||||||
|
return exchange_name in ccxt_exchanges(ccxt_module)
|
||||||
|
|
||||||
|
|
||||||
|
def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
|
||||||
|
"""
|
||||||
|
Return the list of all exchanges known to ccxt
|
||||||
|
"""
|
||||||
|
return ccxt_module.exchanges if ccxt_module is not None else ccxt.exchanges
|
||||||
|
|
||||||
|
|
||||||
|
def available_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
|
||||||
|
"""
|
||||||
|
Return exchanges available to the bot, i.e. non-bad exchanges in the ccxt list
|
||||||
|
"""
|
||||||
|
exchanges = ccxt_exchanges(ccxt_module)
|
||||||
|
return [x for x in exchanges if validate_exchange(x)[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_exchange(exchange: str) -> Tuple[bool, str]:
|
||||||
|
ex_mod = getattr(ccxt, exchange.lower())()
|
||||||
|
if not ex_mod or not ex_mod.has:
|
||||||
|
return False, ''
|
||||||
|
missing = [k for k in EXCHANGE_HAS_REQUIRED if ex_mod.has.get(k) is not True]
|
||||||
|
if missing:
|
||||||
|
return False, f"missing: {', '.join(missing)}"
|
||||||
|
|
||||||
|
missing_opt = [k for k in EXCHANGE_HAS_OPTIONAL if not ex_mod.has.get(k)]
|
||||||
|
|
||||||
|
if exchange.lower() in BAD_EXCHANGES:
|
||||||
|
return False, BAD_EXCHANGES.get(exchange.lower(), '')
|
||||||
|
if missing_opt:
|
||||||
|
return True, f"missing opt: {', '.join(missing_opt)}"
|
||||||
|
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
|
||||||
|
def validate_exchanges(all_exchanges: bool) -> List[Tuple[str, bool, str]]:
|
||||||
|
"""
|
||||||
|
:return: List of tuples with exchangename, valid, reason.
|
||||||
|
"""
|
||||||
|
exchanges = ccxt_exchanges() if all_exchanges else available_exchanges()
|
||||||
|
exchanges_valid = [
|
||||||
|
(e, *validate_exchange(e)) for e in exchanges
|
||||||
|
]
|
||||||
|
return exchanges_valid
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_to_seconds(timeframe: str) -> int:
|
||||||
|
"""
|
||||||
|
Translates the timeframe interval value written in the human readable
|
||||||
|
form ('1m', '5m', '1h', '1d', '1w', etc.) to the number
|
||||||
|
of seconds for one timeframe interval.
|
||||||
|
"""
|
||||||
|
return ccxt.Exchange.parse_timeframe(timeframe)
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_to_minutes(timeframe: str) -> int:
|
||||||
|
"""
|
||||||
|
Same as timeframe_to_seconds, but returns minutes.
|
||||||
|
"""
|
||||||
|
return ccxt.Exchange.parse_timeframe(timeframe) // 60
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_to_msecs(timeframe: str) -> int:
|
||||||
|
"""
|
||||||
|
Same as timeframe_to_seconds, but returns milliseconds.
|
||||||
|
"""
|
||||||
|
return ccxt.Exchange.parse_timeframe(timeframe) * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_to_prev_date(timeframe: str, date: datetime = None) -> datetime:
|
||||||
|
"""
|
||||||
|
Use Timeframe and determine the candle start date for this date.
|
||||||
|
Does not round when given a candle start date.
|
||||||
|
:param timeframe: timeframe in string format (e.g. "5m")
|
||||||
|
:param date: date to use. Defaults to now(utc)
|
||||||
|
:returns: date of previous candle (with utc timezone)
|
||||||
|
"""
|
||||||
|
if not date:
|
||||||
|
date = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000,
|
||||||
|
ROUND_DOWN) // 1000
|
||||||
|
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime:
|
||||||
|
"""
|
||||||
|
Use Timeframe and determine next candle.
|
||||||
|
:param timeframe: timeframe in string format (e.g. "5m")
|
||||||
|
:param date: date to use. Defaults to now(utc)
|
||||||
|
:returns: date of next candle (with utc timezone)
|
||||||
|
"""
|
||||||
|
if not date:
|
||||||
|
date = datetime.now(timezone.utc)
|
||||||
|
new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000,
|
||||||
|
ROUND_UP) // 1000
|
||||||
|
return datetime.fromtimestamp(new_timestamp, tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def date_minus_candles(
|
||||||
|
timeframe: str, candle_count: int, date: Optional[datetime] = None) -> datetime:
|
||||||
|
"""
|
||||||
|
subtract X candles from a date.
|
||||||
|
:param timeframe: timeframe in string format (e.g. "5m")
|
||||||
|
:param candle_count: Amount of candles to subtract.
|
||||||
|
:param date: date to use. Defaults to now(utc)
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not date:
|
||||||
|
date = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
tf_min = timeframe_to_minutes(timeframe)
|
||||||
|
new_date = timeframe_to_prev_date(timeframe, date) - timedelta(minutes=tf_min * candle_count)
|
||||||
|
return new_date
|
||||||
|
|
||||||
|
|
||||||
|
def market_is_active(market: Dict) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if the market is active.
|
||||||
|
"""
|
||||||
|
# "It's active, if the active flag isn't explicitly set to false. If it's missing or
|
||||||
|
# true then it's true. If it's undefined, then it's most likely true, but not 100% )"
|
||||||
|
# See https://github.com/ccxt/ccxt/issues/4874,
|
||||||
|
# https://github.com/ccxt/ccxt/issues/4075#issuecomment-434760520
|
||||||
|
return market.get('active', True) is not False
|
||||||
|
|
||||||
|
|
||||||
|
def amount_to_contracts(amount: float, contract_size: Optional[float]) -> float:
|
||||||
|
"""
|
||||||
|
Convert amount to contracts.
|
||||||
|
:param amount: amount to convert
|
||||||
|
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
|
||||||
|
:return: num-contracts
|
||||||
|
"""
|
||||||
|
if contract_size and contract_size != 1:
|
||||||
|
return float(FtPrecise(amount) / FtPrecise(contract_size))
|
||||||
|
else:
|
||||||
|
return amount
|
||||||
|
|
||||||
|
|
||||||
|
def contracts_to_amount(num_contracts: float, contract_size: Optional[float]) -> float:
|
||||||
|
"""
|
||||||
|
Takes num-contracts and converts it to contract size
|
||||||
|
:param num_contracts: number of contracts
|
||||||
|
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
|
||||||
|
:return: Amount
|
||||||
|
"""
|
||||||
|
|
||||||
|
if contract_size and contract_size != 1:
|
||||||
|
return float(FtPrecise(num_contracts) * FtPrecise(contract_size))
|
||||||
|
else:
|
||||||
|
return num_contracts
|
||||||
|
|
||||||
|
|
||||||
|
def amount_to_precision(amount: float, amount_precision: Optional[float],
|
||||||
|
precisionMode: Optional[int]) -> float:
|
||||||
|
"""
|
||||||
|
Returns the amount to buy or sell to a precision the Exchange accepts
|
||||||
|
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
|
||||||
|
based on our definitions.
|
||||||
|
:param amount: amount to truncate
|
||||||
|
:param amount_precision: amount precision to use.
|
||||||
|
should be retrieved from markets[pair]['precision']['amount']
|
||||||
|
:param precisionMode: precision mode to use. Should be used from precisionMode
|
||||||
|
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
|
||||||
|
:return: truncated amount
|
||||||
|
"""
|
||||||
|
if amount_precision is not None and precisionMode is not None:
|
||||||
|
precision = int(amount_precision) if precisionMode != TICK_SIZE else amount_precision
|
||||||
|
# precision must be an int for non-ticksize inputs.
|
||||||
|
amount = float(decimal_to_precision(amount, rounding_mode=TRUNCATE,
|
||||||
|
precision=precision,
|
||||||
|
counting_mode=precisionMode,
|
||||||
|
))
|
||||||
|
|
||||||
|
return amount
|
||||||
|
|
||||||
|
|
||||||
|
def amount_to_contract_precision(
|
||||||
|
amount, amount_precision: Optional[float], precisionMode: Optional[int],
|
||||||
|
contract_size: Optional[float]) -> float:
|
||||||
|
"""
|
||||||
|
Returns the amount to buy or sell to a precision the Exchange accepts
|
||||||
|
including calculation to and from contracts.
|
||||||
|
Re-implementation of ccxt internal methods - ensuring we can test the result is correct
|
||||||
|
based on our definitions.
|
||||||
|
:param amount: amount to truncate
|
||||||
|
:param amount_precision: amount precision to use.
|
||||||
|
should be retrieved from markets[pair]['precision']['amount']
|
||||||
|
:param precisionMode: precision mode to use. Should be used from precisionMode
|
||||||
|
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
|
||||||
|
:param contract_size: contract size - taken from exchange.get_contract_size(pair)
|
||||||
|
:return: truncated amount
|
||||||
|
"""
|
||||||
|
if amount_precision is not None and precisionMode is not None:
|
||||||
|
contracts = amount_to_contracts(amount, contract_size)
|
||||||
|
amount_p = amount_to_precision(contracts, amount_precision, precisionMode)
|
||||||
|
return contracts_to_amount(amount_p, contract_size)
|
||||||
|
return amount
|
||||||
|
|
||||||
|
|
||||||
|
def price_to_precision(price: float, price_precision: Optional[float],
|
||||||
|
precisionMode: Optional[int]) -> float:
|
||||||
|
"""
|
||||||
|
Returns the price rounded up to the precision the Exchange accepts.
|
||||||
|
Partial Re-implementation of ccxt internal method decimal_to_precision(),
|
||||||
|
which does not support rounding up
|
||||||
|
TODO: If ccxt supports ROUND_UP for decimal_to_precision(), we could remove this and
|
||||||
|
align with amount_to_precision().
|
||||||
|
!!! Rounds up
|
||||||
|
:param price: price to convert
|
||||||
|
:param price_precision: price precision to use. Used from markets[pair]['precision']['price']
|
||||||
|
:param precisionMode: precision mode to use. Should be used from precisionMode
|
||||||
|
one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE
|
||||||
|
:return: price rounded up to the precision the Exchange accepts
|
||||||
|
|
||||||
|
"""
|
||||||
|
if price_precision is not None and precisionMode is not None:
|
||||||
|
# price = float(decimal_to_precision(price, rounding_mode=ROUND,
|
||||||
|
# precision=price_precision,
|
||||||
|
# counting_mode=self.precisionMode,
|
||||||
|
# ))
|
||||||
|
if precisionMode == TICK_SIZE:
|
||||||
|
precision = FtPrecise(price_precision)
|
||||||
|
price_str = FtPrecise(price)
|
||||||
|
missing = price_str % precision
|
||||||
|
if not missing == FtPrecise("0"):
|
||||||
|
price = round(float(str(price_str - missing + precision)), 14)
|
||||||
|
else:
|
||||||
|
symbol_prec = price_precision
|
||||||
|
big_price = price * pow(10, symbol_prec)
|
||||||
|
price = ceil(big_price) / pow(10, symbol_prec)
|
||||||
|
return price
|
@ -126,13 +126,3 @@ class Gateio(Exchange):
|
|||||||
pair=pair,
|
pair=pair,
|
||||||
params={'stop': True}
|
params={'stop': True}
|
||||||
)
|
)
|
||||||
|
|
||||||
def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool:
|
|
||||||
"""
|
|
||||||
Verify stop_loss against stoploss-order value (limit or price)
|
|
||||||
Returns True if adjustment is necessary.
|
|
||||||
"""
|
|
||||||
return (order.get('stopPrice', None) is None or (
|
|
||||||
side == "sell" and stop_loss > float(order['stopPrice'])) or
|
|
||||||
(side == "buy" and stop_loss < float(order['stopPrice']))
|
|
||||||
)
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
|
from freqtrade.constants import BuySell
|
||||||
from freqtrade.exchange import Exchange
|
from freqtrade.exchange import Exchange
|
||||||
|
|
||||||
|
|
||||||
@ -22,20 +23,7 @@ class Huobi(Exchange):
|
|||||||
"l2_limit_range_required": False,
|
"l2_limit_range_required": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool:
|
def _get_stop_params(self, side: BuySell, ordertype: str, stop_price: float) -> Dict:
|
||||||
"""
|
|
||||||
Verify stop_loss against stoploss-order value (limit or price)
|
|
||||||
Returns True if adjustment is necessary.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
order.get('stopPrice', None) is None
|
|
||||||
or (
|
|
||||||
order['type'] == 'stop'
|
|
||||||
and stop_loss > float(order['stopPrice'])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
|
|
||||||
|
|
||||||
params = self._params.copy()
|
params = self._params.copy()
|
||||||
params.update({
|
params.update({
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
|
from freqtrade.constants import BuySell
|
||||||
from freqtrade.exchange import Exchange
|
from freqtrade.exchange import Exchange
|
||||||
|
|
||||||
|
|
||||||
@ -27,17 +28,7 @@ class Kucoin(Exchange):
|
|||||||
"ohlcv_candle_limit": 1500,
|
"ohlcv_candle_limit": 1500,
|
||||||
}
|
}
|
||||||
|
|
||||||
def stoploss_adjust(self, stop_loss: float, order: Dict, side: str) -> bool:
|
def _get_stop_params(self, side: BuySell, ordertype: str, stop_price: float) -> Dict:
|
||||||
"""
|
|
||||||
Verify stop_loss against stoploss-order value (limit or price)
|
|
||||||
Returns True if adjustment is necessary.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
order.get('stopPrice', None) is None
|
|
||||||
or stop_loss > float(order['stopPrice'])
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:
|
|
||||||
|
|
||||||
params = self._params.copy()
|
params = self._params.copy()
|
||||||
params.update({
|
params.update({
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -210,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
|
||||||
@ -241,7 +244,8 @@ class FreqaiDataKitchen:
|
|||||||
self.data["filter_drop_index_training"] = drop_index
|
self.data["filter_drop_index_training"] = drop_index
|
||||||
|
|
||||||
else:
|
else:
|
||||||
filtered_df = self.check_pred_labels(filtered_df)
|
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)
|
||||||
@ -350,13 +354,19 @@ class FreqaiDataKitchen:
|
|||||||
:param df: Dataframe to be standardized
|
:param df: Dataframe to be standardized
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for item in df.keys():
|
train_max = [None] * len(df.keys())
|
||||||
df[item] = (
|
train_min = [None] * len(df.keys())
|
||||||
2
|
|
||||||
* (df[item] - self.data[f"{item}_min"])
|
for i, item in enumerate(df.keys()):
|
||||||
/ (self.data[f"{item}_max"] - self.data[f"{item}_min"])
|
train_max[i] = self.data[f"{item}_max"]
|
||||||
- 1
|
train_min[i] = self.data[f"{item}_min"]
|
||||||
)
|
|
||||||
|
train_max_series = pd.Series(train_max, index=df.keys())
|
||||||
|
train_min_series = pd.Series(train_min, index=df.keys())
|
||||||
|
|
||||||
|
df = (
|
||||||
|
2 * (df - train_min_series) / (train_max_series - train_min_series) - 1
|
||||||
|
)
|
||||||
|
|
||||||
return df
|
return df
|
||||||
|
|
||||||
@ -464,18 +474,16 @@ class FreqaiDataKitchen:
|
|||||||
def check_pred_labels(self, df_predictions: DataFrame) -> DataFrame:
|
def check_pred_labels(self, df_predictions: DataFrame) -> DataFrame:
|
||||||
"""
|
"""
|
||||||
Check that prediction feature labels match training feature labels.
|
Check that prediction feature labels match training feature labels.
|
||||||
:params:
|
:param df_predictions: incoming predictions
|
||||||
:df_predictions: incoming predictions
|
|
||||||
"""
|
"""
|
||||||
train_labels = self.data_dictionary["train_features"].columns
|
constant_labels = self.data['constant_features_list']
|
||||||
pred_labels = df_predictions.columns
|
df_predictions = df_predictions.filter(
|
||||||
num_diffs = len(pred_labels.difference(train_labels))
|
df_predictions.columns.difference(constant_labels)
|
||||||
if num_diffs != 0:
|
)
|
||||||
df_predictions = df_predictions[train_labels]
|
logger.warning(
|
||||||
logger.warning(
|
f"Removed {len(constant_labels)} features from prediction features, "
|
||||||
f"Removed {num_diffs} features from prediction features, "
|
f"these were considered constant values during most recent training."
|
||||||
f"these were likely considered constant values during most recent training."
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return df_predictions
|
return df_predictions
|
||||||
|
|
||||||
@ -957,6 +965,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[f"{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
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import shutil
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@ -20,7 +19,7 @@ from freqtrade.exceptions import OperationalException
|
|||||||
from freqtrade.exchange import timeframe_to_seconds
|
from freqtrade.exchange import timeframe_to_seconds
|
||||||
from freqtrade.freqai.data_drawer import FreqaiDataDrawer
|
from freqtrade.freqai.data_drawer import FreqaiDataDrawer
|
||||||
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
|
||||||
from freqtrade.freqai.utils import plot_feature_importance
|
from freqtrade.freqai.utils import plot_feature_importance, record_params
|
||||||
from freqtrade.strategy.interface import IStrategy
|
from freqtrade.strategy.interface import IStrategy
|
||||||
|
|
||||||
|
|
||||||
@ -60,6 +59,7 @@ class IFreqaiModel(ABC):
|
|||||||
"data_split_parameters", {})
|
"data_split_parameters", {})
|
||||||
self.model_training_parameters: Dict[str, Any] = config.get("freqai", {}).get(
|
self.model_training_parameters: Dict[str, Any] = config.get("freqai", {}).get(
|
||||||
"model_training_parameters", {})
|
"model_training_parameters", {})
|
||||||
|
self.identifier: str = self.freqai_info.get("identifier", "no_id_provided")
|
||||||
self.retrain = False
|
self.retrain = False
|
||||||
self.first = True
|
self.first = True
|
||||||
self.set_full_path()
|
self.set_full_path()
|
||||||
@ -68,7 +68,6 @@ class IFreqaiModel(ABC):
|
|||||||
if self.save_backtest_models:
|
if self.save_backtest_models:
|
||||||
logger.info('Backtesting module configured to save all models.')
|
logger.info('Backtesting module configured to save all models.')
|
||||||
self.dd = FreqaiDataDrawer(Path(self.full_path), self.config, self.follow_mode)
|
self.dd = FreqaiDataDrawer(Path(self.full_path), self.config, self.follow_mode)
|
||||||
self.identifier: str = self.freqai_info.get("identifier", "no_id_provided")
|
|
||||||
self.scanning = False
|
self.scanning = False
|
||||||
self.ft_params = self.freqai_info["feature_parameters"]
|
self.ft_params = self.freqai_info["feature_parameters"]
|
||||||
self.corr_pairlist: List[str] = self.ft_params.get("include_corr_pairlist", [])
|
self.corr_pairlist: List[str] = self.ft_params.get("include_corr_pairlist", [])
|
||||||
@ -96,6 +95,8 @@ class IFreqaiModel(ABC):
|
|||||||
self._threads: List[threading.Thread] = []
|
self._threads: List[threading.Thread] = []
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
record_params(config, self.full_path)
|
||||||
|
|
||||||
def __getstate__(self):
|
def __getstate__(self):
|
||||||
"""
|
"""
|
||||||
Return an empty state to be pickled in hyperopt
|
Return an empty state to be pickled in hyperopt
|
||||||
@ -528,14 +529,13 @@ class IFreqaiModel(ABC):
|
|||||||
return file_exists
|
return file_exists
|
||||||
|
|
||||||
def set_full_path(self) -> None:
|
def set_full_path(self) -> None:
|
||||||
|
"""
|
||||||
|
Creates and sets the full path for the identifier
|
||||||
|
"""
|
||||||
self.full_path = Path(
|
self.full_path = Path(
|
||||||
self.config["user_data_dir"] / "models" / f"{self.freqai_info['identifier']}"
|
self.config["user_data_dir"] / "models" / f"{self.identifier}"
|
||||||
)
|
)
|
||||||
self.full_path.mkdir(parents=True, exist_ok=True)
|
self.full_path.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy(
|
|
||||||
self.config["config_files"][0],
|
|
||||||
Path(self.full_path, Path(self.config["config_files"][0]).name),
|
|
||||||
)
|
|
||||||
|
|
||||||
def extract_data_and_train_model(
|
def extract_data_and_train_model(
|
||||||
self,
|
self,
|
||||||
|
@ -26,9 +26,8 @@ class XGBoostRFClassifier(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 XGBoostRFClassifier(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
|
||||||
|
@ -29,6 +29,7 @@ class XGBoostRFRegressor(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']]
|
||||||
|
@ -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']]
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
import rapidjson
|
||||||
|
|
||||||
from freqtrade.configuration import TimeRange
|
from freqtrade.configuration import TimeRange
|
||||||
from freqtrade.constants import Config
|
from freqtrade.constants import Config
|
||||||
@ -191,3 +193,28 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen,
|
|||||||
fig.update_layout(title_text=f"Best and worst features by importance {pair}")
|
fig.update_layout(title_text=f"Best and worst features by importance {pair}")
|
||||||
label = label.replace('&', '').replace('%', '') # escape two FreqAI specific characters
|
label = label.replace('&', '').replace('%', '') # escape two FreqAI specific characters
|
||||||
store_plot_file(fig, f"{dk.model_filename}-{label}.html", dk.data_path)
|
store_plot_file(fig, f"{dk.model_filename}-{label}.html", dk.data_path)
|
||||||
|
|
||||||
|
|
||||||
|
def record_params(config: Dict[str, Any], full_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Records run params in the full path for reproducibility
|
||||||
|
"""
|
||||||
|
params_record_path = full_path / "run_params.json"
|
||||||
|
|
||||||
|
run_params = {
|
||||||
|
"freqai": config.get('freqai', {}),
|
||||||
|
"timeframe": config.get('timeframe'),
|
||||||
|
"stake_amount": config.get('stake_amount'),
|
||||||
|
"stake_currency": config.get('stake_currency'),
|
||||||
|
"max_open_trades": config.get('max_open_trades'),
|
||||||
|
"pairs": config.get('exchange', {}).get('pair_whitelist')
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(params_record_path, "w") as handle:
|
||||||
|
rapidjson.dump(
|
||||||
|
run_params,
|
||||||
|
handle,
|
||||||
|
indent=4,
|
||||||
|
default=str,
|
||||||
|
number_mode=rapidjson.NM_NATIVE | rapidjson.NM_NAN
|
||||||
|
)
|
||||||
|
@ -1471,12 +1471,13 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
)
|
)
|
||||||
return cancelled
|
return cancelled
|
||||||
|
|
||||||
def _safe_exit_amount(self, pair: str, amount: float) -> float:
|
def _safe_exit_amount(self, trade: Trade, pair: str, amount: float) -> float:
|
||||||
"""
|
"""
|
||||||
Get sellable amount.
|
Get sellable amount.
|
||||||
Should be trade.amount - but will fall back to the available amount if necessary.
|
Should be trade.amount - but will fall back to the available amount if necessary.
|
||||||
This should cover cases where get_real_amount() was not able to update the amount
|
This should cover cases where get_real_amount() was not able to update the amount
|
||||||
for whatever reason.
|
for whatever reason.
|
||||||
|
:param trade: Trade we're working with
|
||||||
:param pair: Pair we're trying to sell
|
:param pair: Pair we're trying to sell
|
||||||
:param amount: amount we expect to be available
|
:param amount: amount we expect to be available
|
||||||
:return: amount to sell
|
:return: amount to sell
|
||||||
@ -1495,6 +1496,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
return amount
|
return amount
|
||||||
elif wallet_amount > amount * 0.98:
|
elif wallet_amount > amount * 0.98:
|
||||||
logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.")
|
logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.")
|
||||||
|
trade.amount = wallet_amount
|
||||||
return wallet_amount
|
return wallet_amount
|
||||||
else:
|
else:
|
||||||
raise DependencyException(
|
raise DependencyException(
|
||||||
@ -1553,7 +1555,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
# Emergency sells (default to market!)
|
# Emergency sells (default to market!)
|
||||||
order_type = self.strategy.order_types.get("emergency_exit", "market")
|
order_type = self.strategy.order_types.get("emergency_exit", "market")
|
||||||
|
|
||||||
amount = self._safe_exit_amount(trade.pair, sub_trade_amt or trade.amount)
|
amount = self._safe_exit_amount(trade, trade.pair, sub_trade_amt or trade.amount)
|
||||||
time_in_force = self.strategy.order_time_in_force['exit']
|
time_in_force = self.strategy.order_time_in_force['exit']
|
||||||
|
|
||||||
if (exit_check.exit_type != ExitType.LIQUIDATION
|
if (exit_check.exit_type != ExitType.LIQUIDATION
|
||||||
@ -1828,7 +1830,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
never in base currency.
|
never in base currency.
|
||||||
"""
|
"""
|
||||||
self.wallets.update()
|
self.wallets.update()
|
||||||
amount_ = amount
|
amount_ = trade.amount
|
||||||
if order_obj.ft_order_side == trade.exit_side or order_obj.ft_order_side == 'stoploss':
|
if order_obj.ft_order_side == trade.exit_side or order_obj.ft_order_side == 'stoploss':
|
||||||
# check against remaining amount!
|
# check against remaining amount!
|
||||||
amount_ = trade.amount - amount
|
amount_ = trade.amount - amount
|
||||||
|
@ -1520,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
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
@ -11,6 +10,7 @@ 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
|
||||||
@ -37,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
|
||||||
@ -74,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
|
||||||
@ -89,9 +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))
|
||||||
# Throttle the messages to 50/s
|
|
||||||
await asyncio.sleep(0.02)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/message/ws")
|
@router.websocket("/message/ws")
|
||||||
@ -116,7 +115,7 @@ 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, WebSocketException):
|
except (WebSocketDisconnect, WebSocketException):
|
||||||
# Handle client disconnects
|
# Handle client disconnects
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import Any, Dict, 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
|
||||||
@ -10,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__)
|
||||||
@ -24,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
|
||||||
):
|
):
|
||||||
|
|
||||||
@ -34,7 +37,11 @@ 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.queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=32)
|
||||||
self._relay_task = asyncio.create_task(self.relay())
|
self._relay_task = asyncio.create_task(self.relay())
|
||||||
|
|
||||||
@ -47,6 +54,10 @@ 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
|
||||||
@ -57,11 +68,19 @@ class WebSocketChannel:
|
|||||||
"""
|
"""
|
||||||
await self._wrapped_ws.send(data)
|
await self._wrapped_ws.send(data)
|
||||||
|
|
||||||
async def send(self, data):
|
async def send(self, data) -> bool:
|
||||||
"""
|
"""
|
||||||
Add the data to the queue to be sent
|
Add the data to the queue to be sent.
|
||||||
|
:returns: True if data added to queue, False otherwise
|
||||||
"""
|
"""
|
||||||
self.queue.put_nowait(data)
|
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):
|
||||||
"""
|
"""
|
||||||
@ -119,8 +138,8 @@ class WebSocketChannel:
|
|||||||
# Limit messages per sec.
|
# Limit messages per sec.
|
||||||
# Could cause problems with queue size if too low, and
|
# Could cause problems with queue size if too low, and
|
||||||
# problems with network traffik if too high.
|
# problems with network traffik if too high.
|
||||||
# 0.001 = 1000/s
|
# 0.01 = 100/s
|
||||||
await asyncio.sleep(0.001)
|
await asyncio.sleep(self.throttle)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# The connection was closed, just exit the task
|
# The connection was closed, just exit the task
|
||||||
return
|
return
|
||||||
@ -160,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()
|
||||||
|
|
||||||
@ -170,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')):
|
||||||
if channel.subscribed_to(message_type):
|
await self.send_direct(channel, message)
|
||||||
if not channel.queue.full():
|
|
||||||
await channel.send(data)
|
|
||||||
else:
|
|
||||||
logger.info(f"Channel {channel} is too far behind, disconnecting")
|
|
||||||
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
|
||||||
|
@ -270,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)
|
||||||
|
@ -1072,28 +1072,26 @@ class IStrategy(ABC, HyperStrategyMixin):
|
|||||||
trade.stop_loss > (high or current_rate)
|
trade.stop_loss > (high or current_rate)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Make sure current_profit is calculated using high for backtesting.
|
||||||
|
bound = (low if trade.is_short else high)
|
||||||
|
bound_profit = current_profit if not bound else trade.calc_profit_ratio(bound)
|
||||||
if self.use_custom_stoploss and dir_correct:
|
if self.use_custom_stoploss and dir_correct:
|
||||||
stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None
|
stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None
|
||||||
)(pair=trade.pair, trade=trade,
|
)(pair=trade.pair, trade=trade,
|
||||||
current_time=current_time,
|
current_time=current_time,
|
||||||
current_rate=current_rate,
|
current_rate=(bound or current_rate),
|
||||||
current_profit=current_profit)
|
current_profit=bound_profit)
|
||||||
# Sanity check - error cases will return None
|
# Sanity check - error cases will return None
|
||||||
if stop_loss_value:
|
if stop_loss_value:
|
||||||
# logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}")
|
# logger.info(f"{trade.pair} {stop_loss_value=} {bound_profit=}")
|
||||||
trade.adjust_stop_loss(current_rate, stop_loss_value)
|
trade.adjust_stop_loss(bound or current_rate, stop_loss_value)
|
||||||
else:
|
else:
|
||||||
logger.warning("CustomStoploss function did not return valid stoploss")
|
logger.warning("CustomStoploss function did not return valid stoploss")
|
||||||
|
|
||||||
sl_lower_long = (trade.stop_loss < (low or current_rate) and not trade.is_short)
|
if self.trailing_stop and dir_correct:
|
||||||
sl_higher_short = (trade.stop_loss > (high or current_rate) and trade.is_short)
|
|
||||||
if self.trailing_stop and (sl_lower_long or sl_higher_short):
|
|
||||||
# trailing stoploss handling
|
# trailing stoploss handling
|
||||||
sl_offset = self.trailing_stop_positive_offset
|
sl_offset = self.trailing_stop_positive_offset
|
||||||
|
|
||||||
# Make sure current_profit is calculated using high for backtesting.
|
# Make sure current_profit is calculated using high for backtesting.
|
||||||
bound = low if trade.is_short else high
|
|
||||||
bound_profit = current_profit if not bound else trade.calc_profit_ratio(bound)
|
|
||||||
|
|
||||||
# Don't update stoploss if trailing_only_offset_is_reached is true.
|
# Don't update stoploss if trailing_only_offset_is_reached is true.
|
||||||
if not (self.trailing_only_offset_is_reached and bound_profit < sl_offset):
|
if not (self.trailing_only_offset_is_reached and bound_profit < sl_offset):
|
||||||
@ -1101,7 +1099,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
|||||||
if self.trailing_stop_positive is not None and bound_profit > sl_offset:
|
if self.trailing_stop_positive is not None and bound_profit > sl_offset:
|
||||||
stop_loss_value = self.trailing_stop_positive
|
stop_loss_value = self.trailing_stop_positive
|
||||||
logger.debug(f"{trade.pair} - Using positive stoploss: {stop_loss_value} "
|
logger.debug(f"{trade.pair} - Using positive stoploss: {stop_loss_value} "
|
||||||
f"offset: {sl_offset:.4g} profit: {current_profit:.2%}")
|
f"offset: {sl_offset:.4g} profit: {bound_profit:.2%}")
|
||||||
|
|
||||||
trade.adjust_stop_loss(bound or current_rate, stop_loss_value)
|
trade.adjust_stop_loss(bound or current_rate, stop_loss_value)
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ from freqtrade.configuration import Configuration
|
|||||||
from freqtrade.constants import PROCESS_THROTTLE_SECS, RETRY_TIMEOUT, Config
|
from freqtrade.constants import PROCESS_THROTTLE_SECS, RETRY_TIMEOUT, Config
|
||||||
from freqtrade.enums import State
|
from freqtrade.enums import State
|
||||||
from freqtrade.exceptions import OperationalException, TemporaryError
|
from freqtrade.exceptions import OperationalException, TemporaryError
|
||||||
|
from freqtrade.exchange import timeframe_to_next_date
|
||||||
from freqtrade.freqtradebot import FreqtradeBot
|
from freqtrade.freqtradebot import FreqtradeBot
|
||||||
|
|
||||||
|
|
||||||
@ -35,7 +36,6 @@ class Worker:
|
|||||||
self._config = config
|
self._config = config
|
||||||
self._init(False)
|
self._init(False)
|
||||||
|
|
||||||
self.last_throttle_start_time: float = 0
|
|
||||||
self._heartbeat_msg: float = 0
|
self._heartbeat_msg: float = 0
|
||||||
|
|
||||||
# Tell systemd that we completed initialization phase
|
# Tell systemd that we completed initialization phase
|
||||||
@ -112,7 +112,10 @@ class Worker:
|
|||||||
# Ping systemd watchdog before throttling
|
# Ping systemd watchdog before throttling
|
||||||
self._notify("WATCHDOG=1\nSTATUS=State: RUNNING.")
|
self._notify("WATCHDOG=1\nSTATUS=State: RUNNING.")
|
||||||
|
|
||||||
self._throttle(func=self._process_running, throttle_secs=self._throttle_secs)
|
# Use an offset of 1s to ensure a new candle has been issued
|
||||||
|
self._throttle(func=self._process_running, throttle_secs=self._throttle_secs,
|
||||||
|
timeframe=self._config['timeframe'] if self._config else None,
|
||||||
|
timeframe_offset=1)
|
||||||
|
|
||||||
if self._heartbeat_interval:
|
if self._heartbeat_interval:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@ -127,24 +130,42 @@ class Worker:
|
|||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def _throttle(self, func: Callable[..., Any], throttle_secs: float, *args, **kwargs) -> Any:
|
def _throttle(self, func: Callable[..., Any], throttle_secs: float,
|
||||||
|
timeframe: Optional[str] = None, timeframe_offset: float = 1.0,
|
||||||
|
*args, **kwargs) -> Any:
|
||||||
"""
|
"""
|
||||||
Throttles the given callable that it
|
Throttles the given callable that it
|
||||||
takes at least `min_secs` to finish execution.
|
takes at least `min_secs` to finish execution.
|
||||||
:param func: Any callable
|
:param func: Any callable
|
||||||
:param throttle_secs: throttling interation execution time limit in seconds
|
:param throttle_secs: throttling interation execution time limit in seconds
|
||||||
|
:param timeframe: ensure iteration is executed at the beginning of the next candle.
|
||||||
|
:param timeframe_offset: offset in seconds to apply to the next candle time.
|
||||||
:return: Any (result of execution of func)
|
:return: Any (result of execution of func)
|
||||||
"""
|
"""
|
||||||
self.last_throttle_start_time = time.time()
|
last_throttle_start_time = time.time()
|
||||||
logger.debug("========================================")
|
logger.debug("========================================")
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
time_passed = time.time() - self.last_throttle_start_time
|
time_passed = time.time() - last_throttle_start_time
|
||||||
sleep_duration = max(throttle_secs - time_passed, 0.0)
|
sleep_duration = throttle_secs - time_passed
|
||||||
|
if timeframe:
|
||||||
|
next_tf = timeframe_to_next_date(timeframe)
|
||||||
|
# Maximum throttling should be until new candle arrives
|
||||||
|
# Offset of 0.2s is added to ensure a new candle has been issued.
|
||||||
|
next_tf_with_offset = next_tf.timestamp() - time.time() + timeframe_offset
|
||||||
|
sleep_duration = min(sleep_duration, next_tf_with_offset)
|
||||||
|
sleep_duration = max(sleep_duration, 0.0)
|
||||||
|
# next_iter = datetime.now(timezone.utc) + timedelta(seconds=sleep_duration)
|
||||||
|
|
||||||
logger.debug(f"Throttling with '{func.__name__}()': sleep for {sleep_duration:.2f} s, "
|
logger.debug(f"Throttling with '{func.__name__}()': sleep for {sleep_duration:.2f} s, "
|
||||||
f"last iteration took {time_passed:.2f} s.")
|
f"last iteration took {time_passed:.2f} s.")
|
||||||
time.sleep(sleep_duration)
|
self._sleep(sleep_duration)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sleep(sleep_duration: float) -> None:
|
||||||
|
"""Local sleep method - to improve testability"""
|
||||||
|
time.sleep(sleep_duration)
|
||||||
|
|
||||||
def _process_stopped(self) -> None:
|
def _process_stopped(self) -> None:
|
||||||
self.freqtrade.process_stopped()
|
self.freqtrade.process_stopped()
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ flake8-tidy-imports==4.8.0
|
|||||||
mypy==0.982
|
mypy==0.982
|
||||||
pre-commit==2.20.0
|
pre-commit==2.20.0
|
||||||
pytest==7.1.3
|
pytest==7.1.3
|
||||||
pytest-asyncio==0.19.0
|
pytest-asyncio==0.20.1
|
||||||
pytest-cov==4.0.0
|
pytest-cov==4.0.0
|
||||||
pytest-mock==3.10.0
|
pytest-mock==3.10.0
|
||||||
pytest-random-order==1.0.4
|
pytest-random-order==1.0.4
|
||||||
@ -27,4 +27,4 @@ types-cachetools==5.2.1
|
|||||||
types-filelock==3.2.7
|
types-filelock==3.2.7
|
||||||
types-requests==2.28.11.2
|
types-requests==2.28.11.2
|
||||||
types-tabulate==0.9.0.0
|
types-tabulate==0.9.0.0
|
||||||
types-python-dateutil==2.8.19.1
|
types-python-dateutil==2.8.19.2
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
|
|
||||||
# Required for hyperopt
|
# Required for hyperopt
|
||||||
scipy==1.9.2
|
scipy==1.9.3
|
||||||
scikit-learn==1.1.2
|
scikit-learn==1.1.2
|
||||||
scikit-optimize==0.9.0
|
scikit-optimize==0.9.0
|
||||||
filelock==3.8.0
|
filelock==3.8.0
|
||||||
progressbar2==4.0.0
|
progressbar2==4.1.1
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Include all requirements to run the bot.
|
# Include all requirements to run the bot.
|
||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
|
|
||||||
plotly==5.10.0
|
plotly==5.11.0
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
numpy==1.23.4
|
numpy==1.23.4
|
||||||
pandas==1.5.0; platform_machine != 'armv7l'
|
pandas==1.5.1
|
||||||
# Piwheels doesn't have 1.5.0 yet.
|
|
||||||
pandas==1.4.3; platform_machine == 'armv7l'
|
|
||||||
pandas-ta==0.3.14b
|
pandas-ta==0.3.14b
|
||||||
|
|
||||||
ccxt==2.0.25
|
ccxt==2.0.90
|
||||||
# Pin cryptography for now due to rust build errors with piwheels
|
# Pin cryptography for now due to rust build errors with piwheels
|
||||||
cryptography==38.0.1
|
cryptography==38.0.1
|
||||||
aiohttp==3.8.3
|
aiohttp==3.8.3
|
||||||
@ -18,7 +16,7 @@ jsonschema==4.16.0
|
|||||||
TA-Lib==0.4.25
|
TA-Lib==0.4.25
|
||||||
technical==1.3.0
|
technical==1.3.0
|
||||||
tabulate==0.9.0
|
tabulate==0.9.0
|
||||||
pycoingecko==3.0.0
|
pycoingecko==3.1.0
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
tables==3.7.0
|
tables==3.7.0
|
||||||
blosc==1.10.6
|
blosc==1.10.6
|
||||||
@ -29,7 +27,7 @@ pyarrow==9.0.0; platform_machine != 'armv7l'
|
|||||||
py_find_1st==1.1.5
|
py_find_1st==1.1.5
|
||||||
|
|
||||||
# Load ticker files 30% faster
|
# Load ticker files 30% faster
|
||||||
python-rapidjson==1.8
|
python-rapidjson==1.9
|
||||||
# Properly format api responses
|
# Properly format api responses
|
||||||
orjson==3.8.0
|
orjson==3.8.0
|
||||||
|
|
||||||
@ -38,14 +36,14 @@ sdnotify==0.3.2
|
|||||||
|
|
||||||
# API Server
|
# API Server
|
||||||
fastapi==0.85.1
|
fastapi==0.85.1
|
||||||
pydantic>=1.8.0
|
pydantic==1.10.2
|
||||||
uvicorn==0.18.3
|
uvicorn==0.18.3
|
||||||
pyjwt==2.5.0
|
pyjwt==2.6.0
|
||||||
aiofiles==22.1.0
|
aiofiles==22.1.0
|
||||||
psutil==5.9.2
|
psutil==5.9.3
|
||||||
|
|
||||||
# Support for colorized terminal output
|
# Support for colorized terminal output
|
||||||
colorama==0.4.5
|
colorama==0.4.6
|
||||||
# Building config files interactively
|
# Building config files interactively
|
||||||
questionary==1.10.0
|
questionary==1.10.0
|
||||||
prompt-toolkit==3.0.31
|
prompt-toolkit==3.0.31
|
||||||
@ -56,5 +54,5 @@ python-dateutil==2.8.2
|
|||||||
schedule==1.1.0
|
schedule==1.1.0
|
||||||
|
|
||||||
#WS Messages
|
#WS Messages
|
||||||
websockets==10.3
|
websockets==10.4
|
||||||
janus==1.0.0
|
janus==1.0.0
|
||||||
|
@ -15,7 +15,7 @@ from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler, g
|
|||||||
from freqtrade.data.history.jsondatahandler import JsonDataHandler, JsonGzDataHandler
|
from freqtrade.data.history.jsondatahandler import JsonDataHandler, JsonGzDataHandler
|
||||||
from freqtrade.data.history.parquetdatahandler import ParquetDataHandler
|
from freqtrade.data.history.parquetdatahandler import ParquetDataHandler
|
||||||
from freqtrade.enums import CandleType, TradingMode
|
from freqtrade.enums import CandleType, TradingMode
|
||||||
from tests.conftest import log_has
|
from tests.conftest import log_has, log_has_re
|
||||||
|
|
||||||
|
|
||||||
def test_datahandler_ohlcv_get_pairs(testdatadir):
|
def test_datahandler_ohlcv_get_pairs(testdatadir):
|
||||||
@ -154,6 +154,85 @@ def test_jsondatahandler_ohlcv_load(testdatadir, caplog):
|
|||||||
assert df.columns.equals(df1.columns)
|
assert df.columns.equals(df1.columns)
|
||||||
|
|
||||||
|
|
||||||
|
def test_datahandler__check_empty_df(testdatadir, caplog):
|
||||||
|
dh = JsonDataHandler(testdatadir)
|
||||||
|
expected_text = r"Price jump in UNITTEST/USDT, 1h, spot between"
|
||||||
|
df = DataFrame([
|
||||||
|
[
|
||||||
|
1511686200000, # 8:50:00
|
||||||
|
8.794, # open
|
||||||
|
8.948, # high
|
||||||
|
8.794, # low
|
||||||
|
8.88, # close
|
||||||
|
2255, # volume (in quote currency)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1511686500000, # 8:55:00
|
||||||
|
8.88,
|
||||||
|
8.942,
|
||||||
|
8.88,
|
||||||
|
8.893,
|
||||||
|
9911,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1511687100000, # 9:05:00
|
||||||
|
8.891,
|
||||||
|
8.893,
|
||||||
|
8.875,
|
||||||
|
8.877,
|
||||||
|
2251
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1511687400000, # 9:10:00
|
||||||
|
8.877,
|
||||||
|
8.883,
|
||||||
|
8.895,
|
||||||
|
8.817,
|
||||||
|
123551
|
||||||
|
]
|
||||||
|
], columns=['date', 'open', 'high', 'low', 'close', 'volume'])
|
||||||
|
|
||||||
|
dh._check_empty_df(df, 'UNITTEST/USDT', '1h', CandleType.SPOT, True, True)
|
||||||
|
assert not log_has_re(expected_text, caplog)
|
||||||
|
df = DataFrame([
|
||||||
|
[
|
||||||
|
1511686200000, # 8:50:00
|
||||||
|
8.794, # open
|
||||||
|
8.948, # high
|
||||||
|
8.794, # low
|
||||||
|
8.88, # close
|
||||||
|
2255, # volume (in quote currency)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1511686500000, # 8:55:00
|
||||||
|
8.88,
|
||||||
|
8.942,
|
||||||
|
8.88,
|
||||||
|
8.893,
|
||||||
|
9911,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1511687100000, # 9:05:00
|
||||||
|
889.1, # Price jump by several decimals
|
||||||
|
889.3,
|
||||||
|
887.5,
|
||||||
|
887.7,
|
||||||
|
2251
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1511687400000, # 9:10:00
|
||||||
|
8.877,
|
||||||
|
8.883,
|
||||||
|
8.895,
|
||||||
|
8.817,
|
||||||
|
123551
|
||||||
|
]
|
||||||
|
], columns=['date', 'open', 'high', 'low', 'close', 'volume'])
|
||||||
|
|
||||||
|
dh._check_empty_df(df, 'UNITTEST/USDT', '1h', CandleType.SPOT, True, True)
|
||||||
|
assert log_has_re(expected_text, caplog)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('datahandler', ['feather', 'parquet'])
|
@pytest.mark.parametrize('datahandler', ['feather', 'parquet'])
|
||||||
def test_datahandler_trades_not_supported(datahandler, testdatadir, ):
|
def test_datahandler_trades_not_supported(datahandler, testdatadir, ):
|
||||||
dh = get_datahandler(testdatadir, datahandler)
|
dh = get_datahandler(testdatadir, datahandler)
|
||||||
|
@ -162,9 +162,6 @@ def test_stoploss_adjust_binance(mocker, default_conf, sl1, sl2, sl3, side):
|
|||||||
}
|
}
|
||||||
assert exchange.stoploss_adjust(sl1, order, side=side)
|
assert exchange.stoploss_adjust(sl1, order, side=side)
|
||||||
assert not exchange.stoploss_adjust(sl2, order, side=side)
|
assert not exchange.stoploss_adjust(sl2, order, side=side)
|
||||||
# Test with invalid order case
|
|
||||||
order['type'] = 'stop_loss'
|
|
||||||
assert not exchange.stoploss_adjust(sl3, order, side=side)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fill_leverage_tiers_binance(default_conf, mocker):
|
def test_fill_leverage_tiers_binance(default_conf, mocker):
|
||||||
|
@ -113,5 +113,4 @@ def test_stoploss_adjust_huobi(mocker, default_conf):
|
|||||||
assert exchange.stoploss_adjust(1501, order, 'sell')
|
assert exchange.stoploss_adjust(1501, order, 'sell')
|
||||||
assert not exchange.stoploss_adjust(1499, order, 'sell')
|
assert not exchange.stoploss_adjust(1499, order, 'sell')
|
||||||
# Test with invalid order case
|
# Test with invalid order case
|
||||||
order['type'] = 'stop_loss'
|
assert exchange.stoploss_adjust(1501, order, 'sell')
|
||||||
assert not exchange.stoploss_adjust(1501, order, 'sell')
|
|
||||||
|
@ -125,7 +125,8 @@ def test_normalize_data(mocker, freqai_conf):
|
|||||||
freqai = make_data_dictionary(mocker, freqai_conf)
|
freqai = make_data_dictionary(mocker, freqai_conf)
|
||||||
data_dict = freqai.dk.data_dictionary
|
data_dict = freqai.dk.data_dictionary
|
||||||
freqai.dk.normalize_data(data_dict)
|
freqai.dk.normalize_data(data_dict)
|
||||||
assert len(freqai.dk.data) == 32
|
assert any('_max' in entry for entry in freqai.dk.data.keys())
|
||||||
|
assert any('_min' in entry for entry in freqai.dk.data.keys())
|
||||||
|
|
||||||
|
|
||||||
def test_filter_features(mocker, freqai_conf):
|
def test_filter_features(mocker, freqai_conf):
|
||||||
|
@ -234,6 +234,7 @@ def test_start_backtesting_subdaily_backtest_period(mocker, freqai_conf):
|
|||||||
metadata = {"pair": "LTC/BTC"}
|
metadata = {"pair": "LTC/BTC"}
|
||||||
freqai.start_backtesting(df, metadata, freqai.dk)
|
freqai.start_backtesting(df, metadata, freqai.dk)
|
||||||
model_folders = [x for x in freqai.dd.full_path.iterdir() if x.is_dir()]
|
model_folders = [x for x in freqai.dd.full_path.iterdir() if x.is_dir()]
|
||||||
|
|
||||||
assert len(model_folders) == 9
|
assert len(model_folders) == 9
|
||||||
|
|
||||||
shutil.rmtree(Path(freqai.dk.full_path))
|
shutil.rmtree(Path(freqai.dk.full_path))
|
||||||
|
0
tests/persistence/__init__.py
Normal file
0
tests/persistence/__init__.py
Normal file
@ -2404,7 +2404,7 @@ def test_Trade_object_idem():
|
|||||||
'get_enter_tag_performance',
|
'get_enter_tag_performance',
|
||||||
'get_mix_tag_performance',
|
'get_mix_tag_performance',
|
||||||
'get_trading_volume',
|
'get_trading_volume',
|
||||||
|
'from_json',
|
||||||
)
|
)
|
||||||
EXCLUDES2 = ('trades', 'trades_open', 'bt_trades_open_pp', 'bt_open_open_trade_count',
|
EXCLUDES2 = ('trades', 'trades_open', 'bt_trades_open_pp', 'bt_open_open_trade_count',
|
||||||
'total_profit')
|
'total_profit')
|
181
tests/persistence/test_trade_fromjson.py
Normal file
181
tests/persistence/test_trade_fromjson.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from freqtrade.persistence.trade_model import Trade
|
||||||
|
|
||||||
|
|
||||||
|
def test_trade_fromjson():
|
||||||
|
"""Test the Trade.from_json() method."""
|
||||||
|
trade_string = """{
|
||||||
|
"trade_id": 25,
|
||||||
|
"pair": "ETH/USDT",
|
||||||
|
"base_currency": "ETH",
|
||||||
|
"quote_currency": "USDT",
|
||||||
|
"is_open": false,
|
||||||
|
"exchange": "binance",
|
||||||
|
"amount": 407.0,
|
||||||
|
"amount_requested": 102.92547026,
|
||||||
|
"stake_amount": 102.7494348,
|
||||||
|
"strategy": "SampleStrategy55",
|
||||||
|
"buy_tag": "Strategy2",
|
||||||
|
"enter_tag": "Strategy2",
|
||||||
|
"timeframe": 5,
|
||||||
|
"fee_open": 0.001,
|
||||||
|
"fee_open_cost": 0.1027494,
|
||||||
|
"fee_open_currency": "ETH",
|
||||||
|
"fee_close": 0.001,
|
||||||
|
"fee_close_cost": 0.1054944,
|
||||||
|
"fee_close_currency": "USDT",
|
||||||
|
"open_date": "2022-10-18 09:12:42",
|
||||||
|
"open_timestamp": 1666084362912,
|
||||||
|
"open_rate": 0.2518998249562391,
|
||||||
|
"open_rate_requested": 0.2516,
|
||||||
|
"open_trade_value": 102.62575199,
|
||||||
|
"close_date": "2022-10-18 09:45:22",
|
||||||
|
"close_timestamp": 1666086322208,
|
||||||
|
"realized_profit": 2.76315361,
|
||||||
|
"close_rate": 0.2592,
|
||||||
|
"close_rate_requested": 0.2592,
|
||||||
|
"close_profit": 0.026865,
|
||||||
|
"close_profit_pct": 2.69,
|
||||||
|
"close_profit_abs": 2.76315361,
|
||||||
|
"trade_duration_s": 1959,
|
||||||
|
"trade_duration": 32,
|
||||||
|
"profit_ratio": 0.02686,
|
||||||
|
"profit_pct": 2.69,
|
||||||
|
"profit_abs": 2.76315361,
|
||||||
|
"sell_reason": "no longer good",
|
||||||
|
"exit_reason": "no longer good",
|
||||||
|
"exit_order_status": "closed",
|
||||||
|
"stop_loss_abs": 0.1981,
|
||||||
|
"stop_loss_ratio": -0.216,
|
||||||
|
"stop_loss_pct": -21.6,
|
||||||
|
"stoploss_order_id": null,
|
||||||
|
"stoploss_last_update": null,
|
||||||
|
"stoploss_last_update_timestamp": null,
|
||||||
|
"initial_stop_loss_abs": 0.1981,
|
||||||
|
"initial_stop_loss_ratio": -0.216,
|
||||||
|
"initial_stop_loss_pct": -21.6,
|
||||||
|
"min_rate": 0.2495,
|
||||||
|
"max_rate": 0.2592,
|
||||||
|
"leverage": 1.0,
|
||||||
|
"interest_rate": 0.0,
|
||||||
|
"liquidation_price": null,
|
||||||
|
"is_short": false,
|
||||||
|
"trading_mode": "spot",
|
||||||
|
"funding_fees": 0.0,
|
||||||
|
"open_order_id": null,
|
||||||
|
"orders": [
|
||||||
|
{
|
||||||
|
"amount": 102.0,
|
||||||
|
"safe_price": 0.2526,
|
||||||
|
"ft_order_side": "buy",
|
||||||
|
"order_filled_timestamp": 1666084370887,
|
||||||
|
"ft_is_entry": true,
|
||||||
|
"pair": "ETH/USDT",
|
||||||
|
"order_id": "78404228",
|
||||||
|
"status": "closed",
|
||||||
|
"average": 0.2526,
|
||||||
|
"cost": 25.7652,
|
||||||
|
"filled": 102.0,
|
||||||
|
"is_open": false,
|
||||||
|
"order_date": "2022-10-18 09:12:42",
|
||||||
|
"order_timestamp": 1666084362684,
|
||||||
|
"order_filled_date": "2022-10-18 09:12:50",
|
||||||
|
"order_type": "limit",
|
||||||
|
"price": 0.2526,
|
||||||
|
"remaining": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 102.0,
|
||||||
|
"safe_price": 0.2517,
|
||||||
|
"ft_order_side": "buy",
|
||||||
|
"order_filled_timestamp": 1666084379056,
|
||||||
|
"ft_is_entry": true,
|
||||||
|
"pair": "ETH/USDT",
|
||||||
|
"order_id": "78405139",
|
||||||
|
"status": "closed",
|
||||||
|
"average": 0.2517,
|
||||||
|
"cost": 25.6734,
|
||||||
|
"filled": 102.0,
|
||||||
|
"is_open": false,
|
||||||
|
"order_date": "2022-10-18 09:12:57",
|
||||||
|
"order_timestamp": 1666084377681,
|
||||||
|
"order_filled_date": "2022-10-18 09:12:59",
|
||||||
|
"order_type": "limit",
|
||||||
|
"price": 0.2517,
|
||||||
|
"remaining": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 102.0,
|
||||||
|
"safe_price": 0.2517,
|
||||||
|
"ft_order_side": "buy",
|
||||||
|
"order_filled_timestamp": 1666084389644,
|
||||||
|
"ft_is_entry": true,
|
||||||
|
"pair": "ETH/USDT",
|
||||||
|
"order_id": "78405265",
|
||||||
|
"status": "closed",
|
||||||
|
"average": 0.2517,
|
||||||
|
"cost": 25.6734,
|
||||||
|
"filled": 102.0,
|
||||||
|
"is_open": false,
|
||||||
|
"order_date": "2022-10-18 09:13:03",
|
||||||
|
"order_timestamp": 1666084383295,
|
||||||
|
"order_filled_date": "2022-10-18 09:13:09",
|
||||||
|
"order_type": "limit",
|
||||||
|
"price": 0.2517,
|
||||||
|
"remaining": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 102.0,
|
||||||
|
"safe_price": 0.2516,
|
||||||
|
"ft_order_side": "buy",
|
||||||
|
"order_filled_timestamp": 1666084723521,
|
||||||
|
"ft_is_entry": true,
|
||||||
|
"pair": "ETH/USDT",
|
||||||
|
"order_id": "78405395",
|
||||||
|
"status": "closed",
|
||||||
|
"average": 0.2516,
|
||||||
|
"cost": 25.6632,
|
||||||
|
"filled": 102.0,
|
||||||
|
"is_open": false,
|
||||||
|
"order_date": "2022-10-18 09:13:13",
|
||||||
|
"order_timestamp": 1666084393920,
|
||||||
|
"order_filled_date": "2022-10-18 09:18:43",
|
||||||
|
"order_type": "limit",
|
||||||
|
"price": 0.2516,
|
||||||
|
"remaining": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 407.0,
|
||||||
|
"safe_price": 0.2592,
|
||||||
|
"ft_order_side": "sell",
|
||||||
|
"order_filled_timestamp": 1666086322198,
|
||||||
|
"ft_is_entry": false,
|
||||||
|
"pair": "ETH/USDT",
|
||||||
|
"order_id": "78432649",
|
||||||
|
"status": "closed",
|
||||||
|
"average": 0.2592,
|
||||||
|
"cost": 105.4944,
|
||||||
|
"filled": 407.0,
|
||||||
|
"is_open": false,
|
||||||
|
"order_date": "2022-10-18 09:45:21",
|
||||||
|
"order_timestamp": 1666086321435,
|
||||||
|
"order_filled_date": "2022-10-18 09:45:22",
|
||||||
|
"order_type": "market",
|
||||||
|
"price": 0.2592,
|
||||||
|
"remaining": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
trade = Trade.from_json(trade_string)
|
||||||
|
|
||||||
|
assert trade.id == 25
|
||||||
|
assert trade.pair == 'ETH/USDT'
|
||||||
|
assert trade.open_date == datetime(2022, 10, 18, 9, 12, 42, tzinfo=timezone.utc)
|
||||||
|
assert isinstance(trade.open_date, datetime)
|
||||||
|
assert trade.exit_reason == 'no longer good'
|
||||||
|
|
||||||
|
assert len(trade.orders) == 5
|
||||||
|
last_o = trade.orders[-1]
|
||||||
|
assert last_o.order_filled_date == datetime(2022, 10, 18, 9, 45, 22, tzinfo=timezone.utc)
|
||||||
|
assert isinstance(last_o.order_date, datetime)
|
@ -3969,15 +3969,17 @@ def test__safe_exit_amount(default_conf_usdt, fee, caplog, mocker, amount_wallet
|
|||||||
patch_get_signal(freqtrade)
|
patch_get_signal(freqtrade)
|
||||||
if has_err:
|
if has_err:
|
||||||
with pytest.raises(DependencyException, match=r"Not enough amount to exit trade."):
|
with pytest.raises(DependencyException, match=r"Not enough amount to exit trade."):
|
||||||
assert freqtrade._safe_exit_amount(trade.pair, trade.amount)
|
assert freqtrade._safe_exit_amount(trade, trade.pair, trade.amount)
|
||||||
else:
|
else:
|
||||||
wallet_update.reset_mock()
|
wallet_update.reset_mock()
|
||||||
assert freqtrade._safe_exit_amount(trade.pair, trade.amount) == amount_wallet
|
assert trade.amount != amount_wallet
|
||||||
|
assert freqtrade._safe_exit_amount(trade, trade.pair, trade.amount) == amount_wallet
|
||||||
assert log_has_re(r'.*Falling back to wallet-amount.', caplog)
|
assert log_has_re(r'.*Falling back to wallet-amount.', caplog)
|
||||||
|
assert trade.amount == amount_wallet
|
||||||
assert wallet_update.call_count == 1
|
assert wallet_update.call_count == 1
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
wallet_update.reset_mock()
|
wallet_update.reset_mock()
|
||||||
assert freqtrade._safe_exit_amount(trade.pair, amount_wallet) == amount_wallet
|
assert freqtrade._safe_exit_amount(trade, trade.pair, amount_wallet) == amount_wallet
|
||||||
assert not log_has_re(r'.*Falling back to wallet-amount.', caplog)
|
assert not log_has_re(r'.*Falling back to wallet-amount.', caplog)
|
||||||
assert wallet_update.call_count == 1
|
assert wallet_update.call_count == 1
|
||||||
|
|
||||||
|
@ -420,7 +420,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker)
|
|||||||
assert trade.open_order_id is None
|
assert trade.open_order_id is None
|
||||||
# Open rate is not adjusted yet
|
# Open rate is not adjusted yet
|
||||||
assert trade.open_rate == 1.99
|
assert trade.open_rate == 1.99
|
||||||
assert trade.stake_amount == 60
|
assert pytest.approx(trade.stake_amount) == 60
|
||||||
assert trade.stop_loss_pct == -0.1
|
assert trade.stop_loss_pct == -0.1
|
||||||
assert pytest.approx(trade.stop_loss) == 1.99 * (1 - 0.1 / leverage)
|
assert pytest.approx(trade.stop_loss) == 1.99 * (1 - 0.1 / leverage)
|
||||||
assert pytest.approx(trade.initial_stop_loss) == 1.99 * (1 - 0.1 / leverage)
|
assert pytest.approx(trade.initial_stop_loss) == 1.99 * (1 - 0.1 / leverage)
|
||||||
@ -446,7 +446,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker)
|
|||||||
assert len(trade.orders) == 4
|
assert len(trade.orders) == 4
|
||||||
assert trade.open_order_id is not None
|
assert trade.open_order_id is not None
|
||||||
assert trade.open_rate == 1.99
|
assert trade.open_rate == 1.99
|
||||||
assert trade.stake_amount == 60
|
assert pytest.approx(trade.stake_amount) == 60
|
||||||
assert trade.orders[-1].price == 1.95
|
assert trade.orders[-1].price == 1.95
|
||||||
assert pytest.approx(trade.orders[-1].cost) == 120 * leverage
|
assert pytest.approx(trade.orders[-1].cost) == 120 * leverage
|
||||||
|
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
from unittest.mock import MagicMock, PropertyMock
|
from unittest.mock import MagicMock, PropertyMock
|
||||||
|
|
||||||
|
import time_machine
|
||||||
|
|
||||||
from freqtrade.data.dataprovider import DataProvider
|
from freqtrade.data.dataprovider import DataProvider
|
||||||
from freqtrade.enums import State
|
from freqtrade.enums import State
|
||||||
from freqtrade.worker import Worker
|
from freqtrade.worker import Worker
|
||||||
@ -59,13 +62,58 @@ def test_throttle(mocker, default_conf, caplog) -> None:
|
|||||||
end = time.time()
|
end = time.time()
|
||||||
|
|
||||||
assert result == 42
|
assert result == 42
|
||||||
assert end - start > 0.1
|
assert 0.3 > end - start > 0.1
|
||||||
assert log_has_re(r"Throttling with 'throttled_func\(\)': sleep for \d\.\d{2} s.*", caplog)
|
assert log_has_re(r"Throttling with 'throttled_func\(\)': sleep for \d\.\d{2} s.*", caplog)
|
||||||
|
|
||||||
result = worker._throttle(throttled_func, throttle_secs=-1)
|
result = worker._throttle(throttled_func, throttle_secs=-1)
|
||||||
assert result == 42
|
assert result == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_throttle_sleep_time(mocker, default_conf, caplog) -> None:
|
||||||
|
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
worker = get_patched_worker(mocker, default_conf)
|
||||||
|
sleep_mock = mocker.patch("freqtrade.worker.Worker._sleep")
|
||||||
|
with time_machine.travel("2022-09-01 05:00:00 +00:00") as t:
|
||||||
|
def throttled_func(x=1):
|
||||||
|
t.shift(timedelta(seconds=x))
|
||||||
|
return 42
|
||||||
|
|
||||||
|
assert worker._throttle(throttled_func, throttle_secs=5) == 42
|
||||||
|
# This moves the clock by 1 second
|
||||||
|
assert sleep_mock.call_count == 1
|
||||||
|
assert 3.8 < sleep_mock.call_args[0][0] < 4.1
|
||||||
|
|
||||||
|
sleep_mock.reset_mock()
|
||||||
|
# This moves the clock by 1 second
|
||||||
|
assert worker._throttle(throttled_func, throttle_secs=10) == 42
|
||||||
|
assert sleep_mock.call_count == 1
|
||||||
|
assert 8.8 < sleep_mock.call_args[0][0] < 9.1
|
||||||
|
|
||||||
|
sleep_mock.reset_mock()
|
||||||
|
# This moves the clock by 5 second, so we only throttle by 5s
|
||||||
|
assert worker._throttle(throttled_func, throttle_secs=10, x=5) == 42
|
||||||
|
assert sleep_mock.call_count == 1
|
||||||
|
assert 4.8 < sleep_mock.call_args[0][0] < 5.1
|
||||||
|
|
||||||
|
t.move_to("2022-09-01 05:01:00 +00:00")
|
||||||
|
sleep_mock.reset_mock()
|
||||||
|
# Throttle for more than 5m (1 timeframe)
|
||||||
|
assert worker._throttle(throttled_func, throttle_secs=400, x=5) == 42
|
||||||
|
assert sleep_mock.call_count == 1
|
||||||
|
assert 394.8 < sleep_mock.call_args[0][0] < 395.1
|
||||||
|
|
||||||
|
t.move_to("2022-09-01 05:01:00 +00:00")
|
||||||
|
|
||||||
|
sleep_mock.reset_mock()
|
||||||
|
# Throttle for more than 5m (1 timeframe)
|
||||||
|
assert worker._throttle(throttled_func, throttle_secs=400, timeframe='5m',
|
||||||
|
timeframe_offset=0.4, x=5) == 42
|
||||||
|
assert sleep_mock.call_count == 1
|
||||||
|
# 300 (5m) - 60 (1m - see set time above) - 5 (duration of throttled_func) = 235
|
||||||
|
assert 235.2 < sleep_mock.call_args[0][0] < 235.6
|
||||||
|
|
||||||
|
|
||||||
def test_throttle_with_assets(mocker, default_conf) -> None:
|
def test_throttle_with_assets(mocker, default_conf) -> None:
|
||||||
def throttled_func(nb_assets=-1):
|
def throttled_func(nb_assets=-1):
|
||||||
return nb_assets
|
return nb_assets
|
||||||
|
Loading…
Reference in New Issue
Block a user