diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ceac4a7f..f4b550855 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -461,7 +461,7 @@ jobs: python setup.py sdist bdist_wheel - name: Publish to PyPI (Test) - uses: pypa/gh-action-pypi-publish@v1.8.7 + uses: pypa/gh-action-pypi-publish@v1.8.8 if: (github.event_name == 'release') with: user: __token__ @@ -469,7 +469,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.7 + uses: pypa/gh-action-pypi-publish@v1.8.8 if: (github.event_name == 'release') with: user: __token__ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56c8a6010..6e4919763 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,12 +13,12 @@ repos: - id: mypy exclude: build_helpers additional_dependencies: - - types-cachetools==5.3.0.5 + - types-cachetools==5.3.0.6 - types-filelock==3.2.7 - - types-requests==2.31.0.1 - - types-tabulate==0.9.0.2 - - types-python-dateutil==2.8.19.13 - - SQLAlchemy==2.0.17 + - types-requests==2.31.0.2 + - types-tabulate==0.9.0.3 + - types-python-dateutil==2.8.19.14 + - SQLAlchemy==2.0.19 # stages: [push] - repo: https://github.com/pycqa/isort diff --git a/build_helpers/TA_Lib-0.4.26-cp310-cp310-win_amd64.whl b/build_helpers/TA_Lib-0.4.26-cp310-cp310-win_amd64.whl deleted file mode 100644 index 466455a8c..000000000 Binary files a/build_helpers/TA_Lib-0.4.26-cp310-cp310-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.26-cp311-cp311-win_amd64.whl b/build_helpers/TA_Lib-0.4.26-cp311-cp311-win_amd64.whl deleted file mode 100644 index 16f4f411a..000000000 Binary files a/build_helpers/TA_Lib-0.4.26-cp311-cp311-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.26-cp38-cp38-win_amd64.whl b/build_helpers/TA_Lib-0.4.26-cp38-cp38-win_amd64.whl deleted file mode 100644 index 324502fb8..000000000 Binary files a/build_helpers/TA_Lib-0.4.26-cp38-cp38-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.26-cp39-cp39-win_amd64.whl b/build_helpers/TA_Lib-0.4.26-cp39-cp39-win_amd64.whl deleted file mode 100644 index 389403041..000000000 Binary files a/build_helpers/TA_Lib-0.4.26-cp39-cp39-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.27-cp310-cp310-win_amd64.whl b/build_helpers/TA_Lib-0.4.27-cp310-cp310-win_amd64.whl new file mode 100644 index 000000000..fba8f932a Binary files /dev/null and b/build_helpers/TA_Lib-0.4.27-cp310-cp310-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.27-cp311-cp311-win_amd64.whl b/build_helpers/TA_Lib-0.4.27-cp311-cp311-win_amd64.whl new file mode 100644 index 000000000..63e6eacce Binary files /dev/null and b/build_helpers/TA_Lib-0.4.27-cp311-cp311-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.27-cp38-cp38-win_amd64.whl b/build_helpers/TA_Lib-0.4.27-cp38-cp38-win_amd64.whl new file mode 100644 index 000000000..80a81af98 Binary files /dev/null and b/build_helpers/TA_Lib-0.4.27-cp38-cp38-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.27-cp39-cp39-win_amd64.whl b/build_helpers/TA_Lib-0.4.27-cp39-cp39-win_amd64.whl new file mode 100644 index 000000000..7b3fb91f9 Binary files /dev/null and b/build_helpers/TA_Lib-0.4.27-cp39-cp39-win_amd64.whl differ diff --git a/build_helpers/install_windows.ps1 b/build_helpers/install_windows.ps1 index 2fc21d317..73a6eea06 100644 --- a/build_helpers/install_windows.ps1 +++ b/build_helpers/install_windows.ps1 @@ -1,21 +1,11 @@ -# Downloads don't work automatically, since the URL is regenerated via javascript. -# Downloaded from https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib +# vendored Wheels compiled via https://github.com/xmatthias/ta-lib-python/tree/ta_bundled_040 python -m pip install --upgrade pip wheel $pyv = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" -if ($pyv -eq '3.8') { - pip install build_helpers\TA_Lib-0.4.26-cp38-cp38-win_amd64.whl -} -if ($pyv -eq '3.9') { - pip install build_helpers\TA_Lib-0.4.26-cp39-cp39-win_amd64.whl -} -if ($pyv -eq '3.10') { - pip install build_helpers\TA_Lib-0.4.26-cp310-cp310-win_amd64.whl -} -if ($pyv -eq '3.11') { - pip install build_helpers\TA_Lib-0.4.26-cp311-cp311-win_amd64.whl -} + +pip install --find-links=build_helpers\ TA-Lib + pip install -r requirements-dev.txt pip install -e . diff --git a/build_helpers/pyarrow-12.0.0-cp39-cp39-linux_armv7l.whl b/build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl similarity index 65% rename from build_helpers/pyarrow-12.0.0-cp39-cp39-linux_armv7l.whl rename to build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl index 2a8d1ff51..55211ca01 100644 Binary files a/build_helpers/pyarrow-12.0.0-cp39-cp39-linux_armv7l.whl and b/build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl differ diff --git a/docker/docker-compose-freqai.yml b/docker/docker-compose-freqai.yml index 6edf41238..ce493d5b7 100644 --- a/docker/docker-compose-freqai.yml +++ b/docker/docker-compose-freqai.yml @@ -32,5 +32,5 @@ services: --logfile /freqtrade/user_data/logs/freqtrade.log --db-url sqlite:////freqtrade/user_data/tradesv3.sqlite --config /freqtrade/user_data/config.json - --freqai-model XGBoostClassifier - --strategy SampleStrategy + --freqaimodel XGBoostRegressor + --strategy FreqaiExampleStrategy diff --git a/docs/advanced-backtesting.md b/docs/advanced-backtesting.md index b587c4157..3926fb5b1 100644 --- a/docs/advanced-backtesting.md +++ b/docs/advanced-backtesting.md @@ -103,6 +103,22 @@ The indicators have to be present in your strategy's main DataFrame (either for timeframe or for informative timeframes) otherwise they will simply be ignored in the script output. +There are a range of candle and trade-related fields that are included in the analysis so are +automatically accessible by including them on the indicator-list, and these include: + +- **open_date :** trade open datetime +- **close_date :** trade close datetime +- **min_rate :** minimum price seen throughout the position +- **max_rate :** maxiumum price seen throughout the position +- **open :** signal candle open price +- **close :** signal candle close price +- **high :** signal candle high price +- **low :** signal candle low price +- **volume :** signal candle volumne +- **profit_ratio :** trade profit ratio +- **profit_abs :** absolute profit return of the trade + + ### Filtering the trade output by date To show only trades between dates within your backtested timerange, supply the usual `timerange` option in `YYYYMMDD-[YYYYMMDD]` format: diff --git a/docs/backtesting.md b/docs/backtesting.md index 166c2b28b..d1cd61057 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -305,7 +305,7 @@ A backtesting result will look like that: | Sharpe | 2.97 | | Calmar | 6.29 | | Profit factor | 1.11 | -| Expectancy | -0.15 | +| Expectancy (Ratio) | -0.15 (-0.05) | | Avg. stake amount | 0.001 BTC | | Total trade volume | 0.429 BTC | | | | @@ -324,6 +324,7 @@ A backtesting result will look like that: | Days win/draw/lose | 12 / 82 / 25 | | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | +| Max Consecutive Wins / Loss | 3 / 4 | | Rejected Entry signals | 3089 | | Entry/Exit Timeouts | 0 / 0 | | Canceled Trade Entries | 34 | @@ -409,7 +410,7 @@ It contains some useful key metrics about performance of your strategy on backte | Sharpe | 2.97 | | Calmar | 6.29 | | Profit factor | 1.11 | -| Expectancy | -0.15 | +| Expectancy (Ratio) | -0.15 (-0.05) | | Avg. stake amount | 0.001 BTC | | Total trade volume | 0.429 BTC | | | | @@ -428,6 +429,7 @@ It contains some useful key metrics about performance of your strategy on backte | Days win/draw/lose | 12 / 82 / 25 | | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | +| Max Consecutive Wins / Loss | 3 / 4 | | Rejected Entry signals | 3089 | | Entry/Exit Timeouts | 0 / 0 | | Canceled Trade Entries | 34 | @@ -467,6 +469,7 @@ It contains some useful key metrics about performance of your strategy on backte - `Best day` / `Worst day`: Best and worst day based on daily profit. - `Days win/draw/lose`: Winning / Losing days (draws are usually days without closed trade). - `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades. +- `Max Consecutive Wins / Loss`: Maximum consecutive wins/losses in a row. - `Rejected Entry signals`: Trade entry signals that could not be acted upon due to `max_open_trades` being reached. - `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used). - `Canceled Trade Entries`: Number of trades that have been canceled by user request via `adjust_entry_price`. @@ -534,6 +537,7 @@ 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 + - ROI entries which came into effect on the triggering candle (e.g. `120: 0.02` for 1h candles, from `60: 0.05`) will use the candle's open as exit rate - 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 diff --git a/docs/configuration.md b/docs/configuration.md index cf3872f1c..6eb245706 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -682,16 +682,14 @@ To use a proxy for exchange connections - you will have to define the proxies as { "exchange": { "ccxt_config": { - "aiohttp_proxy": "http://addr:port", - "proxies": { - "http": "http://addr:port", - "https": "http://addr:port" - }, + "httpsProxy": "http://addr:port", } } } ``` +For more information on available proxy types, please consult the [ccxt proxy documentation](https://docs.ccxt.com/#/README?id=proxy). + ## Next step Now you have configured your config.json, the next step is to [start your bot](bot-usage.md). diff --git a/docs/developer.md b/docs/developer.md index 2782f0117..4784e5352 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -453,7 +453,13 @@ Once the PR against stable is merged (best right after merging): * Use the button "Draft a new release" in the Github UI (subsection releases). * Use the version-number specified as tag. * Use "stable" as reference (this step comes after the above PR is merged). -* Use the above changelog as release comment (as codeblock) +* Use the above changelog as release comment (as codeblock). +* Use the below snippet for the new release + +??? Tip "Release template" + ```` + --8<-- "includes/release_template.md" + ```` ## Releases diff --git a/docs/exchanges.md b/docs/exchanges.md index 997d012e1..fb3049ba5 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -259,10 +259,17 @@ The configuration parameter `exchange.unknown_fee_rate` can be used to specify t Futures trading on bybit is currently supported for USDT markets, and will use isolated futures mode. Users with unified accounts (there's no way back) can create a Sub-account which will start as "non-unified", and can therefore use isolated futures. -On startup, freqtrade will set the position mode to "One-way Mode" for the whole (sub)account. This avoids making this call over and over again (slowing down bot operations), but means that changes to this setting may result in exceptions and errors. +On startup, freqtrade will set the position mode to "One-way Mode" for the whole (sub)account. This avoids making this call over and over again (slowing down bot operations), but means that changes to this setting may result in exceptions and errors As bybit doesn't provide funding rate history, the dry-run calculation is used for live trades as well. +API Keys for live futures trading (Subaccount on non-unified) must have the following permissions: +* Read-write +* Contract - Orders +* Contract - Positions + +We do strongly recommend to limit all API keys to the IP you're going to use it from. + !!! Tip "Stoploss on Exchange" Bybit (futures only) supports `stoploss_on_exchange` and uses `stop-loss-limit` orders. It provides great advantages, so we recommend to benefit from it by enabling stoploss on exchange. On futures, Bybit supports both `stop-limit` as well as `stop-market` orders. You can use either `"limit"` or `"market"` in the `order_types.stoploss` configuration setting to decide which type to use. diff --git a/docs/freqai-feature-engineering.md b/docs/freqai-feature-engineering.md index daf645339..a781d0834 100644 --- a/docs/freqai-feature-engineering.md +++ b/docs/freqai-feature-engineering.md @@ -261,7 +261,7 @@ class MyFreqaiModel(BaseRegressionModel): """ feature_pipeline = Pipeline([ ('qt', SKLearnWrapper(QuantileTransformer(output_distribution='normal'))), - ('di', ds.DissimilarityIndex(di_threshold=1) + ('di', ds.DissimilarityIndex(di_threshold=1)) ]) return feature_pipeline diff --git a/docs/freqai-parameter-table.md b/docs/freqai-parameter-table.md index cc92c2457..5e60d2a07 100644 --- a/docs/freqai-parameter-table.md +++ b/docs/freqai-parameter-table.md @@ -42,7 +42,6 @@ Mandatory parameters are marked as **Required** and have to be set in one of the | `use_SVM_to_remove_outliers` | Train a support vector machine to detect and remove outliers from the training dataset, as well as from incoming data points. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm).
**Datatype:** Boolean. | `svm_params` | All parameters available in Sklearn's `SGDOneClassSVM()`. See details about some select parameters [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm).
**Datatype:** Dictionary. | `use_DBSCAN_to_remove_outliers` | Cluster data using the DBSCAN algorithm to identify and remove outliers from training and prediction data. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan).
**Datatype:** Boolean. -| `inlier_metric_window` | If set, FreqAI adds an `inlier_metric` to the training feature set and set the lookback to be the `inlier_metric_window`, i.e., the number of previous time points to compare the current candle to. Details of how the `inlier_metric` is computed can be found [here](freqai-feature-engineering.md#inlier-metric).
**Datatype:** Integer.
Default: `0`. | `noise_standard_deviation` | If set, FreqAI adds noise to the training features with the aim of preventing overfitting. FreqAI generates random deviates from a gaussian distribution with a standard deviation of `noise_standard_deviation` and adds them to all data points. `noise_standard_deviation` should be kept relative to the normalized space, i.e., between -1 and 1. In other words, since data in FreqAI is always normalized to be between -1 and 1, `noise_standard_deviation: 0.05` would result in 32% of the data being randomly increased/decreased by more than 2.5% (i.e., the percent of data falling within the first standard deviation).
**Datatype:** Integer.
Default: `0`. | `outlier_protection_percentage` | Enable to prevent outlier detection methods from discarding too much data. If more than `outlier_protection_percentage` % of points are detected as outliers by the SVM or DBSCAN, FreqAI will log a warning message and ignore outlier detection, i.e., the original dataset will be kept intact. If the outlier protection is triggered, no predictions will be made based on the training dataset.
**Datatype:** Float.
Default: `30`. | `reverse_train_test_order` | Split the feature dataset (see below) and use the latest data split for training and test on historical split of the data. This allows the model to be trained up to the most recent data point, while avoiding overfitting. However, you should be careful to understand the unorthodox nature of this parameter before employing it.
**Datatype:** Boolean.
Default: `False` (no reversal). diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 5fda038bd..9cdcc9bca 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -184,6 +184,8 @@ The RemotePairList is defined in the pairlists section of the configuration sett "pairlists": [ { "method": "RemotePairList", + "mode": "whitelist", + "processing_mode": "filter", "pairlist_url": "https://example.com/pairlist", "number_assets": 10, "refresh_period": 1800, @@ -194,6 +196,14 @@ The RemotePairList is defined in the pairlists section of the configuration sett ] ``` +The optional `mode` option specifies if the pairlist should be used as a `blacklist` or as a `whitelist`. The default value is "whitelist". + +The optional `processing_mode` option in the RemotePairList configuration determines how the retrieved pairlist is processed. It can have two values: "filter" or "append". + +In "filter" mode, the retrieved pairlist is used as a filter. Only the pairs present in both the original pairlist and the retrieved pairlist are included in the final pairlist. Other pairs are filtered out. + +In "append" mode, the retrieved pairlist is added to the original pairlist. All pairs from both lists are included in the final pairlist without any filtering. + The `pairlist_url` option specifies the URL of the remote server where the pairlist is located, or the path to a local file (if file:/// is prepended). This allows the user to use either a remote server or a local file as the source for the pairlist. The user is responsible for providing a server or local file that returns a JSON object with the following structure: @@ -201,7 +211,7 @@ The user is responsible for providing a server or local file that returns a JSON ```json { "pairs": ["XRP/USDT", "ETH/USDT", "LTC/USDT"], - "refresh_period": 1800, + "refresh_period": 1800 } ``` diff --git a/docs/includes/release_template.md b/docs/includes/release_template.md new file mode 100644 index 000000000..87a3564da --- /dev/null +++ b/docs/includes/release_template.md @@ -0,0 +1,37 @@ +## Highlighted changes + +- ... + +### How to update + +As always, you can update your bot using one of the following commands: + +#### docker-compose + +```bash +docker-compose pull +docker-compose up -d +``` + +#### Installation via setup script + +``` +# Deactivate venv and run +./setup.sh --update +``` + +#### Plain native installation + +``` +git pull +pip install -U -r requirements.txt +``` + +
+Expand full changelog + +``` + +``` + +
diff --git a/docs/includes/showcase.md b/docs/includes/showcase.md new file mode 100644 index 000000000..297685ad4 --- /dev/null +++ b/docs/includes/showcase.md @@ -0,0 +1,11 @@ +This section will highlight a few projects from members of the community. +!!! Note + The projects below are for the most part not maintained by the freqtrade , therefore use your own caution before using them. + +- [Example freqtrade strategies](https://github.com/freqtrade/freqtrade-strategies/) +- [FrequentHippo - Grafana dashboard with dry/live runs and backtests](http://frequenthippo.ddns.net:3000/) (by hippocritical). +- [Online pairlist generator](https://remotepairlist.com/) (by Blood4rc). +- [Freqtrade Backtesting Project](https://bt.robot.co.network/) (by Blood4rc). +- [Freqtrade analysis notebook](https://github.com/froggleston/freqtrade_analysis_notebook) (by Froggleston). +- [TUI for freqtrade](https://github.com/froggleston/freqtrade-frogtrade9000) (by Froggleston). +- [Bot Academy](https://botacademy.ddns.net/) (by stash86) - Blog about crypto bot projects. diff --git a/docs/index.md b/docs/index.md index c24d1f36b..77542ae78 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,6 +63,10 @@ Exchanges confirmed working by the community: - [X] [Bitvavo](https://bitvavo.com/) - [X] [Kucoin](https://www.kucoin.com/) +## Community showcase + +--8<-- "includes/showcase.md" + ## Requirements ### Hardware requirements diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index d6ee2fd14..ab16dc0cf 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,6 +1,6 @@ markdown==3.3.7 mkdocs==1.4.3 -mkdocs-material==9.1.17 +mkdocs-material==9.1.19 mdx_truly_sane_lists==1.3 -pymdown-extensions==10.0.1 +pymdown-extensions==10.1 jinja2==3.1.2 diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 855f2353b..ab8eb9f98 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -750,7 +750,7 @@ class DigDeeperStrategy(IStrategy): # Hope you have a deep wallet! try: # This returns first order stake size - stake_amount = filled_entries[0].cost + stake_amount = filled_entries[0].stake_amount # This then calculates current safety order size stake_amount = stake_amount * (1 + (count_of_entries * 0.25)) return stake_amount diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 1b36c60ad..f501d0e49 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -287,12 +287,17 @@ Return a summary of your profit/loss and performance. > **Best Performing:** `PAY/BTC: 50.23%` > **Trading volume:** `0.5 BTC` > **Profit factor:** `1.04` +> **Win / Loss:** `102 / 36` +> **Winrate:** `73.91%` +> **Expectancy (Ratio):** `4.87 (1.66)` > **Max Drawdown:** `9.23% (0.01255 BTC)` The relative profit of `1.2%` is the average profit per trade. The relative profit of `15.2 Σ%` is be based on the starting capital - so in this case, the starting capital was `0.00485701 * 1.152 = 0.00738 BTC`. Starting capital is either taken from the `available_capital` setting, or calculated by using current wallet size - profits. Profit Factor is calculated as gross profits / gross losses - and should serve as an overall metric for the strategy. +Expectancy corresponds to the average return per currency unit at risk, i.e. the winrate and the risk-reward ratio (the average gain of winning trades compared to the average loss of losing trades). +Expectancy Ratio is expected profit or loss of a subsequent trade based on the performance of all past trades. Max drawdown corresponds to the backtesting metric `Absolute Drawdown (Account)` - calculated as `(Absolute Drawdown) / (DrawdownHigh + startingBalance)`. Bot started date will refer to the date the bot was first started. For older bots, this will default to the first trade's open date. diff --git a/docs/trade-object.md b/docs/trade-object.md index 7e0db1e3b..15a8b1938 100644 --- a/docs/trade-object.md +++ b/docs/trade-object.md @@ -141,7 +141,8 @@ Most properties here can be None as they are dependant on the exchange response. `amount` | float | Amount in base currency `filled` | float | Filled amount (in base currency) `remaining` | float | Remaining amount -`cost` | float | Cost of the order - usually average * filled +`cost` | float | Cost of the order - usually average * filled (*Exchange dependant on futures, may contain the cost with or without leverage and may be in contracts.*) +`stake_amount` | float | Stake amount used for this order. *Added in 2023.7.* `order_date` | datetime | Order creation date **use `order_date_utc` instead** `order_date_utc` | datetime | Order creation date (in UTC) `order_fill_date` | datetime | Order fill date **use `order_fill_utc` instead** diff --git a/docs/webhook-config.md b/docs/webhook-config.md index 00c369919..e18a05e9b 100644 --- a/docs/webhook-config.md +++ b/docs/webhook-config.md @@ -80,12 +80,18 @@ When using the Form-Encoded or JSON-Encoded configuration you can configure any The result would be a POST request with e.g. `Status: running` body and `Content-Type: text/plain` header. -Optional parameters are available to enable automatic retries for webhook messages. The `webhook.retries` parameter can be set for the maximum number of retries the webhook request should attempt if it is unsuccessful (i.e. HTTP response status is not 200). By default this is set to `0` which is disabled. An additional `webhook.retry_delay` parameter can be set to specify the time in seconds between retry attempts. By default this is set to `0.1` (i.e. 100ms). Note that increasing the number of retries or retry delay may slow down the trader if there are connectivity issues with the webhook. Example configuration for retries: +## Additional configurations + +The `webhook.retries` parameter can be set for the maximum number of retries the webhook request should attempt if it is unsuccessful (i.e. HTTP response status is not 200). By default this is set to `0` which is disabled. An additional `webhook.retry_delay` parameter can be set to specify the time in seconds between retry attempts. By default this is set to `0.1` (i.e. 100ms). Note that increasing the number of retries or retry delay may slow down the trader if there are connectivity issues with the webhook. +You can also specify `webhook.timeout` - which defines how long the bot will wait until it assumes the other host as unresponsive (defaults to 10s). + +Example configuration for retries: ```json "webhook": { "enabled": true, "url": "https://", + "timeout": 10, "retries": 3, "retry_delay": 0.2, "status": { @@ -109,6 +115,8 @@ Custom messages can be sent to Webhook endpoints via the `self.dp.send_msg()` fu Different payloads can be configured for different events. Not all fields are necessary, but you should configure at least one of the dicts, otherwise the webhook will never be called. +## Webhook Message types + ### Entry The fields in `webhook.entry` are filled when the bot executes a long/short. Parameters are filled using string.format. diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 7a0a675d3..e3abde361 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2023.6' +__version__ = '2023.7' if 'dev' in __version__: from pathlib import Path diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index b0da8fa9d..5473e95e1 100755 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -67,8 +67,7 @@ ARGS_BUILD_STRATEGY = ["user_data_dir", "strategy", "template"] ARGS_CONVERT_DATA = ["pairs", "format_from", "format_to", "erase", "exchange"] -ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes", "trading_mode", - "candle_types"] +ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes", "trading_mode", "candle_types"] ARGS_CONVERT_TRADES = ["pairs", "timeframes", "exchange", "dataformat_ohlcv", "dataformat_trades"] diff --git a/freqtrade/commands/build_config_commands.py b/freqtrade/commands/build_config_commands.py index 63bb5c211..311622458 100644 --- a/freqtrade/commands/build_config_commands.py +++ b/freqtrade/commands/build_config_commands.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List from questionary import Separator, prompt +from freqtrade.configuration.detect_environment import running_in_docker from freqtrade.configuration.directory_operations import chown_user_directory from freqtrade.constants import UNLIMITED_STAKE_AMOUNT from freqtrade.exceptions import OperationalException @@ -179,7 +180,7 @@ def ask_user_config() -> Dict[str, Any]: "name": "api_server_listen_addr", "message": ("Insert Api server Listen Address (0.0.0.0 for docker, " "otherwise best left untouched)"), - "default": "127.0.0.1", + "default": "127.0.0.1" if not running_in_docker() else "0.0.0.0", "when": lambda x: x['api_server'] }, { diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index 08283430e..aaf538835 100755 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -381,7 +381,7 @@ AVAILABLE_CLI_OPTIONS = { ), "candle_types": Arg( '--candle-types', - help='Select candle type to use', + help='Select candle type to convert. Defaults to all available types.', choices=[c.value for c in CandleType], nargs='+', ), @@ -450,14 +450,12 @@ AVAILABLE_CLI_OPTIONS = { ), "exchange": Arg( '--exchange', - help=f'Exchange name (default: `{constants.DEFAULT_EXCHANGE}`). ' - f'Only valid if no config is provided.', + help='Exchange name. Only valid if no config is provided.', ), "timeframes": Arg( '-t', '--timeframes', help='Specify which tickers to download. Space-separated list. ' 'Default: `1m 5m`.', - default=['1m', '5m'], nargs='+', ), "prepend_data": Arg( diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index 6d26b3ba9..fdfef6316 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -4,10 +4,10 @@ from collections import defaultdict from typing import Any, Dict from freqtrade.configuration import TimeRange, setup_utils_configuration -from freqtrade.constants import DATETIME_PRINT_FORMAT, Config +from freqtrade.constants import DATETIME_PRINT_FORMAT, DL_DATA_TIMEFRAMES, Config from freqtrade.data.converter import convert_ohlcv_format, convert_trades_format from freqtrade.data.history import convert_trades_to_ohlcv, download_data_main -from freqtrade.enums import CandleType, RunMode, TradingMode +from freqtrade.enums import RunMode, TradingMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -57,6 +57,8 @@ def start_convert_trades(args: Dict[str, Any]) -> None: raise OperationalException( "Downloading data requires a list of pairs. " "Please check the documentation on how to configure this.") + if 'timeframes' not in config: + config['timeframes'] = DL_DATA_TIMEFRAMES # Init exchange exchange = ExchangeResolver.load_exchange(config, validate=False) @@ -86,11 +88,10 @@ def start_convert_data(args: Dict[str, Any], ohlcv: bool = True) -> None: config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) if ohlcv: migrate_binance_futures_data(config) - candle_types = [CandleType.from_string(ct) for ct in config.get('candle_types', ['spot'])] - for candle_type in candle_types: - convert_ohlcv_format(config, - convert_from=args['format_from'], convert_to=args['format_to'], - erase=args['erase'], candle_type=candle_type) + convert_ohlcv_format(config, + convert_from=args['format_from'], + convert_to=args['format_to'], + erase=args['erase']) else: convert_trades_format(config, convert_from=args['format_from'], convert_to=args['format_to'], diff --git a/freqtrade/configuration/detect_environment.py b/freqtrade/configuration/detect_environment.py new file mode 100644 index 000000000..99d585e87 --- /dev/null +++ b/freqtrade/configuration/detect_environment.py @@ -0,0 +1,8 @@ +import os + + +def running_in_docker() -> bool: + """ + Check if we are running in a docker container + """ + return os.environ.get('FT_APP_ENV') == 'docker' diff --git a/freqtrade/configuration/directory_operations.py b/freqtrade/configuration/directory_operations.py index e1313749b..267a74928 100644 --- a/freqtrade/configuration/directory_operations.py +++ b/freqtrade/configuration/directory_operations.py @@ -3,6 +3,7 @@ import shutil from pathlib import Path from typing import Optional +from freqtrade.configuration.detect_environment import running_in_docker from freqtrade.constants import (USER_DATA_FILES, USERPATH_FREQAIMODELS, USERPATH_HYPEROPTS, USERPATH_NOTEBOOKS, USERPATH_STRATEGIES, Config) from freqtrade.exceptions import OperationalException @@ -30,8 +31,7 @@ def chown_user_directory(directory: Path) -> None: Use Sudo to change permissions of the home-directory if necessary Only applies when running in docker! """ - import os - if os.environ.get('FT_APP_ENV') == 'docker': + if running_in_docker(): try: import subprocess subprocess.check_output( diff --git a/freqtrade/constants.py b/freqtrade/constants.py index de1e7aa51..3b09b4eae 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -10,7 +10,6 @@ from freqtrade.enums import CandleType, PriceType, RPCMessageType DOCS_LINK = "https://www.freqtrade.io/en/stable" DEFAULT_CONFIG = 'config.json' -DEFAULT_EXCHANGE = 'bittrex' PROCESS_THROTTLE_SECS = 5 # sec HYPEROPT_EPOCH = 100 # epochs RETRY_TIMEOUT = 30 # sec @@ -66,6 +65,7 @@ TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] FULL_DATAFRAME_THRESHOLD = 100 CUSTOM_TAG_MAX_LENGTH = 255 +DL_DATA_TIMEFRAMES = ['1m', '5m'] ENV_VAR_PREFIX = 'FREQTRADE__' diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index c5905acde..12be3c448 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -170,6 +170,7 @@ def load_and_merge_backtest_result(strategy_name: str, filename: Path, results: def _get_backtest_files(dirname: Path) -> List[Path]: + # Weird glob expression here avoids including .meta.json files. return list(reversed(sorted(dirname.glob('backtest-result-*-[0-9][0-9].json')))) @@ -184,7 +185,7 @@ def get_backtest_resultlist(dirname: Path): continue for s, v in metadata.items(): results.append({ - 'filename': filename.name, + 'filename': filename.stem, 'strategy': s, 'run_id': v['run_id'], 'backtest_start_time': v['backtest_start_time'], @@ -193,6 +194,17 @@ def get_backtest_resultlist(dirname: Path): return results +def delete_backtest_result(file_abs: Path): + """ + Delete backtest result file and corresponding metadata file. + """ + # *.meta.json + logger.info(f"Deleting backtest result file: {file_abs.name}") + file_abs_meta = file_abs.with_suffix('.meta.json') + file_abs.unlink() + file_abs_meta.unlink() + + def find_existing_backtest_stats(dirname: Union[Path, str], run_ids: Dict[str, str], min_backtest_date: Optional[datetime] = None) -> Dict[str, Any]: """ @@ -211,7 +223,6 @@ def find_existing_backtest_stats(dirname: Union[Path, str], run_ids: Dict[str, s 'strategy_comparison': [], } - # Weird glob expression here avoids including .meta.json files. for filename in _get_backtest_files(dirname): metadata = load_backtest_metadata(filename) if not metadata: diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index 2d3855d87..0eb6faf77 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -11,7 +11,7 @@ import pandas as pd from pandas import DataFrame, to_datetime from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, DEFAULT_TRADES_COLUMNS, Config, TradeList -from freqtrade.enums import CandleType +from freqtrade.enums import CandleType, TradingMode logger = logging.getLogger(__name__) @@ -96,8 +96,14 @@ def ohlcv_fill_up_missing_data(dataframe: DataFrame, timeframe: str, pair: str) 'volume': 'sum' } timeframe_minutes = timeframe_to_minutes(timeframe) + resample_interval = f'{timeframe_minutes}min' + if timeframe_minutes >= 43200 and timeframe_minutes < 525600: + # Monthly candles need special treatment to stick to the 1st of the month + resample_interval = f'{timeframe}S' + elif timeframe_minutes > 43200: + resample_interval = timeframe # Resample to create "NAN" values - df = dataframe.resample(f'{timeframe_minutes}min', on='date').agg(ohlcv_dict) + df = dataframe.resample(resample_interval, on='date').agg(ohlcv_dict) # Forwardfill close for missing columns df['close'] = df['close'].fillna(method='ffill') @@ -122,7 +128,7 @@ def ohlcv_fill_up_missing_data(dataframe: DataFrame, timeframe: str, pair: str) return df -def trim_dataframe(df: DataFrame, timerange, df_date_col: str = 'date', +def trim_dataframe(df: DataFrame, timerange, *, df_date_col: str = 'date', startup_candles: int = 0) -> DataFrame: """ Trim dataframe based on given timerange @@ -264,7 +270,6 @@ def convert_ohlcv_format( convert_from: str, convert_to: str, erase: bool, - candle_type: CandleType ): """ Convert OHLCV from one format to another @@ -272,7 +277,6 @@ def convert_ohlcv_format( :param convert_from: Source format :param convert_to: Target format :param erase: Erase source data (does not apply if source and target format are identical) - :param candle_type: Any of the enum CandleType (must match trading mode!) """ from freqtrade.data.history.idatahandler import get_datahandler src = get_datahandler(config['datadir'], convert_from) @@ -280,37 +284,45 @@ def convert_ohlcv_format( timeframes = config.get('timeframes', [config.get('timeframe')]) logger.info(f"Converting candle (OHLCV) for timeframe {timeframes}") - if 'pairs' not in config: - config['pairs'] = [] - # Check timeframes or fall back to timeframe. - for timeframe in timeframes: - config['pairs'].extend(src.ohlcv_get_pairs( - config['datadir'], - timeframe, - candle_type=candle_type - )) - config['pairs'] = sorted(set(config['pairs'])) - logger.info(f"Converting candle (OHLCV) data for {config['pairs']}") + candle_types = [CandleType.from_string(ct) for ct in config.get('candle_types', [ + c.value for c in CandleType])] + logger.info(candle_types) + paircombs = src.ohlcv_get_available_data(config['datadir'], TradingMode.SPOT) + paircombs.extend(src.ohlcv_get_available_data(config['datadir'], TradingMode.FUTURES)) - for timeframe in timeframes: - for pair in config['pairs']: - data = src.ohlcv_load(pair=pair, timeframe=timeframe, - timerange=None, - fill_missing=False, - drop_incomplete=False, - startup_candles=0, - candle_type=candle_type) - logger.info(f"Converting {len(data)} {timeframe} {candle_type} candles for {pair}") - if len(data) > 0: - trg.ohlcv_store( - pair=pair, - timeframe=timeframe, - data=data, - candle_type=candle_type - ) - if erase and convert_from != convert_to: - logger.info(f"Deleting source data for {pair} / {timeframe}") - src.ohlcv_purge(pair=pair, timeframe=timeframe, candle_type=candle_type) + if 'pairs' in config: + # Filter pairs + paircombs = [comb for comb in paircombs if comb[0] in config['pairs']] + + if 'timeframes' in config: + paircombs = [comb for comb in paircombs if comb[1] in config['timeframes']] + paircombs = [comb for comb in paircombs if comb[2] in candle_types] + + paircombs = sorted(paircombs, key=lambda x: (x[0], x[1], x[2].value)) + + formatted_paircombs = '\n'.join([f"{pair}, {timeframe}, {candle_type}" + for pair, timeframe, candle_type in paircombs]) + + logger.info(f"Converting candle (OHLCV) data for the following pair combinations:\n" + f"{formatted_paircombs}") + for pair, timeframe, candle_type in paircombs: + data = src.ohlcv_load(pair=pair, timeframe=timeframe, + timerange=None, + fill_missing=False, + drop_incomplete=False, + startup_candles=0, + candle_type=candle_type) + logger.info(f"Converting {len(data)} {timeframe} {candle_type} candles for {pair}") + if len(data) > 0: + trg.ohlcv_store( + pair=pair, + timeframe=timeframe, + data=data, + candle_type=candle_type + ) + if erase and convert_from != convert_to: + logger.info(f"Deleting source data for {pair} / {timeframe}") + src.ohlcv_purge(pair=pair, timeframe=timeframe, candle_type=candle_type) def reduce_dataframe_footprint(df: DataFrame) -> DataFrame: diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index e61f59cfa..39556889e 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -7,15 +7,16 @@ from typing import Dict, List, Optional, Tuple from pandas import DataFrame, concat from freqtrade.configuration import TimeRange -from freqtrade.constants import DATETIME_PRINT_FORMAT, DEFAULT_DATAFRAME_COLUMNS, Config +from freqtrade.constants import (DATETIME_PRINT_FORMAT, DEFAULT_DATAFRAME_COLUMNS, + DL_DATA_TIMEFRAMES, Config) from freqtrade.data.converter import (clean_ohlcv_dataframe, ohlcv_to_dataframe, trades_remove_duplicates, trades_to_ohlcv) from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler from freqtrade.enums import CandleType from freqtrade.exceptions import OperationalException from freqtrade.exchange import Exchange -from freqtrade.misc import format_ms_time from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist +from freqtrade.util import format_ms_time from freqtrade.util.binance_mig import migrate_binance_futures_data @@ -354,7 +355,7 @@ def _download_trades_history(exchange: Exchange, trades = [] if not since: - since = int((datetime.now() - timedelta(days=-new_pairs_days)).timestamp()) * 1000 + since = int((datetime.now() - timedelta(days=new_pairs_days)).timestamp()) * 1000 from_id = trades[-1][1] if trades else None if trades and since < trades[-1][0]: @@ -512,6 +513,8 @@ def download_data_main(config: Config) -> None: ] expanded_pairs = dynamic_expand_pairlist(config, available_pairs) + if 'timeframes' not in config: + config['timeframes'] = DL_DATA_TIMEFRAMES # Manual validations of relevant settings if not config['exchange'].get('skip_pair_validation', False): diff --git a/freqtrade/data/metrics.py b/freqtrade/data/metrics.py index 138903b57..c22dcccef 100644 --- a/freqtrade/data/metrics.py +++ b/freqtrade/data/metrics.py @@ -194,32 +194,35 @@ def calculate_cagr(days_passed: int, starting_balance: float, final_balance: flo return (final_balance / starting_balance) ** (1 / (days_passed / 365)) - 1 -def calculate_expectancy(trades: pd.DataFrame) -> float: +def calculate_expectancy(trades: pd.DataFrame) -> Tuple[float, float]: """ Calculate expectancy :param trades: DataFrame containing trades (requires columns close_date and profit_abs) - :return: expectancy + :return: expectancy, expectancy_ratio """ - if len(trades) == 0: - return 0 - expectancy = 1 + expectancy = 0 + expectancy_ratio = 100 - profit_sum = trades.loc[trades['profit_abs'] > 0, 'profit_abs'].sum() - loss_sum = abs(trades.loc[trades['profit_abs'] < 0, 'profit_abs'].sum()) - nb_win_trades = len(trades.loc[trades['profit_abs'] > 0]) - nb_loss_trades = len(trades.loc[trades['profit_abs'] < 0]) + if len(trades) > 0: + winning_trades = trades.loc[trades['profit_abs'] > 0] + losing_trades = trades.loc[trades['profit_abs'] < 0] + profit_sum = winning_trades['profit_abs'].sum() + loss_sum = abs(losing_trades['profit_abs'].sum()) + nb_win_trades = len(winning_trades) + nb_loss_trades = len(losing_trades) - if (nb_win_trades > 0) and (nb_loss_trades > 0): - average_win = profit_sum / nb_win_trades - average_loss = loss_sum / nb_loss_trades - risk_reward_ratio = average_win / average_loss - winrate = nb_win_trades / len(trades) - expectancy = ((1 + risk_reward_ratio) * winrate) - 1 - elif nb_win_trades == 0: - expectancy = 0 + average_win = (profit_sum / nb_win_trades) if nb_win_trades > 0 else 0 + average_loss = (loss_sum / nb_loss_trades) if nb_loss_trades > 0 else 0 + winrate = (nb_win_trades / len(trades)) + loserate = (nb_loss_trades / len(trades)) - return expectancy + expectancy = (winrate * average_win) - (loserate * average_loss) + if (average_loss > 0): + risk_reward_ratio = average_win / average_loss + expectancy_ratio = ((1 + risk_reward_ratio) * winrate) - 1 + + return expectancy, expectancy_ratio def calculate_sortino(trades: pd.DataFrame, min_date: datetime, max_date: datetime, diff --git a/freqtrade/edge/edge_positioning.py b/freqtrade/edge/edge_positioning.py index f2df0d3f2..ac0262d18 100644 --- a/freqtrade/edge/edge_positioning.py +++ b/freqtrade/edge/edge_positioning.py @@ -172,13 +172,7 @@ class Edge: pair_data = pair_data.sort_values(by=['date']) pair_data = pair_data.reset_index(drop=True) - df_analyzed = self.strategy.advise_exit( - dataframe=self.strategy.advise_entry( - dataframe=pair_data, - metadata={'pair': pair} - ), - metadata={'pair': pair} - )[headers].copy() + df_analyzed = self.strategy.ft_advise_signals(pair_data, {'pair': pair})[headers].copy() trades += self._find_trades_for_stoploss_range(df_analyzed, pair, self._stoploss_range) diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 8075d775a..e8be804cb 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -34,6 +34,7 @@ class Binance(Exchange): "tickers_have_price": False, "floor_leverage": True, "stop_price_type_field": "workingType", + "order_props_in_contracts": ['amount', 'cost', 'filled', 'remaining'], "stop_price_type_value_mapping": { PriceType.LAST: "CONTRACT_PRICE", PriceType.MARK: "MARK_PRICE", diff --git a/freqtrade/exchange/binance_leverage_tiers.json b/freqtrade/exchange/binance_leverage_tiers.json index b211be701..f1b2c8322 100644 --- a/freqtrade/exchange/binance_leverage_tiers.json +++ b/freqtrade/exchange/binance_leverage_tiers.json @@ -1374,10 +1374,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 15.0, + "maxLeverage": 10.0, "info": { "bracket": "1", - "initialLeverage": "15", + "initialLeverage": "10", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -1390,10 +1390,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "8", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -1406,10 +1406,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "8", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -1452,13 +1452,13 @@ "tier": 6.0, "currency": "BUSD", "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "maxNotional": 1500000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "6", "initialLeverage": "1", - "notionalCap": "5000000", + "notionalCap": "1500000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", "cum": "386900.0" @@ -4744,13 +4744,13 @@ "tier": 4.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 250000.0, + "maxNotional": 500000.0, "maintenanceMarginRate": 0.02, "maxLeverage": 25.0, "info": { "bracket": "4", "initialLeverage": "25", - "notionalCap": "250000", + "notionalCap": "500000", "notionalFloor": "50000", "maintMarginRatio": "0.02", "cum": "542.5" @@ -4759,71 +4759,71 @@ { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, - "maxNotional": 1000000.0, + "minNotional": 500000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "5", "initialLeverage": "10", - "notionalCap": "1000000", - "notionalFloor": "250000", + "notionalCap": "2000000", + "notionalFloor": "500000", "maintMarginRatio": "0.05", - "cum": "8042.5" + "cum": "15542.5" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 2000000.0, + "minNotional": 2000000.0, + "maxNotional": 4000000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "6", "initialLeverage": "5", - "notionalCap": "2000000", - "notionalFloor": "1000000", + "notionalCap": "4000000", + "notionalFloor": "2000000", "maintMarginRatio": "0.1", - "cum": "58042.5" + "cum": "115542.5" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 2000000.0, - "maxNotional": 5000000.0, + "minNotional": 4000000.0, + "maxNotional": 8000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "7", "initialLeverage": "4", - "notionalCap": "5000000", - "notionalFloor": "2000000", + "notionalCap": "8000000", + "notionalFloor": "4000000", "maintMarginRatio": "0.125", - "cum": "108042.5" + "cum": "215542.5" } }, { "tier": 8.0, "currency": "USDT", - "minNotional": 5000000.0, - "maxNotional": 10000000.0, + "minNotional": 8000000.0, + "maxNotional": 15000000.0, "maintenanceMarginRate": 0.15, "maxLeverage": 3.0, "info": { "bracket": "8", "initialLeverage": "3", - "notionalCap": "10000000", - "notionalFloor": "5000000", + "notionalCap": "15000000", + "notionalFloor": "8000000", "maintMarginRatio": "0.15", - "cum": "233042.5" + "cum": "415542.5" } }, { "tier": 9.0, "currency": "USDT", - "minNotional": 10000000.0, + "minNotional": 15000000.0, "maxNotional": 20000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, @@ -4831,9 +4831,9 @@ "bracket": "9", "initialLeverage": "2", "notionalCap": "20000000", - "notionalFloor": "10000000", + "notionalFloor": "15000000", "maintMarginRatio": "0.25", - "cum": "1233042.5" + "cum": "1915542.5" } }, { @@ -4849,7 +4849,7 @@ "notionalCap": "50000000", "notionalFloor": "20000000", "maintMarginRatio": "0.5", - "cum": "6233042.5" + "cum": "6915542.5" } } ], @@ -5959,7 +5959,7 @@ } } ], - "BTC/USDT:USDT-230630": [ + "BTC/USDT:USDT-230929": [ { "tier": 1.0, "currency": "USDT", @@ -6060,13 +6060,13 @@ "tier": 7.0, "currency": "USDT", "minNotional": 40000000.0, - "maxNotional": 400000000.0, + "maxNotional": 120000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "400000000", + "notionalCap": "120000000", "notionalFloor": "40000000", "maintMarginRatio": "0.5", "cum": "1.246125E7" @@ -7382,13 +7382,13 @@ "tier": 2.0, "currency": "USDT", "minNotional": 5000.0, - "maxNotional": 25000.0, + "maxNotional": 50000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "2", "initialLeverage": "20", - "notionalCap": "25000", + "notionalCap": "50000", "notionalFloor": "5000", "maintMarginRatio": "0.025", "cum": "75.0" @@ -7397,39 +7397,39 @@ { "tier": 3.0, "currency": "USDT", - "minNotional": 25000.0, - "maxNotional": 100000.0, + "minNotional": 50000.0, + "maxNotional": 200000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "100000", - "notionalFloor": "25000", + "notionalCap": "200000", + "notionalFloor": "50000", "maintMarginRatio": "0.05", - "cum": "700.0" + "cum": "1325.0" } }, { "tier": 4.0, "currency": "USDT", - "minNotional": 100000.0, - "maxNotional": 250000.0, + "minNotional": 200000.0, + "maxNotional": 500000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "250000", - "notionalFloor": "100000", + "notionalCap": "500000", + "notionalFloor": "200000", "maintMarginRatio": "0.1", - "cum": "5700.0" + "cum": "11325.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, + "minNotional": 500000.0, "maxNotional": 1000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 2.0, @@ -7437,9 +7437,9 @@ "bracket": "5", "initialLeverage": "2", "notionalCap": "1000000", - "notionalFloor": "250000", + "notionalFloor": "500000", "maintMarginRatio": "0.125", - "cum": "11950.0" + "cum": "23825.0" } }, { @@ -7455,7 +7455,7 @@ "notionalCap": "5000000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", - "cum": "386950.0" + "cum": "398825.0" } } ], @@ -8869,14 +8869,14 @@ "currency": "USDT", "minNotional": 0.0, "maxNotional": 5000.0, - "maintenanceMarginRate": 0.006, - "maxLeverage": 50.0, + "maintenanceMarginRate": 0.005, + "maxLeverage": 75.0, "info": { "bracket": "1", - "initialLeverage": "50", + "initialLeverage": "75", "notionalCap": "5000", "notionalFloor": "0", - "maintMarginRatio": "0.006", + "maintMarginRatio": "0.005", "cum": "0.0" } }, @@ -8884,128 +8884,144 @@ "tier": 2.0, "currency": "USDT", "minNotional": 5000.0, - "maxNotional": 25000.0, - "maintenanceMarginRate": 0.007, - "maxLeverage": 40.0, + "maxNotional": 10000.0, + "maintenanceMarginRate": 0.006, + "maxLeverage": 50.0, "info": { "bracket": "2", - "initialLeverage": "40", - "notionalCap": "25000", + "initialLeverage": "50", + "notionalCap": "10000", "notionalFloor": "5000", - "maintMarginRatio": "0.007", + "maintMarginRatio": "0.006", "cum": "5.0" } }, { "tier": 3.0, "currency": "USDT", - "minNotional": 25000.0, - "maxNotional": 600000.0, - "maintenanceMarginRate": 0.01, - "maxLeverage": 25.0, + "minNotional": 10000.0, + "maxNotional": 50000.0, + "maintenanceMarginRate": 0.007, + "maxLeverage": 40.0, "info": { "bracket": "3", - "initialLeverage": "25", - "notionalCap": "600000", - "notionalFloor": "25000", - "maintMarginRatio": "0.01", - "cum": "80.0" + "initialLeverage": "40", + "notionalCap": "50000", + "notionalFloor": "10000", + "maintMarginRatio": "0.007", + "cum": "15.0" } }, { "tier": 4.0, "currency": "USDT", - "minNotional": 600000.0, - "maxNotional": 900000.0, - "maintenanceMarginRate": 0.025, - "maxLeverage": 20.0, + "minNotional": 50000.0, + "maxNotional": 750000.0, + "maintenanceMarginRate": 0.01, + "maxLeverage": 25.0, "info": { "bracket": "4", - "initialLeverage": "20", - "notionalCap": "900000", - "notionalFloor": "600000", - "maintMarginRatio": "0.025", - "cum": "9080.0" + "initialLeverage": "25", + "notionalCap": "750000", + "notionalFloor": "50000", + "maintMarginRatio": "0.01", + "cum": "165.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 900000.0, - "maxNotional": 1800000.0, - "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "minNotional": 750000.0, + "maxNotional": 1100000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 20.0, "info": { "bracket": "5", - "initialLeverage": "10", - "notionalCap": "1800000", - "notionalFloor": "900000", - "maintMarginRatio": "0.05", - "cum": "31580.0" + "initialLeverage": "20", + "notionalCap": "1100000", + "notionalFloor": "750000", + "maintMarginRatio": "0.025", + "cum": "11415.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1800000.0, - "maxNotional": 4800000.0, - "maintenanceMarginRate": 0.1, - "maxLeverage": 5.0, + "minNotional": 1100000.0, + "maxNotional": 2200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, "info": { "bracket": "6", - "initialLeverage": "5", - "notionalCap": "4800000", - "notionalFloor": "1800000", - "maintMarginRatio": "0.1", - "cum": "121580.0" + "initialLeverage": "10", + "notionalCap": "2200000", + "notionalFloor": "1100000", + "maintMarginRatio": "0.05", + "cum": "38915.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 4800000.0, - "maxNotional": 6000000.0, - "maintenanceMarginRate": 0.125, - "maxLeverage": 4.0, + "minNotional": 2200000.0, + "maxNotional": 5600000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, "info": { "bracket": "7", - "initialLeverage": "4", - "notionalCap": "6000000", - "notionalFloor": "4800000", - "maintMarginRatio": "0.125", - "cum": "241580.0" + "initialLeverage": "5", + "notionalCap": "5600000", + "notionalFloor": "2200000", + "maintMarginRatio": "0.1", + "cum": "148915.0" } }, { "tier": 8.0, "currency": "USDT", - "minNotional": 6000000.0, - "maxNotional": 18000000.0, - "maintenanceMarginRate": 0.25, - "maxLeverage": 2.0, + "minNotional": 5600000.0, + "maxNotional": 7000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, "info": { "bracket": "8", - "initialLeverage": "2", - "notionalCap": "18000000", - "notionalFloor": "6000000", - "maintMarginRatio": "0.25", - "cum": "991580.0" + "initialLeverage": "4", + "notionalCap": "7000000", + "notionalFloor": "5600000", + "maintMarginRatio": "0.125", + "cum": "288915.0" } }, { "tier": 9.0, "currency": "USDT", + "minNotional": 7000000.0, + "maxNotional": 18000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "9", + "initialLeverage": "2", + "notionalCap": "18000000", + "notionalFloor": "7000000", + "maintMarginRatio": "0.25", + "cum": "1163915.0" + } + }, + { + "tier": 10.0, + "currency": "USDT", "minNotional": 18000000.0, "maxNotional": 30000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { - "bracket": "9", + "bracket": "10", "initialLeverage": "1", "notionalCap": "30000000", "notionalFloor": "18000000", "maintMarginRatio": "0.5", - "cum": "5491580.0" + "cum": "5663915.0" } } ], @@ -10877,7 +10893,7 @@ } } ], - "ETH/USDT:USDT-230630": [ + "ETH/USDT:USDT-230929": [ { "tier": 1.0, "currency": "USDT", @@ -10978,13 +10994,13 @@ "tier": 7.0, "currency": "USDT", "minNotional": 40000000.0, - "maxNotional": 400000000.0, + "maxNotional": 120000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "400000000", + "notionalCap": "120000000", "notionalFloor": "40000000", "maintMarginRatio": "0.5", "cum": "1.246125E7" @@ -17721,6 +17737,234 @@ } } ], + "MAV/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], + "MDT/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "MINA/USDT:USDT": [ { "tier": 1.0, @@ -18519,6 +18763,120 @@ } } ], + "NMR/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "OCEAN/USDT:USDT": [ { "tier": 1.0, @@ -22389,14 +22747,14 @@ "currency": "USDT", "minNotional": 0.0, "maxNotional": 5000.0, - "maintenanceMarginRate": 0.01, - "maxLeverage": 25.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 21.0, "info": { "bracket": "1", - "initialLeverage": "25", + "initialLeverage": "21", "notionalCap": "5000", "notionalFloor": "0", - "maintMarginRatio": "0.01", + "maintMarginRatio": "0.02", "cum": "0.0" } }, @@ -22413,7 +22771,7 @@ "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", - "cum": "75.0" + "cum": "25.0" } }, { @@ -22429,7 +22787,7 @@ "notionalCap": "300000", "notionalFloor": "25000", "maintMarginRatio": "0.05", - "cum": "700.0" + "cum": "650.0" } }, { @@ -22445,7 +22803,7 @@ "notionalCap": "800000", "notionalFloor": "300000", "maintMarginRatio": "0.1", - "cum": "15700.0" + "cum": "15650.0" } }, { @@ -22461,7 +22819,7 @@ "notionalCap": "1000000", "notionalFloor": "800000", "maintMarginRatio": "0.125", - "cum": "35700.0" + "cum": "35650.0" } }, { @@ -22477,23 +22835,23 @@ "notionalCap": "3000000", "notionalFloor": "1000000", "maintMarginRatio": "0.25", - "cum": "160700.0" + "cum": "160650.0" } }, { "tier": 7.0, "currency": "USDT", "minNotional": 3000000.0, - "maxNotional": 5000000.0, + "maxNotional": 3500000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "5000000", + "notionalCap": "3500000", "notionalFloor": "3000000", "maintMarginRatio": "0.5", - "cum": "910700.0" + "cum": "910650.0" } } ], @@ -22534,13 +22892,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 600000.0, + "maxNotional": 720000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "600000", + "notionalCap": "720000", "notionalFloor": "50000", "maintMarginRatio": "0.05", "cum": "1325.0" @@ -22549,39 +22907,39 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 600000.0, - "maxNotional": 1600000.0, + "minNotional": 720000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "1600000", - "notionalFloor": "600000", + "notionalCap": "2000000", + "notionalFloor": "720000", "maintMarginRatio": "0.1", - "cum": "31325.0" + "cum": "37325.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 1600000.0, - "maxNotional": 2000000.0, + "minNotional": 2000000.0, + "maxNotional": 2400000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "5", "initialLeverage": "4", - "notionalCap": "2000000", - "notionalFloor": "1600000", + "notionalCap": "2400000", + "notionalFloor": "2000000", "maintMarginRatio": "0.125", - "cum": "71325.0" + "cum": "87325.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 2000000.0, + "minNotional": 2400000.0, "maxNotional": 6000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, @@ -22589,9 +22947,9 @@ "bracket": "6", "initialLeverage": "2", "notionalCap": "6000000", - "notionalFloor": "2000000", + "notionalFloor": "2400000", "maintMarginRatio": "0.25", - "cum": "321325.0" + "cum": "387325.0" } }, { @@ -22607,7 +22965,7 @@ "notionalCap": "10000000", "notionalFloor": "6000000", "maintMarginRatio": "0.5", - "cum": "1821325.0" + "cum": "1887325.0" } } ], @@ -22648,13 +23006,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 450000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "3", "initialLeverage": "20", - "notionalCap": "450000", + "notionalCap": "600000", "notionalFloor": "50000", "maintMarginRatio": "0.025", "cum": "300.0" @@ -22663,55 +23021,55 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 450000.0, - "maxNotional": 900000.0, + "minNotional": 600000.0, + "maxNotional": 1200000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "4", "initialLeverage": "10", - "notionalCap": "900000", - "notionalFloor": "450000", + "notionalCap": "1200000", + "notionalFloor": "600000", "maintMarginRatio": "0.05", - "cum": "11550.0" + "cum": "15300.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 900000.0, - "maxNotional": 2400000.0, + "minNotional": 1200000.0, + "maxNotional": 3000000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "5", "initialLeverage": "5", - "notionalCap": "2400000", - "notionalFloor": "900000", + "notionalCap": "3000000", + "notionalFloor": "1200000", "maintMarginRatio": "0.1", - "cum": "56550.0" + "cum": "75300.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 2400000.0, - "maxNotional": 3000000.0, + "minNotional": 3000000.0, + "maxNotional": 4000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "6", "initialLeverage": "4", - "notionalCap": "3000000", - "notionalFloor": "2400000", + "notionalCap": "4000000", + "notionalFloor": "3000000", "maintMarginRatio": "0.125", - "cum": "116550.0" + "cum": "150300.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 3000000.0, + "minNotional": 4000000.0, "maxNotional": 6000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, @@ -22719,9 +23077,9 @@ "bracket": "7", "initialLeverage": "2", "notionalCap": "6000000", - "notionalFloor": "3000000", + "notionalFloor": "4000000", "maintMarginRatio": "0.25", - "cum": "491550.0" + "cum": "650300.0" } }, { @@ -22737,7 +23095,7 @@ "notionalCap": "10000000", "notionalFloor": "6000000", "maintMarginRatio": "0.5", - "cum": "1991550.0" + "cum": "2150300.0" } } ], @@ -24830,13 +25188,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 300000.0, + "maxNotional": 450000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "300000", + "notionalCap": "450000", "notionalFloor": "50000", "maintMarginRatio": "0.05", "cum": "1275.0" @@ -24845,39 +25203,39 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 300000.0, - "maxNotional": 800000.0, + "minNotional": 450000.0, + "maxNotional": 1200000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "800000", - "notionalFloor": "300000", + "notionalCap": "1200000", + "notionalFloor": "450000", "maintMarginRatio": "0.1", - "cum": "16275.0" + "cum": "23775.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 800000.0, - "maxNotional": 1000000.0, + "minNotional": 1200000.0, + "maxNotional": 1500000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "5", "initialLeverage": "4", - "notionalCap": "1000000", - "notionalFloor": "800000", + "notionalCap": "1500000", + "notionalFloor": "1200000", "maintMarginRatio": "0.125", - "cum": "36275.0" + "cum": "53775.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, + "minNotional": 1500000.0, "maxNotional": 3000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, @@ -24885,9 +25243,9 @@ "bracket": "6", "initialLeverage": "2", "notionalCap": "3000000", - "notionalFloor": "1000000", + "notionalFloor": "1500000", "maintMarginRatio": "0.25", - "cum": "161275.0" + "cum": "241275.0" } }, { @@ -24903,7 +25261,7 @@ "notionalCap": "5000000", "notionalFloor": "3000000", "maintMarginRatio": "0.5", - "cum": "911275.0" + "cum": "991275.0" } } ], @@ -25721,6 +26079,104 @@ } } ], + "XVG/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "25000", + "notionalFloor": "0", + "maintMarginRatio": "0.025", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "2", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "625.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "3", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10625.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "4", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23125.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "5", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148125.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "6", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898125.0" + } + } + ], "XVS/USDT:USDT": [ { "tier": 1.0, diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index a4b070741..145a501c9 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -28,7 +28,7 @@ class Bybit(Exchange): _ft_has: Dict = { "ohlcv_candle_limit": 200, - "ohlcv_has_history": False, + "ohlcv_has_history": True, } _ft_has_futures: Dict = { "ohlcv_has_history": True, diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 2cf98c266..5f2530431 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -80,9 +80,8 @@ class Exchange: "mark_ohlcv_price": "mark", "mark_ohlcv_timeframe": "8h", "ccxt_futures_name": "swap", - "fee_cost_in_contracts": False, # Fee cost needs contract conversion "needs_trading_fees": False, # use fetch_trading_fees to cache fees - "order_props_in_contracts": ['amount', 'cost', 'filled', 'remaining'], + "order_props_in_contracts": ['amount', 'filled', 'remaining'], # Override createMarketBuyOrderRequiresPrice where ccxt has it wrong "marketOrderRequiresPrice": False, } @@ -1859,9 +1858,6 @@ class Exchange: if fee_curr is None: return None fee_cost = float(fee['cost']) - if self._ft_has['fee_cost_in_contracts']: - # Convert cost via "contracts" conversion - fee_cost = self._contracts_to_amount(symbol, fee['cost']) # Calculate fee based on order details if fee_curr == self.get_pair_base_currency(symbol): diff --git a/freqtrade/exchange/gate.py b/freqtrade/exchange/gate.py index eceab4ec1..d36f57da7 100644 --- a/freqtrade/exchange/gate.py +++ b/freqtrade/exchange/gate.py @@ -33,8 +33,6 @@ class Gate(Exchange): _ft_has_futures: Dict = { "needs_trading_fees": True, "marketOrderRequiresPrice": False, - "fee_cost_in_contracts": False, # Set explicitly to false for clarity - "order_props_in_contracts": ['amount', 'filled', 'remaining'], "stop_price_type_field": "price_type", "stop_price_type_value_mapping": { PriceType.LAST: 0, diff --git a/freqtrade/exchange/okx.py b/freqtrade/exchange/okx.py index c703e3a78..7b1c90515 100644 --- a/freqtrade/exchange/okx.py +++ b/freqtrade/exchange/okx.py @@ -32,7 +32,6 @@ class Okx(Exchange): } _ft_has_futures: Dict = { "tickers_have_quoteVolume": False, - "fee_cost_in_contracts": True, "stop_price_type_field": "slTriggerPxType", "stop_price_type_value_mapping": { PriceType.LAST: "last", diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 36c94130c..efae6d060 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -86,8 +86,6 @@ class IFreqaiModel(ABC): logger.warning("DI threshold is not configured for Keras models yet. Deactivating.") self.CONV_WIDTH = self.freqai_info.get('conv_width', 1) - if self.ft_params.get("inlier_metric_window", 0): - self.CONV_WIDTH = self.ft_params.get("inlier_metric_window", 0) * 2 self.class_names: List[str] = [] # used in classification subclasses self.pair_it = 0 self.pair_it_train = 0 @@ -676,15 +674,6 @@ class IFreqaiModel(ABC): hist_preds_df['close_price'] = strat_df['close'] hist_preds_df['date_pred'] = strat_df['date'] - # # for keras type models, the conv_window needs to be prepended so - # # viewing is correct in frequi - if self.ft_params.get('inlier_metric_window', 0): - n_lost_points = self.freqai_info.get('conv_width', 2) - zeros_df = DataFrame(np.zeros((n_lost_points, len(hist_preds_df.columns))), - columns=hist_preds_df.columns) - self.dd.historic_predictions[pair] = pd.concat( - [zeros_df, hist_preds_df], axis=0, ignore_index=True) - def fit_live_predictions(self, dk: FreqaiDataKitchen, pair: str) -> None: """ Fit the labels with a gaussian distribution diff --git a/freqtrade/freqai/prediction_models/LightGBMClassifier.py b/freqtrade/freqai/prediction_models/LightGBMClassifier.py index 45f3a31d0..4c481adff 100644 --- a/freqtrade/freqai/prediction_models/LightGBMClassifier.py +++ b/freqtrade/freqai/prediction_models/LightGBMClassifier.py @@ -32,8 +32,8 @@ class LightGBMClassifier(BaseClassifierModel): eval_set = None test_weights = None else: - eval_set = (data_dictionary["test_features"].to_numpy(), - data_dictionary["test_labels"].to_numpy()[:, 0]) + eval_set = [(data_dictionary["test_features"].to_numpy(), + data_dictionary["test_labels"].to_numpy()[:, 0])] test_weights = data_dictionary["test_weights"] X = data_dictionary["train_features"].to_numpy() y = data_dictionary["train_labels"].to_numpy()[:, 0] @@ -42,7 +42,6 @@ class LightGBMClassifier(BaseClassifierModel): init_model = self.get_init_model(dk.pair) model = LGBMClassifier(**self.model_training_parameters) - model.fit(X=X, y=y, eval_set=eval_set, sample_weight=train_weights, eval_sample_weight=[test_weights], init_model=init_model) diff --git a/freqtrade/freqai/prediction_models/LightGBMRegressor.py b/freqtrade/freqai/prediction_models/LightGBMRegressor.py index 3d1c30ed3..15849f446 100644 --- a/freqtrade/freqai/prediction_models/LightGBMRegressor.py +++ b/freqtrade/freqai/prediction_models/LightGBMRegressor.py @@ -32,7 +32,7 @@ class LightGBMRegressor(BaseRegressionModel): eval_set = None eval_weights = None else: - eval_set = (data_dictionary["test_features"], data_dictionary["test_labels"]) + eval_set = [(data_dictionary["test_features"], data_dictionary["test_labels"])] eval_weights = data_dictionary["test_weights"] X = data_dictionary["train_features"] y = data_dictionary["train_labels"] diff --git a/freqtrade/freqai/prediction_models/LightGBMRegressorMultiTarget.py b/freqtrade/freqai/prediction_models/LightGBMRegressorMultiTarget.py index 663a611f0..5827dcefe 100644 --- a/freqtrade/freqai/prediction_models/LightGBMRegressorMultiTarget.py +++ b/freqtrade/freqai/prediction_models/LightGBMRegressorMultiTarget.py @@ -42,10 +42,10 @@ class LightGBMRegressorMultiTarget(BaseRegressionModel): eval_weights = [data_dictionary["test_weights"]] eval_sets = [(None, None)] * data_dictionary['test_labels'].shape[1] # type: ignore for i in range(data_dictionary['test_labels'].shape[1]): - eval_sets[i] = ( # type: ignore + eval_sets[i] = [( # type: ignore data_dictionary["test_features"], data_dictionary["test_labels"].iloc[:, i] - ) + )] init_model = self.get_init_model(dk.pair) if init_model: diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 42ac85fdb..9d35aee0f 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -231,7 +231,7 @@ class FreqtradeBot(LoggingMixin): self.manage_open_orders() # Protect from collisions with force_exit. - # Without this, freqtrade my try to recreate stoploss_on_exchange orders + # Without this, freqtrade may try to recreate stoploss_on_exchange orders # while exiting is in process, since telegram messages arrive in an different thread. with self._exit_lock: trades = Trade.get_open_trades() @@ -1383,7 +1383,10 @@ class FreqtradeBot(LoggingMixin): latest_candle_close_date = timeframe_to_next_date(self.strategy.timeframe, latest_candle_open_date) # Check if new candle - if order_obj and latest_candle_close_date > order_obj.order_date_utc: + if ( + order_obj and order_obj.side == trade.entry_side + and latest_candle_close_date > order_obj.order_date_utc + ): # New candle proposed_rate = self.exchange.get_rate( trade.pair, side='entry', is_short=trade.is_short, refresh=True) @@ -1939,6 +1942,7 @@ class FreqtradeBot(LoggingMixin): """ Applies the fee to amount (either from Order or from Trades). Can eat into dust if more than the required asset is available. + In case of trade adjustment orders, trade.amount will not have been adjusted yet. Can't happen in Futures mode - where Fees are always in settlement currency, never in base currency. """ @@ -1948,6 +1952,10 @@ class FreqtradeBot(LoggingMixin): # check against remaining amount! amount_ = trade.amount - amount + if trade.nr_of_successful_entries >= 1 and order_obj.ft_order_side == trade.entry_side: + # In case of rebuy's, trade.amount doesn't contain the amount of the last entry. + amount_ = trade.amount + amount + if fee_abs != 0 and self.wallets.get_free(trade_base_currency) >= amount_: # Eat into dust if we own more than base currency logger.info(f"Fee amount for {trade} was in base currency - " @@ -1977,7 +1985,11 @@ class FreqtradeBot(LoggingMixin): # Init variables order_amount = safe_value_fallback(order, 'filled', 'amount') # Only run for closed orders - if trade.fee_updated(order.get('side', '')) or order['status'] == 'open': + if ( + trade.fee_updated(order.get('side', '')) + or order['status'] == 'open' + or order_obj.ft_fee_base + ): return None trade_base_currency = self.exchange.get_pair_base_currency(trade.pair) diff --git a/freqtrade/misc.py b/freqtrade/misc.py index 1e84bba87..e715c280a 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -3,7 +3,6 @@ Various tool function for Freqtrade and scripts """ import gzip import logging -from datetime import datetime from pathlib import Path from typing import Any, Dict, Iterator, List, Mapping, Optional, TextIO, Union from urllib.parse import urlparse @@ -117,20 +116,19 @@ def file_load_json(file: Path): return pairdata +def is_file_in_dir(file: Path, directory: Path) -> bool: + """ + Helper function to check if file is in directory. + """ + return file.is_file() and file.parent.samefile(directory) + + def pair_to_filename(pair: str) -> str: for ch in ['/', ' ', '.', '@', '$', '+', ':']: pair = pair.replace(ch, '_') return pair -def format_ms_time(date: int) -> str: - """ - convert MS date to readable format. - : epoch-string in ms - """ - return datetime.fromtimestamp(date / 1000.0).strftime('%Y-%m-%dT%H:%M:%S') - - def deep_merge_dicts(source, destination, allow_null_overrides: bool = True): """ Values from Source override destination, destination is returned (and modified!!) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 4a5536e84..852f9df8d 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -367,11 +367,7 @@ class Backtesting: if not pair_data.empty: # Cleanup from prior runs pair_data.drop(HEADERS[5:] + ['buy', 'sell'], axis=1, errors='ignore') - - df_analyzed = self.strategy.advise_exit( - self.strategy.advise_entry(pair_data, {'pair': pair}), - {'pair': pair} - ).copy() + df_analyzed = self.strategy.ft_advise_signals(pair_data, {'pair': pair}) # Trim startup period from analyzed dataframe df_analyzed = processed[pair] = pair_data = trim_dataframe( df_analyzed, self.timerange, startup_candles=self.required_startup) @@ -679,6 +675,7 @@ class Backtesting: remaining=amount, cost=amount * close_rate, ) + order._trade_bt = trade trade.orders.append(order) return trade @@ -901,8 +898,9 @@ class Backtesting: amount=amount, filled=0, remaining=amount, - cost=stake_amount + trade.fee_open, + cost=amount * propose_rate + trade.fee_open, ) + order._trade_bt = trade trade.orders.append(order) if pos_adjust and self._get_order_filled(order.ft_price, row): order.close_bt_order(current_time, trade) @@ -1275,6 +1273,7 @@ class Backtesting: preprocessed = self.strategy.advise_all_indicators(data) # Trim startup period from analyzed dataframe + # This only used to determine if trimming would result in an empty dataframe preprocessed_tmp = trim_dataframes(preprocessed, timerange, self.required_startup) if not preprocessed_tmp: diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index fe590f0d2..cba38d84a 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -446,6 +446,8 @@ class Hyperopt: preprocessed = self.backtesting.strategy.advise_all_indicators(data) # Trim startup period from analyzed dataframe to get correct dates for output. + # This is only used to keep track of min/max date after trimming. + # The result is NOT returned from this method, actual trimming happens in backtesting. trimmed = trim_dataframes(preprocessed, self.timerange, self.backtesting.required_startup) self.min_date, self.max_date = get_timerange(trimmed) if not self.market_change: diff --git a/freqtrade/optimize/hyperopt_tools.py b/freqtrade/optimize/hyperopt_tools.py index bc5b85309..6bbf3c15d 100644 --- a/freqtrade/optimize/hyperopt_tools.py +++ b/freqtrade/optimize/hyperopt_tools.py @@ -432,12 +432,10 @@ class HyperoptTools: for i in range(len(trials)): if trials.loc[i]['is_profit']: for j in range(len(trials.loc[i]) - 3): - trials.iat[i, j] = "{}{}{}".format(Fore.GREEN, - str(trials.loc[i][j]), Fore.RESET) + trials.iat[i, j] = f"{Fore.GREEN}{str(trials.loc[i][j])}{Fore.RESET}" if trials.loc[i]['is_best'] and highlight_best: for j in range(len(trials.loc[i]) - 3): - trials.iat[i, j] = "{}{}{}".format(Style.BRIGHT, - str(trials.loc[i][j]), Style.RESET_ALL) + trials.iat[i, j] = f"{Style.BRIGHT}{str(trials.loc[i][j])}{Style.RESET_ALL}" trials = trials.drop(columns=['is_initial_point', 'is_best', 'is_profit', 'is_random']) if remove_header > 0: diff --git a/freqtrade/optimize/optimize_reports/__init__.py b/freqtrade/optimize/optimize_reports/__init__.py index 68e222d00..9e3ac46bc 100644 --- a/freqtrade/optimize/optimize_reports/__init__.py +++ b/freqtrade/optimize/optimize_reports/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa: F401 from freqtrade.optimize.optimize_reports.bt_output import (generate_edge_table, + generate_wins_draws_losses, show_backtest_result, show_backtest_results, show_sorted_pairlist, @@ -14,5 +15,4 @@ from freqtrade.optimize.optimize_reports.optimize_reports import ( generate_all_periodic_breakdown_stats, generate_backtest_stats, generate_daily_stats, generate_exit_reason_stats, generate_pair_metrics, generate_periodic_breakdown_stats, generate_rejected_signals, generate_strategy_comparison, generate_strategy_stats, - generate_tag_metrics, generate_trade_signal_candles, generate_trading_stats, - generate_wins_draws_losses) + generate_tag_metrics, generate_trade_signal_candles, generate_trading_stats) diff --git a/freqtrade/optimize/optimize_reports/bt_output.py b/freqtrade/optimize/optimize_reports/bt_output.py index 1fd1f7a34..eb30d0c97 100644 --- a/freqtrade/optimize/optimize_reports/bt_output.py +++ b/freqtrade/optimize/optimize_reports/bt_output.py @@ -5,8 +5,7 @@ from tabulate import tabulate from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config from freqtrade.misc import decimals_per_coin, round_coin_value -from freqtrade.optimize.optimize_reports.optimize_reports import (generate_periodic_breakdown_stats, - generate_wins_draws_losses) +from freqtrade.optimize.optimize_reports.optimize_reports import generate_periodic_breakdown_stats logger = logging.getLogger(__name__) @@ -30,6 +29,16 @@ def _get_line_header(first_column: str, stake_currency: str, 'Win Draw Loss Win%'] +def generate_wins_draws_losses(wins, draws, losses): + if wins > 0 and losses == 0: + wl_ratio = '100' + elif wins == 0: + wl_ratio = '0' + else: + wl_ratio = f'{100.0 / (wins + draws + losses) * wins:.1f}' if losses > 0 else '100' + return f'{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}' + + def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: str) -> str: """ Generates and returns a text table for the given backtest data and the results dataframe @@ -233,8 +242,9 @@ def text_table_add_metrics(strat_results: Dict) -> str: ('Calmar', f"{strat_results['calmar']:.2f}" if 'calmar' in strat_results else 'N/A'), ('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor' in strat_results else 'N/A'), - ('Expectancy', f"{strat_results['expectancy']:.2f}" if 'expectancy' - in strat_results else 'N/A'), + ('Expectancy (Ratio)', ( + f"{strat_results['expectancy']:.2f} ({strat_results['expectancy_ratio']:.2f})" if + 'expectancy_ratio' in strat_results else 'N/A')), ('Trades per day', strat_results['trades_per_day']), ('Avg. daily profit %', f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"), @@ -260,6 +270,9 @@ def text_table_add_metrics(strat_results: Dict) -> str: f"{strat_results['draw_days']} / {strat_results['losing_days']}"), ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"), ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"), + ('Max Consecutive Wins / Loss', + f"{strat_results['max_consecutive_wins']} / {strat_results['max_consecutive_losses']}" + if 'max_consecutive_losses' in strat_results else 'N/A'), ('Rejected Entry signals', strat_results.get('rejected_signals', 'N/A')), ('Entry/Exit Timeouts', f"{strat_results.get('timedout_entry_orders', 'N/A')} / " diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index 015f163e3..f24e30318 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -1,9 +1,10 @@ import logging from copy import deepcopy from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Tuple, Union -from pandas import DataFrame, concat, to_datetime +import numpy as np +from pandas import DataFrame, Series, concat, to_datetime from freqtrade.constants import BACKTEST_BREAKDOWNS, DATETIME_PRINT_FORMAT, IntOrInf from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum, @@ -57,16 +58,6 @@ def generate_rejected_signals(preprocessed_df: Dict[str, DataFrame], return rejected_candles_only -def generate_wins_draws_losses(wins, draws, losses): - if wins > 0 and losses == 0: - wl_ratio = '100' - elif wins == 0: - wl_ratio = '0' - else: - wl_ratio = f'{100.0 / (wins + draws + losses) * wins:.1f}' if losses > 0 else '100' - return f'{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}' - - def _generate_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict: """ Generate one result dict, with "first_column" as key. @@ -97,6 +88,7 @@ def _generate_result_line(result: DataFrame, starting_balance: int, first_column 'wins': len(result[result['profit_abs'] > 0]), 'draws': len(result[result['profit_abs'] == 0]), 'losses': len(result[result['profit_abs'] < 0]), + 'winrate': len(result[result['profit_abs'] > 0]) / len(result) if len(result) else 0.0, } @@ -184,6 +176,7 @@ def generate_exit_reason_stats(max_open_trades: IntOrInf, results: DataFrame) -> 'wins': len(result[result['profit_abs'] > 0]), 'draws': len(result[result['profit_abs'] == 0]), 'losses': len(result[result['profit_abs'] < 0]), + 'winrate': len(result[result['profit_abs'] > 0]) / count if count else 0.0, 'profit_mean': profit_mean, 'profit_mean_pct': round(profit_mean * 100, 2), 'profit_sum': profit_sum, @@ -238,6 +231,7 @@ def generate_periodic_breakdown_stats(trade_list: List, period: str) -> List[Dic wins = sum(day['profit_abs'] > 0) draws = sum(day['profit_abs'] == 0) loses = sum(day['profit_abs'] < 0) + trades = (wins + draws + loses) stats.append( { 'date': name.strftime('%d/%m/%Y'), @@ -245,7 +239,8 @@ def generate_periodic_breakdown_stats(trade_list: List, period: str) -> List[Dic 'profit_abs': profit_abs, 'wins': wins, 'draws': draws, - 'loses': loses + 'loses': loses, + 'winrate': wins / trades if trades else 0.0, } ) return stats @@ -258,6 +253,23 @@ def generate_all_periodic_breakdown_stats(trade_list: List) -> Dict[str, List]: return result +def calc_streak(dataframe: DataFrame) -> Tuple[int, int]: + """ + Calculate consecutive win and loss streaks + :param dataframe: Dataframe containing the trades dataframe, with profit_ratio column + :return: Tuple containing consecutive wins and losses + """ + + df = Series(np.where(dataframe['profit_ratio'] > 0, 'win', 'loss')).to_frame('result') + df['streaks'] = df['result'].ne(df['result'].shift()).cumsum().rename('streaks') + df['counter'] = df['streaks'].groupby(df['streaks']).cumcount() + 1 + res = df.groupby(df['result']).max() + # + cons_wins = int(res.loc['win', 'counter']) if 'win' in res.index else 0 + cons_losses = int(res.loc['loss', 'counter']) if 'loss' in res.index else 0 + return cons_wins, cons_losses + + def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: """ Generate overall trade statistics """ if len(results) == 0: @@ -265,9 +277,12 @@ def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: 'wins': 0, 'losses': 0, 'draws': 0, + 'winrate': 0, 'holding_avg': timedelta(), 'winner_holding_avg': timedelta(), 'loser_holding_avg': timedelta(), + 'max_consecutive_wins': 0, + 'max_consecutive_losses': 0, } winning_trades = results.loc[results['profit_ratio'] > 0] @@ -280,17 +295,21 @@ def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: if not winning_trades.empty else timedelta()) loser_holding_avg = (timedelta(minutes=round(losing_trades['trade_duration'].mean())) if not losing_trades.empty else timedelta()) + winstreak, loss_streak = calc_streak(results) return { 'wins': len(winning_trades), 'losses': len(losing_trades), 'draws': len(draw_trades), + 'winrate': len(winning_trades) / len(results) if len(results) else 0.0, 'holding_avg': holding_avg, 'holding_avg_s': holding_avg.total_seconds(), 'winner_holding_avg': winner_holding_avg, 'winner_holding_avg_s': winner_holding_avg.total_seconds(), 'loser_holding_avg': loser_holding_avg, 'loser_holding_avg_s': loser_holding_avg.total_seconds(), + 'max_consecutive_wins': winstreak, + 'max_consecutive_losses': loss_streak, } @@ -383,6 +402,7 @@ def generate_strategy_stats(pairlist: List[str], losing_profit = results.loc[results['profit_abs'] < 0, 'profit_abs'].sum() profit_factor = winning_profit / abs(losing_profit) if losing_profit else 0.0 + expectancy, expectancy_ratio = calculate_expectancy(results) backtest_days = (max_date - min_date).days or 1 strat_stats = { 'trades': results.to_dict(orient='records'), @@ -408,7 +428,8 @@ def generate_strategy_stats(pairlist: List[str], 'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(), 'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(), 'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']), - 'expectancy': calculate_expectancy(results), + 'expectancy': expectancy, + 'expectancy_ratio': expectancy_ratio, 'sortino': calculate_sortino(results, min_date, max_date, start_balance), 'sharpe': calculate_sharpe(results, min_date, max_date, start_balance), 'calmar': calculate_calmar(results, min_date, max_date, start_balance), diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 35a44e3fc..f686e4f8c 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -38,6 +38,7 @@ class Order(ModelBase): Mirrors CCXT Order structure """ __tablename__ = 'orders' + __allow_unmapped__ = True session: ClassVar[SessionType] # Uniqueness should be ensured over pair, order_id @@ -47,7 +48,8 @@ class Order(ModelBase): id: Mapped[int] = mapped_column(Integer, primary_key=True) ft_trade_id: Mapped[int] = mapped_column(Integer, ForeignKey('trades.id'), index=True) - trade: Mapped[List["Trade"]] = relationship("Trade", back_populates="orders") + _trade_live: Mapped["Trade"] = relationship("Trade", back_populates="orders") + _trade_bt: "LocalTrade" = None # type: ignore # order_side can only be 'buy', 'sell' or 'stoploss' ft_order_side: Mapped[str] = mapped_column(String(25), nullable=False) @@ -119,11 +121,20 @@ class Order(ModelBase): def safe_amount_after_fee(self) -> float: return self.safe_filled - self.safe_fee_base + @property + def trade(self) -> "LocalTrade": + return self._trade_bt or self._trade_live + + @property + def stake_amount(self) -> float: + """ Amount in stake currency used for this order""" + return self.safe_amount * self.safe_price / self.trade.leverage + def __repr__(self): - return (f"Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, " + return (f"Order(id={self.id}, trade={self.ft_trade_id}, order_id={self.order_id}, " f"side={self.side}, filled={self.safe_filled}, price={self.safe_price}, " - f"order_type={self.order_type}, status={self.status})") + f"status={self.status}, date={self.order_date:{DATETIME_PRINT_FORMAT}})") def update_from_ccxt_object(self, order): """ @@ -212,6 +223,7 @@ class Order(ModelBase): 'order_type': self.order_type, 'price': self.price, 'remaining': self.remaining, + 'ft_fee_base': self.ft_fee_base, }) return resp @@ -1298,9 +1310,12 @@ class Trade(ModelBase, LocalTrade): Float(), nullable=True, default=None) # type: ignore def __init__(self, **kwargs): + from_json = kwargs.pop('__FROM_JSON', None) super().__init__(**kwargs) - self.realized_profit = 0 - self.recalc_open_trade_value() + if not from_json: + # Skip recalculation when loading from json + self.realized_profit = 0 + self.recalc_open_trade_value() @validates('enter_tag', 'exit_reason') def validate_string_len(self, key, value): @@ -1654,6 +1669,7 @@ class Trade(ModelBase, LocalTrade): import rapidjson data = rapidjson.loads(json_str) trade = cls( + __FROM_JSON=True, id=data["trade_id"], pair=data["pair"], base_currency=data["base_currency"], @@ -1708,6 +1724,7 @@ class Trade(ModelBase, LocalTrade): order_obj = Order( amount=order["amount"], + ft_amount=order["amount"], ft_order_side=order["ft_order_side"], ft_pair=order["pair"], ft_is_open=order["is_open"], @@ -1722,6 +1739,7 @@ class Trade(ModelBase, LocalTrade): if order["order_filled_timestamp"] else None), order_type=order["order_type"], price=order["price"], + ft_price=order["price"], remaining=order["remaining"], ) trade.orders.append(order_obj) diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 7fd20f041..c45a09fbb 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -84,7 +84,7 @@ def init_plotscript(config, markets: List, startup_candles: int = 0): except ValueError as e: raise OperationalException(e) from e if not trades.empty: - trades = trim_dataframe(trades, timerange, 'open_date') + trades = trim_dataframe(trades, timerange, df_date_col='open_date') return {"ohlcv": data, "trades": trades, diff --git a/freqtrade/plugins/pairlist/PerformanceFilter.py b/freqtrade/plugins/pairlist/PerformanceFilter.py index 06c504317..b45259605 100644 --- a/freqtrade/plugins/pairlist/PerformanceFilter.py +++ b/freqtrade/plugins/pairlist/PerformanceFilter.py @@ -89,10 +89,10 @@ class PerformanceFilter(IPairList): # Sort the list using: # - primarily performance (high to low) # - then count (low to high, so as to favor same performance with fewer trades) - # - then pair name alphametically + # - then by prior index, keeping original sorting order sorted_df = list_df.merge(performance, on='pair', how='left')\ - .fillna(0).sort_values(by=['count', 'prior_idx'], ascending=True)\ - .sort_values(by=['profit_ratio'], ascending=False) + .fillna(0).sort_values(by=['profit_ratio', 'count', 'prior_idx'], + ascending=[False, True, True]) if self._min_profit is not None: removed = sorted_df[sorted_df['profit_ratio'] < self._min_profit] for _, row in removed.iterrows(): diff --git a/freqtrade/plugins/pairlist/RemotePairList.py b/freqtrade/plugins/pairlist/RemotePairList.py index 372f9a593..2f03678e2 100644 --- a/freqtrade/plugins/pairlist/RemotePairList.py +++ b/freqtrade/plugins/pairlist/RemotePairList.py @@ -3,19 +3,21 @@ Remote PairList provider Provides pair list fetched from a remote source """ -import json import logging from pathlib import Path from typing import Any, Dict, List, Tuple +import rapidjson import requests from cachetools import TTLCache from freqtrade import __version__ +from freqtrade.configuration.load_config import CONFIG_PARSE_MODE from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Tickers from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist logger = logging.getLogger(__name__) @@ -40,6 +42,8 @@ class RemotePairList(IPairList): '`pairlist_url` not specified. Please check your configuration ' 'for "pairlist.config.pairlist_url"') + self._mode = self._pairlistconfig.get('mode', 'whitelist') + self._processing_mode = self._pairlistconfig.get('processing_mode', 'filter') self._number_pairs = self._pairlistconfig['number_assets'] self._refresh_period: int = self._pairlistconfig.get('refresh_period', 1800) self._keep_pairlist_on_failure = self._pairlistconfig.get('keep_pairlist_on_failure', True) @@ -50,6 +54,21 @@ class RemotePairList(IPairList): self._init_done = False self._last_pairlist: List[Any] = list() + if self._mode not in ['whitelist', 'blacklist']: + raise OperationalException( + '`mode` not configured correctly. Supported Modes ' + 'are "whitelist","blacklist"') + + if self._processing_mode not in ['filter', 'append']: + raise OperationalException( + '`processing_mode` not configured correctly. Supported Modes ' + 'are "filter","append"') + + if self._pairlist_pos == 0 and self._mode == 'blacklist': + raise OperationalException( + 'A `blacklist` mode RemotePairList can not be on the first ' + 'position of your pairlist.') + @property def needstickers(self) -> bool: """ @@ -67,23 +86,37 @@ class RemotePairList(IPairList): @staticmethod def description() -> str: - return "Retrieve pairs from a remote API." + return "Retrieve pairs from a remote API or local file." @staticmethod def available_parameters() -> Dict[str, PairlistParameter]: return { - "number_assets": { - "type": "number", - "default": 0, - "description": "Number of assets", - "help": "Number of assets to use from the pairlist.", - }, "pairlist_url": { "type": "string", "default": "", "description": "URL to fetch pairlist from", "help": "URL to fetch pairlist from", }, + "number_assets": { + "type": "number", + "default": 30, + "description": "Number of assets", + "help": "Number of assets to use from the pairlist.", + }, + "mode": { + "type": "option", + "default": "whitelist", + "options": ["whitelist", "blacklist"], + "description": "Pairlist mode", + "help": "Should this pairlist operate as a whitelist or blacklist?", + }, + "processing_mode": { + "type": "option", + "default": "filter", + "options": ["filter", "append"], + "description": "Processing mode", + "help": "Append pairs to incomming pairlist or filter them?", + }, **IPairList.refresh_period_parameter(), "keep_pairlist_on_failure": { "type": "boolean", @@ -204,7 +237,7 @@ class RemotePairList(IPairList): if file_path.exists(): with file_path.open() as json_file: # Load the JSON data into a dictionary - jsonparse = json.load(json_file) + jsonparse = rapidjson.load(json_file, parse_mode=CONFIG_PARSE_MODE) try: pairlist = self.process_json(jsonparse) @@ -223,6 +256,7 @@ class RemotePairList(IPairList): self.log_once(f"Fetched pairs: {pairlist}", logger.debug) + pairlist = expand_pairlist(pairlist, list(self._exchange.get_markets().keys())) pairlist = self._whitelist_for_active_markets(pairlist) pairlist = pairlist[:self._number_pairs] @@ -250,6 +284,23 @@ class RemotePairList(IPairList): :return: new whitelist """ rpl_pairlist = self.gen_pairlist(tickers) - merged_list = pairlist + rpl_pairlist - merged_list = sorted(set(merged_list), key=merged_list.index) + merged_list = [] + filtered = [] + + if self._mode == "whitelist": + if self._processing_mode == "filter": + merged_list = [pair for pair in pairlist if pair in rpl_pairlist] + elif self._processing_mode == "append": + merged_list = pairlist + rpl_pairlist + merged_list = sorted(set(merged_list), key=merged_list.index) + else: + for pair in pairlist: + if pair not in rpl_pairlist: + merged_list.append(pair) + else: + filtered.append(pair) + if filtered: + self.log_once(f"Blacklist - Filtered out pairs: {filtered}", logger.info) + + merged_list = merged_list[:self._number_pairs] return merged_list diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 0d5e33847..9e4a4fca9 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -13,9 +13,8 @@ from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date from freqtrade.exchange.types import Tickers -from freqtrade.misc import format_ms_time from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter -from freqtrade.util import dt_now +from freqtrade.util import dt_now, format_ms_time logger = logging.getLogger(__name__) diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index 411ba4978..9c17bc9e0 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -2,6 +2,7 @@ import asyncio import logging from copy import deepcopy from datetime import datetime +from pathlib import Path from typing import Any, Dict, List from fastapi import APIRouter, BackgroundTasks, Depends @@ -9,11 +10,12 @@ from fastapi.exceptions import HTTPException from freqtrade.configuration.config_validation import validate_config_consistency from freqtrade.constants import Config -from freqtrade.data.btanalysis import get_backtest_resultlist, load_and_merge_backtest_result +from freqtrade.data.btanalysis import (delete_backtest_result, get_backtest_resultlist, + load_and_merge_backtest_result) from freqtrade.enums import BacktestState from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.exchange.common import remove_exchange_credentials -from freqtrade.misc import deep_merge_dicts +from freqtrade.misc import deep_merge_dicts, is_file_in_dir from freqtrade.rpc.api_server.api_schemas import (BacktestHistoryEntry, BacktestRequest, BacktestResponse) from freqtrade.rpc.api_server.deps import get_config @@ -245,13 +247,16 @@ def api_backtest_history(config=Depends(get_config)): tags=['webserver', 'backtest']) def api_backtest_history_result(filename: str, strategy: str, config=Depends(get_config)): # Get backtest result history, read from metadata files - fn = config['user_data_dir'] / 'backtest_results' / filename + bt_results_base: Path = config['user_data_dir'] / 'backtest_results' + fn = (bt_results_base / filename).with_suffix('.json') + results: Dict[str, Any] = { 'metadata': {}, 'strategy': {}, 'strategy_comparison': [], } - + if not is_file_in_dir(fn, bt_results_base): + raise HTTPException(status_code=404, detail="File not found.") load_and_merge_backtest_result(strategy, fn, results) return { "status": "ended", @@ -261,3 +266,17 @@ def api_backtest_history_result(filename: str, strategy: str, config=Depends(get "status_msg": "Historic result", "backtest_result": results, } + + +@router.delete('/backtest/history/{file}', response_model=List[BacktestHistoryEntry], + tags=['webserver', 'backtest']) +def api_delete_backtest_history_entry(file: str, config=Depends(get_config)): + # Get backtest result history, read from metadata files + bt_results_base: Path = config['user_data_dir'] / 'backtest_results' + file_abs = (bt_results_base / file).with_suffix('.json') + # Ensure file is in backtest_results directory + if not is_file_in_dir(file_abs, bt_results_base): + raise HTTPException(status_code=404, detail="File not found.") + + delete_backtest_result(file_abs) + return get_backtest_resultlist(config['user_data_dir'] / 'backtest_results') diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 3f4dd99e1..04769b119 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -136,6 +136,9 @@ class Profit(BaseModel): winning_trades: int losing_trades: int profit_factor: float + winrate: float + expectancy: float + expectancy_ratio: float max_drawdown: float max_drawdown_abs: float trading_volume: Optional[float] @@ -238,6 +241,7 @@ class OrderSchema(BaseModel): is_open: bool order_timestamp: Optional[int] order_filled_timestamp: Optional[int] + ft_fee_base: Optional[float] class TradeSchema(BaseModel): diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 143f110f0..3e5d55f71 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -49,7 +49,8 @@ logger = logging.getLogger(__name__) # 2.28: Switch reload endpoint to Post # 2.29: Add /exchanges endpoint # 2.30: new /pairlists endpoint -API_VERSION = 2.30 +# 2.31: new /backtest/history/ delete endpoint +API_VERSION = 2.31 # Public API, requires no auth. router_public = APIRouter() @@ -268,7 +269,10 @@ def pair_history(pair: str, timeframe: str, timerange: str, strategy: str, 'timerange': timerange, 'freqaimodel': freqaimodel if freqaimodel else config.get('freqaimodel'), }) - return RPC._rpc_analysed_history_full(config, pair, timeframe, exchange) + try: + return RPC._rpc_analysed_history_full(config, pair, timeframe, exchange) + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) @router.get('/plot_config', response_model=PlotConfig, tags=['candle data']) @@ -283,7 +287,10 @@ def plot_config(strategy: Optional[str] = None, config=Depends(get_config), config1.update({ 'strategy': strategy }) + try: return PlotConfig.parse_obj(RPC._rpc_plot_config_with_strategy(config1)) + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) @router.get('/strategies', response_model=StrategyListResponse, tags=['strategy']) @@ -308,7 +315,8 @@ def get_strategy(strategy: str, config=Depends(get_config)): extra_dir=config_.get('strategy_path')) except OperationalException: raise HTTPException(status_code=404, detail='Strategy not found') - + except Exception as e: + raise HTTPException(status_code=502, detail=str(e)) return { 'strategy': strategy_obj.get_strategy_name(), 'code': strategy_obj.__source__, diff --git a/freqtrade/rpc/api_server/web_ui.py b/freqtrade/rpc/api_server/web_ui.py index e1a277b30..b701b4901 100644 --- a/freqtrade/rpc/api_server/web_ui.py +++ b/freqtrade/rpc/api_server/web_ui.py @@ -30,7 +30,7 @@ async def ui_version(): } -def is_relative_to(path, base) -> bool: +def is_relative_to(path: Path, base: Path) -> bool: # Helper function simulating behaviour of is_relative_to, which was only added in python 3.9 try: path.relative_to(base) diff --git a/freqtrade/rpc/fiat_convert.py b/freqtrade/rpc/fiat_convert.py index 24c34af72..ea13209c4 100644 --- a/freqtrade/rpc/fiat_convert.py +++ b/freqtrade/rpc/fiat_convert.py @@ -25,6 +25,7 @@ coingecko_mapping = { 'bnb': 'binancecoin', 'sol': 'solana', 'usdt': 'tether', + 'busd': 'binance-usd', } diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index c3759e03a..bfcfef909 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -18,7 +18,7 @@ from freqtrade import __version__ from freqtrade.configuration.timerange import TimeRange from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT, Config from freqtrade.data.history import load_data -from freqtrade.data.metrics import calculate_max_drawdown +from freqtrade.data.metrics import calculate_expectancy, calculate_max_drawdown from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, MarketDirection, SignalDirection, State, TradingMode) from freqtrade.exceptions import ExchangeError, PricingError @@ -494,6 +494,8 @@ class RPC: profit_all_coin.append(profit_abs) profit_all_ratio.append(profit_ratio) + closed_trade_count = len([t for t in trades if not t.is_open]) + best_pair = Trade.get_best_pair(start_date) trading_volume = Trade.get_trading_volume(start_date) @@ -521,9 +523,14 @@ class RPC: profit_factor = winning_profit / abs(losing_profit) if losing_profit else float('inf') + winrate = (winning_trades / closed_trade_count) if closed_trade_count > 0 else 0 + trades_df = DataFrame([{'close_date': trade.close_date.strftime(DATETIME_PRINT_FORMAT), 'profit_abs': trade.close_profit_abs} for trade in trades if not trade.is_open and trade.close_date]) + + expectancy, expectancy_ratio = calculate_expectancy(trades_df) + max_drawdown_abs = 0.0 max_drawdown = 0.0 if len(trades_df) > 0: @@ -562,7 +569,7 @@ class RPC: 'profit_all_percent': round(profit_all_ratio_fromstart * 100, 2), 'profit_all_fiat': profit_all_fiat, 'trade_count': len(trades), - 'closed_trade_count': len([t for t in trades if not t.is_open]), + 'closed_trade_count': closed_trade_count, 'first_trade_date': first_date.strftime(DATETIME_PRINT_FORMAT) if first_date else '', 'first_trade_humanized': dt_humanize(first_date) if first_date else '', 'first_trade_timestamp': int(first_date.timestamp() * 1000) if first_date else 0, @@ -576,6 +583,9 @@ class RPC: 'winning_trades': winning_trades, 'losing_trades': losing_trades, 'profit_factor': profit_factor, + 'winrate': winrate, + 'expectancy': expectancy, + 'expectancy_ratio': expectancy_ratio, 'max_drawdown': max_drawdown, 'max_drawdown_abs': max_drawdown_abs, 'trading_volume': trading_volume, @@ -1169,8 +1179,8 @@ class RPC: """ Analyzed dataframe in Dict form """ _data, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) - return self._convert_dataframe_to_dict(self._freqtrade.config['strategy'], - pair, timeframe, _data, last_analyzed) + return RPC._convert_dataframe_to_dict(self._freqtrade.config['strategy'], + pair, timeframe, _data, last_analyzed) def __rpc_analysed_dataframe_raw( self, @@ -1240,27 +1250,34 @@ class RPC: exchange) -> Dict[str, Any]: timerange_parsed = TimeRange.parse_timerange(config.get('timerange')) + from freqtrade.data.converter import trim_dataframe + from freqtrade.data.dataprovider import DataProvider + from freqtrade.resolvers.strategy_resolver import StrategyResolver + + strategy = StrategyResolver.load_strategy(config) + startup_candles = strategy.startup_candle_count + _data = load_data( datadir=config["datadir"], pairs=[pair], timeframe=timeframe, timerange=timerange_parsed, data_format=config.get('dataformat_ohlcv', 'json'), - candle_type=config.get('candle_type_def', CandleType.SPOT) + candle_type=config.get('candle_type_def', CandleType.SPOT), + startup_candles=startup_candles, ) if pair not in _data: raise RPCException( f"No data for {pair}, {timeframe} in {config.get('timerange')} found.") - from freqtrade.data.dataprovider import DataProvider - from freqtrade.resolvers.strategy_resolver import StrategyResolver - strategy = StrategyResolver.load_strategy(config) + strategy.dp = DataProvider(config, exchange=exchange, pairlists=None) strategy.ft_bot_start() df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair}) + df_analyzed = trim_dataframe(df_analyzed, timerange_parsed, startup_candles=startup_candles) return RPC._convert_dataframe_to_dict(strategy.get_strategy_name(), pair, timeframe, - df_analyzed, dt_now()) + df_analyzed.copy(), dt_now()) def _rpc_plot_config(self) -> Dict[str, Any]: if (self._freqtrade.strategy.plot_config and diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index aad7fd8c4..aced89d7a 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -849,6 +849,10 @@ class Telegram(RPCHandler): avg_duration = stats['avg_duration'] best_pair = stats['best_pair'] best_pair_profit_ratio = stats['best_pair_profit_ratio'] + winrate = stats['winrate'] + expectancy = stats['expectancy'] + expectancy_ratio = stats['expectancy_ratio'] + if stats['trade_count'] == 0: markdown_msg = f"No trades yet.\n*Bot started:* `{stats['bot_start_date']}`" else: @@ -873,7 +877,9 @@ class Telegram(RPCHandler): f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* " f"`{first_trade_date}`\n" f"*Latest Trade opened:* `{latest_trade_date}`\n" - f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`" + f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n" + f"*Winrate:* `{winrate:.2%}`\n" + f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`" ) if stats['closed_trade_count'] > 0: markdown_msg += ( diff --git a/freqtrade/rpc/webhook.py b/freqtrade/rpc/webhook.py index 80690ec0c..b9bdbd435 100644 --- a/freqtrade/rpc/webhook.py +++ b/freqtrade/rpc/webhook.py @@ -34,6 +34,7 @@ class Webhook(RPCHandler): self._format = self._config['webhook'].get('format', 'form') self._retries = self._config['webhook'].get('retries', 0) self._retry_delay = self._config['webhook'].get('retry_delay', 0.1) + self._timeout = self._config['webhook'].get('timeout', 10) def cleanup(self) -> None: """ @@ -107,12 +108,13 @@ class Webhook(RPCHandler): try: if self._format == 'form': - response = post(self._url, data=payload) + response = post(self._url, data=payload, timeout=self._timeout) elif self._format == 'json': - response = post(self._url, json=payload) + response = post(self._url, json=payload, timeout=self._timeout) elif self._format == 'raw': response = post(self._url, data=payload['data'], - headers={'Content-Type': 'text/plain'}) + headers={'Content-Type': 'text/plain'}, + timeout=self._timeout) else: raise NotImplementedError(f'Unknown format: {self._format}') diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 1e9ebe1ae..0f848130f 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -825,6 +825,7 @@ class IStrategy(ABC, HyperStrategyMixin): """ Parses the given candle (OHLCV) data and returns a populated DataFrame add several TA indicators and entry order signal to it + Should only be used in live. :param dataframe: Dataframe containing data from exchange :param metadata: Metadata dictionary with additional data (e.g. 'pair') :return: DataFrame of candle (OHLCV) data with indicator data and signals added @@ -1321,6 +1322,20 @@ class IStrategy(ABC, HyperStrategyMixin): return {pair: self.advise_indicators(pair_data.copy(), {'pair': pair}).copy() for pair, pair_data in data.items()} + def ft_advise_signals(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Call advise_entry and advise_exit and return the resulting dataframe. + :param dataframe: Dataframe containing data from exchange, as well as pre-calculated + indicators + :param metadata: Metadata dictionary with additional data (e.g. 'pair') + :return: DataFrame of candle (OHLCV) data with indicator data and signals added + + """ + + dataframe = self.advise_entry(dataframe, metadata) + dataframe = self.advise_exit(dataframe, metadata) + return dataframe + def advise_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Populate indicators that will be used in the Buy, Sell, short, exit_short strategy diff --git a/freqtrade/util/__init__.py b/freqtrade/util/__init__.py index bed65a54b..92c79b899 100644 --- a/freqtrade/util/__init__.py +++ b/freqtrade/util/__init__.py @@ -1,5 +1,5 @@ from freqtrade.util.datetime_helpers import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, - dt_utc, shorten_date) + dt_utc, format_ms_time, shorten_date) from freqtrade.util.ft_precise import FtPrecise from freqtrade.util.periodic_cache import PeriodicCache @@ -7,11 +7,12 @@ from freqtrade.util.periodic_cache import PeriodicCache __all__ = [ 'dt_floor_day', 'dt_from_ts', + 'dt_humanize', 'dt_now', 'dt_ts', 'dt_utc', - 'dt_humanize', - 'shorten_date', + 'format_ms_time', 'FtPrecise', 'PeriodicCache', + 'shorten_date', ] diff --git a/freqtrade/util/datetime_helpers.py b/freqtrade/util/datetime_helpers.py index 39d134e11..7f44cbdb0 100644 --- a/freqtrade/util/datetime_helpers.py +++ b/freqtrade/util/datetime_helpers.py @@ -61,3 +61,11 @@ def dt_humanize(dt: datetime, **kwargs) -> str: :param kwargs: kwargs to pass to arrow's humanize() """ return arrow.get(dt).humanize(**kwargs) + + +def format_ms_time(date: int) -> str: + """ + convert MS date to readable format. + : epoch-string in ms + """ + return datetime.fromtimestamp(date / 1000.0).strftime('%Y-%m-%dT%H:%M:%S') diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index da64515a4..ceef8d158 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -84,6 +84,7 @@ class Wallets: tot_profit = Trade.get_total_closed_profit() else: tot_profit = LocalTrade.total_profit + tot_profit += sum(trade.realized_profit for trade in open_trades) tot_in_trades = sum(trade.stake_amount for trade in open_trades) used_stake = 0.0 diff --git a/requirements-dev.txt b/requirements-dev.txt index 282d35101..08d269063 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,24 +7,24 @@ -r docs/requirements-docs.txt coveralls==3.3.1 -ruff==0.0.275 +ruff==0.0.280 mypy==1.4.1 pre-commit==3.3.3 pytest==7.4.0 -pytest-asyncio==0.21.0 +pytest-asyncio==0.21.1 pytest-cov==4.1.0 pytest-mock==3.11.1 pytest-random-order==1.1.0 isort==5.12.0 # For datetime mocking -time-machine==2.10.0 +time-machine==2.11.0 # Convert jupyter notebooks to markdown documents -nbconvert==7.6.0 +nbconvert==7.7.2 # mypy types -types-cachetools==5.3.0.5 +types-cachetools==5.3.0.6 types-filelock==3.2.7 -types-requests==2.31.0.1 -types-tabulate==0.9.0.2 -types-python-dateutil==2.8.19.13 +types-requests==2.31.0.2 +types-tabulate==0.9.0.3 +types-python-dateutil==2.8.19.14 diff --git a/requirements-freqai-rl.txt b/requirements-freqai-rl.txt index 2672f9c38..74c6d4ebe 100644 --- a/requirements-freqai-rl.txt +++ b/requirements-freqai-rl.txt @@ -5,7 +5,7 @@ torch==2.0.1 #until these branches will be released we can use this gymnasium==0.28.1 -stable_baselines3==2.0.0a13 +stable_baselines3==2.0.0 sb3_contrib>=2.0.0a9 # Progress bar for stable-baselines3 and sb3-contrib tqdm==4.65.0 diff --git a/requirements-freqai.txt b/requirements-freqai.txt index e8e3b9334..325b92544 100644 --- a/requirements-freqai.txt +++ b/requirements-freqai.txt @@ -4,9 +4,9 @@ # Required for freqai scikit-learn==1.1.3 -joblib==1.2.0 +joblib==1.3.1 catboost==1.2; 'arm' not in platform_machine -lightgbm==3.3.5 +lightgbm==4.0.0 xgboost==1.7.6 tensorboard==2.13.0 -datasieve==0.1.6 +datasieve==0.1.7 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 163fee75f..aafbad608 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,7 +2,8 @@ -r requirements.txt # Required for hyperopt -scipy==1.10.1 +scipy==1.11.1; python_version >= '3.9' +scipy==1.10.1; python_version < '3.9' scikit-learn==1.1.3 scikit-optimize==0.9.0 filelock==3.12.2 diff --git a/requirements.txt b/requirements.txt index ebb1c9a16..32d2b63ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,30 +1,31 @@ -numpy==1.24.3 -pandas==2.0.2 +numpy==1.25.1; python_version > '3.8' +numpy==1.24.3; python_version <= '3.8' +pandas==2.0.3 pandas-ta==0.3.14b -ccxt==3.1.44 -cryptography==41.0.1; platform_machine != 'armv7l' +ccxt==4.0.36 +cryptography==41.0.2; platform_machine != 'armv7l' cryptography==40.0.1; platform_machine == 'armv7l' -aiohttp==3.8.4 -SQLAlchemy==2.0.17 -python-telegram-bot==20.3 +aiohttp==3.8.5 +SQLAlchemy==2.0.19 +python-telegram-bot==20.4 # can't be hard-pinned due to telegram-bot pinning httpx with ~ httpx>=0.24.1 arrow==1.2.3 cachetools==5.3.1 requests==2.31.0 -urllib3==2.0.3 -jsonschema==4.17.3 -TA-Lib==0.4.26 +urllib3==2.0.4 +jsonschema==4.18.4 +TA-Lib==0.4.27 technical==1.4.0 tabulate==0.9.0 pycoingecko==3.1.0 jinja2==3.1.2 tables==3.8.0 blosc==1.11.1 -joblib==1.2.0 +joblib==1.3.1 rich==13.4.2 -pyarrow==12.0.0; platform_machine != 'armv7l' +pyarrow==12.0.1; platform_machine != 'armv7l' # find first, C search in arrays py_find_1st==1.1.5 @@ -32,16 +33,16 @@ py_find_1st==1.1.5 # Load ticker files 30% faster python-rapidjson==1.10 # Properly format api responses -orjson==3.9.1 +orjson==3.9.2 # Notify systemd sdnotify==0.3.2 # API Server -fastapi==0.99.1 -pydantic==1.10.9 -uvicorn==0.22.0 -pyjwt==2.7.0 +fastapi==0.100.0 +pydantic==1.10.11 +uvicorn==0.23.1 +pyjwt==2.8.0 aiofiles==23.1.0 psutil==5.9.5 @@ -49,7 +50,7 @@ psutil==5.9.5 colorama==0.4.6 # Building config files interactively questionary==1.10.0 -prompt-toolkit==3.0.38 +prompt-toolkit==3.0.39 # Extensions to datetime library python-dateutil==2.8.2 diff --git a/setup.py b/setup.py index 4b73ae653..c2b725f87 100644 --- a/setup.py +++ b/setup.py @@ -70,7 +70,7 @@ setup( ], install_requires=[ # from requirements.txt - 'ccxt>=3.0.0', + 'ccxt>=4.0.0', 'SQLAlchemy>=2.0.6', 'python-telegram-bot>=20.1', 'arrow>=1.0.0', @@ -97,7 +97,7 @@ setup( 'rich', 'pyarrow; platform_machine != "armv7l"', 'fastapi', - 'pydantic>=1.8.0', + 'pydantic>=1.8.0,<2.0', 'uvicorn', 'psutil', 'pyjwt', diff --git a/tests/conftest.py b/tests/conftest.py index 66f331cae..43cf55a4b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3001,85 +3001,85 @@ def mark_ohlcv(): def funding_rate_history_hourly(): return [ { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": -0.000008, "timestamp": 1630454400000, "datetime": "2021-09-01T00:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": -0.000004, "timestamp": 1630458000000, "datetime": "2021-09-01T01:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0.000012, "timestamp": 1630461600000, "datetime": "2021-09-01T02:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": -0.000003, "timestamp": 1630465200000, "datetime": "2021-09-01T03:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": -0.000007, "timestamp": 1630468800000, "datetime": "2021-09-01T04:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0.000003, "timestamp": 1630472400000, "datetime": "2021-09-01T05:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0.000019, "timestamp": 1630476000000, "datetime": "2021-09-01T06:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0.000003, "timestamp": 1630479600000, "datetime": "2021-09-01T07:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": -0.000003, "timestamp": 1630483200000, "datetime": "2021-09-01T08:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0, "timestamp": 1630486800000, "datetime": "2021-09-01T09:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0.000013, "timestamp": 1630490400000, "datetime": "2021-09-01T10:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0.000077, "timestamp": 1630494000000, "datetime": "2021-09-01T11:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0.000072, "timestamp": 1630497600000, "datetime": "2021-09-01T12:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": 0.000097, "timestamp": 1630501200000, "datetime": "2021-09-01T13:00:00.000Z" @@ -3091,13 +3091,13 @@ def funding_rate_history_hourly(): def funding_rate_history_octohourly(): return [ { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": -0.000008, "timestamp": 1630454400000, "datetime": "2021-09-01T00:00:00.000Z" }, { - "symbol": "ADA/USDT", + "symbol": "ADA/USDT:USDT", "fundingRate": -0.000003, "timestamp": 1630483200000, "datetime": "2021-09-01T08:00:00.000Z" diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 5e377f851..c1b007e77 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -343,12 +343,24 @@ def test_calculate_expectancy(testdatadir): filename = testdatadir / "backtest_results/backtest-result.json" bt_data = load_backtest_data(filename) - expectancy = calculate_expectancy(DataFrame()) + expectancy, expectancy_ratio = calculate_expectancy(DataFrame()) assert expectancy == 0.0 + assert expectancy_ratio == 100 - expectancy = calculate_expectancy(bt_data) + expectancy, expectancy_ratio = calculate_expectancy(bt_data) assert isinstance(expectancy, float) - assert pytest.approx(expectancy) == 0.07151374226574791 + assert isinstance(expectancy_ratio, float) + assert pytest.approx(expectancy) == 5.820687070932315e-06 + assert pytest.approx(expectancy_ratio) == 0.07151374226574791 + + data = { + 'profit_abs': [100, 200, 50, -150, 300, -100, 80, -30] + } + df = DataFrame(data) + expectancy, expectancy_ratio = calculate_expectancy(df) + + assert pytest.approx(expectancy) == 56.25 + assert pytest.approx(expectancy_ratio) == 0.60267857 def test_calculate_sortino(testdatadir): diff --git a/tests/data/test_converter.py b/tests/data/test_converter.py index b37a2100d..8b4ecc6f7 100644 --- a/tests/data/test_converter.py +++ b/tests/data/test_converter.py @@ -135,6 +135,73 @@ def test_ohlcv_fill_up_missing_data2(caplog): f"{len(data)} - after: {len(data2)}.*", caplog) +def test_ohlcv_to_dataframe_1M(): + + # Monthly ticks from 2019-09-01 to 2023-07-01 + ticks = [ + [1567296000000, 8042.08, 10475.54, 7700.67, 8041.96, 608742.1109999999], + [1569888000000, 8285.31, 10408.48, 7172.76, 9150.0, 2439561.887], + [1572566400000, 9149.88, 9550.0, 6510.19, 7542.93, 4042674.725], + [1575158400000, 7541.08, 7800.0, 6427.0, 7189.0, 4063882.296], + [1577836800000, 7189.43, 9599.0, 6863.44, 9364.51, 5165281.358], + [1580515200000, 9364.5, 10540.0, 8450.0, 8531.98, 4581788.124], + [1583020800000, 8532.5, 9204.0, 3621.81, 6407.1, 10859497.479], + [1585699200000, 6407.1, 9479.77, 6140.0, 8624.76, 11276526.968], + [1588291200000, 8623.61, 10080.0, 7940.0, 9446.43, 12469561.02], + [1590969600000, 9446.49, 10497.25, 8816.4, 9138.87, 6684044.201], + [1593561600000, 9138.88, 11488.0, 8900.0, 11343.68, 5709327.926], + [1596240000000, 11343.67, 12499.42, 10490.0, 11658.11, 6746487.129], + [1598918400000, 11658.11, 12061.07, 9808.58, 10773.0, 6442697.051], + [1601510400000, 10773.0, 14140.0, 10371.03, 13783.73, 7404103.004], + [1604188800000, 13783.73, 19944.0, 13195.0, 19720.0, 12328272.549], + [1606780800000, 19722.09, 29376.7, 17555.0, 28951.68, 10067314.24], + [1609459200000, 28948.19, 42125.51, 27800.0, 33126.21, 12408873.079], + [1612137600000, 33125.11, 58472.14, 32322.47, 45163.36, 8784474.482], + [1614556800000, 45162.64, 61950.0, 44972.49, 58807.24, 9459821.267], + [1617235200000, 58810.99, 64986.11, 46930.43, 57684.16, 7895051.389], + [1619827200000, 57688.29, 59654.0, 28688.0, 37243.38, 16790964.443], + [1622505600000, 37244.36, 41413.0, 28780.01, 35031.39, 23474519.886], + [1625097600000, 35031.39, 48168.6, 29242.24, 41448.11, 16932491.175], + [1627776000000, 41448.1, 50600.0, 37291.0, 47150.32, 13645800.254], + [1630454400000, 47150.32, 52950.0, 39503.58, 43796.57, 10734742.869], + [1633046400000, 43799.49, 67150.0, 43260.01, 61348.61, 9111112.847], + [1635724800000, 61347.14, 69198.7, 53245.0, 56975.0, 7111424.463], + [1638316800000, 56978.06, 59100.0, 40888.89, 46210.56, 8404449.024], + [1640995200000, 46210.57, 48000.0, 32853.83, 38439.04, 11047479.277], + [1643673600000, 38439.04, 45847.5, 34303.7, 43155.0, 10910339.91], + [1646092800000, 43155.0, 48200.0, 37134.0, 45506.0, 10459721.586], + [1648771200000, 45505.9, 47448.0, 37550.0, 37614.5, 8463568.862], + [1651363200000, 37614.4, 40071.7, 26631.0, 31797.8, 14463715.774], + [1654041600000, 31797.9, 31986.1, 17593.2, 19923.5, 20710810.306], + [1656633600000, 19923.3, 24700.0, 18780.1, 23290.1, 20582518.513], + [1659312000000, 23290.1, 25200.0, 19508.0, 20041.5, 17221921.557], + [1661990400000, 20041.4, 22850.0, 18084.3, 19411.7, 21935261.414], + [1664582400000, 19411.6, 21088.0, 17917.8, 20482.0, 16625843.584], + [1667260800000, 20482.1, 21473.7, 15443.2, 17153.3, 18460614.013], + [1669852800000, 17153.4, 18400.0, 16210.0, 16537.6, 9702408.711], + [1672531200000, 16537.5, 23962.7, 16488.0, 23119.4, 14732180.645], + [1675209600000, 23119.5, 25347.6, 21338.0, 23129.6, 15025197.415], + [1677628800000, 23129.7, 29184.8, 19521.6, 28454.9, 23317458.541], + [1680307200000, 28454.8, 31059.0, 26919.3, 29223.0, 14654208.219], + [1682899200000, 29223.0, 29840.0, 25751.0, 27201.1, 13328157.284], + [1685577600000, 27201.1, 31500.0, 24777.0, 30460.2, 14099299.273], + [1688169600000, 30460.2, 31850.0, 28830.0, 29338.8, 8760361.377] + ] + + data = ohlcv_to_dataframe(ticks, '1M', pair="UNITTEST/USDT", + fill_missing=False, drop_incomplete=False) + assert len(data) == len(ticks) + assert data.iloc[0]['date'].strftime('%Y-%m-%d') == '2019-09-01' + assert data.iloc[-1]['date'].strftime('%Y-%m-%d') == '2023-07-01' + + # Test with filling missing data + data = ohlcv_to_dataframe(ticks, '1M', pair="UNITTEST/USDT", + fill_missing=True, drop_incomplete=False) + assert len(data) == len(ticks) + assert data.iloc[0]['date'].strftime('%Y-%m-%d') == '2019-09-01' + assert data.iloc[-1]['date'].strftime('%Y-%m-%d') == '2023-07-01' + + def test_ohlcv_drop_incomplete(caplog): timeframe = '1d' ticks = [ @@ -315,6 +382,8 @@ def test_convert_ohlcv_format(default_conf, testdatadir, tmpdir, file_base, cand files_new.append(file_new) default_conf['datadir'] = tmpdir1 + default_conf['candle_types'] = [candletype] + if candletype == CandleType.SPOT: default_conf['pairs'] = ['XRP/ETH', 'XRP/USDT', 'UNITTEST/USDT'] else: @@ -328,7 +397,6 @@ def test_convert_ohlcv_format(default_conf, testdatadir, tmpdir, file_base, cand convert_from='json', convert_to='jsongz', erase=False, - candle_type=candletype ) for file in (files_temp + files_new): assert file.exists() @@ -342,7 +410,6 @@ def test_convert_ohlcv_format(default_conf, testdatadir, tmpdir, file_base, cand convert_from='jsongz', convert_to='json', erase=True, - candle_type=candletype ) for file in (files_temp): assert file.exists() diff --git a/tests/exchange/test_ccxt_compat.py b/tests/exchange/test_ccxt_compat.py index b1a6f1c65..c57e32633 100644 --- a/tests/exchange/test_ccxt_compat.py +++ b/tests/exchange/test_ccxt_compat.py @@ -43,6 +43,7 @@ EXCHANGES = { 'hasQuoteVolumeFutures': True, 'leverage_tiers_public': False, 'leverage_in_spot_market': False, + 'trades_lookback_hours': 4, 'private_methods': [ 'fapiPrivateGetPositionSideDual', 'fapiPrivateGetMultiAssetsMargin' @@ -92,12 +93,13 @@ EXCHANGES = { }] }, 'kraken': { - 'pair': 'BTC/USDT', - 'stake_currency': 'USDT', + 'pair': 'BTC/USD', + 'stake_currency': 'USD', 'hasQuoteVolume': True, 'timeframe': '1h', 'leverage_tiers_public': False, 'leverage_in_spot_market': True, + 'trades_lookback_hours': 12, }, 'kucoin': { 'pair': 'XRP/USDT', @@ -291,11 +293,7 @@ def set_test_proxy(config: Config, use_proxy: bool) -> Config: if use_proxy and (proxy := os.environ.get('CI_WEB_PROXY')): config1 = deepcopy(config) config1['exchange']['ccxt_config'] = { - "aiohttp_proxy": proxy, - 'proxies': { - 'https': proxy, - 'http': proxy, - } + "httpsProxy": proxy, } return config1 @@ -546,6 +544,8 @@ class TestCCXTExchange: if exchangename in ('bittrex'): # For some weired reason, this test returns random lengths for bittrex. pytest.skip("Exchange doesn't provide stable ohlcv history") + if exchangename in ('bitvavo'): + pytest.skip("Exchange Downtime ") if not exc._ft_has['ohlcv_has_history']: pytest.skip("Exchange does not support candle history") @@ -640,7 +640,21 @@ class TestCCXTExchange: assert isinstance(funding_fee, float) # assert funding_fee > 0 - # TODO: tests fetch_trades (?) + def test_ccxt__async_get_trade_history(self, exchange: EXCHANGE_FIXTURE_TYPE): + exch, exchangename = exchange + if not (lookback := EXCHANGES[exchangename].get('trades_lookback_hours')): + pytest.skip('test_fetch_trades not enabled for this exchange') + pair = EXCHANGES[exchangename]['pair'] + since = int((datetime.now(timezone.utc) - timedelta(hours=lookback)).timestamp() * 1000) + res = exch.loop.run_until_complete( + exch._async_get_trade_history(pair, since, None, None) + ) + assert len(res) == 2 + res_pair, res_trades = res + assert res_pair == pair + assert isinstance(res_trades, list) + assert res_trades[0][0] >= since + assert len(res_trades) > 1200 def test_ccxt_get_fee(self, exchange: EXCHANGE_FIXTURE_TYPE): exch, exchangename = exchange diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 5fa2755d2..289a67c4a 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -4343,11 +4343,11 @@ def test__fetch_and_calculate_funding_fees( ex = get_patched_exchange(mocker, default_conf, api_mock, id=exchange) mocker.patch(f'{EXMS}.timeframes', PropertyMock(return_value=['1h', '4h', '8h'])) funding_fees = ex._fetch_and_calculate_funding_fees( - pair='ADA/USDT', amount=amount, is_short=True, open_date=d1, close_date=d2) + pair='ADA/USDT:USDT', amount=amount, is_short=True, open_date=d1, close_date=d2) assert pytest.approx(funding_fees) == expected_fees # Fees for Longs are inverted funding_fees = ex._fetch_and_calculate_funding_fees( - pair='ADA/USDT', amount=amount, is_short=False, open_date=d1, close_date=d2) + pair='ADA/USDT:USDT', amount=amount, is_short=False, open_date=d1, close_date=d2) assert pytest.approx(funding_fees) == -expected_fees # Return empty "refresh_latest" @@ -4355,7 +4355,7 @@ def test__fetch_and_calculate_funding_fees( ex = get_patched_exchange(mocker, default_conf, api_mock, id=exchange) with pytest.raises(ExchangeError, match="Could not find funding rates."): ex._fetch_and_calculate_funding_fees( - pair='ADA/USDT', amount=amount, is_short=False, open_date=d1, close_date=d2) + pair='ADA/USDT:USDT', amount=amount, is_short=False, open_date=d1, close_date=d2) @pytest.mark.parametrize('exchange,expected_fees', [ @@ -5424,7 +5424,7 @@ def test_stoploss_contract_size(mocker, default_conf, contract_size, order_amoun assert api_mock.create_order.call_args_list[0][1]['amount'] == order_amount assert order['amount'] == 100 - assert order['cost'] == 100 + assert order['cost'] == order_amount assert order['filled'] == 100 assert order['remaining'] == 100 diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index a333cda9d..cc1d9e95b 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -601,6 +601,9 @@ def test_backtest__enter_trade_futures(default_conf_usdt, fee, mocker) -> None: trade = backtesting._enter_trade(pair, row=row, direction='short') assert pytest.approx(trade.liquidation_price) == 0.11787191 + assert pytest.approx(trade.orders[0].cost) == ( + trade.stake_amount * trade.leverage + trade.fee_open) + assert pytest.approx(trade.orders[-1].stake_amount) == trade.stake_amount # Stake-amount too high! mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=600.0) diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 7b85e7978..2ea6a33a4 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -23,7 +23,8 @@ from freqtrade.optimize.optimize_reports import (generate_backtest_stats, genera store_backtest_analysis_results, store_backtest_stats, text_table_bt_results, text_table_exit_reason, text_table_strategy) -from freqtrade.optimize.optimize_reports.optimize_reports import _get_resample_from_period +from freqtrade.optimize.optimize_reports.optimize_reports import (_get_resample_from_period, + calc_streak) from freqtrade.resolvers.strategy_resolver import StrategyResolver from freqtrade.util import dt_ts from freqtrade.util.datetime_helpers import dt_from_ts, dt_utc @@ -348,6 +349,32 @@ def test_generate_trading_stats(testdatadir): assert res['losses'] == 0 +def test_calc_streak(testdatadir): + df = pd.DataFrame({ + 'profit_ratio': [0.05, -0.02, -0.03, -0.05, 0.01, 0.02, 0.03, 0.04, -0.02, -0.03], + }) + # 4 consecutive wins, 3 consecutive losses + res = calc_streak(df) + assert res == (4, 3) + assert isinstance(res[0], int) + assert isinstance(res[1], int) + + # invert situation + df1 = df.copy() + df1['profit_ratio'] = df1['profit_ratio'] * -1 + assert calc_streak(df1) == (3, 4) + + df_empty = pd.DataFrame({ + 'profit_ratio': [], + }) + assert df_empty.empty + assert calc_streak(df_empty) == (0, 0) + + filename = testdatadir / "backtest_results/backtest-result.json" + bt_data = load_backtest_data(filename) + assert calc_streak(bt_data) == (7, 18) + + def test_text_table_exit_reason(): results = pd.DataFrame( diff --git a/tests/persistence/test_persistence.py b/tests/persistence/test_persistence.py index 4aa3b1e96..958db8c72 100644 --- a/tests/persistence/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -563,14 +563,14 @@ def test_calc_open_close_trade_price( trade.open_order_id = f'something-{is_short}-{lev}-{exchange}' oobj = Order.parse_from_ccxt_object(entry_order, 'ADA/USDT', trade.entry_side) - oobj.trade = trade + oobj._trade_live = trade oobj.update_from_ccxt_object(entry_order) trade.update_trade(oobj) trade.funding_fees = funding_fees oobj = Order.parse_from_ccxt_object(exit_order, 'ADA/USDT', trade.exit_side) - oobj.trade = trade + oobj._trade_live = trade oobj.update_from_ccxt_object(exit_order) trade.update_trade(oobj) diff --git a/tests/persistence/test_trade_fromjson.py b/tests/persistence/test_trade_fromjson.py index 22053463d..24522e744 100644 --- a/tests/persistence/test_trade_fromjson.py +++ b/tests/persistence/test_trade_fromjson.py @@ -1,8 +1,11 @@ from datetime import datetime, timezone +import pytest + from freqtrade.persistence.trade_model import Trade +@pytest.mark.usefixtures("init_persistence") def test_trade_fromjson(): """Test the Trade.from_json() method.""" trade_string = """{ @@ -168,14 +171,17 @@ def test_trade_fromjson(): ] }""" trade = Trade.from_json(trade_string) + Trade.session.add(trade) + Trade.commit() assert trade.id == 25 assert trade.pair == 'ETH/USDT' - assert trade.open_date == datetime(2022, 10, 18, 9, 12, 42, tzinfo=timezone.utc) + assert trade.open_date_utc == datetime(2022, 10, 18, 9, 12, 42, tzinfo=timezone.utc) assert isinstance(trade.open_date, datetime) assert trade.exit_reason == 'no longer good' + assert trade.realized_profit == 2.76315361 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 last_o.order_filled_utc == 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 bc8fe84f1..cbbf1e6da 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1200,6 +1200,10 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo "[{'ProducerPairList': 'ProducerPairList - default'}]", None ), + ({"method": "RemotePairList", "number_assets": 10, "pairlist_url": "https://example.com"}, + "[{'RemotePairList': 'RemotePairList - 10 pairs from RemotePairlist.'}]", + None + ), ]) def test_pricefilter_desc(mocker, whitelist_conf, markets, pairlistconfig, desc_expected, exception_expected): diff --git a/tests/plugins/test_remotepairlist.py b/tests/plugins/test_remotepairlist.py index ac1d1f5ed..9814e5662 100644 --- a/tests/plugins/test_remotepairlist.py +++ b/tests/plugins/test_remotepairlist.py @@ -1,5 +1,5 @@ import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, PropertyMock import pytest import requests @@ -7,7 +7,7 @@ import requests from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.RemotePairList import RemotePairList from freqtrade.plugins.pairlistmanager import PairListManager -from tests.conftest import get_patched_exchange, get_patched_freqtradebot, log_has +from tests.conftest import EXMS, get_patched_exchange, get_patched_freqtradebot, log_has @pytest.fixture(scope="function") @@ -16,11 +16,12 @@ def rpl_config(default_conf): default_conf['exchange']['pair_whitelist'] = [ 'ETH/USDT', - 'BTC/USDT', + 'XRP/USDT', ] default_conf['exchange']['pair_blacklist'] = [ 'BLK/USDT' ] + return default_conf @@ -34,7 +35,7 @@ def test_gen_pairlist_with_local_file(mocker, rpl_config): mock_file_path.exists.return_value = True jsonparse = json.loads(mock_file.read.return_value) - mocker.patch('freqtrade.plugins.pairlist.RemotePairList.json.load', return_value=jsonparse) + mocker.patch('freqtrade.plugins.pairlist.RemotePairList.rapidjson.load', return_value=jsonparse) rpl_config['pairlists'] = [ { @@ -183,3 +184,159 @@ def test_fetch_pairlist_mock_response_valid(mocker, rpl_config): assert pairs == ["ETH/USDT", "XRP/USDT", "LTC/USDT", "EOS/USDT"] assert time_elapsed == 0.4 assert remote_pairlist._refresh_period == 60 + + +def test_remote_pairlist_init_wrong_mode(mocker, rpl_config): + rpl_config['pairlists'] = [ + { + "method": "RemotePairList", + "mode": "blacklis", + "number_assets": 20, + "pairlist_url": "http://example.com/pairlist", + "keep_pairlist_on_failure": True, + } + ] + + with pytest.raises( + OperationalException, + match=r'`mode` not configured correctly. Supported Modes are "whitelist","blacklist"' + ): + get_patched_freqtradebot(mocker, rpl_config) + + rpl_config['pairlists'] = [ + { + "method": "RemotePairList", + "mode": "blacklist", + "number_assets": 20, + "pairlist_url": "http://example.com/pairlist", + "keep_pairlist_on_failure": True, + } + ] + + with pytest.raises( + OperationalException, + match=r'A `blacklist` mode RemotePairList can not be.*first.*' + ): + get_patched_freqtradebot(mocker, rpl_config) + + +def test_remote_pairlist_init_wrong_proc_mode(mocker, rpl_config): + rpl_config['pairlists'] = [ + { + "method": "RemotePairList", + "processing_mode": "filler", + "mode": "whitelist", + "number_assets": 20, + "pairlist_url": "http://example.com/pairlist", + "keep_pairlist_on_failure": True, + } + ] + + get_patched_exchange(mocker, rpl_config) + with pytest.raises( + OperationalException, + match=r'`processing_mode` not configured correctly. Supported Modes are "filter","append"' + ): + get_patched_freqtradebot(mocker, rpl_config) + + +def test_remote_pairlist_blacklist(mocker, rpl_config, caplog, markets, tickers): + + mock_response = MagicMock() + + mock_response.json.return_value = { + "pairs": ["XRP/USDT"], + "refresh_period": 60 + } + + mock_response.headers = { + "content-type": "application/json" + } + + rpl_config['pairlists'] = [ + { + "method": "StaticPairList", + }, + { + "method": "RemotePairList", + "mode": "blacklist", + "pairlist_url": "http://example.com/pairlist", + "number_assets": 3 + } + ] + + mocker.patch.multiple(EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + mocker.patch("freqtrade.plugins.pairlist.RemotePairList.requests.get", + return_value=mock_response) + + exchange = get_patched_exchange(mocker, rpl_config) + + pairlistmanager = PairListManager(exchange, rpl_config) + + remote_pairlist = RemotePairList(exchange, pairlistmanager, rpl_config, + rpl_config["pairlists"][1], 1) + + pairs, time_elapsed = remote_pairlist.fetch_pairlist() + + assert pairs == ["XRP/USDT"] + + whitelist = remote_pairlist.filter_pairlist(rpl_config['exchange']['pair_whitelist'], {}) + assert whitelist == ["ETH/USDT"] + + assert log_has(f"Blacklist - Filtered out pairs: {pairs}", caplog) + + +@pytest.mark.parametrize("processing_mode", ["filter", "append"]) +def test_remote_pairlist_whitelist(mocker, rpl_config, processing_mode, markets, tickers): + + mock_response = MagicMock() + + mock_response.json.return_value = { + "pairs": ["XRP/USDT"], + "refresh_period": 60 + } + + mock_response.headers = { + "content-type": "application/json" + } + + rpl_config['pairlists'] = [ + { + "method": "StaticPairList", + }, + { + "method": "RemotePairList", + "mode": "whitelist", + "processing_mode": processing_mode, + "pairlist_url": "http://example.com/pairlist", + "number_assets": 3 + } + ] + + mocker.patch.multiple(EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + mocker.patch("freqtrade.plugins.pairlist.RemotePairList.requests.get", + return_value=mock_response) + + exchange = get_patched_exchange(mocker, rpl_config) + + pairlistmanager = PairListManager(exchange, rpl_config) + + remote_pairlist = RemotePairList(exchange, pairlistmanager, rpl_config, + rpl_config["pairlists"][1], 1) + + pairs, time_elapsed = remote_pairlist.fetch_pairlist() + + assert pairs == ["XRP/USDT"] + + whitelist = remote_pairlist.filter_pairlist(rpl_config['exchange']['pair_whitelist'], {}) + assert whitelist == (["XRP/USDT"] if processing_mode == "filter" else ['ETH/USDT', 'XRP/USDT']) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 31d7ae37b..3bc725f3a 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -97,7 +97,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'order_date': ANY, 'order_timestamp': ANY, 'order_filled_date': ANY, 'order_filled_timestamp': ANY, 'order_type': 'limit', 'price': 1.098e-05, 'is_open': False, 'pair': 'ETH/BTC', 'order_id': ANY, - 'remaining': ANY, 'status': ANY, 'ft_is_entry': True, + 'remaining': ANY, 'status': ANY, 'ft_is_entry': True, 'ft_fee_base': None, }], } mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) @@ -402,6 +402,8 @@ def test_rpc_trade_statistics(default_conf_usdt, ticker, fee, mocker) -> None: assert res['first_trade_timestamp'] == 0 assert res['latest_trade_date'] == '' assert res['latest_trade_timestamp'] == 0 + assert res['expectancy'] == 0 + assert res['expectancy_ratio'] == 100 # Create some test data create_mock_trades_usdt(fee) @@ -413,6 +415,9 @@ def test_rpc_trade_statistics(default_conf_usdt, ticker, fee, mocker) -> None: assert pytest.approx(stats['profit_all_coin']) == -77.45964918 assert pytest.approx(stats['profit_all_percent_mean']) == -57.86 assert pytest.approx(stats['profit_all_fiat']) == -85.205614098 + assert pytest.approx(stats['winrate']) == 0.666666667 + assert pytest.approx(stats['expectancy']) == 0.913333333 + assert pytest.approx(stats['expectancy_ratio']) == 0.223308883 assert stats['trade_count'] == 7 assert stats['first_trade_humanized'] == '2 days ago' assert stats['latest_trade_humanized'] == '17 minutes ago' diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index f793b1f9c..8c456b6db 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -829,7 +829,8 @@ def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): 'profit_closed_percent_mean': -0.75, 'profit_closed_ratio_sum': -0.015, 'profit_closed_percent_sum': -1.5, 'profit_closed_ratio': -6.739057628404269e-06, 'profit_closed_percent': -0.0, 'winning_trades': 0, 'losing_trades': 2, - 'profit_factor': 0.0, 'trading_volume': 91.074, + 'profit_factor': 0.0, 'winrate': 0.0, 'expectancy': -0.0033695635, + 'expectancy_ratio': -1.0, 'trading_volume': 91.074, } ), ( @@ -844,7 +845,8 @@ def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): 'profit_closed_percent_mean': 0.75, 'profit_closed_ratio_sum': 0.015, 'profit_closed_percent_sum': 1.5, 'profit_closed_ratio': 7.391275897987988e-07, 'profit_closed_percent': 0.0, 'winning_trades': 2, 'losing_trades': 0, - 'profit_factor': None, 'trading_volume': 91.074, + 'profit_factor': None, 'winrate': 1.0, 'expectancy': 0.0003695635, + 'expectancy_ratio': 100, 'trading_volume': 91.074, } ), ( @@ -859,7 +861,9 @@ def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): 'profit_closed_percent_mean': 0.25, 'profit_closed_ratio_sum': 0.005, 'profit_closed_percent_sum': 0.5, 'profit_closed_ratio': -5.429078808526421e-06, 'profit_closed_percent': -0.0, 'winning_trades': 1, 'losing_trades': 1, - 'profit_factor': 0.02775724835771106, 'trading_volume': 91.074, + 'profit_factor': 0.02775724835771106, 'winrate': 0.5, + 'expectancy': -0.0027145635000000003, 'expectancy_ratio': -0.48612137582114445, + 'trading_volume': 91.074, } ) ]) @@ -916,6 +920,9 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, is_short, expected) 'winning_trades': expected['winning_trades'], 'losing_trades': expected['losing_trades'], 'profit_factor': expected['profit_factor'], + 'winrate': expected['winrate'], + 'expectancy': expected['expectancy'], + 'expectancy_ratio': expected['expectancy_ratio'], 'max_drawdown': ANY, 'max_drawdown_abs': ANY, 'trading_volume': expected['trading_volume'], @@ -1469,30 +1476,47 @@ def test_api_pair_history(botclient, mocker): "&timerange=20180111-20180112") assert_response(rc, 422) + # Invalid strategy + rc = client_get(client, + f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" + "&timerange=20180111-20180112&strategy={CURRENT_TEST_STRATEGY}11") + assert_response(rc, 502) + # Working rc = client_get(client, f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" f"&timerange=20180111-20180112&strategy={CURRENT_TEST_STRATEGY}") assert_response(rc, 200) - assert rc.json()['length'] == 289 - assert len(rc.json()['data']) == rc.json()['length'] - assert 'columns' in rc.json() - assert 'data' in rc.json() + result = rc.json() + assert result['length'] == 289 + assert len(result['data']) == result['length'] + assert 'columns' in result + assert 'data' in result + data = result['data'] + assert len(data) == 289 + # analyed DF has 28 columns + assert len(result['columns']) == 28 + assert len(data[0]) == 28 + date_col_idx = [idx for idx, c in enumerate(result['columns']) if c == 'date'][0] + rsi_col_idx = [idx for idx, c in enumerate(result['columns']) if c == 'rsi'][0] + + assert data[0][date_col_idx] == '2018-01-11 00:00:00' + assert data[0][rsi_col_idx] is not None + assert data[0][rsi_col_idx] > 0 assert lfm.call_count == 1 - assert rc.json()['pair'] == 'UNITTEST/BTC' - assert rc.json()['strategy'] == CURRENT_TEST_STRATEGY - assert rc.json()['data_start'] == '2018-01-11 00:00:00+00:00' - assert rc.json()['data_start_ts'] == 1515628800000 - assert rc.json()['data_stop'] == '2018-01-12 00:00:00+00:00' - assert rc.json()['data_stop_ts'] == 1515715200000 + assert result['pair'] == 'UNITTEST/BTC' + assert result['strategy'] == CURRENT_TEST_STRATEGY + assert result['data_start'] == '2018-01-11 00:00:00+00:00' + assert result['data_start_ts'] == 1515628800000 + assert result['data_stop'] == '2018-01-12 00:00:00+00:00' + assert result['data_stop_ts'] == 1515715200000 # No data found rc = client_get(client, f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" f"&timerange=20200111-20200112&strategy={CURRENT_TEST_STRATEGY}") assert_response(rc, 502) - assert rc.json()['error'] == ("Error querying /api/v1/pair_history: " - "No data for UNITTEST/BTC, 5m in 20200111-20200112 found.") + assert rc.json()['detail'] == ("No data for UNITTEST/BTC, 5m in 20200111-20200112 found.") def test_api_plot_config(botclient, mocker): @@ -1529,6 +1553,10 @@ def test_api_plot_config(botclient, mocker): assert_response(rc) assert rc.json()['subplots'] == {} + rc = client_get(client, f"{BASE_URI}/plot_config?strategy=NotAStrategy") + assert_response(rc, 502) + assert rc.json()['detail'] is not None + mocker.patch('freqtrade.rpc.api_server.api_v1.get_rpc_optional', return_value=None) rc = client_get(client, f"{BASE_URI}/plot_config") @@ -1981,7 +2009,7 @@ def test_api_backtest_history(botclient, mocker, testdatadir): result = rc.json() assert len(result) == 3 fn = result[0]['filename'] - assert fn == "backtest-result_multistrat.json" + assert fn == "backtest-result_multistrat" strategy = result[0]['strategy'] rc = client_get(client, f"{BASE_URI}/backtest/history/result?filename={fn}&strategy={strategy}") assert_response(rc) @@ -1995,6 +2023,34 @@ def test_api_backtest_history(botclient, mocker, testdatadir): assert result2['backtest_result']['strategy'][strategy] +def test_api_delete_backtest_history_entry(botclient, mocker, tmp_path: Path): + ftbot, client = botclient + + # Create a temporary directory and file + bt_results_base = tmp_path / "backtest_results" + bt_results_base.mkdir() + file_path = bt_results_base / "test.json" + file_path.touch() + meta_path = file_path.with_suffix('.meta.json') + meta_path.touch() + + rc = client_delete(client, f"{BASE_URI}/backtest/history/randomFile.json") + assert_response(rc, 503) + assert rc.json()['detail'] == 'Bot is not in the correct state.' + + ftbot.config['user_data_dir'] = tmp_path + ftbot.config['runmode'] = RunMode.WEBSERVER + rc = client_delete(client, f"{BASE_URI}/backtest/history/randomFile.json") + assert rc.status_code == 404 + assert rc.json()['detail'] == 'File not found.' + + rc = client_delete(client, f"{BASE_URI}/backtest/history/{file_path.name}") + assert rc.status_code == 200 + + assert not file_path.exists() + assert not meta_path.exists() + + def test_health(botclient): ftbot, client = botclient diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 51879f5ad..72d70bfb9 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -799,6 +799,8 @@ async def test_telegram_profit_handle( assert '*Best Performing:* `ETH/USDT: 9.45%`' in msg_mock.call_args_list[-1][0][0] assert '*Max Drawdown:*' in msg_mock.call_args_list[-1][0][0] assert '*Profit factor:*' in msg_mock.call_args_list[-1][0][0] + assert '*Winrate:*' in msg_mock.call_args_list[-1][0][0] + assert '*Expectancy (Ratio):*' in msg_mock.call_args_list[-1][0][0] assert '*Trading volume:* `126 USDT`' in msg_mock.call_args_list[-1][0][0] diff --git a/tests/rpc/test_rpc_webhook.py b/tests/rpc/test_rpc_webhook.py index d0a0f5b1e..36b96ace5 100644 --- a/tests/rpc/test_rpc_webhook.py +++ b/tests/rpc/test_rpc_webhook.py @@ -381,7 +381,7 @@ def test__send_msg(default_conf, mocker, caplog): webhook._send_msg(msg) assert post.call_count == 1 - assert post.call_args[1] == {'data': msg} + assert post.call_args[1] == {'data': msg, 'timeout': 10} assert post.call_args[0] == (default_conf['webhook']['url'], ) post = MagicMock(side_effect=RequestException) @@ -399,7 +399,7 @@ def test__send_msg_with_json_format(default_conf, mocker, caplog): mocker.patch("freqtrade.rpc.webhook.post", post) webhook._send_msg(msg) - assert post.call_args[1] == {'json': msg} + assert post.call_args[1] == {'json': msg, 'timeout': 10} def test__send_msg_with_raw_format(default_conf, mocker, caplog): @@ -411,7 +411,11 @@ def test__send_msg_with_raw_format(default_conf, mocker, caplog): mocker.patch("freqtrade.rpc.webhook.post", post) webhook._send_msg(msg) - assert post.call_args[1] == {'data': msg['data'], 'headers': {'Content-Type': 'text/plain'}} + assert post.call_args[1] == { + 'data': msg['data'], + 'headers': {'Content-Type': 'text/plain'}, + 'timeout': 10 + } def test_send_msg_discord(default_conf, mocker): diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 3bfa5a127..e533acbb8 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -2793,7 +2793,7 @@ def test_manage_open_orders_entry( freqtrade.strategy.check_entry_timeout = MagicMock(return_value=False) freqtrade.strategy.adjust_entry_price = MagicMock(return_value=1234) - # check it does cancel buy orders over the time limit + # check it does cancel entry orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 assert rpc_mock.call_count == 2 @@ -2801,7 +2801,7 @@ def test_manage_open_orders_entry( select(Trade).filter(Trade.open_order_id.is_(open_trade.open_order_id))).all() nb_trades = len(trades) assert nb_trades == 0 - # Custom user buy-timeout is never called + # Custom user entry-timeout is never called assert freqtrade.strategy.check_entry_timeout.call_count == 0 # Entry adjustment is never called assert freqtrade.strategy.adjust_entry_price.call_count == 0 @@ -5023,7 +5023,7 @@ def test_get_real_amount_in_point(default_conf_usdt, buy_order_fee, fee, mocker, (8.0, 0.1, 8.0, None), (8.0, 0.1, 7.9, 0.1), ]) -def test_apply_fee_conditional(default_conf_usdt, fee, mocker, +def test_apply_fee_conditional(default_conf_usdt, fee, mocker, caplog, amount, fee_abs, wallet, amount_exp): walletmock = mocker.patch('freqtrade.wallets.Wallets.update') mocker.patch('freqtrade.wallets.Wallets.get_free', return_value=wallet) @@ -5048,6 +5048,60 @@ def test_apply_fee_conditional(default_conf_usdt, fee, mocker, # Amount is kept as is assert freqtrade.apply_fee_conditional(trade, 'LTC', amount, fee_abs, order) == amount_exp assert walletmock.call_count == 1 + if fee_abs != 0 and amount_exp is None: + assert log_has_re(r"Fee amount.*Eating.*dust\.", caplog) + + +@pytest.mark.parametrize('amount,fee_abs,wallet,amount_exp', [ + (8.0, 0.0, 16, None), + (8.0, 0.0, 0, None), + (8.0, 0.1, 8, 0.1), + (8.0, 0.1, 20, None), + (8.0, 0.1, 16.0, None), + (8.0, 0.1, 7.9, 0.1), + (8.0, 0.1, 12, 0.1), + (8.0, 0.1, 15.9, 0.1), +]) +def test_apply_fee_conditional_multibuy(default_conf_usdt, fee, mocker, caplog, + amount, fee_abs, wallet, amount_exp): + walletmock = mocker.patch('freqtrade.wallets.Wallets.update') + mocker.patch('freqtrade.wallets.Wallets.get_free', return_value=wallet) + trade = Trade( + pair='LTC/ETH', + amount=amount, + exchange='binance', + open_rate=0.245441, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_order_id="123456" + ) + # One closed order + order = Order( + ft_order_side='buy', + order_id='10', + ft_pair=trade.pair, + ft_is_open=False, + filled=amount, + status="closed" + ) + trade.orders.append(order) + # Add additional order - this should NOT eat into dust unless the wallet was bigger already. + order1 = Order( + ft_order_side='buy', + order_id='100', + ft_pair=trade.pair, + ft_is_open=True, + ) + trade.orders.append(order1) + + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + + walletmock.reset_mock() + # The new trade amount will be 2x amount - fee / wallet will have to be adapted to this. + assert freqtrade.apply_fee_conditional(trade, 'LTC', amount, fee_abs, order1) == amount_exp + assert walletmock.call_count == 1 + if fee_abs != 0 and amount_exp is None: + assert log_has_re(r"Fee amount.*Eating.*dust\.", caplog) @pytest.mark.parametrize("delta, is_high_delta", [ diff --git a/tests/test_integration.py b/tests/test_integration.py index 2949f1ef2..41265ae74 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -429,6 +429,7 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) assert pytest.approx(trade.stop_loss) == 1.99 * (1 - 0.1 / leverage) assert pytest.approx(trade.initial_stop_loss) == 1.96 * (1 - 0.1 / leverage) assert trade.initial_stop_loss_pct == -0.1 + assert pytest.approx(trade.orders[-1].stake_amount) == trade.stake_amount # 2nd order - not filling freqtrade.strategy.adjust_trade_position = MagicMock(return_value=120) @@ -473,13 +474,38 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) assert pytest.approx(trade.orders[1].amount) == 30.150753768 * leverage assert pytest.approx(trade.orders[-1].amount) == 61.538461232 * leverage + # Full exit + mocker.patch(f'{EXMS}._dry_is_price_crossed', return_value=False) + freqtrade.strategy.custom_exit = MagicMock(return_value='Exit now') + freqtrade.strategy.adjust_entry_price = MagicMock(return_value=2.02) + freqtrade.process() + trade = Trade.get_trades().first() + assert len(trade.orders) == 5 + assert trade.orders[-1].side == trade.exit_side + assert trade.orders[-1].status == 'open' + assert trade.orders[-1].price == 2.02 + assert pytest.approx(trade.amount) == 91.689215 * leverage + assert pytest.approx(trade.orders[-1].amount) == 91.689215 * leverage + assert freqtrade.strategy.adjust_entry_price.call_count == 0 + # Process again, should not adjust entry price + freqtrade.process() + trade = Trade.get_trades().first() + assert len(trade.orders) == 5 + assert trade.orders[-1].status == 'open' + assert trade.orders[-1].price == 2.02 + # Adjust entry price cannot be called - this is an exit order + assert freqtrade.strategy.adjust_entry_price.call_count == 0 + @pytest.mark.parametrize('leverage', [1, 2]) def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, leverage) -> None: default_conf_usdt['position_adjustment_enable'] = True - + spot = leverage == 1 + if not spot: + default_conf_usdt['trading_mode'] = 'futures' + default_conf_usdt['margin_mode'] = 'isolated' freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) - freqtrade.trading_mode = TradingMode.FUTURES + assert freqtrade.trading_mode == TradingMode.FUTURES if not spot else TradingMode.SPOT mocker.patch.multiple( EXMS, fetch_ticker=ticker_usdt, @@ -487,8 +513,11 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, levera amount_to_precision=lambda s, x, y: y, price_to_precision=lambda s, x, y: y, get_min_pair_stake_amount=MagicMock(return_value=10), + get_funding_fees=MagicMock(return_value=0), ) mocker.patch(f"{EXMS}.get_max_leverage", return_value=10) + starting_amount = freqtrade.wallets.get_total('USDT') + assert starting_amount == 1000 patch_get_signal(freqtrade) freqtrade.strategy.leverage = MagicMock(return_value=leverage) @@ -498,8 +527,14 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, levera trade = Trade.get_trades().first() assert len(trade.orders) == 1 assert pytest.approx(trade.stake_amount) == 60 + assert trade.leverage == leverage assert pytest.approx(trade.amount) == 30.0 * leverage assert trade.open_rate == 2.0 + assert pytest.approx(freqtrade.wallets.get_free('USDT')) == starting_amount - 60 + if spot: + assert pytest.approx(freqtrade.wallets.get_total('USDT')) == starting_amount - 60 + else: + assert freqtrade.wallets.get_total('USDT') == starting_amount # Too small size freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-59) @@ -521,6 +556,15 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, levera assert pytest.approx(trade.amount) == 20.099 * leverage assert trade.open_rate == 2.0 assert trade.is_open + assert trade.realized_profit > 0.098 * leverage + expected_profit = starting_amount - 40.1980 + trade.realized_profit + assert pytest.approx(freqtrade.wallets.get_free('USDT')) == expected_profit + + if spot: + assert pytest.approx(freqtrade.wallets.get_total('USDT')) == expected_profit + else: + # total won't change in futures mode, only free / used will. + assert freqtrade.wallets.get_total('USDT') == starting_amount + trade.realized_profit caplog.clear() # Sell more than what we got (we got ~20 coins left) @@ -545,3 +589,10 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, levera assert pytest.approx(trade.stake_amount) == 40.198 assert trade.is_open assert log_has_re('Amount to exit is 0.0 due to exchange limits - not exiting.', caplog) + expected_profit = starting_amount - 40.1980 + trade.realized_profit + assert pytest.approx(freqtrade.wallets.get_free('USDT')) == expected_profit + if spot: + assert pytest.approx(freqtrade.wallets.get_total('USDT')) == expected_profit + else: + # total won't change in futures mode, only free / used will. + assert freqtrade.wallets.get_total('USDT') == starting_amount + trade.realized_profit diff --git a/tests/test_misc.py b/tests/test_misc.py index 03a236d73..3943e7f15 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,6 +1,5 @@ # pragma pylint: disable=missing-docstring,C0103 -import datetime from copy import deepcopy from pathlib import Path from unittest.mock import MagicMock @@ -9,7 +8,7 @@ import pandas as pd import pytest from freqtrade.misc import (dataframe_to_json, decimals_per_coin, deep_merge_dicts, file_dump_json, - file_load_json, format_ms_time, json_to_dataframe, pair_to_filename, + file_load_json, is_file_in_dir, json_to_dataframe, pair_to_filename, parse_db_uri_for_logging, plural, render_template, render_template_with_fallback, round_coin_value, safe_value_fallback, safe_value_fallback2) @@ -65,6 +64,24 @@ def test_file_load_json(mocker, testdatadir) -> None: assert ret +def test_is_file_in_dir(tmp_path): + + # Create a temporary directory and file + dir_path = tmp_path / "subdir" + dir_path.mkdir() + file_path = dir_path / "test.txt" + file_path.touch() + + # Test that the function returns True when the file is in the directory + assert is_file_in_dir(file_path, dir_path) is True + + # Test that the function returns False when the file is not in the directory + assert is_file_in_dir(file_path, tmp_path) is False + + file_path2 = tmp_path / "../../test2.txt" + assert is_file_in_dir(file_path2, tmp_path) is False + + @pytest.mark.parametrize("pair,expected_result", [ ("ETH/BTC", 'ETH_BTC'), ("ETH/USDT", 'ETH_USDT'), @@ -91,19 +108,6 @@ def test_pair_to_filename(pair, expected_result): assert pair_s == expected_result -def test_format_ms_time() -> None: - # Date 2018-04-10 18:02:01 - date_in_epoch_ms = 1523383321000 - date = format_ms_time(date_in_epoch_ms) - assert type(date) is str - res = datetime.datetime(2018, 4, 10, 18, 2, 1, tzinfo=datetime.timezone.utc) - assert date == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S') - res = datetime.datetime(2017, 12, 13, 8, 2, 1, tzinfo=datetime.timezone.utc) - # Date 2017-12-13 08:02:01 - date_in_epoch_ms = 1513152121000 - assert format_ms_time(date_in_epoch_ms) == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S') - - def test_safe_value_fallback(): dict1 = {'keya': None, 'keyb': 2, 'keyc': 5, 'keyd': None} assert safe_value_fallback(dict1, 'keya', 'keyb') == 2 diff --git a/tests/test_wallets.py b/tests/test_wallets.py index c3ff4ccd0..478993058 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -8,7 +8,8 @@ from sqlalchemy import select from freqtrade.constants import UNLIMITED_STAKE_AMOUNT from freqtrade.exceptions import DependencyException from freqtrade.persistence import Trade -from tests.conftest import EXMS, create_mock_trades, get_patched_freqtradebot, patch_wallet +from tests.conftest import (EXMS, create_mock_trades, create_mock_trades_usdt, + get_patched_freqtradebot, patch_wallet) def test_sync_wallet_at_boot(mocker, default_conf): @@ -341,6 +342,33 @@ def test_sync_wallet_futures_live(mocker, default_conf): assert 'ETH/USDT:USDT' not in freqtrade.wallets._positions +def test_sync_wallet_dry(mocker, default_conf_usdt, fee): + default_conf_usdt['dry_run'] = True + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + assert len(freqtrade.wallets._wallets) == 1 + assert len(freqtrade.wallets._positions) == 0 + assert freqtrade.wallets.get_total('USDT') == 1000 + + create_mock_trades_usdt(fee, is_short=None) + + freqtrade.wallets.update() + + assert len(freqtrade.wallets._wallets) == 5 + assert len(freqtrade.wallets._positions) == 0 + bal = freqtrade.wallets.get_all_balances() + assert bal['NEO'].total == 10 + assert bal['XRP'].total == 10 + assert bal['LTC'].total == 2 + assert bal['USDT'].total == 922.74 + + assert freqtrade.wallets.get_starting_balance() == default_conf_usdt['dry_run_wallet'] + total = freqtrade.wallets.get_total('LTC') + free = freqtrade.wallets.get_free('LTC') + used = freqtrade.wallets.get_used('LTC') + assert free != 0 + assert free + used == total + + def test_sync_wallet_futures_dry(mocker, default_conf, fee): default_conf['dry_run'] = True default_conf['trading_mode'] = 'futures' diff --git a/tests/utils/test_datetime_helpers.py b/tests/utils/test_datetime_helpers.py index 5aec0da54..222410027 100644 --- a/tests/utils/test_datetime_helpers.py +++ b/tests/utils/test_datetime_helpers.py @@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone import pytest import time_machine -from freqtrade.util import dt_floor_day, dt_from_ts, dt_now, dt_ts, dt_utc, shorten_date -from freqtrade.util.datetime_helpers import dt_humanize +from freqtrade.util import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, dt_utc, + format_ms_time, shorten_date) def test_dt_now(): @@ -57,3 +57,16 @@ def test_dt_humanize() -> None: assert dt_humanize(dt_now()) == 'just now' assert dt_humanize(dt_now(), only_distance=True) == 'instantly' assert dt_humanize(dt_now() - timedelta(hours=16), only_distance=True) == '16 hours' + + +def test_format_ms_time() -> None: + # Date 2018-04-10 18:02:01 + date_in_epoch_ms = 1523383321000 + date = format_ms_time(date_in_epoch_ms) + assert type(date) is str + res = datetime(2018, 4, 10, 18, 2, 1, tzinfo=timezone.utc) + assert date == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S') + res = datetime(2017, 12, 13, 8, 2, 1, tzinfo=timezone.utc) + # Date 2017-12-13 08:02:01 + date_in_epoch_ms = 1513152121000 + assert format_ms_time(date_in_epoch_ms) == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S')