diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47b9a9279..0d5a7540d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,7 +258,7 @@ jobs: webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} mypy_version_check: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 @@ -283,7 +283,7 @@ jobs: - uses: pre-commit/action@v3.0.0 docs_check: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 @@ -313,7 +313,7 @@ jobs: # Notify only once - when CI completes (and after deploy) in case it's successfull notify-complete: 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 if: (github.event_name != 'schedule') permissions: @@ -338,7 +338,7 @@ jobs: deploy: 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' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 92d9dc450..ca42402dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - types-filelock==3.2.7 - types-requests==2.28.11.2 - types-tabulate==0.9.0.0 - - types-python-dateutil==2.8.19.1 + - types-python-dateutil==2.8.19.2 # stages: [push] - repo: https://github.com/pycqa/isort diff --git a/build_helpers/pyarrow-10.0.0-cp39-cp39-linux_armv7l.whl b/build_helpers/pyarrow-10.0.0-cp39-cp39-linux_armv7l.whl new file mode 100644 index 000000000..a6c879cf5 Binary files /dev/null and b/build_helpers/pyarrow-10.0.0-cp39-cp39-linux_armv7l.whl differ diff --git a/config_examples/config_binance.example.json b/config_examples/config_binance.example.json index 35b9fcd20..3e99bd114 100644 --- a/config_examples/config_binance.example.json +++ b/config_examples/config_binance.example.json @@ -53,7 +53,7 @@ "XTZ/BTC" ], "pair_blacklist": [ - "BNB/BTC" + "BNB/.*" ] }, "pairlists": [ diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index db8ae7181..5e564a1fc 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -18,13 +18,8 @@ "name": "binance", "key": "", "secret": "", - "ccxt_config": { - "enableRateLimit": true - }, - "ccxt_async_config": { - "enableRateLimit": true, - "rateLimit": 200 - }, + "ccxt_config": {}, + "ccxt_async_config": {}, "pair_whitelist": [ "1INCH/USDT", "ALGO/USDT" diff --git a/docker/Dockerfile.armhf b/docker/Dockerfile.armhf index 73fc681eb..7b663ae6c 100644 --- a/docker/Dockerfile.armhf +++ b/docker/Dockerfile.armhf @@ -11,7 +11,7 @@ ENV FT_APP_ENV="docker" # Prepare environment RUN mkdir /freqtrade \ && 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 \ && useradd -u 1000 -G sudo -U -m ftuser \ && chown ftuser:ftuser /freqtrade \ @@ -37,6 +37,7 @@ ENV LD_LIBRARY_PATH /usr/local/lib COPY --chown=ftuser:ftuser requirements.txt /freqtrade/ USER ftuser RUN pip install --user --no-cache-dir numpy \ + && pip install --user /tmp/pyarrow-*.whl \ && pip install --user --no-cache-dir -r requirements.txt # Copy dependencies to runtime-image diff --git a/docs/advanced-hyperopt.md b/docs/advanced-hyperopt.md index 9933628d1..0dace9985 100644 --- a/docs/advanced-hyperopt.md +++ b/docs/advanced-hyperopt.md @@ -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: ```python +from freqtrade.optimize.space import Categorical, Dimension, Integer, SKDecimal + class MyAwesomeStrategy(IStrategy): class HyperOpt: # 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.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 diff --git a/docs/backtesting.md b/docs/backtesting.md index f20a53d22..e3cddb7a1 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -522,13 +522,13 @@ Since backtesting lacks some detailed information about what happens within a ca - 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 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 `=-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 `=-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 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 - Trailing stoploss - 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 - 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 @@ -546,8 +546,8 @@ In addition to the above assumptions, strategy authors should carefully read the ### Trading limits in backtesting -Exchanges have certain trading limits, like minimum base currency, or minimum stake (quote) currency. -These limits are usually listed in the exchange documentation as "trading rules" or similar. +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 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. Freqtrade has however no information about historic limits. diff --git a/docs/faq.md b/docs/faq.md index a72268ef9..bcceaf898 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -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. +### 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 The bot is trying to tell you that it got an outdated last candle (not the last complete candle). diff --git a/docs/freqai-configuration.md b/docs/freqai-configuration.md index d162fe373..5c3bbf90c 100644 --- a/docs/freqai-configuration.md +++ b/docs/freqai-configuration.md @@ -61,7 +61,7 @@ The FreqAI strategy requires including the following lines of code in the standa """ Function designed to automatically generate, name and merge features from user indicated timeframes in the configuration file. User controls the indicators - passed to the training/prediction by prepending indicators with `'%-' + coin ` + passed to the training/prediction by prepending indicators with `'%-' + pair ` (see convention below). I.e. user should not prepend any supporting metrics (e.g. bb_lowerband below) with % unless they explicitly want to pass that metric to the model. @@ -69,20 +69,17 @@ The FreqAI strategy requires including the following lines of code in the standa :param df: strategy dataframe which will receive merges from informatives :param tf: timeframe of the dataframe which will modify the feature names :param informative: the dataframe associated with the informative pair - :param coin: the name of the coin which will modify the feature names. """ - coin = pair.split('/')[0] - if informative is None: informative = self.dp.get_pair_dataframe(pair, tf) # first loop is automatically duplicating indicators for time periods for t in self.freqai_info["feature_parameters"]["indicator_periods_candles"]: t = int(t) - informative[f"%-{coin}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) - informative[f"%-{coin}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) - informative[f"%-{coin}adx-period_{t}"] = ta.ADX(informative, window=t) + informative[f"%-{pair}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) + informative[f"%-{pair}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) + informative[f"%-{pair}adx-period_{t}"] = ta.ADX(informative, window=t) indicators = [col for col in informative if col.startswith("%")] # This loop duplicates and shifts all indicators to add a sense of recency to data @@ -134,7 +131,7 @@ Notice also the location of the labels under `if set_generalized_indicators:` at (as exemplified in `freqtrade/templates/FreqaiExampleStrategy.py`): ```python - def populate_any_indicators(self, metadata, pair, df, tf, informative=None, coin="", set_generalized_indicators=False): + def populate_any_indicators(self, pair, df, tf, informative=None, set_generalized_indicators=False): ... @@ -192,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 ``` -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 "freqai": { - "fit_live_prediction_candles": 300, + "fit_live_predictions_candles": 300, } ``` diff --git a/docs/freqai-feature-engineering.md b/docs/freqai-feature-engineering.md index b7c23aa60..3462955cc 100644 --- a/docs/freqai-feature-engineering.md +++ b/docs/freqai-feature-engineering.md @@ -2,7 +2,10 @@ ## Defining the features -Low level feature engineering is performed in the user strategy within a function called `populate_any_indicators()`. That function sets the `base features` such as, `RSI`, `MFI`, `EMA`, `SMA`, time of day, volume, etc. The `base features` can be custom indicators or they can be imported from any technical-analysis library that you can find. One important syntax rule is that all `base features` string names are prepended with `%`, while labels/targets are prepended with `&`. +Low level feature engineering is performed in the user strategy within a function called `populate_any_indicators()`. That function sets the `base features` such as, `RSI`, `MFI`, `EMA`, `SMA`, time of day, volume, etc. The `base features` can be custom indicators or they can be imported from any technical-analysis library that you can find. One important syntax rule is that all `base features` string names are prepended with `%-{pair}`, while labels/targets are prepended with `&`. + +!!! Note + Adding the full pair string, e.g. XYZ/USD, in the feature name enables improved performance for dataframe caching on the backend. If you decide *not* to add the full pair string in the feature string, FreqAI will operate in a reduced performance mode. Meanwhile, high level feature engineering is handled within `"feature_parameters":{}` in the FreqAI config. Within this file, it is possible to decide large scale feature expansions on top of the `base_features` such as "including correlated pairs" or "including informative timeframes" or even "including recent candles." @@ -15,7 +18,7 @@ It is advisable to start from the template `populate_any_indicators()` in the so """ Function designed to automatically generate, name, and merge features from user-indicated timeframes in the configuration file. The user controls the indicators - passed to the training/prediction by prepending indicators with `'%-' + coin ` + passed to the training/prediction by prepending indicators with `'%-' + pair ` (see convention below). I.e., the user should not prepend any supporting metrics (e.g., bb_lowerband below) with % unless they explicitly want to pass that metric to the model. @@ -23,37 +26,34 @@ It is advisable to start from the template `populate_any_indicators()` in the so :param df: strategy dataframe which will receive merges from informatives :param tf: timeframe of the dataframe which will modify the feature names :param informative: the dataframe associated with the informative pair - :param coin: the name of the coin which will modify the feature names. """ - coin = pair.split('/')[0] - if informative is None: informative = self.dp.get_pair_dataframe(pair, tf) # first loop is automatically duplicating indicators for time periods for t in self.freqai_info["feature_parameters"]["indicator_periods_candles"]: t = int(t) - informative[f"%-{coin}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) - informative[f"%-{coin}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) - informative[f"%-{coin}adx-period_{t}"] = ta.ADX(informative, window=t) + informative[f"%-{pair}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) + informative[f"%-{pair}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) + informative[f"%-{pair}adx-period_{t}"] = ta.ADX(informative, window=t) bollinger = qtpylib.bollinger_bands( qtpylib.typical_price(informative), window=t, stds=2.2 ) - informative[f"{coin}bb_lowerband-period_{t}"] = bollinger["lower"] - informative[f"{coin}bb_middleband-period_{t}"] = bollinger["mid"] - informative[f"{coin}bb_upperband-period_{t}"] = bollinger["upper"] + informative[f"{pair}bb_lowerband-period_{t}"] = bollinger["lower"] + informative[f"{pair}bb_middleband-period_{t}"] = bollinger["mid"] + informative[f"{pair}bb_upperband-period_{t}"] = bollinger["upper"] - informative[f"%-{coin}bb_width-period_{t}"] = ( - informative[f"{coin}bb_upperband-period_{t}"] - - informative[f"{coin}bb_lowerband-period_{t}"] - ) / informative[f"{coin}bb_middleband-period_{t}"] - informative[f"%-{coin}close-bb_lower-period_{t}"] = ( - informative["close"] / informative[f"{coin}bb_lowerband-period_{t}"] + informative[f"%-{pair}bb_width-period_{t}"] = ( + informative[f"{pair}bb_upperband-period_{t}"] + - informative[f"{pair}bb_lowerband-period_{t}"] + ) / informative[f"{pair}bb_middleband-period_{t}"] + informative[f"%-{pair}close-bb_lower-period_{t}"] = ( + informative["close"] / informative[f"{pair}bb_lowerband-period_{t}"] ) - informative[f"%-{coin}relative_volume-period_{t}"] = ( + informative[f"%-{pair}relative_volume-period_{t}"] = ( informative["volume"] / informative["volume"].rolling(t).mean() ) diff --git a/docs/freqai-running.md b/docs/freqai-running.md index ebc82387d..693fe087c 100644 --- a/docs/freqai-running.md +++ b/docs/freqai-running.md @@ -173,9 +173,13 @@ You can indicate to the bot that it should not train models, but instead should ```json "freqai": { + "enabled": 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. diff --git a/docs/freqai.md b/docs/freqai.md index b7f0fe21a..efa279704 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -4,7 +4,7 @@ ## Introduction -FreqAI is a software designed to automate a variety of tasks associated with training a predictive machine learning model to generate market forecasts given a set of input features. +FreqAI is a software designed to automate a variety of tasks associated with training a predictive machine learning model to generate market forecasts given a set of input signals. In general, the FreqAI aims to be a sand-box for easily deploying robust machine-learning libraries on real-time data ([details])(#freqai-position-in-open-source-machine-learning-landscape). Features include: @@ -72,6 +72,11 @@ pip install -r requirements-freqai.txt If you are using docker, a dedicated tag with FreqAI dependencies is available as `:freqai`. As such - you can replace the image line in your docker-compose file with `image: freqtradeorg/freqtrade:develop_freqai`. This image contains the regular FreqAI dependencies. Similar to native installs, Catboost will not be available on ARM based devices. + +### FreqAI position in open-source machine learning landscape + +Forecasting chaotic time-series based systems, such as equity/cryptocurrency markets, requires a broad set of tools geared toward testing a wide range of hypotheses. Fortunately, a recent maturation of robust machine learning libraries (e.g. `scikit-learn`) has opened up a wide range of research possibilities. Scientists from a diverse range of fields can now easily prototype their studies on an abundance of established machine learning algorithms. Similarly, these user-friendly libraries enable "citzen scientists" to use their basic Python skills for data-exploration. However, leveraging these machine learning libraries on historical and live chaotic data sources can be logistically difficult and expensive. Additionally, robust data-collection, storage, and handling presents a disparate challenge. [`FreqAI`](#freqai) aims to provide a generalized and extensible open-sourced framework geared toward live deployments of adaptive modeling for market forecasting. The `FreqAI` framework is effectively a sandbox for the rich world of open-source machine learning libraries. Inside the `FreqAI` sandbox, users find they can combine a wide variety of third-party libraries to test creative hypotheses on a free live 24/7 chaotic data source - cryptocurrency exchange data. + ## Common pitfalls FreqAI cannot be combined with dynamic `VolumePairlists` (or any pairlist filter that adds and removes pairs dynamically). diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 7dff75a02..dd3149e98 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -286,6 +286,18 @@ Min price precision for SHITCOIN/BTC is 8 decimals. If its price is 0.00000011 - Shuffles (randomizes) pairs in the pairlist. It can be used for preventing the bot from trading some of the pairs more frequently then others when you want all pairs be treated with the same priority. +By default, ShuffleFilter will shuffle pairs once per candle. +To shuffle on every iteration, set `"shuffle_frequency"` to `"iteration"` instead of the default of `"candle"`. + +``` json + { + "method": "ShuffleFilter", + "shuffle_frequency": "candle", + "seed": 42 + } + +``` + !!! Tip You may set the `seed` value for this Pairlist to obtain reproducible results, which can be useful for repeated backtesting sessions. If `seed` is not set, the pairs are shuffled in the non-repeatable random order. ShuffleFilter will automatically detect runmodes and apply the `seed` only for backtesting modes - if a `seed` value is set. diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index ad4aa7e89..0e1e80e09 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,6 +1,6 @@ markdown==3.3.7 mkdocs==1.4.1 -mkdocs-material==8.5.6 +mkdocs-material==8.5.7 mdx_truly_sane_lists==1.3 -pymdown-extensions==9.6 +pymdown-extensions==9.7 jinja2==3.1.2 diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index ea10fc472..230968fb0 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -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. 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. diff --git a/docs/windows_installation.md b/docs/windows_installation.md index 5cfae8c10..1b0d9d724 100644 --- a/docs/windows_installation.md +++ b/docs/windows_installation.md @@ -3,15 +3,16 @@ We **strongly** recommend that Windows users use [Docker](docker_quickstart.md) as this will work much easier and smoother (also more secure). If that is not possible, try using the Windows Linux subsystem (WSL) - for which the Ubuntu instructions should work. -Otherwise, try the instructions below. +Otherwise, please follow the instructions below. ## Install freqtrade manually -!!! Note - Make sure to use 64bit Windows and 64bit Python to avoid problems with backtesting or hyperopt due to the memory constraints 32bit applications have under Windows. +!!! Note "64bit Python version" + Please make sure to use 64bit Windows and 64bit Python to avoid problems with backtesting or hyperopt due to the memory constraints 32bit applications have under Windows. + 32bit python versions are no longer supported under Windows. !!! Hint - Using the [Anaconda Distribution](https://www.anaconda.com/distribution/) under Windows can greatly help with installation problems. Check out the [Anaconda installation section](installation.md#Anaconda) in this document for more information. + Using the [Anaconda Distribution](https://www.anaconda.com/distribution/) under Windows can greatly help with installation problems. Check out the [Anaconda installation section](installation.md#installation-with-conda) in the documentation for more information. ### 1. Clone the git repository diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 1e62266a8..ad80410ee 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2022.10.dev' +__version__ = '2022.11.dev' if 'dev' in __version__: try: @@ -16,6 +16,6 @@ if 'dev' in __version__: from pathlib import Path versionfile = Path('./freqtrade_commit') if versionfile.is_file(): - __version__ = f"docker-{versionfile.read_text()[:8]}" + __version__ = f"docker-{__version__}-{versionfile.read_text()[:8]}" except Exception: pass diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index 53a6f478d..91ac16365 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -49,7 +49,7 @@ AVAILABLE_CLI_OPTIONS = { default=0, ), "logfile": Arg( - '--logfile', + '--logfile', '--log-file', help="Log to the file specified. Special values are: 'syslog', 'journald'. " "See the documentation for more details.", metavar='FILE', diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 80e29f4c0..cbc3f1a34 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -303,7 +303,7 @@ class IDataHandler(ABC): timerange=timerange_startup, 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 else: enddate = pairdf.iloc[-1]['date'] @@ -323,8 +323,9 @@ class IDataHandler(ABC): self._check_empty_df(pairdf, pair, timeframe, candle_type, warn_no_data) return pairdf - def _check_empty_df(self, pairdf: DataFrame, pair: str, timeframe: str, - candle_type: CandleType, warn_no_data: bool): + def _check_empty_df( + self, pairdf: DataFrame, pair: str, timeframe: str, candle_type: CandleType, + warn_no_data: bool, warn_price: bool = False) -> bool: """ Warn on empty dataframe """ @@ -335,6 +336,20 @@ class IDataHandler(ABC): "Use `freqtrade download-data` to download the data" ) 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 def _validate_pairdata(self, pair, pairdata: DataFrame, timeframe: str, diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 1b5ca11ee..8294838b1 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -9,14 +9,15 @@ from freqtrade.exchange.bitpanda import Bitpanda from freqtrade.exchange.bittrex import Bittrex from freqtrade.exchange.bybit import Bybit from freqtrade.exchange.coinbasepro import Coinbasepro -from freqtrade.exchange.exchange import (amount_to_contract_precision, amount_to_contracts, - amount_to_precision, available_exchanges, ccxt_exchanges, - contracts_to_amount, date_minus_candles, - is_exchange_known_ccxt, market_is_active, - price_to_precision, timeframe_to_minutes, - timeframe_to_msecs, timeframe_to_next_date, - timeframe_to_prev_date, timeframe_to_seconds, - validate_exchange, validate_exchanges) +from freqtrade.exchange.exchange_utils import (amount_to_contract_precision, amount_to_contracts, + amount_to_precision, available_exchanges, + ccxt_exchanges, contracts_to_amount, + date_minus_candles, is_exchange_known_ccxt, + market_is_active, price_to_precision, + timeframe_to_minutes, timeframe_to_msecs, + timeframe_to_next_date, timeframe_to_prev_date, + timeframe_to_seconds, validate_exchange, + validate_exchanges) from freqtrade.exchange.ftx import Ftx from freqtrade.exchange.gateio import Gateio from freqtrade.exchange.hitbtc import Hitbtc diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 6d818bab9..b21e64eb2 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -42,24 +42,6 @@ class Binance(Exchange): (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: tickers = super().get_tickers(symbols=symbols, cached=cached) if self.trading_mode == TradingMode.FUTURES: diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 313d89e09..7a2b8ce7d 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -8,7 +8,6 @@ import inspect import logging from copy import deepcopy from datetime import datetime, timedelta, timezone -from math import ceil from threading import Lock from typing import Any, Coroutine, Dict, List, Literal, Optional, Tuple, Union @@ -16,7 +15,7 @@ import arrow import ccxt import ccxt.async_support as ccxt_async from cachetools import TTLCache -from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision +from ccxt import TICK_SIZE from dateutil import parser 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, InvalidOrderException, OperationalException, PricingError, RetryableOrderError, TemporaryError) -from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, BAD_EXCHANGES, - EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED, - remove_credentials, retrier, retrier_async) +from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, remove_credentials, retrier, + retrier_async) +from freqtrade.exchange.exchange_utils import (CcxtModuleType, amount_to_contract_precision, + amount_to_contracts, amount_to_precision, + contracts_to_amount, date_minus_candles, + is_exchange_known_ccxt, market_is_active, + price_to_precision, timeframe_to_minutes, + timeframe_to_msecs, timeframe_to_next_date, + timeframe_to_prev_date, timeframe_to_seconds) from freqtrade.exchange.types import Ticker, Tickers from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_json, safe_value_fallback2) from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist -from freqtrade.util import FtPrecise - - -CcxtModuleType = Any logger = logging.getLogger(__name__) @@ -1076,7 +1077,14 @@ class Exchange: Verify stop_loss against stoploss-order value (limit or price) 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]: @@ -1106,7 +1114,7 @@ class Exchange: 'In stoploss limit order, stop price should be more than limit price') 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() # Verify if stopPrice works for your exchange! params.update({'stopPrice': stop_price}) @@ -1155,7 +1163,8 @@ class Exchange: return dry_order 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: params['reduceOnly'] = True @@ -1995,11 +2004,8 @@ class Exchange: def _now_is_time_to_refresh(self, pair: str, timeframe: str, candle_type: CandleType) -> bool: # Timeframe in seconds interval_in_sec = timeframe_to_seconds(timeframe) - - return not ( - (self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0) - + interval_in_sec) >= arrow.utcnow().int_timestamp - ) + plr = self._pairs_last_refresh_time.get((pair, timeframe, candle_type), 0) + interval_in_sec + return plr < arrow.utcnow().int_timestamp @retrier_async 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 else: 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 diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py new file mode 100644 index 000000000..cb6333869 --- /dev/null +++ b/freqtrade/exchange/exchange_utils.py @@ -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 diff --git a/freqtrade/exchange/gateio.py b/freqtrade/exchange/gateio.py index ab127a036..de178af02 100644 --- a/freqtrade/exchange/gateio.py +++ b/freqtrade/exchange/gateio.py @@ -126,13 +126,3 @@ class Gateio(Exchange): pair=pair, 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'])) - ) diff --git a/freqtrade/exchange/huobi.py b/freqtrade/exchange/huobi.py index 736515dec..fdb6050a3 100644 --- a/freqtrade/exchange/huobi.py +++ b/freqtrade/exchange/huobi.py @@ -2,6 +2,7 @@ import logging from typing import Dict +from freqtrade.constants import BuySell from freqtrade.exchange import Exchange @@ -22,20 +23,7 @@ class Huobi(Exchange): "l2_limit_range_required": False, } - 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 ( - order['type'] == 'stop' - and stop_loss > float(order['stopPrice']) - ) - ) - - 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.update({ diff --git a/freqtrade/exchange/kucoin.py b/freqtrade/exchange/kucoin.py index f05fd3f56..6c7d7acfc 100644 --- a/freqtrade/exchange/kucoin.py +++ b/freqtrade/exchange/kucoin.py @@ -2,6 +2,7 @@ import logging from typing import Dict +from freqtrade.constants import BuySell from freqtrade.exchange import Exchange @@ -27,17 +28,7 @@ class Kucoin(Exchange): "ohlcv_candle_limit": 1500, } - 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 stop_loss > float(order['stopPrice']) - ) - - 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.update({ diff --git a/freqtrade/freqai/base_models/BaseClassifierModel.py b/freqtrade/freqai/base_models/BaseClassifierModel.py index 691c27e23..17bffa85b 100644 --- a/freqtrade/freqai/base_models/BaseClassifierModel.py +++ b/freqtrade/freqai/base_models/BaseClassifierModel.py @@ -51,7 +51,7 @@ class BaseClassifierModel(IFreqaiModel): f"{end_date} --------------------") # split data into train/test data. 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() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) diff --git a/freqtrade/freqai/base_models/BaseRegressionModel.py b/freqtrade/freqai/base_models/BaseRegressionModel.py index 79f6f0d3c..766579cb6 100644 --- a/freqtrade/freqai/base_models/BaseRegressionModel.py +++ b/freqtrade/freqai/base_models/BaseRegressionModel.py @@ -50,7 +50,7 @@ class BaseRegressionModel(IFreqaiModel): f"{end_date} --------------------") # split data into train/test data. 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() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) diff --git a/freqtrade/freqai/base_models/BaseTensorFlowModel.py b/freqtrade/freqai/base_models/BaseTensorFlowModel.py index 00f9d6cba..b41ee0175 100644 --- a/freqtrade/freqai/base_models/BaseTensorFlowModel.py +++ b/freqtrade/freqai/base_models/BaseTensorFlowModel.py @@ -47,7 +47,7 @@ class BaseTensorFlowModel(IFreqaiModel): f"{end_date} --------------------") # split data into train/test data. 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() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index dbb25bf7d..7241bb48d 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -214,7 +214,10 @@ class FreqaiDataKitchen: const_cols = list((filtered_df.nunique() == 1).loc[lambda x: x].index) if 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.") + else: + self.data['constant_features_list'] = [] # we don't care about total row number (total no. datapoints) in training, we only care # about removing any row with NaNs # if labels has multiple columns (user wants to train multiple modelEs), we detect here @@ -245,7 +248,8 @@ class FreqaiDataKitchen: self.data["filter_drop_index_training"] = drop_index 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, # so now we use do_predict to avoid any prediction based on a NaN drop_index = pd.isnull(filtered_df).any(axis=1) @@ -354,13 +358,19 @@ class FreqaiDataKitchen: :param df: Dataframe to be standardized """ - for item in df.keys(): - df[item] = ( - 2 - * (df[item] - self.data[f"{item}_min"]) - / (self.data[f"{item}_max"] - self.data[f"{item}_min"]) - - 1 - ) + train_max = [None] * len(df.keys()) + train_min = [None] * len(df.keys()) + + for i, item in enumerate(df.keys()): + train_max[i] = self.data[f"{item}_max"] + 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 @@ -491,18 +501,16 @@ class FreqaiDataKitchen: def check_pred_labels(self, df_predictions: DataFrame) -> DataFrame: """ Check that prediction feature labels match training feature labels. - :params: - :df_predictions: incoming predictions + :param df_predictions: incoming predictions """ - train_labels = self.data_dictionary["train_features"].columns - pred_labels = df_predictions.columns - num_diffs = len(pred_labels.difference(train_labels)) - if num_diffs != 0: - df_predictions = df_predictions[train_labels] - logger.warning( - f"Removed {num_diffs} features from prediction features, " - f"these were likely considered constant values during most recent training." - ) + constant_labels = self.data['constant_features_list'] + df_predictions = df_predictions.filter( + df_predictions.columns.difference(constant_labels) + ) + logger.warning( + f"Removed {len(constant_labels)} features from prediction features, " + f"these were considered constant values during most recent training." + ) return df_predictions @@ -986,6 +994,9 @@ class FreqaiDataKitchen: if "labels_std" in self.data: 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 if self.freqai_config["feature_parameters"].get("DI_threshold", 0) > 0: append_df["DI_values"] = self.DI_values @@ -1150,6 +1161,51 @@ class FreqaiDataKitchen: if pair not in self.all_pairs: self.all_pairs.append(pair) + def extract_corr_pair_columns_from_populated_indicators( + self, + dataframe: DataFrame + ) -> Dict[str, DataFrame]: + """ + Find the columns of the dataframe corresponding to the corr_pairlist, save them + in a dictionary to be reused and attached to other pairs. + + :param dataframe: fully populated dataframe (current pair + corr_pairs) + :return: corr_dataframes, dictionary of dataframes to be attached + to other pairs in same candle. + """ + corr_dataframes: Dict[str, DataFrame] = {} + pairs = self.freqai_config["feature_parameters"].get("include_corr_pairlist", []) + + for pair in pairs: + valid_strs = [f"%-{pair}", f"%{pair}", f"%_{pair}"] + pair_cols = [col for col in dataframe.columns if + any(substr in col for substr in valid_strs)] + pair_cols.insert(0, 'date') + corr_dataframes[pair] = dataframe.filter(pair_cols, axis=1) + + return corr_dataframes + + def attach_corr_pair_columns(self, dataframe: DataFrame, + corr_dataframes: Dict[str, DataFrame], + current_pair: str) -> DataFrame: + """ + Attach the existing corr_pair dataframes to the current pair dataframe before training + + :param dataframe: current pair strategy dataframe, indicators populated already + :param corr_dataframes: dictionary of saved dataframes from earlier in the same candle + :param current_pair: current pair to which we will attach corr pair dataframe + :return: + :dataframe: current pair dataframe of populated indicators, concatenated with corr_pairs + ready for training + """ + pairs = self.freqai_config["feature_parameters"].get("include_corr_pairlist", []) + + for pair in pairs: + if current_pair != pair: + dataframe = dataframe.merge(corr_dataframes[pair], how='left', on='date') + + return dataframe + def use_strategy_to_populate_indicators( self, strategy: IStrategy, @@ -1157,6 +1213,7 @@ class FreqaiDataKitchen: base_dataframes: dict = {}, pair: str = "", prediction_dataframe: DataFrame = pd.DataFrame(), + do_corr_pairs: bool = True, ) -> DataFrame: """ Use the user defined strategy for populating indicators during retrain @@ -1166,15 +1223,15 @@ class FreqaiDataKitchen: :param base_dataframes: dict = dict containing the current pair dataframes (for user defined timeframes) :param metadata: dict = strategy furnished pair metadata - :returns: + :return: dataframe: DataFrame = dataframe containing populated indicators """ # for prediction dataframe creation, we let dataprovider handle everything in the strategy # so we create empty dictionaries, which allows us to pass None to # `populate_any_indicators()`. Signaling we want the dp to give us the live dataframe. - tfs = self.freqai_config["feature_parameters"].get("include_timeframes") - pairs = self.freqai_config["feature_parameters"].get("include_corr_pairlist", []) + tfs: List[str] = self.freqai_config["feature_parameters"].get("include_timeframes") + pairs: List[str] = self.freqai_config["feature_parameters"].get("include_corr_pairlist", []) if not prediction_dataframe.empty: dataframe = prediction_dataframe.copy() for tf in tfs: @@ -1197,15 +1254,18 @@ class FreqaiDataKitchen: informative=base_dataframes[tf], set_generalized_indicators=sgi ) - if pairs: - for i in pairs: - if pair in i: - continue # dont repeat anything from whitelist + + # ensure corr pairs are always last + for corr_pair in pairs: + if pair == corr_pair: + continue # dont repeat anything from whitelist + for tf in tfs: + if pairs and do_corr_pairs: dataframe = strategy.populate_any_indicators( - i, + corr_pair, dataframe.copy(), tf, - informative=corr_dataframes[i][tf] + informative=corr_dataframes[corr_pair][tf] ) self.get_unique_classes_from_labels(dataframe) diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 14967e931..e6d40c141 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -1,12 +1,10 @@ import logging -import shutil import threading import time from abc import ABC, abstractmethod from collections import deque from datetime import datetime, timezone from pathlib import Path -from threading import Lock from typing import Any, Dict, List, Literal, Tuple import numpy as np @@ -21,7 +19,7 @@ from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_seconds from freqtrade.freqai.data_drawer import FreqaiDataDrawer 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 @@ -61,6 +59,7 @@ class IFreqaiModel(ABC): "data_split_parameters", {}) self.model_training_parameters: Dict[str, Any] = config.get("freqai", {}).get( "model_training_parameters", {}) + self.identifier: str = self.freqai_info.get("identifier", "no_id_provided") self.retrain = False self.first = True self.set_full_path() @@ -69,9 +68,9 @@ class IFreqaiModel(ABC): if self.save_backtest_models: logger.info('Backtesting module configured to save all models.') 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.ft_params = self.freqai_info["feature_parameters"] + self.corr_pairlist: List[str] = self.ft_params.get("include_corr_pairlist", []) self.keras: bool = self.freqai_info.get("keras", False) if self.keras and self.ft_params.get("DI_threshold", 0): self.ft_params["DI_threshold"] = 0 @@ -83,9 +82,6 @@ class IFreqaiModel(ABC): self.pair_it_train = 0 self.total_pairs = len(self.config.get("exchange", {}).get("pair_whitelist")) self.train_queue = self._set_train_queue() - self.last_trade_database_summary: DataFrame = {} - self.current_trade_database_summary: DataFrame = {} - self.analysis_lock = Lock() self.inference_time: float = 0 self.train_time: float = 0 self.begin_time: float = 0 @@ -93,10 +89,16 @@ class IFreqaiModel(ABC): self.base_tf_seconds = timeframe_to_seconds(self.config['timeframe']) self.continual_learning = self.freqai_info.get('continual_learning', False) self.plot_features = self.ft_params.get("plot_feature_importances", 0) + self.corr_dataframes: Dict[str, DataFrame] = {} + # get_corr_dataframes is controlling the caching of corr_dataframes + # for improved performance. Careful with this boolean. + self.get_corr_dataframes: bool = True self._threads: List[threading.Thread] = [] self._stop_event = threading.Event() + record_params(config, self.full_path) + def __getstate__(self): """ Return an empty state to be pickled in hyperopt @@ -385,10 +387,10 @@ class IFreqaiModel(ABC): # load the model and associated data into the data kitchen self.model = self.dd.load_data(metadata["pair"], dk) - with self.analysis_lock: - dataframe = self.dk.use_strategy_to_populate_indicators( - strategy, prediction_dataframe=dataframe, pair=metadata["pair"] - ) + dataframe = dk.use_strategy_to_populate_indicators( + strategy, prediction_dataframe=dataframe, pair=metadata["pair"], + do_corr_pairs=self.get_corr_dataframes + ) if not self.model: logger.warning( @@ -397,6 +399,9 @@ class IFreqaiModel(ABC): self.dd.return_null_values_to_strategy(dataframe, dk) return dk + if self.corr_pairlist: + dataframe = self.cache_corr_pairlist_dfs(dataframe, dk) + dk.find_labels(dataframe) self.build_strategy_return_arrays(dataframe, dk, metadata["pair"], trained_timestamp) @@ -548,14 +553,13 @@ class IFreqaiModel(ABC): return file_exists def set_full_path(self) -> None: + """ + Creates and sets the full path for the identifier + """ 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) - shutil.copy( - self.config["config_files"][0], - Path(self.full_path, Path(self.config["config_files"][0]).name), - ) def extract_data_and_train_model( self, @@ -581,10 +585,9 @@ class IFreqaiModel(ABC): data_load_timerange, pair, dk ) - with self.analysis_lock: - unfiltered_dataframe = dk.use_strategy_to_populate_indicators( - strategy, corr_dataframes, base_dataframes, pair - ) + unfiltered_dataframe = dk.use_strategy_to_populate_indicators( + strategy, corr_dataframes, base_dataframes, pair + ) unfiltered_dataframe = dk.slice_dataframe(new_trained_timerange, unfiltered_dataframe) @@ -702,6 +705,8 @@ class IFreqaiModel(ABC): " avoid blinding open trades and degrading performance.") self.pair_it = 0 self.inference_time = 0 + if self.corr_pairlist: + self.get_corr_dataframes = True return def train_timer(self, do: Literal['start', 'stop'] = 'start', pair: str = ''): @@ -760,6 +765,29 @@ class IFreqaiModel(ABC): f'Best approximation queue: {best_queue}') return best_queue + def cache_corr_pairlist_dfs(self, dataframe: DataFrame, dk: FreqaiDataKitchen) -> DataFrame: + """ + Cache the corr_pairlist dfs to speed up performance for subsequent pairs during the + current candle. + :param dataframe: strategy fed dataframe + :param dk: datakitchen object for current asset + :return: dataframe to attach/extract cached corr_pair dfs to/from. + """ + + if self.get_corr_dataframes: + self.corr_dataframes = dk.extract_corr_pair_columns_from_populated_indicators(dataframe) + if not self.corr_dataframes: + logger.warning("Couldn't cache corr_pair dataframes for improved performance. " + "Consider ensuring that the full coin/stake, e.g. XYZ/USD, " + "is included in the column names when you are creating features " + "in `populate_any_indicators()`.") + self.get_corr_dataframes = not bool(self.corr_dataframes) + else: + dataframe = dk.attach_corr_pair_columns( + dataframe, self.corr_dataframes, dk.pair) + + return dataframe + # Following methods which are overridden by user made prediction models. # See freqai/prediction_models/CatboostPredictionModel.py for an example. diff --git a/freqtrade/freqai/prediction_models/XGBoostRFClassifier.py b/freqtrade/freqai/prediction_models/XGBoostRFClassifier.py index 1aba8df85..470c283ea 100644 --- a/freqtrade/freqai/prediction_models/XGBoostRFClassifier.py +++ b/freqtrade/freqai/prediction_models/XGBoostRFClassifier.py @@ -26,9 +26,8 @@ class XGBoostRFClassifier(BaseClassifierModel): def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: """ User sets up the training and test data to fit their desired model here - :params: - :data_dictionary: the dictionary constructed by DataHandler to hold - all the training and test data/labels. + :param data_dictionary: the dictionary constructed by DataHandler to hold + all the training and test data/labels. """ X = data_dictionary["train_features"].to_numpy() @@ -65,7 +64,7 @@ class XGBoostRFClassifier(BaseClassifierModel): ) -> Tuple[DataFrame, npt.NDArray[np.int_]]: """ Filter the prediction features data and predict with it. - :param: unfiltered_df: Full dataframe for the current backtest period. + :param unfiltered_df: Full dataframe for the current backtest period. :return: :pred_df: dataframe containing the predictions :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove diff --git a/freqtrade/freqai/prediction_models/XGBoostRFRegressor.py b/freqtrade/freqai/prediction_models/XGBoostRFRegressor.py index 4c18d594d..e7cc27f2e 100644 --- a/freqtrade/freqai/prediction_models/XGBoostRFRegressor.py +++ b/freqtrade/freqai/prediction_models/XGBoostRFRegressor.py @@ -29,6 +29,7 @@ class XGBoostRFRegressor(BaseRegressionModel): if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) == 0: eval_set = None + eval_weights = None else: eval_set = [(data_dictionary["test_features"], data_dictionary["test_labels"])] eval_weights = [data_dictionary['test_weights']] diff --git a/freqtrade/freqai/prediction_models/XGBoostRegressor.py b/freqtrade/freqai/prediction_models/XGBoostRegressor.py index c9be9ce74..9a280286b 100644 --- a/freqtrade/freqai/prediction_models/XGBoostRegressor.py +++ b/freqtrade/freqai/prediction_models/XGBoostRegressor.py @@ -29,6 +29,7 @@ class XGBoostRegressor(BaseRegressionModel): if self.freqai_info.get("data_split_parameters", {}).get("test_size", 0.1) == 0: eval_set = None + eval_weights = None else: eval_set = [(data_dictionary["test_features"], data_dictionary["test_labels"])] eval_weights = [data_dictionary['test_weights']] diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index d3ba09592..5a04497d0 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -1,9 +1,11 @@ import logging from datetime import datetime, timezone -from typing import Any +from pathlib import Path +from typing import Any, Dict import numpy as np import pandas as pd +import rapidjson from freqtrade.configuration import TimeRange from freqtrade.constants import Config @@ -193,6 +195,31 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, 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 + ) + + def get_timerange_backtest_live_models(config: Config): """ Returns a formated timerange for backtest live/ready models diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index cd111679c..ea7c2f1f9 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1471,12 +1471,13 @@ class FreqtradeBot(LoggingMixin): ) 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. 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 for whatever reason. + :param trade: Trade we're working with :param pair: Pair we're trying to sell :param amount: amount we expect to be available :return: amount to sell @@ -1495,6 +1496,7 @@ class FreqtradeBot(LoggingMixin): return amount elif wallet_amount > amount * 0.98: logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.") + trade.amount = wallet_amount return wallet_amount else: raise DependencyException( @@ -1553,7 +1555,7 @@ class FreqtradeBot(LoggingMixin): # Emergency sells (default to 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'] if (exit_check.exit_type != ExitType.LIQUIDATION @@ -1828,7 +1830,7 @@ class FreqtradeBot(LoggingMixin): never in base currency. """ self.wallets.update() - amount_ = amount + amount_ = trade.amount if order_obj.ft_order_side == trade.exit_side or order_obj.ft_order_side == 'stoploss': # check against remaining amount! amount_ = trade.amount - amount diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 73e067480..70c460e89 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -1520,3 +1520,87 @@ class Trade(_DECL_BASE, LocalTrade): Order.status == 'closed' ).scalar() 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 diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index 660d6228c..d0382c778 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -36,7 +36,6 @@ class IPairList(LoggingMixin, ABC): self._pairlistconfig = pairlistconfig self._pairlist_pos = pairlist_pos self.refresh_period = self._pairlistconfig.get('refresh_period', 1800) - self._last_refresh = 0 LoggingMixin.__init__(self, logger, self.refresh_period) @property diff --git a/freqtrade/plugins/pairlist/ShuffleFilter.py b/freqtrade/plugins/pairlist/ShuffleFilter.py index 1bc114d4e..76d7600d2 100644 --- a/freqtrade/plugins/pairlist/ShuffleFilter.py +++ b/freqtrade/plugins/pairlist/ShuffleFilter.py @@ -3,16 +3,20 @@ Shuffle pair list filter """ import logging import random -from typing import Any, Dict, List +from typing import Any, Dict, List, Literal from freqtrade.constants import Config from freqtrade.enums import RunMode +from freqtrade.exchange import timeframe_to_seconds from freqtrade.exchange.types import Tickers from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.util.periodic_cache import PeriodicCache logger = logging.getLogger(__name__) +ShuffleValues = Literal['candle', 'iteration'] + class ShuffleFilter(IPairList): @@ -31,6 +35,9 @@ class ShuffleFilter(IPairList): logger.info(f"Backtesting mode detected, applying seed value: {self._seed}") self._random = random.Random(self._seed) + self._shuffle_freq: ShuffleValues = pairlistconfig.get('shuffle_frequency', 'candle') + self.__pairlist_cache = PeriodicCache( + maxsize=1000, ttl=timeframe_to_seconds(self._config['timeframe'])) @property def needstickers(self) -> bool: @@ -45,7 +52,7 @@ class ShuffleFilter(IPairList): """ Short whitelist method description - used for startup-messages """ - return (f"{self.name} - Shuffling pairs" + + return (f"{self.name} - Shuffling pairs every {self._shuffle_freq}" + (f", seed = {self._seed}." if self._seed is not None else ".")) def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]: @@ -56,7 +63,13 @@ class ShuffleFilter(IPairList): :param tickers: Tickers (from exchange.get_tickers). May be cached. :return: new whitelist """ + pairlist_bef = tuple(pairlist) + pairlist_new = self.__pairlist_cache.get(pairlist_bef) + if pairlist_new and self._shuffle_freq == 'candle': + # Use cached pairlist. + return pairlist_new # Shuffle is done inplace self._random.shuffle(pairlist) + self.__pairlist_cache[pairlist_bef] = pairlist return pairlist diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index 2f490b8a8..118d70d78 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -1,4 +1,3 @@ -import asyncio import logging 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.deps import get_channel_manager, get_rpc 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, WSRequestSchema, WSWhitelistMessage) from freqtrade.rpc.rpc import RPC @@ -37,7 +37,8 @@ async def is_websocket_alive(ws: WebSocket) -> bool: async def _process_consumer_request( request: Dict[str, Any], channel: WebSocketChannel, - rpc: RPC + rpc: RPC, + channel_manager: ChannelManager ): """ Validate and handle a request from a websocket consumer @@ -74,7 +75,7 @@ async def _process_consumer_request( # Format response response = WSWhitelistMessage(data=whitelist) # 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: limit = None @@ -89,9 +90,7 @@ async def _process_consumer_request( # For every dataframe, send as a separate message for _, message in analyzed_df.items(): response = WSAnalyzedDFMessage(data=message) - await channel.send(response.dict(exclude_none=True)) - # Throttle the messages to 50/s - await asyncio.sleep(0.02) + await channel_manager.send_direct(channel, response.dict(exclude_none=True)) @router.websocket("/message/ws") @@ -116,7 +115,7 @@ async def message_endpoint( request = await channel.recv() # Process the request here - await _process_consumer_request(request, channel, rpc) + await _process_consumer_request(request, channel, rpc, channel_manager) except (WebSocketDisconnect, WebSocketException): # Handle client disconnects @@ -128,13 +127,6 @@ async def message_endpoint( except Exception as e: logger.info(f"Consumer connection failed - {channel}: {e}") logger.debug(e, exc_info=e) - finally: - await channel_manager.on_disconnect(ws) - - else: - if channel: - await channel_manager.on_disconnect(ws) - await ws.close() except RuntimeError: # WebSocket was closed @@ -145,4 +137,5 @@ async def message_endpoint( # Log tracebacks to keep track of what errors are happening logger.exception(e) finally: - await channel_manager.on_disconnect(ws) + if channel: + await channel_manager.on_disconnect(ws) diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 4a09fd78e..e9a12e4df 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -16,6 +16,7 @@ from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer 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 @@ -127,7 +128,7 @@ class ApiServer(RPCHandler): cls._has_rpc = False 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: sync_q = self._ws_queue.sync_q sync_q.put(msg) @@ -194,14 +195,11 @@ class ApiServer(RPCHandler): while True: logger.debug("Getting queue messages...") # 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')}") + async_queue.task_done() # Broadcast it 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: pass @@ -213,6 +211,9 @@ class ApiServer(RPCHandler): # Disconnect channels and stop the loop on cancel await self._ws_channel_manager.disconnect_all() self._ws_loop.stop() + # Avoid adding more items to the queue if they aren't + # going to get broadcasted. + self._ws_queue = None def start_api(self): """ diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py index e9dbd63be..3c97d05b1 100644 --- a/freqtrade/rpc/api_server/ws/channel.py +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -1,7 +1,8 @@ import asyncio import logging +import time 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 fastapi import WebSocket as FastAPIWebSocket @@ -10,6 +11,7 @@ from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy from freqtrade.rpc.api_server.ws.serializer import (HybridJSONWebSocketSerializer, WebSocketSerializer) from freqtrade.rpc.api_server.ws.types import WebSocketType +from freqtrade.rpc.api_server.ws_schemas import WSMessageSchemaType logger = logging.getLogger(__name__) @@ -24,6 +26,8 @@ class WebSocketChannel: self, websocket: WebSocketType, channel_id: Optional[str] = None, + drain_timeout: int = 3, + throttle: float = 0.01, serializer_cls: Type[WebSocketSerializer] = HybridJSONWebSocketSerializer ): @@ -34,12 +38,16 @@ class WebSocketChannel: # The Serializing class for the WebSocket object self._serializer_cls = serializer_cls + self.drain_timeout = drain_timeout + self.throttle = throttle + self._subscriptions: List[str] = [] + # 32 is the size of the receiving queue in websockets package self.queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=32) self._relay_task = asyncio.create_task(self.relay()) # Internal event to signify a closed websocket - self._closed = False + self._closed = asyncio.Event() # Wrap the WebSocket in the Serializing class self._wrapped_ws = self._serializer_cls(self._websocket) @@ -47,6 +55,10 @@ class WebSocketChannel: def __repr__(self): return f"WebSocketChannel({self.channel_id}, {self.remote_addr})" + @property + def raw_websocket(self): + return self._websocket.raw_websocket + @property def remote_addr(self): return self._websocket.remote_addr @@ -57,11 +69,30 @@ class WebSocketChannel: """ 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) + + # This block only runs if the queue is full, it will wait + # until self.drain_timeout for the relay to drain the outgoing queue + # We can't use asyncio.wait_for here because the queue may have been created with a + # different eventloop + start = time.time() + while self.queue.full(): + await asyncio.sleep(1) + if (time.time() - start) > self.drain_timeout: + return False + + # If for some reason the queue is still full, just return False + try: + self.queue.put_nowait(data) + except asyncio.QueueFull: + return False + + # If we got here everything is ok + return True async def recv(self): """ @@ -80,14 +111,19 @@ class WebSocketChannel: Close the WebSocketChannel """ - self._closed = True + try: + await self.raw_websocket.close() + except Exception: + pass + + self._closed.set() self._relay_task.cancel() def is_closed(self) -> bool: """ Closed flag """ - return self._closed + return self._closed.is_set() def set_subscriptions(self, subscriptions: List[str] = []) -> None: """ @@ -110,7 +146,7 @@ class WebSocketChannel: Relay messages from the channel's queue and send them out. This is started as a task. """ - while True: + while not self._closed.is_set(): message = await self.queue.get() try: await self._send(message) @@ -119,8 +155,8 @@ class WebSocketChannel: # Limit messages per sec. # Could cause problems with queue size if too low, and # problems with network traffik if too high. - # 0.001 = 1000/s - await asyncio.sleep(0.001) + # 0.01 = 100/s + await asyncio.sleep(self.throttle) except RuntimeError: # The connection was closed, just exit the task return @@ -160,6 +196,7 @@ class ChannelManager: with self._lock: channel = self.channels.get(websocket) if channel: + logger.info(f"Disconnecting channel {channel}") if not channel.is_closed(): await channel.close() @@ -170,36 +207,30 @@ class ChannelManager: Disconnect all Channels """ with self._lock: - for websocket, channel in self.channels.copy().items(): - if not channel.is_closed(): - await channel.close() + for websocket in self.channels.copy().keys(): + await self.on_disconnect(websocket) - self.channels = dict() - - async def broadcast(self, data): + async def broadcast(self, message: WSMessageSchemaType): """ - 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: - message_type = data.get('type') - for websocket, channel in self.channels.copy().items(): - if channel.subscribed_to(message_type): - 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) + for channel in self.channels.copy().values(): + if channel.subscribed_to(message.get('type')): + await self.send_direct(channel, message) - 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 data: The data to send + :param direct_channel: The WebSocketChannel object to send the message through + :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): """ diff --git a/freqtrade/rpc/api_server/ws/proxy.py b/freqtrade/rpc/api_server/ws/proxy.py index 2e5a59f05..ae123dd2d 100644 --- a/freqtrade/rpc/api_server/ws/proxy.py +++ b/freqtrade/rpc/api_server/ws/proxy.py @@ -15,6 +15,10 @@ class WebSocketProxy: def __init__(self, websocket: WebSocketType): self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket + @property + def raw_websocket(self): + return self._websocket + @property def remote_addr(self) -> Tuple[Any, ...]: if isinstance(self._websocket, WebSocket): diff --git a/freqtrade/rpc/api_server/ws_schemas.py b/freqtrade/rpc/api_server/ws_schemas.py index 255226d84..877232213 100644 --- a/freqtrade/rpc/api_server/ws_schemas.py +++ b/freqtrade/rpc/api_server/ws_schemas.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TypedDict from pandas import DataFrame from pydantic import BaseModel @@ -18,6 +18,12 @@ class WSRequestSchema(BaseArbitraryModel): 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): type: RPCMessageType data: Optional[Any] = None diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py index 01bc974ad..b978407e4 100644 --- a/freqtrade/rpc/external_message_consumer.py +++ b/freqtrade/rpc/external_message_consumer.py @@ -264,14 +264,19 @@ class ExternalMessageConsumer: # We haven't received data yet. Check the connection and continue. try: # ping - ping = await channel.ping() + pong = await channel.ping() + latency = (await asyncio.wait_for(pong, timeout=self.ping_timeout) * 1000) - await asyncio.wait_for(ping, timeout=self.ping_timeout) - logger.debug(f"Connection to {channel} still alive...") + logger.info(f"Connection to {channel} still alive, latency: {latency}ms") 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: - logger.warning(f"Ping error {channel} - retrying in {self.sleep_time}s") + logger.warning(f"Ping error {channel} - {e} - retrying in {self.sleep_time}s") logger.debug(e, exc_info=e) await asyncio.sleep(self.sleep_time) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 96e3065c4..681c5fcbb 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -1072,28 +1072,26 @@ class IStrategy(ABC, HyperStrategyMixin): 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: stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None )(pair=trade.pair, trade=trade, current_time=current_time, - current_rate=current_rate, - current_profit=current_profit) + current_rate=(bound or current_rate), + current_profit=bound_profit) # Sanity check - error cases will return None if stop_loss_value: - # logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}") - trade.adjust_stop_loss(current_rate, stop_loss_value) + # logger.info(f"{trade.pair} {stop_loss_value=} {bound_profit=}") + trade.adjust_stop_loss(bound or current_rate, stop_loss_value) else: logger.warning("CustomStoploss function did not return valid stoploss") - sl_lower_long = (trade.stop_loss < (low or current_rate) and not trade.is_short) - 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): + if self.trailing_stop and dir_correct: # trailing stoploss handling sl_offset = self.trailing_stop_positive_offset - # 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. 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: stop_loss_value = self.trailing_stop_positive 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) diff --git a/freqtrade/templates/FreqaiHybridExampleStrategy.py b/freqtrade/templates/FreqaiExampleHybridStrategy.py similarity index 94% rename from freqtrade/templates/FreqaiHybridExampleStrategy.py rename to freqtrade/templates/FreqaiExampleHybridStrategy.py index 593a6062b..26335956f 100644 --- a/freqtrade/templates/FreqaiHybridExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleHybridStrategy.py @@ -110,8 +110,6 @@ class FreqaiExampleHybridStrategy(IStrategy): :param informative: the dataframe associated with the informative pair """ - coin = pair.split('/')[0] - if informative is None: informative = self.dp.get_pair_dataframe(pair, tf) @@ -119,13 +117,13 @@ class FreqaiExampleHybridStrategy(IStrategy): for t in self.freqai_info["feature_parameters"]["indicator_periods_candles"]: t = int(t) - informative[f"%-{coin}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) - informative[f"%-{coin}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) - informative[f"%-{coin}adx-period_{t}"] = ta.ADX(informative, timeperiod=t) - informative[f"%-{coin}sma-period_{t}"] = ta.SMA(informative, timeperiod=t) - informative[f"%-{coin}ema-period_{t}"] = ta.EMA(informative, timeperiod=t) - informative[f"%-{coin}roc-period_{t}"] = ta.ROC(informative, timeperiod=t) - informative[f"%-{coin}relative_volume-period_{t}"] = ( + informative[f"%-{pair}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) + informative[f"%-{pair}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) + informative[f"%-{pair}adx-period_{t}"] = ta.ADX(informative, timeperiod=t) + informative[f"%-{pair}sma-period_{t}"] = ta.SMA(informative, timeperiod=t) + informative[f"%-{pair}ema-period_{t}"] = ta.EMA(informative, timeperiod=t) + informative[f"%-{pair}roc-period_{t}"] = ta.ROC(informative, timeperiod=t) + informative[f"%-{pair}relative_volume-period_{t}"] = ( informative["volume"] / informative["volume"].rolling(t).mean() ) diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index d58d61025..fc39b0ab4 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -53,7 +53,7 @@ class FreqaiExampleStrategy(IStrategy): """ Function designed to automatically generate, name and merge features from user indicated timeframes in the configuration file. User controls the indicators - passed to the training/prediction by prepending indicators with `'%-' + coin ` + passed to the training/prediction by prepending indicators with `f'%-{pair}` (see convention below). I.e. user should not prepend any supporting metrics (e.g. bb_lowerband below) with % unless they explicitly want to pass that metric to the model. @@ -63,8 +63,6 @@ class FreqaiExampleStrategy(IStrategy): :param informative: the dataframe associated with the informative pair """ - coin = pair.split('/')[0] - if informative is None: informative = self.dp.get_pair_dataframe(pair, tf) @@ -72,36 +70,36 @@ class FreqaiExampleStrategy(IStrategy): for t in self.freqai_info["feature_parameters"]["indicator_periods_candles"]: t = int(t) - informative[f"%-{coin}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) - informative[f"%-{coin}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) - informative[f"%-{coin}adx-period_{t}"] = ta.ADX(informative, timeperiod=t) - informative[f"%-{coin}sma-period_{t}"] = ta.SMA(informative, timeperiod=t) - informative[f"%-{coin}ema-period_{t}"] = ta.EMA(informative, timeperiod=t) + informative[f"%-{pair}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) + informative[f"%-{pair}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) + informative[f"%-{pair}adx-period_{t}"] = ta.ADX(informative, timeperiod=t) + informative[f"%-{pair}sma-period_{t}"] = ta.SMA(informative, timeperiod=t) + informative[f"%-{pair}ema-period_{t}"] = ta.EMA(informative, timeperiod=t) bollinger = qtpylib.bollinger_bands( qtpylib.typical_price(informative), window=t, stds=2.2 ) - informative[f"{coin}bb_lowerband-period_{t}"] = bollinger["lower"] - informative[f"{coin}bb_middleband-period_{t}"] = bollinger["mid"] - informative[f"{coin}bb_upperband-period_{t}"] = bollinger["upper"] + informative[f"{pair}bb_lowerband-period_{t}"] = bollinger["lower"] + informative[f"{pair}bb_middleband-period_{t}"] = bollinger["mid"] + informative[f"{pair}bb_upperband-period_{t}"] = bollinger["upper"] - informative[f"%-{coin}bb_width-period_{t}"] = ( - informative[f"{coin}bb_upperband-period_{t}"] - - informative[f"{coin}bb_lowerband-period_{t}"] - ) / informative[f"{coin}bb_middleband-period_{t}"] - informative[f"%-{coin}close-bb_lower-period_{t}"] = ( - informative["close"] / informative[f"{coin}bb_lowerband-period_{t}"] + informative[f"%-{pair}bb_width-period_{t}"] = ( + informative[f"{pair}bb_upperband-period_{t}"] + - informative[f"{pair}bb_lowerband-period_{t}"] + ) / informative[f"{pair}bb_middleband-period_{t}"] + informative[f"%-{pair}close-bb_lower-period_{t}"] = ( + informative["close"] / informative[f"{pair}bb_lowerband-period_{t}"] ) - informative[f"%-{coin}roc-period_{t}"] = ta.ROC(informative, timeperiod=t) + informative[f"%-{pair}roc-period_{t}"] = ta.ROC(informative, timeperiod=t) - informative[f"%-{coin}relative_volume-period_{t}"] = ( + informative[f"%-{pair}relative_volume-period_{t}"] = ( informative["volume"] / informative["volume"].rolling(t).mean() ) - informative[f"%-{coin}pct-change"] = informative["close"].pct_change() - informative[f"%-{coin}raw_volume"] = informative["volume"] - informative[f"%-{coin}raw_price"] = informative["close"] + informative[f"%-{pair}pct-change"] = informative["close"].pct_change() + informative[f"%-{pair}raw_volume"] = informative["volume"] + informative[f"%-{pair}raw_price"] = informative["close"] indicators = [col for col in informative if col.startswith("%")] # This loop duplicates and shifts all indicators to add a sense of recency to data diff --git a/freqtrade/worker.py b/freqtrade/worker.py index dea0acc44..a407de0d7 100755 --- a/freqtrade/worker.py +++ b/freqtrade/worker.py @@ -14,6 +14,7 @@ from freqtrade.configuration import Configuration from freqtrade.constants import PROCESS_THROTTLE_SECS, RETRY_TIMEOUT, Config from freqtrade.enums import State from freqtrade.exceptions import OperationalException, TemporaryError +from freqtrade.exchange import timeframe_to_next_date from freqtrade.freqtradebot import FreqtradeBot @@ -35,7 +36,6 @@ class Worker: self._config = config self._init(False) - self.last_throttle_start_time: float = 0 self._heartbeat_msg: float = 0 # Tell systemd that we completed initialization phase @@ -112,7 +112,10 @@ class Worker: # Ping systemd watchdog before throttling 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: now = time.time() @@ -127,24 +130,42 @@ class Worker: 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 takes at least `min_secs` to finish execution. :param func: Any callable :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) """ - self.last_throttle_start_time = time.time() + last_throttle_start_time = time.time() logger.debug("========================================") result = func(*args, **kwargs) - time_passed = time.time() - self.last_throttle_start_time - sleep_duration = max(throttle_secs - time_passed, 0.0) + time_passed = time.time() - last_throttle_start_time + 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, " f"last iteration took {time_passed:.2f} s.") - time.sleep(sleep_duration) + self._sleep(sleep_duration) return result + @staticmethod + def _sleep(sleep_duration: float) -> None: + """Local sleep method - to improve testability""" + time.sleep(sleep_duration) + def _process_stopped(self) -> None: self.freqtrade.process_stopped() diff --git a/requirements-dev.txt b/requirements-dev.txt index 3f7277020..66da72969 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,7 +11,7 @@ flake8-tidy-imports==4.8.0 mypy==0.982 pre-commit==2.20.0 pytest==7.1.3 -pytest-asyncio==0.19.0 +pytest-asyncio==0.20.1 pytest-cov==4.0.0 pytest-mock==3.10.0 pytest-random-order==1.0.4 @@ -20,11 +20,11 @@ isort==5.10.1 time-machine==2.8.2 # Convert jupyter notebooks to markdown documents -nbconvert==7.2.1 +nbconvert==7.2.3 # mypy types types-cachetools==5.2.1 types-filelock==3.2.7 types-requests==2.28.11.2 types-tabulate==0.9.0.0 -types-python-dateutil==2.8.19.1 +types-python-dateutil==2.8.19.2 diff --git a/requirements-freqai.txt b/requirements-freqai.txt index 201d5be1b..0071c5ad1 100644 --- a/requirements-freqai.txt +++ b/requirements-freqai.txt @@ -2,7 +2,7 @@ -r requirements.txt # Required for freqai -scikit-learn==1.1.2 +scikit-learn==1.1.3 joblib==1.2.0 catboost==1.1; platform_machine != 'aarch64' lightgbm==3.3.3 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 3e76a6c22..fd2731256 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,8 +2,8 @@ -r requirements.txt # Required for hyperopt -scipy==1.9.2 -scikit-learn==1.1.2 +scipy==1.9.3 +scikit-learn==1.1.3 scikit-optimize==0.9.0 filelock==3.8.0 -progressbar2==4.0.0 +progressbar2==4.1.1 diff --git a/requirements-plot.txt b/requirements-plot.txt index 80cd3f4f2..75e3234a1 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,4 +1,4 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==5.10.0 +plotly==5.11.0 diff --git a/requirements.txt b/requirements.txt index 64d861469..cae9cf3b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,8 @@ numpy==1.23.4 -pandas==1.5.0; platform_machine != 'armv7l' -# Piwheels doesn't have 1.5.0 yet. -pandas==1.4.3; platform_machine == 'armv7l' +pandas==1.5.1 pandas-ta==0.3.14b -ccxt==2.0.25 +ccxt==2.0.96 # Pin cryptography for now due to rust build errors with piwheels cryptography==38.0.1 aiohttp==3.8.3 @@ -18,18 +16,18 @@ jsonschema==4.16.0 TA-Lib==0.4.25 technical==1.3.0 tabulate==0.9.0 -pycoingecko==3.0.0 +pycoingecko==3.1.0 jinja2==3.1.2 tables==3.7.0 blosc==1.10.6 joblib==1.2.0 -pyarrow==9.0.0; platform_machine != 'armv7l' +pyarrow==10.0.0; platform_machine != 'armv7l' # find first, C search in arrays py_find_1st==1.1.5 # Load ticker files 30% faster -python-rapidjson==1.8 +python-rapidjson==1.9 # Properly format api responses orjson==3.8.0 @@ -38,14 +36,14 @@ sdnotify==0.3.2 # API Server fastapi==0.85.1 -pydantic>=1.8.0 -uvicorn==0.18.3 -pyjwt==2.5.0 +pydantic==1.10.2 +uvicorn==0.19.0 +pyjwt==2.6.0 aiofiles==22.1.0 -psutil==5.9.2 +psutil==5.9.3 # Support for colorized terminal output -colorama==0.4.5 +colorama==0.4.6 # Building config files interactively questionary==1.10.0 prompt-toolkit==3.0.31 @@ -56,5 +54,5 @@ python-dateutil==2.8.2 schedule==1.1.0 #WS Messages -websockets==10.3 +websockets==10.4 janus==1.0.0 diff --git a/scripts/ws_client.py b/scripts/ws_client.py index 23ad9296d..090039cde 100644 --- a/scripts/ws_client.py +++ b/scripts/ws_client.py @@ -18,7 +18,6 @@ import orjson import pandas import rapidjson import websockets -from dateutil.relativedelta import relativedelta logger = logging.getLogger("WebSocketClient") @@ -28,7 +27,7 @@ logger = logging.getLogger("WebSocketClient") def setup_logging(filename: str): logging.basicConfig( - level=logging.INFO, + level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(filename), @@ -75,16 +74,15 @@ def load_config(configfile): def readable_timedelta(delta): """ - Convert a dateutil.relativedelta to a readable format + Convert a millisecond delta to a readable format - :param delta: A dateutil.relativedelta + :param delta: A delta between two timestamps in milliseconds :returns: The readable time difference string """ - attrs = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'microseconds'] - return ", ".join([ - '%d %s' % (getattr(delta, attr), attr if getattr(delta, attr) > 0 else attr[:-1]) - for attr in attrs if getattr(delta, attr) - ]) + seconds, milliseconds = divmod(delta, 1000) + minutes, seconds = divmod(seconds, 60) + + return f"{int(minutes)}:{int(seconds)}.{int(milliseconds)}" # ---------------------------------------------------------------------------- @@ -170,8 +168,8 @@ class ClientProtocol: def _calculate_time_difference(self): old_last_received_at = self._LAST_RECEIVED_AT - self._LAST_RECEIVED_AT = time.time() * 1e6 - time_delta = relativedelta(microseconds=(self._LAST_RECEIVED_AT - old_last_received_at)) + self._LAST_RECEIVED_AT = time.time() * 1e3 + time_delta = self._LAST_RECEIVED_AT - old_last_received_at return readable_timedelta(time_delta) @@ -242,12 +240,10 @@ async def create_client( ): # Try pinging try: - pong = ws.ping() - await asyncio.wait_for( - pong, - timeout=ping_timeout - ) - logger.info("Connection still alive...") + pong = await ws.ping() + latency = (await asyncio.wait_for(pong, timeout=ping_timeout) * 1000) + + logger.info(f"Connection still alive, latency: {latency}ms") continue @@ -272,6 +268,7 @@ async def create_client( websockets.exceptions.ConnectionClosedError, websockets.exceptions.ConnectionClosedOK ): + logger.info("Connection was closed") # Just keep trying to connect again indefinitely await asyncio.sleep(sleep_time) diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py index 5d6d60f84..67eeda7d0 100644 --- a/tests/data/test_datahandler.py +++ b/tests/data/test_datahandler.py @@ -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.parquetdatahandler import ParquetDataHandler 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): @@ -154,6 +154,85 @@ def test_jsondatahandler_ohlcv_load(testdatadir, caplog): 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']) def test_datahandler_trades_not_supported(datahandler, testdatadir, ): dh = get_datahandler(testdatadir, datahandler) diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 75aaa0081..1fc8b4153 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -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 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): diff --git a/tests/exchange/test_huobi.py b/tests/exchange/test_huobi.py index fc7c7cefb..2ce379a47 100644 --- a/tests/exchange/test_huobi.py +++ b/tests/exchange/test_huobi.py @@ -113,5 +113,4 @@ def test_stoploss_adjust_huobi(mocker, default_conf): assert exchange.stoploss_adjust(1501, order, 'sell') assert not exchange.stoploss_adjust(1499, order, 'sell') # Test with invalid order case - order['type'] = 'stop_loss' - assert not exchange.stoploss_adjust(1501, order, 'sell') + assert exchange.stoploss_adjust(1501, order, 'sell') diff --git a/tests/freqai/test_freqai_datakitchen.py b/tests/freqai/test_freqai_datakitchen.py index 43acae7c3..9abe60edb 100644 --- a/tests/freqai/test_freqai_datakitchen.py +++ b/tests/freqai/test_freqai_datakitchen.py @@ -130,7 +130,8 @@ def test_normalize_data(mocker, freqai_conf): freqai = make_data_dictionary(mocker, freqai_conf) data_dict = freqai.dk.data_dictionary freqai.dk.normalize_data(data_dict) - assert len(freqai.dk.data) == 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): diff --git a/tests/freqai/test_freqai_interface.py b/tests/freqai/test_freqai_interface.py index b619c0611..2bc65d52e 100644 --- a/tests/freqai/test_freqai_interface.py +++ b/tests/freqai/test_freqai_interface.py @@ -27,13 +27,13 @@ def is_mac() -> bool: return "Darwin" in machine -@pytest.mark.parametrize('model', [ - 'LightGBMRegressor', - 'XGBoostRegressor', - 'XGBoostRFRegressor', - 'CatboostRegressor', +@pytest.mark.parametrize('model, pca, dbscan', [ + ('LightGBMRegressor', True, False), + ('XGBoostRegressor', False, True), + ('XGBoostRFRegressor', False, False), + ('CatboostRegressor', False, False), ]) -def test_extract_data_and_train_model_Standard(mocker, freqai_conf, model): +def test_extract_data_and_train_model_Standard(mocker, freqai_conf, model, pca, dbscan): if is_arm() and model == 'CatboostRegressor': pytest.skip("CatBoost is not supported on ARM") @@ -41,6 +41,8 @@ def test_extract_data_and_train_model_Standard(mocker, freqai_conf, model): freqai_conf.update({"freqaimodel": model}) freqai_conf.update({"timerange": "20180110-20180130"}) freqai_conf.update({"strategy": "freqai_test_strat"}) + freqai_conf['freqai']['feature_parameters'].update({"principal_component_analysis": pca}) + freqai_conf['freqai']['feature_parameters'].update({"use_DBSCAN_to_remove_outliers": dbscan}) strategy = get_patched_freqai_strategy(mocker, freqai_conf) exchange = get_patched_exchange(mocker, freqai_conf) @@ -234,6 +236,7 @@ def test_start_backtesting_subdaily_backtest_period(mocker, freqai_conf): metadata = {"pair": "LTC/BTC"} freqai.start_backtesting(df, metadata, freqai.dk) model_folders = [x for x in freqai.dd.full_path.iterdir() if x.is_dir()] + assert len(model_folders) == 9 shutil.rmtree(Path(freqai.dk.full_path)) diff --git a/tests/persistence/__init__.py b/tests/persistence/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_persistence.py b/tests/persistence/test_persistence.py similarity index 99% rename from tests/test_persistence.py rename to tests/persistence/test_persistence.py index ae2672830..3323dd7c6 100644 --- a/tests/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -2404,7 +2404,7 @@ def test_Trade_object_idem(): 'get_enter_tag_performance', 'get_mix_tag_performance', 'get_trading_volume', - + 'from_json', ) EXCLUDES2 = ('trades', 'trades_open', 'bt_trades_open_pp', 'bt_open_open_trade_count', 'total_profit') diff --git a/tests/persistence/test_trade_fromjson.py b/tests/persistence/test_trade_fromjson.py new file mode 100644 index 000000000..529008e02 --- /dev/null +++ b/tests/persistence/test_trade_fromjson.py @@ -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) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index f0b983063..359291476 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -2,6 +2,8 @@ import logging import time +from copy import deepcopy +from datetime import timedelta from unittest.mock import MagicMock, PropertyMock import pandas as pd @@ -719,15 +721,26 @@ def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None: def test_ShuffleFilter_init(mocker, whitelist_conf, caplog) -> None: whitelist_conf['pairlists'] = [ {"method": "StaticPairList"}, - {"method": "ShuffleFilter", "seed": 42} + {"method": "ShuffleFilter", "seed": 43} ] exchange = get_patched_exchange(mocker, whitelist_conf) - PairListManager(exchange, whitelist_conf) - assert log_has("Backtesting mode detected, applying seed value: 42", caplog) + plm = PairListManager(exchange, whitelist_conf) + assert log_has("Backtesting mode detected, applying seed value: 43", caplog) + + with time_machine.travel("2021-09-01 05:01:00 +00:00") as t: + plm.refresh_pairlist() + pl1 = deepcopy(plm.whitelist) + plm.refresh_pairlist() + assert plm.whitelist == pl1 + + t.shift(timedelta(minutes=10)) + plm.refresh_pairlist() + assert plm.whitelist != pl1 + caplog.clear() whitelist_conf['runmode'] = RunMode.DRY_RUN - PairListManager(exchange, whitelist_conf) + plm = PairListManager(exchange, whitelist_conf) assert not log_has("Backtesting mode detected, applying seed value: 42", caplog) assert log_has("Live mode detected, not applying seed.", caplog) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index c127e3850..a0d38563e 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -3969,15 +3969,17 @@ def test__safe_exit_amount(default_conf_usdt, fee, caplog, mocker, amount_wallet patch_get_signal(freqtrade) if has_err: 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: 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 trade.amount == amount_wallet assert wallet_update.call_count == 1 caplog.clear() 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 wallet_update.call_count == 1 diff --git a/tests/test_integration.py b/tests/test_integration.py index f2504c23a..01a2801ad 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -420,7 +420,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) assert trade.open_order_id is None # Open rate is not adjusted yet assert trade.open_rate == 1.99 - assert trade.stake_amount == 60 + assert pytest.approx(trade.stake_amount) == 60 assert trade.stop_loss_pct == -0.1 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) @@ -446,7 +446,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) assert len(trade.orders) == 4 assert trade.open_order_id is not None assert trade.open_rate == 1.99 - assert trade.stake_amount == 60 + assert pytest.approx(trade.stake_amount) == 60 assert trade.orders[-1].price == 1.95 assert pytest.approx(trade.orders[-1].cost) == 120 * leverage diff --git a/tests/test_worker.py b/tests/test_worker.py index ddca9525b..ae511852f 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1,7 +1,10 @@ import logging import time +from datetime import timedelta from unittest.mock import MagicMock, PropertyMock +import time_machine + from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import State from freqtrade.worker import Worker @@ -59,13 +62,58 @@ def test_throttle(mocker, default_conf, caplog) -> None: end = time.time() 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) result = worker._throttle(throttled_func, throttle_secs=-1) 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 throttled_func(nb_assets=-1): return nb_assets