Merge pull request #11160 from freqtrade/new_release

New release 2024.12
This commit is contained in:
Matthias
2024-12-30 06:58:30 +01:00
committed by GitHub
111 changed files with 8757 additions and 2784 deletions
+44 -14
View File
@@ -276,14 +276,23 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Pip cache (Windows)
uses: actions/cache@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
path: ~\AppData\Local\pip\Cache
key: pip-${{ matrix.os }}-${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: "requirements**.txt"
cache-suffix: "${{ matrix.python-version }}"
prune-cache: false
- name: Installation
run: |
uv venv
.venv\Scripts\activate
# persist the venv path for future steps
"$(pwd)/.venv/Scripts" >> $env:GITHUB_PATH
function uvpipFunction { uv pip $args }
Set-Alias -name pip -value uvpipFunction
./build_helpers/install_windows.ps1
- name: Tests
@@ -517,13 +526,40 @@ jobs:
ft_client/dist
retention-days: 10
deploy-pypi:
name: "Deploy to PyPI"
deploy-test-pypi:
name: "Publish Python 🐍 distribution 📦 to TestPyPI"
needs: [ build ]
runs-on: ubuntu-22.04
if: (github.event_name == 'release')
environment:
name: release
name: testpypi
url: https://test.pypi.org/p/freqtrade
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- name: Download artifact 📦
uses: actions/download-artifact@v4
with:
pattern: freqtrade*-build
path: dist
merge-multiple: true
- name: Publish to PyPI (Test)
uses: pypa/gh-action-pypi-publish@v1.12.3
with:
repository-url: https://test.pypi.org/legacy/
deploy-pypi:
name: "Publish Python 🐍 distribution 📦 to PyPI"
needs: [ build ]
runs-on: ubuntu-22.04
if: (github.event_name == 'release')
environment:
name: pypi
url: https://pypi.org/p/freqtrade
permissions:
id-token: write
@@ -538,14 +574,8 @@ jobs:
path: dist
merge-multiple: true
- name: Publish to PyPI (Test)
uses: pypa/gh-action-pypi-publish@v1.12.2
with:
repository-url: https://test.pypi.org/legacy/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@v1.12.2
uses: pypa/gh-action-pypi-publish@v1.12.3
deploy-docker:
+9 -4
View File
@@ -9,7 +9,7 @@ repos:
# stages: [push]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.13.0"
rev: "v1.14.0"
hooks:
- id: mypy
exclude: build_helpers
@@ -17,8 +17,8 @@ repos:
- types-cachetools==5.5.0.20240820
- types-filelock==3.2.7
- types-requests==2.32.0.20241016
- types-tabulate==0.9.0.20240106
- types-python-dateutil==2.9.0.20241003
- types-tabulate==0.9.0.20241207
- types-python-dateutil==2.9.0.20241206
- SQLAlchemy==2.0.36
# stages: [push]
@@ -31,7 +31,7 @@ repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version.
rev: 'v0.8.0'
rev: 'v0.8.4'
hooks:
- id: ruff
- id: ruff-format
@@ -56,6 +56,11 @@ repos:
.*\.md
)$
- repo: https://github.com/stefmolin/exif-stripper
rev: 0.6.1
hooks:
- id: strip-exif
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
+3 -2
View File
@@ -32,10 +32,11 @@ Please read the [exchange specific notes](docs/exchanges.md) to learn about even
- [X] [BingX](https://bingx.com/invite/0EM9RX)
- [X] [Bybit](https://bybit.com/)
- [X] [Gate.io](https://www.gate.io/ref/6266643)
- [X] [HTX](https://www.htx.com/) (Former Huobi)
- [X] [HTX](https://www.htx.com/)
- [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
- [X] [Kraken](https://kraken.com/)
- [X] [OKX](https://okx.com/) (Former OKEX)
- [X] [OKX](https://okx.com/)
- [X] [MyOKX](https://okx.com/) (OKX EEA)
- [ ] [potentially many others](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_
### Supported Futures Exchanges (experimental)
+4 -5
View File
@@ -1,11 +1,10 @@
# vendored Wheels compiled via https://github.com/xmatthias/ta-lib-python/tree/ta_bundled_040
python -m pip install --upgrade pip wheel
python -m pip install --upgrade pip
python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
$pyv = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
pip install --find-links=build_helpers\ --prefer-binary TA-Lib
pip install -U wheel "numpy<2"
pip install --only-binary ta-lib --find-links=build_helpers\ ta-lib
pip install -r requirements-dev.txt
pip install -e .
+16 -3
View File
@@ -102,8 +102,17 @@
},
"dry_run_wallet": {
"description": "Initial wallet balance for dry run mode.",
"type": "number",
"default": 1000
"type": [
"number",
"object"
],
"default": 1000,
"patternProperties": {
"^[a-zA-Z0-9]+$": {
"type": "number"
}
},
"additionalProperties": false
},
"cancel_open_orders_on_exit": {
"description": "Cancel open orders when exiting.",
@@ -592,7 +601,11 @@
"type": "string"
},
"chat_id": {
"description": "Telegram chat ID",
"description": "Telegram chat or group ID",
"type": "string"
},
"topic_id": {
"description": "Telegram topic ID - only applicable for group chats",
"type": "string"
},
"allow_custom_messages": {
+2
View File
@@ -39,6 +39,7 @@ class SuperDuperHyperOptLoss(IHyperOptLoss):
config: Config,
processed: dict[str, DataFrame],
backtest_stats: dict[str, Any],
starting_balance: float,
**kwargs,
) -> float:
"""
@@ -70,6 +71,7 @@ Currently, the arguments are:
* `config`: Config object used (Note: Not all strategy-related parameters will be updated here if they are part of a hyperopt space).
* `processed`: Dict of Dataframes with the pair as keys containing the data used for backtesting.
* `backtest_stats`: Backtesting statistics using the same format as the backtesting file "strategy" substructure. Available fields can be seen in `generate_strategy_stats()` in `optimize_reports.py`.
* `starting_balance`: Starting balance used for backtesting.
This function needs to return a floating point number (`float`). Smaller numbers will be interpreted as better results. The parameters and balancing for this is up to you.
+1
View File
@@ -4,6 +4,7 @@ This guide walks you through utilizing public trade data for advanced orderflow
!!! Warning "Experimental Feature"
The orderflow feature is currently in beta and may be subject to changes in future releases. Please report any issues or feedback on the [Freqtrade GitHub repository](https://github.com/freqtrade/freqtrade/issues).
It's also currently not been tested with freqAI - and combining these two features is considered out of scope at this point.
!!! Warning "Performance"
Orderflow requires raw trades data. This data is rather large, and can cause a slow initial startup, when freqtrade needs to download the trades data for the last X candles. Additionally, enabling this feature will cause increased memory usage. Please ensure to have sufficient resources available.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+29 -3
View File
@@ -39,13 +39,19 @@ Please note that Environment variables will overwrite corresponding settings in
Common example:
```
``` bash
FREQTRADE__TELEGRAM__CHAT_ID=<telegramchatid>
FREQTRADE__TELEGRAM__TOKEN=<telegramToken>
FREQTRADE__EXCHANGE__KEY=<yourExchangeKey>
FREQTRADE__EXCHANGE__SECRET=<yourExchangeSecret>
```
Json lists are parsed as json - so you can use the following to set a list of pairs:
``` bash
export FREQTRADE__EXCHANGE__PAIR_WHITELIST='["BTC/USDT", "ETH/USDT"]'
```
!!! Note
Environment variables detected are logged at startup - so if you can't find why a value is not what you think it should be based on the configuration, make sure it's not loaded from an environment variable.
@@ -54,7 +60,7 @@ FREQTRADE__EXCHANGE__SECRET=<yourExchangeSecret>
??? Warning "Loading sequence"
Environment variables are loaded after the initial configuration. As such, you cannot provide the path to the configuration through environment variables. Please use `--config path/to/config.json` for that.
This also applies to user_dir to some degree. while the user directory can be set through environment variables - the configuration will **not** be loaded from that location.
This also applies to `user_dir` to some degree. while the user directory can be set through environment variables - the configuration will **not** be loaded from that location.
### Multiple configuration files
@@ -168,7 +174,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `timeframe` | The timeframe to use (e.g `1m`, `5m`, `15m`, `30m`, `1h` ...). Usually missing in configuration, and specified in the strategy. [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** String
| `fiat_display_currency` | Fiat currency used to show your profits. [More information below](#what-values-can-be-used-for-fiat_display_currency). <br> **Datatype:** String
| `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode. <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in Dry Run mode.<br>*Defaults to `1000`.* <br> **Datatype:** Float
| `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in Dry Run mode. [More information below](#dry-run-wallet)<br>*Defaults to `1000`.* <br> **Datatype:** Float or Dict
| `cancel_open_orders_on_exit` | Cancel open orders when the `/stop` RPC command is issued, `Ctrl+C` is pressed or the bot dies unexpectedly. When set to `true`, this allows you to use `/stop` to cancel unfilled and partially filled orders in the event of a market crash. It does not impact open positions. <br>*Defaults to `false`.* <br> **Datatype:** Boolean
| `process_only_new_candles` | Enable processing of indicators only when new candles arrive. If false each loop populates the indicators, this will mean the same candle is processed many times creating system load but can be useful of your strategy depends on tick data not only candle. [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| `minimal_roi` | **Required.** Set the threshold as ratio the bot will use to exit a trade. [More information below](#understand-minimal_roi). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** Dict
@@ -225,6 +231,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `exchange.skip_open_order_update` | Skips open order updates on startup should the exchange cause problems. Only relevant in live conditions.<br>*Defaults to `false`*<br> **Datatype:** Boolean
| `exchange.unknown_fee_rate` | Fallback value to use when calculating trading fees. This can be useful for exchanges which have fees in non-tradable currencies. The value provided here will be multiplied with the "fee cost".<br>*Defaults to `None`<br> **Datatype:** float
| `exchange.log_responses` | Log relevant exchange responses. For debug mode only - use with care.<br>*Defaults to `false`*<br> **Datatype:** Boolean
| `exchange.only_from_ccxt` | Prevent data-download from data.binance.vision. Leaving this as false can greatly speed up downloads, but may be problematic if the site is not available.<br>*Defaults to `false`*<br> **Datatype:** Boolean
| `experimental.block_bad_exchanges` | Block exchanges known to not work with freqtrade. Leave on default unless you want to test if that exchange works now. <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| | **Plugins**
| `edge.*` | Please refer to [edge configuration document](edge.md) for detailed explanation of all possible configuration options.
@@ -323,6 +330,25 @@ To limit this calculation in case of large stoploss values, the calculated minim
!!! Warning
Since the limits on exchanges are usually stable and are not updated often, some pairs can show pretty high minimum limits, simply because the price increased a lot since the last limit adjustment by the exchange. Freqtrade adjusts the stake-amount to this value, unless it's > 30% more than the calculated/desired stake-amount - in which case the trade is rejected.
#### Dry-run wallet
When running in dry-run mode, the bot will use a simulated wallet to execute trades. The starting balance of this wallet is defined by `dry_run_wallet` (defaults to 1000).
For more complex scenarios, you can also assign a dictionary to `dry_run_wallet` to define the starting balance for each currency.
```json
"dry_run_wallet": {
"BTC": 0.01,
"ETH": 2,
"USDT": 1000
}
```
Command line options (`--dry-run-wallet`) can be used to override the configuration value, but only for the float value, not for the dictionary. If you'd like to use the dictionary, please adjust the configuration file.
!!! Note
Balances not in stake-currency will not be used for trading, but are shown as part of the wallet balance.
On Cross-margin exchanges, the wallet balance may be used to calculate the available collateral for trading.
#### Tradable balance
By default, the bot assumes that the `complete amount - 1%` is at it's disposal, and when using [dynamic stake amount](#dynamic-stake-amount), it will split the complete balance into `max_open_trades` buckets per trade.
+1 -1
View File
@@ -162,7 +162,7 @@ Freqtrade currently supports the following data-formats:
* `feather` - a dataformat based on Apache Arrow
* `json` - plain "text" json files
* `jsongz` - a gzip-zipped version of json files
* `hdf5` - a high performance datastore
* `hdf5` - a high performance datastore (deprecated)
* `parquet` - columnar datastore (OHLCV only)
By default, both OHLCV data and trades data are stored in the `feather` format.
+7 -1
View File
@@ -81,4 +81,10 @@ version 2023.3 saw the removal of `populate_any_indicators` in favor of split me
## Removal of `protections` from configuration
Setting protections from the configuration via `"protections": [],` has been removed in 2024.10, after having raised deprecation warnings for over 3 years.
Setting protections from the configuration via `"protections": [],` has been removed in 2024.10, after having raised deprecation warnings for over 3 years.
## hdf5 data storage
Using hdf5 as data storage has been deprecated in 2024.12 and will be removed in 2025.1. We recommend switching to the feather data format.
Please use the [`convert-data` subcommand](data-download.md#sub-command-convert-data) to convert your existing data to one of the supported formats.
+5 -2
View File
@@ -217,12 +217,12 @@ Kucoin supports [time_in_force](configuration.md#understand-order_time_in_force)
For Kucoin, it is suggested to add `"KCS/<STAKE>"` to your blacklist to avoid issues, unless you are willing to maintain enough extra `KCS` on the account or unless you're willing to disable using `KCS` for fees.
Kucoin accounts may use `KCS` for fees, and if a trade happens to be on `KCS`, further trades may consume this position and make the initial `KCS` trade unsellable as the expected amount is not there anymore.
## HTX (formerly Huobi)
## HTX
!!! Tip "Stoploss on Exchange"
HTX supports `stoploss_on_exchange` and uses `stop-limit` orders. It provides great advantages, so we recommend to benefit from it by enabling stoploss on exchange.
## OKX (former OKEX)
## OKX
OKX requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows:
@@ -236,6 +236,9 @@ OKX requires a passphrase for each api key, you will therefore need to add this
}
```
If you've registered with OKX on the host my.okx.com (OKX EAA)- you will need to use `"myokx"` as the exchange name.
Using the wrong exchange will result in the error "OKX Error 50119: API key doesn't exist" - as the 2 are separate entities.
!!! Warning
OKX only provides 100 candles per api call. Therefore, the strategy will only have a pretty low amount of data available in backtesting mode.
+8
View File
@@ -40,6 +40,10 @@ This could be caused by the following reasons:
* The installation did not complete successfully.
* Please check the [Installation documentation](installation.md).
### The bot starts, but in STOPPED mode
Make sure you set the `initial_state` config option to `"running"` in your config.json
### I have waited 5 minutes, why hasn't the bot made any trades yet?
* Depending on the buy strategy, the amount of whitelisted coins, the
@@ -129,6 +133,10 @@ This message is a warning that the candles had a price jump of > 30%.
This might be a sign that the pair stopped trading, and some token exchange took place (e.g. COCOS in 2021 - where price jumped from 0.0000154 to 0.01621).
This message is often accompanied by ["Missing data fillup"](#im-getting-missing-data-fillup-messages-in-the-log) - as trading on such pairs is often stopped for some time.
### I want to reset the bot's database
To reset the bot's database, you can either delete the database (by default `tradesv3.sqlite` or `tradesv3.dryrun.sqlite`), or use a different database url via `--db-url` (e.g. `sqlite:///mynewdatabase.sqlite`).
### I'm getting "Outdated history for pair xxx" in the log
The bot is trying to tell you that it got an outdated last candle (not the last complete candle).
+3 -2
View File
@@ -44,10 +44,11 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual,
- [X] [Bitmart](https://bitmart.com/)
- [X] [Bybit](https://bybit.com/)
- [X] [Gate.io](https://www.gate.io/ref/6266643)
- [X] [HTX](https://www.htx.com/) (Former Huobi)
- [X] [HTX](https://www.htx.com/)
- [X] [Hyperliquid](https://hyperliquid.xyz/) (A decentralized exchange, or DEX)
- [X] [Kraken](https://kraken.com/)
- [X] [OKX](https://okx.com/) (Former OKEX)
- [X] [OKX](https://okx.com/)
- [X] [MyOKX](https://okx.com/) (OKX EEA)
- [ ] [potentially many others through <img alt="ccxt" width="30px" src="assets/ccxt-logo.svg" />](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_
### Supported Futures Exchanges (experimental)
+2 -2
View File
@@ -1,7 +1,7 @@
markdown==3.7
mkdocs==1.6.1
mkdocs-material==9.5.45
mkdocs-material==9.5.49
mdx_truly_sane_lists==1.3
pymdown-extensions==10.12
jinja2==3.1.4
jinja2==3.1.5
mike==2.1.3
+67 -53
View File
@@ -88,8 +88,9 @@ Make sure that the following 2 lines are available in your docker-compose file:
### Consuming the API
You can consume the API by using `freqtrade-client` (also available as `scripts/rest_client.py`).
This command can be installed independent of the bot by using `pip install freqtrade-client`.
We advise consuming the API by using the supported `freqtrade-client` package (also available as `scripts/rest_client.py`).
This command can be installed independent of any running freqtrade bot by using `pip install freqtrade-client`.
This module is designed to be lightweight, and only depends on the `requests` and `python-rapidjson` modules, skipping all heavy dependencies freqtrade otherwise needs.
@@ -144,57 +145,6 @@ This method will work for all arguments - check the "show" command for a list of
For a full list of available commands, please refer to the list below.
### Available endpoints
| Command | Description |
|----------|-------------|
| `ping` | Simple command testing the API Readiness - requires no authentication.
| `start` | Starts the trader.
| `stop` | Stops the trader.
| `stopbuy` | Stops the trader from opening new trades. Gracefully closes open trades according to their rules.
| `reload_config` | Reloads the configuration file.
| `trades` | List last trades. Limited to 500 trades per call.
| `trade/<tradeid>` | Get specific trade.
| `trades/<tradeid>` | DELETE - Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange.
| `trades/<tradeid>/open-order` | DELETE - Cancel open order for this trade.
| `trades/<tradeid>/reload` | GET - Reload a trade from the Exchange. Only works in live, and can potentially help recover a trade that was manually sold on the exchange.
| `show_config` | Shows part of the current configuration with relevant settings to operation.
| `logs` | Shows last log messages.
| `status` | Lists all open trades.
| `count` | Displays number of trades used and available.
| `entries [pair]` | Shows profit statistics for each enter tags for given pair (or all pairs if pair isn't given). Pair is optional.
| `exits [pair]` | Shows profit statistics for each exit reasons for given pair (or all pairs if pair isn't given). Pair is optional.
| `mix_tags [pair]` | Shows profit statistics for each combinations of enter tag + exit reasons for given pair (or all pairs if pair isn't given). Pair is optional.
| `locks` | Displays currently locked pairs.
| `delete_lock <lock_id>` | Deletes (disables) the lock by id.
| `locks add <pair>, <until>, [side], [reason]` | Locks a pair until "until". (Until will be rounded up to the nearest timeframe).
| `profit` | Display a summary of your profit/loss from close trades and some stats about your performance.
| `forceexit <trade_id> [order_type] [amount]` | Instantly exits the given trade (ignoring `minimum_roi`), using the given order type ("market" or "limit", uses your config setting if not specified), and the chosen amount (full sell if not specified).
| `forceexit all` | Instantly exits all open trades (Ignoring `minimum_roi`).
| `forceenter <pair> [rate]` | Instantly enters the given pair. Rate is optional. (`force_entry_enable` must be set to True)
| `forceenter <pair> <side> [rate]` | Instantly longs or shorts the given pair. Rate is optional. (`force_entry_enable` must be set to True)
| `performance` | Show performance of each finished trade grouped by pair.
| `balance` | Show account balance per currency.
| `daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7).
| `weekly <n>` | Shows profit or loss per week, over the last n days (n defaults to 4).
| `monthly <n>` | Shows profit or loss per month, over the last n days (n defaults to 3).
| `stats` | Display a summary of profit / loss reasons as well as average holding times.
| `whitelist` | Show the current whitelist.
| `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist.
| `edge` | Show validated pairs by Edge if it is enabled.
| `pair_candles` | Returns dataframe for a pair / timeframe combination while the bot is running. **Alpha**
| `pair_history` | Returns an analyzed dataframe for a given timerange, analyzed by a given strategy. **Alpha**
| `plot_config` | Get plot config from the strategy (or nothing if not configured). **Alpha**
| `strategies` | List strategies in strategy directory. **Alpha**
| `strategy <strategy>` | Get specific Strategy content. **Alpha**
| `available_pairs` | List available backtest data. **Alpha**
| `version` | Show version.
| `sysinfo` | Show information about the system load.
| `health` | Show bot health (last bot loop).
!!! Warning "Alpha status"
Endpoints labeled with *Alpha status* above may change at any time without notice.
Possible commands can be listed from the rest-client script using the `help` command.
``` bash
@@ -266,6 +216,14 @@ forceexit
health
Provides a quick health check of the running bot.
lock_add
Manually lock a specific pair
:param pair: Pair to lock
:param until: Lock until this date (format "2024-03-30 16:00:00Z")
:param side: Side to lock (long, short, *)
:param reason: Reason for the lock
locks
Return current locks
@@ -353,6 +311,62 @@ whitelist
```
### Available endpoints
If you wish to call the REST API manually via another route, e.g. directly via `curl`, the table below shows the relevant URL endpoints and parameters.
All endpoints in the below table need to be prefixed with the base URL of the API, e.g. `http://127.0.0.1:8080/api/v1/` - so the command becomes `http://127.0.0.1:8080/api/v1/<command>`.
| Endpoint | Method | Description / Parameters |
|-----------|--------|--------------------------|
| `/ping` | GET | Simple command testing the API Readiness - requires no authentication.
| `/start` | POST | Starts the trader.
| `/stop` | POST | Stops the trader.
| `/stopbuy` | POST | Stops the trader from opening new trades. Gracefully closes open trades according to their rules.
| `/reload_config` | POST | Reloads the configuration file.
| `/trades` | GET | List last trades. Limited to 500 trades per call.
| `/trade/<tradeid>` | GET | Get specific trade.<br/>*Params:*<br/>- `tradeid` (`int`)
| `/trades/<tradeid>` | DELETE | Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange.<br/>*Params:*<br/>- `tradeid` (`int`)
| `/trades/<tradeid>/open-order` | DELETE | Cancel open order for this trade.<br/>*Params:*<br/>- `tradeid` (`int`)
| `/trades/<tradeid>/reload` | POST | Reload a trade from the Exchange. Only works in live, and can potentially help recover a trade that was manually sold on the exchange.<br/>*Params:*<br/>- `tradeid` (`int`)
| `/show_config` | GET | Shows part of the current configuration with relevant settings to operation.
| `/logs` | GET | Shows last log messages.
| `/status` | GET | Lists all open trades.
| `/count` | GET | Displays number of trades used and available.
| `/entries` | GET | Shows profit statistics for each enter tags for given pair (or all pairs if pair isn't given). Pair is optional.<br/>*Params:*<br/>- `pair` (`str`)
| `/exits` | GET | Shows profit statistics for each exit reasons for given pair (or all pairs if pair isn't given). Pair is optional.<br/>*Params:*<br/>- `pair` (`str`)
| `/mix_tags` | GET | Shows profit statistics for each combinations of enter tag + exit reasons for given pair (or all pairs if pair isn't given). Pair is optional.<br/>*Params:*<br/>- `pair` (`str`)
| `/locks` | GET | Displays currently locked pairs.
| `/locks` | POST | Locks a pair until "until". (Until will be rounded up to the nearest timeframe). Side is optional and is either `long` or `short` (default is `long`). Reason is optional.<br/>*Params:*<br/>- `<pair>` (`str`)<br/>- `<until>` (`datetime`)<br/>- `[side]` (`str`)<br/>- `[reason]` (`str`)
| `/locks/<lockid>` | DELETE | Deletes (disables) the lock by id.<br/>*Params:*<br/>- `lockid` (`int`)
| `/profit` | GET | Display a summary of your profit/loss from close trades and some stats about your performance.
| `/forceexit` | POST | Instantly exits the given trade (ignoring `minimum_roi`), using the given order type ("market" or "limit", uses your config setting if not specified), and the chosen amount (full sell if not specified). If `all` is supplied as the `tradeid`, then all currently open trades will be forced to exit.<br/>*Params:*<br/>- `<tradeid>` (`int` or `str`)<br/>- `<ordertype>` (`str`)<br/>- `[amount]` (`float`)
| `/forceenter` | POST | Instantly enters the given pair. Side is optional and is either `long` or `short` (default is `long`). Rate is optional. (`force_entry_enable` must be set to True)<br/>*Params:*<br/>- `<pair>` (`str`)<br/>- `<side>` (`str`)<br/>- `[rate]` (`float`)
| `/performance` | GET | Show performance of each finished trade grouped by pair.
| `/balance` | GET | Show account balance per currency.
| `/daily` | GET | Shows profit or loss per day, over the last n days (n defaults to 7).<br/>*Params:*<br/>- `<n>` (`int`)
| `/weekly` | GET | Shows profit or loss per week, over the last n days (n defaults to 4).<br/>*Params:*<br/>- `<n>` (`int`)
| `/monthly` | GET | Shows profit or loss per month, over the last n days (n defaults to 3).<br/>*Params:*<br/>- `<n>` (`int`)
| `/stats` | GET | Display a summary of profit / loss reasons as well as average holding times.
| `/whitelist` | GET | Show the current whitelist.
| `/blacklist` | GET | Show the current blacklist.
| `/blacklist` | POST | Adds the specified pair to the blacklist.<br/>*Params:*<br/>- `pair` (`str`)
| `/blacklist` | DELETE | Deletes the specified list of pairs from the blacklist.<br/>*Params:*<br/>- `[pair,pair]` (`list[str]`)
| `/edge` | GET | Show validated pairs by Edge if it is enabled.
| `/pair_candles` | GET | Returns dataframe for a pair / timeframe combination while the bot is running. **Alpha**
| `/pair_candles` | POST | Returns dataframe for a pair / timeframe combination while the bot is running, filtered by a provided list of columns to return. **Alpha**<br/>*Params:*<br/>- `<column_list>` (`list[str]`)
| `/pair_history` | GET | Returns an analyzed dataframe for a given timerange, analyzed by a given strategy. **Alpha**
| `/pair_history` | POST | Returns an analyzed dataframe for a given timerange, analyzed by a given strategy, filtered by a provided list of columns to return. **Alpha**<br/>*Params:*<br/>- `<column_list>` (`list[str]`)
| `/plot_config` | GET | Get plot config from the strategy (or nothing if not configured). **Alpha**
| `/strategies` | GET | List strategies in strategy directory. **Alpha**
| `/strategy/<strategy>` | GET | Get specific Strategy content by strategy class name. **Alpha**<br/>*Params:*<br/>- `<strategy>` (`str`)
| `/available_pairs` | GET | List available backtest data. **Alpha**
| `/version` | GET | Show version.
| `/sysinfo` | GET | Show information about the system load.
| `/health` | GET | Show bot health (last bot loop).
!!! Warning "Alpha status"
Endpoints labeled with *Alpha status* above may change at any time without notice.
### Message WebSocket
The API Server includes a websocket endpoint for subscribing to RPC messages from the freqtrade Bot.
+2 -2
View File
@@ -30,8 +30,8 @@ The Order-type will be ignored if only one mode is available.
|----------|-------------|
| Binance | limit |
| Binance Futures | market, limit |
| Bingx | market, limit |
| HTX (former Huobi) | limit |
| Bingx | market, limit |
| HTX | limit |
| kraken | market, limit |
| Gate | limit |
| Okx | limit |
+19 -8
View File
@@ -767,6 +767,15 @@ Adjustment orders can be assigned with a tag by returning a 2 element Tuple, wit
Modifications to leverage are not possible, and the stake-amount returned is assumed to be before applying leverage.
!!! Danger "Loose Logic"
On dry and live run, this function will be called every `throttle_process_secs` (default to 5s). If you have a loose logic, for example your logic for extra entry is only to check RSI of last candle is below 30, then when such condition fulfilled, your bot will do extra re-entry every 5 secs until either it run out of money, it hit the `max_position_adjustment` limit, or a new candle with RSI more than 30 arrived.
Same thing also can happen with partial exit. So be sure to have a strict logic and/or check for the last filled order.
!!! Warning "Backtesting"
During backtesting this callback is called for each candle in `timeframe` or `timeframe_detail`, so run-time performance will be affected.
This can also cause deviating results between live and backtesting, since backtesting can adjust the trade only once per candle, whereas live could adjust the trade multiple times per candle.
### Increase position
The strategy is expected to return a positive **stake_amount** (in stake currency) between `min_stake` and `max_stake` if and when an additional entry order should be made (position is increased -> buy order for long trades, sell order for short trades).
@@ -776,16 +785,22 @@ If there are not enough funds in the wallet (the return value is above `max_stak
Additional entries are ignored once you have reached the maximum amount of extra entries that you have set on `max_entry_position_adjustment`, but the callback is called anyway looking for partial exits.
!!! Note "About stake size"
Using fixed stake size means it will be the amount used for the first order, just like without position adjustment.
If you wish to buy additional orders with DCA, then make sure to leave enough funds in the wallet for that.
Using `"unlimited"` stake amount with DCA orders requires you to also implement the `custom_stake_amount()` callback to avoid allocating all funds to the initial order.
### Decrease position
The strategy is expected to return a negative stake_amount (in stake currency) for a partial exit.
Returning the full owned stake at that point (`-trade.stake_amount`) results in a full exit.
Returning a value more than the above (so remaining stake_amount would become negative) will result in the bot ignoring the signal.
!!! Note "About stake size"
Using fixed stake size means it will be the amount used for the first order, just like without position adjustment.
If you wish to buy additional orders with DCA, then make sure to leave enough funds in the wallet for that.
Using `"unlimited"` stake amount with DCA orders requires you to also implement the `custom_stake_amount()` callback to avoid allocating all funds to the initial order.
For a partial exit, it's important to know that the formula used to calculate the amount of the coin for the partial exit order is `amount to be exited partially = negative_stake_amount * trade.amount / trade.stake_amount`, where `negative_stake_amount` is the value returned from the `adjust_trade_position` function. As seen in the formula, the formula doesn't care about current profit/loss of the position. It only cares about `trade.amount` and `trade.stake_amount` which aren't affected by the price movement at all.
For example, let's say you buy 2 SHITCOIN/USDT at open rate of 50, which means the trade's stake amount is 100 USDT. Now the price raises to 200 and you want to sell half of it. In that case, you have to return -50% of `trade.stake_amount` (0.5 * 100 USDT) which equals to -50. The bot will calculate the amount it needed to sell, which is `50 * 2 / 100` which equals 1 SHITCOIN/USDT. If you return -200 (50% of 2 * 200), the bot will ignore it since `trade.stake_amount` is only 100 USDT but you asked to sell 200 USDT which means you are asking to sell 4 SHITCOIN/USDT.
Back to the example above, since current rate is 200, the current USDT value of your trade is now 400 USDT. Let's say you want to partially sell 100 USDT to take out the initial investment and leave the profit in the trade hoping that the price keeps rising. In that case, you have to do a different approach. First, you need to calculate the exact amount you needed to sell. In this case, since you want to sell 100 USDT worth based of current rate, the exact amount you need to partially sell is `100 * 2 / 400` which equals 0.5 SHITCOIN/USDT. Since we know now the exact amount we want to sell (0.5), the value you need to return in the `adjust_trade_position` function is `-amount to be exited partially * trade.stake_amount / trade.amount`, which equals -25. The bot will sell 0.5 SHITCOIN/USDT, keeping 1.5 in trade. You will receive 100 USDT from the partial exit.
!!! Warning "Stoploss calculation"
Stoploss is still calculated from the initial opening price, not averaged price.
@@ -793,10 +808,6 @@ Returning a value more than the above (so remaining stake_amount would become ne
While `/stopentry` command stops the bot from entering new trades, the position adjustment feature will continue buying new orders on existing trades.
!!! Warning "Backtesting"
During backtesting this callback is called for each candle in `timeframe` or `timeframe_detail`, so run-time performance will be affected.
This can also cause deviating results between live and backtesting, since backtesting can adjust the trade only once per candle, whereas live could adjust the trade multiple times per candle.
!!! Warning "Performance with many position adjustments"
Position adjustments can be a good approach to increase a strategy's output - but it can also have drawbacks if using this feature extensively.
Each of the orders will be attached to the trade object for the duration of the trade - hence increasing memory usage.
+1 -1
View File
@@ -152,7 +152,7 @@ print(stats["strategy"][strategy]["pairlist"])
# Get market change (average change of all pairs from start to end of the backtest period)
print(stats["strategy"][strategy]["market_change"])
# Maximum drawdown ()
print(stats["strategy"][strategy]["max_drawdown"])
print(stats["strategy"][strategy]["max_drawdown_abs"])
# Maximum drawdown start and end
print(stats["strategy"][strategy]["drawdown_start"])
print(stats["strategy"][strategy]["drawdown_end"])
+4
View File
@@ -15,3 +15,7 @@
.md-version__list {
font-weight: 500 !important;
}
#available-endpoints ~ .md-typeset__scrollwrap .md-typeset__table th:first-of-type {
width: 35% !important;
}
+23 -4
View File
@@ -45,15 +45,22 @@ Get your "Id", you will use it for the config parameter `chat_id`.
#### Use Group id
You can use bots in telegram groups by just adding them to the group. You can find the group id by first adding a [RawDataBot](https://telegram.me/rawdatabot) to your group. The Group id is shown as id in the `"chat"` section, which the RawDataBot will send to you:
To get the group ID, you can add the bot to the group, start freqtrade, and issue a `/tg_info` command.
This will return the group id to you, without having to use some random bot.
While "chat_id" is still required, it doesn't need to be set to this particular group id for this command.
The response will also contain the "topic_id" if necessary - both in a format ready to copy/paste into your configuration.
``` json
"chat":{
"id":-1001332619709
{
"enabled": true,
"token": "********",
"chat_id": "-1001332619709",
"topic_id": "122"
}
```
For the Freqtrade configuration, you can then use the full value (including `-` if it's there) as string:
For the Freqtrade configuration, you can then use the full value (including `-` ) as string:
```json
"chat_id": "-1001332619709"
@@ -62,6 +69,18 @@ For the Freqtrade configuration, you can then use the full value (including `-`
!!! Warning "Using telegram groups"
When using telegram groups, you're giving every member of the telegram group access to your freqtrade bot and to all commands possible via telegram. Please make sure that you can trust everyone in the telegram group to avoid unpleasant surprises.
##### Group Topic ID
To use a specific topic in a group, you can use the `topic_id` parameter in the configuration. This will allow you to use the bot in a specific topic in a group.
Without this, the bot will always respond to the general channel in the group if topics are enabled for a group chat.
```json
"chat_id": "-1001332619709",
"topic_id": "3"
```
Similar to the group-id - you can use `/tg_info` from the topic/thread to get the correct topic-id.
## Control telegram noise
Freqtrade provides means to control the verbosity of your telegram bot.
+1 -1
View File
@@ -1,6 +1,6 @@
"""Freqtrade bot"""
__version__ = "2024.11"
__version__ = "2024.12"
if "dev" in __version__:
from pathlib import Path
+2 -2
View File
@@ -17,7 +17,7 @@ def setup_optimize_configuration(args: dict[str, Any], method: RunMode) -> dict[
:return: Configuration
"""
from freqtrade.configuration import setup_utils_configuration
from freqtrade.util import fmt_coin
from freqtrade.util import fmt_coin, get_dry_run_wallet
config = setup_utils_configuration(args, method)
@@ -26,7 +26,7 @@ def setup_optimize_configuration(args: dict[str, Any], method: RunMode) -> dict[
RunMode.HYPEROPT: "hyperoptimization",
}
if method in no_unlimited_runmodes.keys():
wallet_size = config["dry_run_wallet"] * config["tradable_balance_ratio"]
wallet_size = get_dry_run_wallet(config) * config["tradable_balance_ratio"]
# tradable_balance_ratio
if (
config["stake_amount"] != constants.UNLIMITED_STAKE_AMOUNT
+8 -2
View File
@@ -85,8 +85,10 @@ CONF_SCHEMA = {
},
"dry_run_wallet": {
"description": "Initial wallet balance for dry run mode.",
"type": "number",
"type": ["number", "object"],
"default": DRY_RUN_WALLET,
"patternProperties": {r"^[a-zA-Z0-9]+$": {"type": "number"}},
"additionalProperties": False,
},
"cancel_open_orders_on_exit": {
"description": "Cancel open orders when exiting.",
@@ -458,7 +460,11 @@ CONF_SCHEMA = {
},
"token": {"description": "Telegram bot token.", "type": "string"},
"chat_id": {
"description": "Telegram chat ID",
"description": "Telegram chat or group ID",
"type": "string",
},
"topic_id": {
"description": "Telegram topic ID - only applicable for group chats",
"type": "string",
},
"allow_custom_messages": {
@@ -2,6 +2,8 @@ import logging
import os
from typing import Any
import rapidjson
from freqtrade.constants import ENV_VAR_PREFIX
from freqtrade.misc import deep_merge_dicts
@@ -20,6 +22,14 @@ def _get_var_typed(val):
return True
elif val.lower() in ("f", "false"):
return False
# try to convert from json
try:
value = rapidjson.loads(val)
# Limited to lists for now
if isinstance(value, list):
return value
except rapidjson.JSONDecodeError:
pass
# keep as string
return val
+95 -102
View File
@@ -4,20 +4,31 @@ Functions to convert orderflow data from public_trades
import logging
import time
import typing
from collections import OrderedDict
from datetime import datetime
import numpy as np
import pandas as pd
from freqtrade.constants import DEFAULT_ORDERFLOW_COLUMNS, Config
from freqtrade.enums import RunMode
from freqtrade.exceptions import DependencyException
logger = logging.getLogger(__name__)
ORDERFLOW_ADDED_COLUMNS = [
"trades",
"orderflow",
"imbalances",
"stacked_imbalances_bid",
"stacked_imbalances_ask",
"max_delta",
"min_delta",
"bid",
"ask",
"delta",
"total_trades",
]
def _init_dataframe_with_trades_columns(dataframe: pd.DataFrame):
"""
@@ -25,53 +36,73 @@ def _init_dataframe_with_trades_columns(dataframe: pd.DataFrame):
:param dataframe: Dataframe to populate
"""
# Initialize columns with appropriate dtypes
dataframe["trades"] = np.nan
dataframe["orderflow"] = np.nan
dataframe["imbalances"] = np.nan
dataframe["stacked_imbalances_bid"] = np.nan
dataframe["stacked_imbalances_ask"] = np.nan
dataframe["max_delta"] = np.nan
dataframe["min_delta"] = np.nan
dataframe["bid"] = np.nan
dataframe["ask"] = np.nan
dataframe["delta"] = np.nan
dataframe["total_trades"] = np.nan
for column in ORDERFLOW_ADDED_COLUMNS:
dataframe[column] = np.nan
# Ensure the 'trades' column is of object type
dataframe["trades"] = dataframe["trades"].astype(object)
dataframe["orderflow"] = dataframe["orderflow"].astype(object)
dataframe["imbalances"] = dataframe["imbalances"].astype(object)
dataframe["stacked_imbalances_bid"] = dataframe["stacked_imbalances_bid"].astype(object)
dataframe["stacked_imbalances_ask"] = dataframe["stacked_imbalances_ask"].astype(object)
# Set columns to object type
for column in (
"trades",
"orderflow",
"imbalances",
"stacked_imbalances_bid",
"stacked_imbalances_ask",
):
dataframe[column] = dataframe[column].astype(object)
def timeframe_to_DateOffset(timeframe: str) -> pd.DateOffset:
"""
Translates the timeframe interval value written in the human readable
form ('1m', '5m', '1h', '1d', '1w', etc.) to the number
of seconds for one timeframe interval.
"""
from freqtrade.exchange import timeframe_to_seconds
timeframe_seconds = timeframe_to_seconds(timeframe)
timeframe_minutes = timeframe_seconds // 60
if timeframe_minutes < 1:
return pd.DateOffset(seconds=timeframe_seconds)
elif 59 < timeframe_minutes < 1440:
return pd.DateOffset(hours=timeframe_minutes // 60)
elif 1440 <= timeframe_minutes < 10080:
return pd.DateOffset(days=timeframe_minutes // 1440)
elif 10000 < timeframe_minutes < 43200:
return pd.DateOffset(weeks=1)
elif timeframe_minutes >= 43200 and timeframe_minutes < 525600:
return pd.DateOffset(months=1)
elif timeframe == "1y":
return pd.DateOffset(years=1)
else:
return pd.DateOffset(minutes=timeframe_minutes)
def _calculate_ohlcv_candle_start_and_end(df: pd.DataFrame, timeframe: str):
from freqtrade.exchange import timeframe_to_next_date, timeframe_to_resample_freq
from freqtrade.exchange import timeframe_to_resample_freq
timeframe_frequency = timeframe_to_resample_freq(timeframe)
# calculate ohlcv candle start and end
if df is not None and not df.empty:
timeframe_frequency = timeframe_to_resample_freq(timeframe)
dofs = timeframe_to_DateOffset(timeframe)
# calculate ohlcv candle start and end
df["datetime"] = pd.to_datetime(df["date"], unit="ms")
df["candle_start"] = df["datetime"].dt.floor(timeframe_frequency)
# used in _now_is_time_to_refresh_trades
df["candle_end"] = df["candle_start"].apply(
lambda candle_start: timeframe_to_next_date(timeframe, candle_start)
)
df["candle_end"] = df["candle_start"] + dofs
df.drop(columns=["datetime"], inplace=True)
def populate_dataframe_with_trades(
cached_grouped_trades: OrderedDict[tuple[datetime, datetime], pd.DataFrame],
cached_grouped_trades: pd.DataFrame | None,
config: Config,
dataframe: pd.DataFrame,
trades: pd.DataFrame,
) -> tuple[pd.DataFrame, OrderedDict[tuple[datetime, datetime], pd.DataFrame]]:
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Populates a dataframe with trades
:param dataframe: Dataframe to populate
:param trades: Trades to populate with
:return: Dataframe with trades populated
"""
timeframe = config["timeframe"]
config_orderflow = config["orderflow"]
@@ -94,71 +125,52 @@ def populate_dataframe_with_trades(
# group trades by candle start
trades_grouped_by_candle_start = trades.groupby("candle_start", group_keys=False)
# Create Series to hold complex data
trades_series = pd.Series(index=dataframe.index, dtype=object)
orderflow_series = pd.Series(index=dataframe.index, dtype=object)
imbalances_series = pd.Series(index=dataframe.index, dtype=object)
stacked_imbalances_bid_series = pd.Series(index=dataframe.index, dtype=object)
stacked_imbalances_ask_series = pd.Series(index=dataframe.index, dtype=object)
trades_grouped_by_candle_start = trades.groupby("candle_start", group_keys=False)
candle_start: datetime
for candle_start, trades_grouped_df in trades_grouped_by_candle_start:
is_between = candle_start == dataframe["date"]
if is_between.any():
from freqtrade.exchange import timeframe_to_next_date
# there can only be one row with the same date
index = dataframe.index[is_between][0]
candle_next = timeframe_to_next_date(timeframe, typing.cast(datetime, candle_start))
if candle_next not in trades_grouped_by_candle_start.groups:
logger.warning(
f"candle at {candle_start} with {len(trades_grouped_df)} trades "
f"might be unfinished, because no finished trades at {candle_next}"
)
indices = dataframe.index[is_between].tolist()
# Add trades to each candle
trades_series.loc[indices] = [
trades_grouped_df.drop(columns=["candle_start", "candle_end"]).to_dict(
orient="records"
)
]
# Use caching mechanism
if (candle_start, candle_next) in cached_grouped_trades:
cache_entry = cached_grouped_trades[
(typing.cast(datetime, candle_start), candle_next)
]
# dataframe.loc[is_between] = cache_entry # doesn't take, so we need workaround:
# Create a dictionary of the column values to be assigned
update_dict = {c: cache_entry[c].iat[0] for c in cache_entry.columns}
# Assign the values using the update_dict
dataframe.loc[is_between, update_dict.keys()] = pd.DataFrame(
[update_dict], index=dataframe.loc[is_between].index
)
if (
cached_grouped_trades is not None
and (candle_start == cached_grouped_trades["date"]).any()
):
# Check if the trades are already in the cache
cache_idx = cached_grouped_trades.index[
cached_grouped_trades["date"] == candle_start
][0]
for col in ORDERFLOW_ADDED_COLUMNS:
dataframe.at[index, col] = cached_grouped_trades.at[cache_idx, col]
continue
dataframe.at[index, "trades"] = trades_grouped_df.drop(
columns=["candle_start", "candle_end"]
).to_dict(orient="records")
# Calculate orderflow for each candle
orderflow = trades_to_volumeprofile_with_total_delta_bid_ask(
trades_grouped_df, scale=config_orderflow["scale"]
)
orderflow_series.loc[indices] = [orderflow.to_dict(orient="index")]
dataframe.at[index, "orderflow"] = orderflow.to_dict(orient="index")
# orderflow_series.loc[[index]] = [orderflow.to_dict(orient="index")]
# Calculate imbalances for each candle's orderflow
imbalances = trades_orderflow_to_imbalances(
orderflow,
imbalance_ratio=config_orderflow["imbalance_ratio"],
imbalance_volume=config_orderflow["imbalance_volume"],
)
imbalances_series.loc[indices] = [imbalances.to_dict(orient="index")]
dataframe.at[index, "imbalances"] = imbalances.to_dict(orient="index")
stacked_imbalance_range = config_orderflow["stacked_imbalance_range"]
stacked_imbalances_bid_series.loc[indices] = [
stacked_imbalance_bid(
imbalances, stacked_imbalance_range=stacked_imbalance_range
)
]
stacked_imbalances_ask_series.loc[indices] = [
stacked_imbalance_ask(
imbalances, stacked_imbalance_range=stacked_imbalance_range
)
]
dataframe.at[index, "stacked_imbalances_bid"] = stacked_imbalance_bid(
imbalances, stacked_imbalance_range=stacked_imbalance_range
)
dataframe.at[index, "stacked_imbalances_ask"] = stacked_imbalance_ask(
imbalances, stacked_imbalance_range=stacked_imbalance_range
)
bid = np.where(
trades_grouped_df["side"].str.contains("sell"), trades_grouped_df["amount"], 0
@@ -168,39 +180,20 @@ def populate_dataframe_with_trades(
trades_grouped_df["side"].str.contains("buy"), trades_grouped_df["amount"], 0
)
deltas_per_trade = ask - bid
min_delta = deltas_per_trade.cumsum().min()
max_delta = deltas_per_trade.cumsum().max()
dataframe.loc[indices, "max_delta"] = max_delta
dataframe.loc[indices, "min_delta"] = min_delta
dataframe.at[index, "max_delta"] = deltas_per_trade.cumsum().max()
dataframe.at[index, "min_delta"] = deltas_per_trade.cumsum().min()
dataframe.loc[indices, "bid"] = bid.sum()
dataframe.loc[indices, "ask"] = ask.sum()
dataframe.loc[indices, "delta"] = (
dataframe.loc[indices, "ask"] - dataframe.loc[indices, "bid"]
dataframe.at[index, "bid"] = bid.sum()
dataframe.at[index, "ask"] = ask.sum()
dataframe.at[index, "delta"] = (
dataframe.at[index, "ask"] - dataframe.at[index, "bid"]
)
dataframe.loc[indices, "total_trades"] = len(trades_grouped_df)
dataframe.at[index, "total_trades"] = len(trades_grouped_df)
# Cache the result
cached_grouped_trades[(typing.cast(datetime, candle_start), candle_next)] = (
dataframe.loc[is_between].copy()
)
# Maintain cache size
if (
config.get("runmode") in (RunMode.DRY_RUN, RunMode.LIVE)
and len(cached_grouped_trades) > config_orderflow["cache_size"]
):
cached_grouped_trades.popitem(last=False)
else:
logger.debug(f"Found NO candles for trades starting with {candle_start}")
logger.debug(f"trades.groups_keys in {time.time() - start_time} seconds")
# Merge the complex data Series back into the DataFrame
dataframe["trades"] = trades_series
dataframe["orderflow"] = orderflow_series
dataframe["imbalances"] = imbalances_series
dataframe["stacked_imbalances_bid"] = stacked_imbalances_bid_series
dataframe["stacked_imbalances_ask"] = stacked_imbalances_ask_series
# Cache the entire dataframe
cached_grouped_trades = dataframe.tail(config_orderflow["cache_size"]).copy()
except Exception as e:
logger.exception("Error populating dataframe with trades")
@@ -551,6 +551,13 @@ def get_datahandlerclass(datatype: str) -> type[IDataHandler]:
elif datatype == "hdf5":
from .hdf5datahandler import HDF5DataHandler
logger.warning(
"DEPRECATED: The hdf5 dataformat is deprecated and will be removed in the "
"next release. "
"Please use the convert-data command to convert your data to a supported format."
"We recommend using the feather format, as it is faster and is more space-efficient."
)
return HDF5DataHandler
elif datatype == "feather":
from .featherdatahandler import FeatherDataHandler
+2 -1
View File
@@ -285,6 +285,7 @@ def _download_pair_history(
candle_type=candle_type,
until_ms=until_ms if until_ms else None,
)
logger.info(f"Downloaded data for {pair} with length {len(new_dataframe)}.")
if data.empty:
data = new_dataframe
else:
@@ -603,7 +604,7 @@ def download_data(
Download data function. Used from both cli and API.
"""
timerange = TimeRange()
if "days" in config:
if "days" in config and config["days"] is not None:
time_since = (datetime.now() - timedelta(days=config["days"])).strftime("%Y%m%d")
timerange = TimeRange.parse_timerange(f"{time_since}-")
+103 -16
View File
@@ -5,13 +5,18 @@ from datetime import datetime, timezone
from pathlib import Path
import ccxt
from pandas import DataFrame
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
from freqtrade.enums import CandleType, MarginMode, PriceType, TradingMode
from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError
from freqtrade.exchange import Exchange
from freqtrade.exchange.binance_public_data import concat_safe, download_archive_ohlcv
from freqtrade.exchange.common import retrier
from freqtrade.exchange.exchange_types import FtHas, OHLCVResponse, Tickers
from freqtrade.exchange.exchange_types import FtHas, Tickers
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
from freqtrade.misc import deep_merge_dicts, json_load
from freqtrade.util.datetime_helpers import dt_from_ts, dt_ts
logger = logging.getLogger(__name__)
@@ -52,8 +57,14 @@ class Binance(Exchange):
(TradingMode.FUTURES, MarginMode.ISOLATED)
]
def get_tickers(self, symbols: list[str] | None = None, *, cached: bool = False) -> Tickers:
tickers = super().get_tickers(symbols=symbols, cached=cached)
def get_tickers(
self,
symbols: list[str] | None = None,
*,
cached: bool = False,
market_type: TradingMode | None = None,
) -> Tickers:
tickers = super().get_tickers(symbols=symbols, cached=cached, market_type=market_type)
if self.trading_mode == TradingMode.FUTURES:
# Binance's future result has no bid/ask values.
# Therefore we must fetch that from fetch_bids_asks and combine the two results.
@@ -80,7 +91,10 @@ class Binance(Exchange):
"\nHedge Mode is not supported by freqtrade. "
"Please change 'Position Mode' on your binance futures account."
)
if assets_margin.get("multiAssetsMargin") is True:
if (
assets_margin.get("multiAssetsMargin") is True
and self.margin_mode != MarginMode.CROSS
):
msg += (
"\nMulti-Asset Mode is not supported by freqtrade. "
"Please change 'Asset Mode' on your binance futures account."
@@ -97,23 +111,24 @@ class Binance(Exchange):
except ccxt.BaseError as e:
raise OperationalException(e) from e
async def _async_get_historic_ohlcv(
def get_historic_ohlcv(
self,
pair: str,
timeframe: str,
since_ms: int,
candle_type: CandleType,
is_new_pair: bool = False,
raise_: bool = False,
until_ms: int | None = None,
) -> OHLCVResponse:
) -> DataFrame:
"""
Overwrite to introduce "fast new pair" functionality by detecting the pair's listing date
Does not work for other exchanges, which don't return the earliest data when called with "0"
:param candle_type: Any of the enum CandleType (must match trading mode!)
"""
if is_new_pair:
x = await self._async_get_candle_history(pair, timeframe, candle_type, 0)
x = self.loop.run_until_complete(
self._async_get_candle_history(pair, timeframe, candle_type, 0)
)
if x and x[3] and x[3][0] and x[3][0][0] > since_ms:
# Set starting date to first available candle.
since_ms = x[3][0][0]
@@ -121,17 +136,89 @@ class Binance(Exchange):
f"Candle-data for {pair} available starting with "
f"{datetime.fromtimestamp(since_ms // 1000, tz=timezone.utc).isoformat()}."
)
if until_ms and since_ms >= until_ms:
logger.warning(
f"No available candle-data for {pair} before "
f"{dt_from_ts(until_ms).isoformat()}"
)
return DataFrame(columns=DEFAULT_DATAFRAME_COLUMNS)
return await super()._async_get_historic_ohlcv(
pair=pair,
timeframe=timeframe,
since_ms=since_ms,
is_new_pair=is_new_pair,
raise_=raise_,
candle_type=candle_type,
until_ms=until_ms,
if (
self._config["exchange"].get("only_from_ccxt", False)
or
# only download timeframes with significant improvements,
# otherwise fall back to rest API
not (
(candle_type == CandleType.SPOT and timeframe in ["1s", "1m", "3m", "5m"])
or (
candle_type == CandleType.FUTURES
and timeframe in ["1m", "3m", "5m", "15m", "30m"]
)
)
):
return super().get_historic_ohlcv(
pair=pair,
timeframe=timeframe,
since_ms=since_ms,
candle_type=candle_type,
is_new_pair=is_new_pair,
until_ms=until_ms,
)
else:
# Download from data.binance.vision
return self.get_historic_ohlcv_fast(
pair=pair,
timeframe=timeframe,
since_ms=since_ms,
candle_type=candle_type,
is_new_pair=is_new_pair,
until_ms=until_ms,
)
def get_historic_ohlcv_fast(
self,
pair: str,
timeframe: str,
since_ms: int,
candle_type: CandleType,
is_new_pair: bool = False,
until_ms: int | None = None,
) -> DataFrame:
"""
Fastly fetch OHLCV data by leveraging https://data.binance.vision.
"""
df = self.loop.run_until_complete(
download_archive_ohlcv(
candle_type=candle_type,
pair=pair,
timeframe=timeframe,
since_ms=since_ms,
until_ms=until_ms,
markets=self.markets,
)
)
# download the remaining data from rest API
if df.empty:
rest_since_ms = since_ms
else:
rest_since_ms = dt_ts(df.iloc[-1].date) + timeframe_to_msecs(timeframe)
# make sure since <= until
if until_ms and rest_since_ms > until_ms:
rest_df = DataFrame()
else:
rest_df = super().get_historic_ohlcv(
pair=pair,
timeframe=timeframe,
since_ms=rest_since_ms,
candle_type=candle_type,
is_new_pair=is_new_pair,
until_ms=until_ms,
)
all_df = concat_safe([df, rest_df])
return all_df
def funding_fee_cutoff(self, open_date: datetime):
"""
Funding fees are only charged at full hours (usually every 4-8h).
File diff suppressed because it is too large Load Diff
+281
View File
@@ -0,0 +1,281 @@
"""
Fetch daily-archived OHLCV data from https://data.binance.vision/
"""
import asyncio
import logging
import zipfile
from datetime import date, timedelta
from io import BytesIO
from typing import Any
import aiohttp
import pandas as pd
from pandas import DataFrame
from freqtrade.enums import CandleType
from freqtrade.misc import chunks
from freqtrade.util.datetime_helpers import dt_from_ts, dt_now
logger = logging.getLogger(__name__)
class Http404(Exception):
def __init__(self, msg, date, url):
super().__init__(msg)
self.date = date
self.url = url
class BadHttpStatus(Exception):
"""Not 200/404"""
pass
async def download_archive_ohlcv(
candle_type: CandleType,
pair: str,
timeframe: str,
*,
since_ms: int,
until_ms: int | None,
markets: dict[str, Any],
stop_on_404: bool = True,
) -> DataFrame:
"""
Fetch OHLCV data from https://data.binance.vision
The function makes its best effort to download data within the time range
[`since_ms`, `until_ms`] -- including `since_ms`, but excluding `until_ms`.
If `stop_one_404` is True, this returned DataFrame is guaranteed to start from `since_ms`
with no gaps in the data.
:candle_type: Currently only spot and futures are supported
:pair: symbol name in CCXT convention
:since_ms: the start timestamp of data, including itself
:until_ms: the end timestamp of data, excluding itself
:param until_ms: `None` indicates the timestamp of the latest available data
:markets: the CCXT markets dict, when it's None, the function will load the markets data
from a new `ccxt.binance` instance
:param stop_on_404: Stop to download the following data when a 404 returned
:return: the date range is between [since_ms, until_ms), return an empty DataFrame if no data
available in the time range
"""
try:
if candle_type == CandleType.SPOT:
asset_type_url_segment = "spot"
elif candle_type == CandleType.FUTURES:
asset_type_url_segment = "futures/um"
else:
raise ValueError(f"Unsupported CandleType: {candle_type}")
symbol = markets[pair]["id"]
start = dt_from_ts(since_ms)
end = dt_from_ts(until_ms) if until_ms else dt_now()
# We use two days ago as the last available day because the daily archives are daily
# uploaded and have several hours delay
last_available_date = dt_now() - timedelta(days=2)
end = min(end, last_available_date)
if start >= end:
return DataFrame()
df = await _download_archive_ohlcv(
asset_type_url_segment, symbol, pair, timeframe, start, end, stop_on_404
)
logger.debug(
f"Downloaded data for {pair} from https://data.binance.vision with length {len(df)}."
)
except Exception as e:
logger.warning(
"An exception occurred during fast download from Binance, falling back to "
"the slower REST API, this can take more time.",
exc_info=e,
)
df = DataFrame()
if not df.empty:
# only return the data within the requested time range
return df.loc[(df["date"] >= start) & (df["date"] < end)]
else:
return df
def concat_safe(dfs) -> DataFrame:
if all(df is None for df in dfs):
return DataFrame()
else:
return pd.concat(dfs)
async def _download_archive_ohlcv(
asset_type_url_segment: str,
symbol: str,
pair: str,
timeframe: str,
start: date,
end: date,
stop_on_404: bool,
) -> DataFrame:
# daily dataframes, `None` indicates missing data in that day (when `stop_on_404` is False)
dfs: list[DataFrame | None] = []
# the current day being processing, starting at 1.
current_day = 0
connector = aiohttp.TCPConnector(limit=100)
async with aiohttp.ClientSession(connector=connector, trust_env=True) as session:
# the HTTP connections has been throttled by TCPConnector
for dates in chunks(list(date_range(start, end)), 1000):
tasks = [
asyncio.create_task(
get_daily_ohlcv(asset_type_url_segment, symbol, timeframe, date, session)
)
for date in dates
]
for task in tasks:
current_day += 1
try:
df = await task
except Http404 as e:
if stop_on_404:
logger.debug(f"Failed to download {e.url} due to 404.")
# A 404 error on the first day indicates missing data
# on https://data.binance.vision, we provide the warning and the advice.
# https://github.com/freqtrade/freqtrade/blob/acc53065e5fa7ab5197073276306dc9dc3adbfa3/tests/exchange_online/test_binance_compare_ohlcv.py#L7
if current_day == 1:
logger.warning(
f"Fast download is unavailable due to missing data: "
f"{e.url}. Falling back to the slower REST API, "
"which may take more time."
)
if pair in ["BTC/USDT:USDT", "ETH/USDT:USDT", "BCH/USDT:USDT"]:
logger.warning(
f"To avoid the delay, you can first download {pair} using "
"`--timerange <start date>-20200101`, and then download the "
"remaining data with `--timerange 20200101-<end date>`."
)
else:
logger.warning(
f"Binance fast download for {pair} stopped at {e.date} due to "
f"missing data: {e.url}, falling back to rest API for the "
"remaining data, this can take more time."
)
await cancel_and_await_tasks(tasks[tasks.index(task) + 1 :])
return concat_safe(dfs)
else:
dfs.append(None)
except BaseException as e:
logger.warning(f"An exception raised: : {e}")
# Directly return the existing data, do not allow the gap within the data
await cancel_and_await_tasks(tasks[tasks.index(task) + 1 :])
return concat_safe(dfs)
else:
dfs.append(df)
return concat_safe(dfs)
async def cancel_and_await_tasks(unawaited_tasks):
"""Cancel and await the tasks"""
logger.debug("Try to cancel uncompleted download tasks.")
for task in unawaited_tasks:
task.cancel()
await asyncio.gather(*unawaited_tasks, return_exceptions=True)
logger.debug("All download tasks were awaited.")
def date_range(start: date, end: date):
date = start
while date <= end:
yield date
date += timedelta(days=1)
def binance_vision_zip_name(symbol: str, timeframe: str, date: date) -> str:
return f"{symbol}-{timeframe}-{date.strftime('%Y-%m-%d')}.zip"
def binance_vision_zip_url(
asset_type_url_segment: str, symbol: str, timeframe: str, date: date
) -> str:
"""
example urls:
https://data.binance.vision/data/spot/daily/klines/BTCUSDT/1s/BTCUSDT-1s-2023-10-27.zip
https://data.binance.vision/data/futures/um/daily/klines/BTCUSDT/1h/BTCUSDT-1h-2023-10-27.zip
"""
url = (
f"https://data.binance.vision/data/{asset_type_url_segment}/daily/klines/{symbol}"
f"/{timeframe}/{binance_vision_zip_name(symbol, timeframe, date)}"
)
return url
async def get_daily_ohlcv(
asset_type_url_segment: str,
symbol: str,
timeframe: str,
date: date,
session: aiohttp.ClientSession,
retry_count: int = 3,
retry_delay: float = 0.0,
) -> DataFrame:
"""
Get daily OHLCV from https://data.binance.vision
See https://github.com/binance/binance-public-data
:asset_type_url_segment: `spot` or `futures/um`
:symbol: binance symbol name, e.g. BTCUSDT
:timeframe: e.g. 1m, 1h
:date: the returned DataFrame will cover the entire day of `date` in UTC
:session: an aiohttp.ClientSession instance
:retry_count: times to retry before returning the exceptions
:retry_delay: the time to wait before every retry
:return: A dataframe containing columns date,open,high,low,close,volume
"""
url = binance_vision_zip_url(asset_type_url_segment, symbol, timeframe, date)
logger.debug(f"download data from binance: {url}")
retry = 0
while True:
if retry > 0:
sleep_secs = retry * retry_delay
logger.debug(
f"[{retry}/{retry_count}] retry to download {url} after {sleep_secs} seconds"
)
await asyncio.sleep(sleep_secs)
try:
async with session.get(url) as resp:
if resp.status == 200:
content = await resp.read()
logger.debug(f"Successfully downloaded {url}")
with zipfile.ZipFile(BytesIO(content)) as zipf:
with zipf.open(zipf.namelist()[0]) as csvf:
# https://github.com/binance/binance-public-data/issues/283
first_byte = csvf.read(1)[0]
if chr(first_byte).isdigit():
header = None
else:
header = 0
csvf.seek(0)
df = pd.read_csv(
csvf,
usecols=[0, 1, 2, 3, 4, 5],
names=["date", "open", "high", "low", "close", "volume"],
header=header,
)
df["date"] = pd.to_datetime(df["date"], unit="ms", utc=True)
return df
elif resp.status == 404:
logger.debug(f"Failed to download {url}")
raise Http404(f"404: {url}", date, url)
else:
raise BadHttpStatus(f"{resp.status} - {resp.reason}")
except Exception as e:
retry += 1
if isinstance(e, Http404) or retry > retry_count:
logger.debug(f"Failed to get data from {url}: {e}")
raise
+1
View File
@@ -47,6 +47,7 @@ MAP_EXCHANGE_CHILDCLASS = {
"binanceje": "binance",
"binanceusdm": "binance",
"okex": "okx",
"myokx": "okx",
"gateio": "gate",
"huboi": "htx",
}
+75 -21
View File
@@ -7,7 +7,7 @@ import asyncio
import inspect
import logging
import signal
from collections.abc import Coroutine
from collections.abc import Coroutine, Generator
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from math import floor, isnan
@@ -201,7 +201,7 @@ class Exchange:
self._cache_lock = Lock()
# Cache for 10 minutes ...
self._fetch_tickers_cache: TTLCache = TTLCache(maxsize=2, ttl=60 * 10)
self._fetch_tickers_cache: TTLCache = TTLCache(maxsize=4, ttl=60 * 10)
# Cache values for 300 to avoid frequent polling of the exchange for prices
# Caching only applies to RPC methods, so prices for open trades are still
# refreshed once every iteration.
@@ -705,14 +705,22 @@ class Exchange:
f"Available currencies are: {', '.join(quote_currencies)}"
)
def get_valid_pair_combination(self, curr_1: str, curr_2: str) -> str:
def get_valid_pair_combination(self, curr_1: str, curr_2: str) -> Generator[str, None, None]:
"""
Get valid pair combination of curr_1 and curr_2 by trying both combinations.
"""
for pair in [f"{curr_1}/{curr_2}", f"{curr_2}/{curr_1}"]:
yielded = False
for pair in (
f"{curr_1}/{curr_2}",
f"{curr_2}/{curr_1}",
f"{curr_1}/{curr_2}:{curr_2}",
f"{curr_2}/{curr_1}:{curr_1}",
):
if pair in self.markets and self.markets[pair].get("active"):
return pair
raise ValueError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")
yielded = True
yield pair
if not yielded:
raise ValueError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.")
def validate_timeframes(self, timeframe: str | None) -> None:
"""
@@ -1801,24 +1809,37 @@ class Exchange:
raise OperationalException(e) from e
@retrier
def get_tickers(self, symbols: list[str] | None = None, *, cached: bool = False) -> Tickers:
def get_tickers(
self,
symbols: list[str] | None = None,
*,
cached: bool = False,
market_type: TradingMode | None = None,
) -> Tickers:
"""
:param symbols: List of symbols to fetch
:param cached: Allow cached result
:param market_type: Market type to fetch - either spot or futures.
:return: fetch_tickers result
"""
tickers: Tickers
if not self.exchange_has("fetchTickers"):
return {}
cache_key = f"fetch_tickers_{market_type}" if market_type else "fetch_tickers"
if cached:
with self._cache_lock:
tickers = self._fetch_tickers_cache.get("fetch_tickers") # type: ignore
tickers = self._fetch_tickers_cache.get(cache_key) # type: ignore
if tickers:
return tickers
try:
tickers = self._api.fetch_tickers(symbols)
# Re-map futures to swap
market_types = {
TradingMode.FUTURES: "swap",
}
params = {"type": market_types.get(market_type, market_type)} if market_type else {}
tickers = self._api.fetch_tickers(symbols, params)
with self._cache_lock:
self._fetch_tickers_cache["fetch_tickers"] = tickers
self._fetch_tickers_cache[cache_key] = tickers
return tickers
except ccxt.NotSupported as e:
raise OperationalException(
@@ -1842,7 +1863,39 @@ class Exchange:
except ccxt.BaseError as e:
raise OperationalException(e) from e
# Pricing info
def get_conversion_rate(self, coin: str, currency: str) -> float | None:
"""
Quick and cached way to get conversion rate one currency to the other.
Can then be used as "rate * amount" to convert between currencies.
:param coin: Coin to convert
:param currency: Currency to convert to
:returns: Conversion rate from coin to currency
:raises: ExchangeErrors
"""
if coin == currency:
return 1.0
tickers = self.get_tickers(cached=True)
try:
for pair in self.get_valid_pair_combination(coin, currency):
ticker: Ticker | None = tickers.get(pair, None)
if not ticker:
tickers_other: Tickers = self.get_tickers(
cached=True,
market_type=(
TradingMode.SPOT
if self.trading_mode != TradingMode.SPOT
else TradingMode.FUTURES
),
)
ticker = tickers_other.get(pair, None)
if ticker:
rate: float | None = ticker.get("last", None)
if rate and pair.startswith(currency) and not pair.endswith(currency):
rate = 1.0 / rate
return rate
except ValueError:
return None
return None
@retrier
def fetch_ticker(self, pair: str) -> Ticker:
@@ -2198,10 +2251,13 @@ class Exchange:
# If cost is None or 0.0 -> falsy, return None
return None
try:
comb = self.get_valid_pair_combination(fee_curr, self._config["stake_currency"])
tick = self.fetch_ticker(comb)
fee_to_quote_rate = safe_value_fallback2(tick, tick, "last", "ask")
for comb in self.get_valid_pair_combination(
fee_curr, self._config["stake_currency"]
):
tick = self.fetch_ticker(comb)
fee_to_quote_rate = safe_value_fallback2(tick, tick, "last", "ask")
if tick:
break
except (ValueError, ExchangeError):
fee_to_quote_rate = self._config["exchange"].get("unknown_fee_rate", None)
if not fee_to_quote_rate:
@@ -2244,8 +2300,9 @@ class Exchange:
:param pair: Pair to download
:param timeframe: Timeframe to get data for
:param since_ms: Timestamp in milliseconds to get history from
:param until_ms: Timestamp in milliseconds to get history up to
:param candle_type: '', mark, index, premiumIndex, or funding_rate
:param is_new_pair: used by binance subclass to allow "fast" new pair downloading
:param until_ms: Timestamp in milliseconds to get history up to
:return: Dataframe with candle (OHLCV) data
"""
pair, _, _, data, _ = self.loop.run_until_complete(
@@ -2254,11 +2311,10 @@ class Exchange:
timeframe=timeframe,
since_ms=since_ms,
until_ms=until_ms,
is_new_pair=is_new_pair,
candle_type=candle_type,
)
)
logger.info(f"Downloaded data for {pair} with length {len(data)}.")
logger.debug(f"Downloaded data for {pair} from ccxt with length {len(data)}.")
return ohlcv_to_dataframe(data, timeframe, pair, fill_missing=False, drop_incomplete=True)
async def _async_get_historic_ohlcv(
@@ -2267,13 +2323,11 @@ class Exchange:
timeframe: str,
since_ms: int,
candle_type: CandleType,
is_new_pair: bool = False,
raise_: bool = False,
until_ms: int | None = None,
) -> OHLCVResponse:
"""
Download historic ohlcv
:param is_new_pair: used by binance subclass to allow "fast" new pair downloading
:param candle_type: Any of the enum CandleType (must match trading mode!)
"""
@@ -3612,7 +3666,7 @@ class Exchange:
Wherein, "+" or "-" depends on whether the contract goes long or short:
"-" for long, and "+" for short.
okex: https://www.okx.com/support/hc/en-us/articles/
okx: https://www.okx.com/support/hc/en-us/articles/
360053909592-VI-Introduction-to-the-isolated-mode-of-Single-Multi-currency-Portfolio-margin
:param pair: Pair to calculate liquidation price for
+10 -4
View File
@@ -23,6 +23,7 @@ from freqtrade.exchange.common import (
BAD_EXCHANGES,
EXCHANGE_HAS_OPTIONAL,
EXCHANGE_HAS_REQUIRED,
MAP_EXCHANGE_CHILDCLASS,
SUPPORTED_EXCHANGES,
)
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_minutes, timeframe_to_prev_date
@@ -91,21 +92,24 @@ def validate_exchange(exchange: str) -> tuple[bool, str, ccxt.Exchange | None]:
def _build_exchange_list_entry(
exchange_name: str, exchangeClasses: dict[str, Any]
) -> ValidExchangesType:
exchange_name = exchange_name.lower()
valid, comment, ex_mod = validate_exchange(exchange_name)
mapped_exchange_name = MAP_EXCHANGE_CHILDCLASS.get(exchange_name, exchange_name).lower()
is_alias = getattr(ex_mod, "alias", False)
result: ValidExchangesType = {
"name": getattr(ex_mod, "name", exchange_name),
"classname": exchange_name,
"valid": valid,
"supported": exchange_name.lower() in SUPPORTED_EXCHANGES,
"supported": mapped_exchange_name in SUPPORTED_EXCHANGES and not is_alias,
"comment": comment,
"dex": getattr(ex_mod, "dex", False),
"is_alias": getattr(ex_mod, "alias", False),
"is_alias": is_alias,
"alias_for": inspect.getmro(ex_mod.__class__)[1]().id
if getattr(ex_mod, "alias", False)
else None,
"trade_modes": [{"trading_mode": "spot", "margin_mode": ""}],
}
if resolved := exchangeClasses.get(exchange_name.lower()):
if resolved := exchangeClasses.get(mapped_exchange_name):
supported_modes = [{"trading_mode": "spot", "margin_mode": ""}] + [
{"trading_mode": tm.value, "margin_mode": mm.value}
for tm, mm in resolved["class"]._supported_trading_mode_margin_pairs
@@ -308,7 +312,9 @@ def price_to_precision(
decimal_to_precision(
price,
rounding_mode=rounding_mode,
precision=price_precision,
precision=int(price_precision)
if precisionMode != TICK_SIZE
else price_precision,
counting_mode=precisionMode,
)
)
+31 -4
View File
@@ -1,6 +1,7 @@
"""Hyperliquid exchange subclass"""
import logging
from copy import deepcopy
from datetime import datetime
from freqtrade.constants import BuySell
@@ -157,9 +158,15 @@ class Hyperliquid(Exchange):
logger.warning(f"Could not update funding fees for {pair}.")
return 0.0
def fetch_order(self, order_id: str, pair: str, params: dict | None = None) -> CcxtOrder:
order = super().fetch_order(order_id, pair, params)
def _adjust_hyperliquid_order(
self,
order: dict,
) -> dict:
"""
Adjusts order response for Hyperliquid
:param order: Order response from Hyperliquid
:return: Adjusted order response
"""
if (
order["average"] is None
and order["status"] in ("canceled", "closed")
@@ -168,7 +175,9 @@ class Hyperliquid(Exchange):
# Hyperliquid does not fill the average price in the order response
# Fetch trades to calculate the average price to have the actual price
# the order was executed at
trades = self.get_trades_for_order(order_id, pair, since=dt_from_ts(order["timestamp"]))
trades = self.get_trades_for_order(
order["id"], order["symbol"], since=dt_from_ts(order["timestamp"])
)
if trades:
total_amount = sum(t["amount"] for t in trades)
@@ -177,5 +186,23 @@ class Hyperliquid(Exchange):
if total_amount
else None
)
return order
def fetch_order(self, order_id: str, pair: str, params: dict | None = None) -> CcxtOrder:
order = super().fetch_order(order_id, pair, params)
order = self._adjust_hyperliquid_order(order)
self._log_exchange_response("fetch_order2", order)
return order
def fetch_orders(
self, pair: str, since: datetime, params: dict | None = None
) -> list[CcxtOrder]:
orders = super().fetch_orders(pair, since, params)
for idx, order in enumerate(deepcopy(orders)):
order2 = self._adjust_hyperliquid_order(order)
orders[idx] = order2
self._log_exchange_response("fetch_orders2", orders)
return orders
+8 -2
View File
@@ -50,11 +50,17 @@ class Kraken(Exchange):
return parent_check and market.get("darkpool", False) is False
def get_tickers(self, symbols: list[str] | None = None, *, cached: bool = False) -> Tickers:
def get_tickers(
self,
symbols: list[str] | None = None,
*,
cached: bool = False,
market_type: TradingMode | None = None,
) -> Tickers:
# Only fetch tickers for current stake currency
# Otherwise the request for kraken becomes too large.
symbols = list(self.get_markets(quote_currencies=[self._config["stake_currency"]]))
return super().get_tickers(symbols=symbols, cached=cached)
return super().get_tickers(symbols=symbols, cached=cached, market_type=market_type)
def consolidate_balances(self, balances: CcxtBalances) -> CcxtBalances:
"""
+1 -1
View File
@@ -849,7 +849,7 @@ class FreqaiDataKitchen:
dataframe = strategy.set_freqai_targets(dataframe.copy(), metadata=metadata)
dataframe = self.remove_special_chars_from_feature_names(dataframe)
self.get_unique_classes_from_labels(dataframe)
self.get_unique_classes_from_labels(dataframe)
if self.config.get("reduce_df_footprint", False):
dataframe = reduce_dataframe_footprint(dataframe)
@@ -5,7 +5,6 @@ from xgboost import XGBRFRegressor
from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from freqtrade.freqai.tensorboard import TBCallback
logger = logging.getLogger(__name__)
@@ -45,7 +44,12 @@ class XGBoostRFRegressor(BaseRegressionModel):
model = XGBRFRegressor(**self.model_training_parameters)
model.set_params(callbacks=[TBCallback(dk.data_path)])
# Callbacks are not supported for XGBRFRegressor, and version 2.1.x started to throw
# the following error:
# NotImplementedError: `early_stopping_rounds` and `callbacks` are not implemented
# for random forest.
# model.set_params(callbacks=[TBCallback(dk.data_path)])
model.fit(
X=X,
y=y,
@@ -55,6 +59,6 @@ class XGBoostRFRegressor(BaseRegressionModel):
xgb_model=xgb_model,
)
# set the callbacks to empty so that we can serialize to disk later
model.set_params(callbacks=[])
# model.set_params(callbacks=[])
return model
+2 -2
View File
@@ -1197,7 +1197,7 @@ class FreqtradeBot(LoggingMixin):
trade.pair, side="entry", is_short=trade.is_short, refresh=False
)
stake_amount = trade.stake_amount
if not fill:
if not fill and trade.nr_of_successful_entries > 0:
# If we have open orders, we need to add the stake amount of the open orders
# as it's not yet included in the trade.stake_amount
stake_amount += sum(
@@ -1303,7 +1303,7 @@ class FreqtradeBot(LoggingMixin):
logger.warning(
f"Unable to handle stoploss on exchange for {trade.pair}: {exception}"
)
# Check if we can sell our current pair
# Check if we can exit our current pair
if not trade.has_open_orders and trade.is_open and self.handle_trade(trade):
trades_closed += 1
+6 -3
View File
@@ -27,10 +27,13 @@ def update_liquidation_prices(
total_wallet_stake = 0.0
if dry_run:
# Parameters only needed for cross margin
total_wallet_stake = wallets.get_total(stake_currency)
total_wallet_stake = wallets.get_collateral()
logger.info("Updating liquidation price for all open trades.")
open_trades = Trade.get_open_trades()
logger.info(
"Updating liquidation price for all open trades. "
f"Collateral {total_wallet_stake} {stake_currency}."
)
open_trades: list[Trade] = Trade.get_open_trades()
for t in open_trades:
# TODO: This should be done in a batch update
t.set_liquidation_price(
@@ -10,7 +10,7 @@ from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException
from freqtrade.optimize.analysis.lookahead import LookaheadAnalysis
from freqtrade.resolvers import StrategyResolver
from freqtrade.util import print_rich_table
from freqtrade.util import get_dry_run_wallet, print_rich_table
logger = logging.getLogger(__name__)
@@ -163,7 +163,7 @@ class LookaheadAnalysisSubFunctions:
config["max_open_trades"] = len(config["pairs"])
min_dry_run_wallet = 1000000000
if config["dry_run_wallet"] < min_dry_run_wallet:
if get_dry_run_wallet(config) < min_dry_run_wallet:
logger.info(
"Dry run wallet was not set to 1 billion, pushing it up there "
"just to avoid false positives"
+16 -28
View File
@@ -47,8 +47,7 @@ from freqtrade.optimize.optimize_reports import (
generate_rejected_signals,
generate_trade_signal_candles,
show_backtest_results,
store_backtest_analysis_results,
store_backtest_stats,
store_backtest_results,
)
from freqtrade.persistence import (
CustomDataWrapper,
@@ -121,10 +120,12 @@ class Backtesting:
self.run_ids: dict[str, str] = {}
self.strategylist: list[IStrategy] = []
self.all_results: dict[str, dict] = {}
self.processed_dfs: dict[str, dict] = {}
self.analysis_results: dict[str, dict[str, DataFrame]] = {
"signals": {},
"rejected": {},
"exited": {},
}
self.rejected_dict: dict[str, list] = {}
self.rejected_df: dict[str, dict] = {}
self.exited_dfs: dict[str, dict] = {}
self._exchange_name = self.config["exchange"]["name"]
if not exchange:
@@ -1590,15 +1591,13 @@ class Backtesting:
self.config.get("export", "none") == "signals"
and self.dataprovider.runmode == RunMode.BACKTEST
):
self.processed_dfs[strategy_name] = generate_trade_signal_candles(
preprocessed_tmp, results, "open_date"
)
self.rejected_df[strategy_name] = generate_rejected_signals(
preprocessed_tmp, self.rejected_dict
)
self.exited_dfs[strategy_name] = generate_trade_signal_candles(
preprocessed_tmp, results, "close_date"
)
signals = generate_trade_signal_candles(preprocessed_tmp, results, "open_date")
rejected = generate_rejected_signals(preprocessed_tmp, self.rejected_dict)
exited = generate_trade_signal_candles(preprocessed_tmp, results, "close_date")
self.analysis_results["signals"][strategy_name] = signals
self.analysis_results["rejected"][strategy_name] = rejected
self.analysis_results["exited"][strategy_name] = exited
return min_date, max_date
@@ -1662,23 +1661,12 @@ class Backtesting:
dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
if self.config.get("export", "none") in ("trades", "signals"):
combined_res = combined_dataframes_with_rel_mean(data, min_date, max_date)
store_backtest_stats(
self.config["exportfilename"],
store_backtest_results(
self.config,
self.results,
dt_appendix,
market_change_data=combined_res,
)
if (
self.config.get("export", "none") == "signals"
and self.dataprovider.runmode == RunMode.BACKTEST
):
store_backtest_analysis_results(
self.config["exportfilename"],
self.processed_dfs,
self.rejected_df,
self.exited_dfs,
dt_appendix,
analysis_results=self.analysis_results,
)
# Results may be mixed up now. Sort them so they follow --strategy-list order.
@@ -28,6 +28,7 @@ from freqtrade.optimize.hyperopt_loss.hyperopt_loss_interface import IHyperOptLo
from freqtrade.optimize.hyperopt_tools import HyperoptStateContainer, HyperoptTools
from freqtrade.optimize.optimize_reports import generate_strategy_stats
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver
from freqtrade.util.dry_run_wallet import get_dry_run_wallet
# Suppress scikit-learn FutureWarnings from skopt
@@ -363,6 +364,7 @@ class HyperOptimizer:
config=self.config,
processed=processed,
backtest_stats=strat_stats,
starting_balance=get_dry_run_wallet(self.config),
)
return {
"loss": loss,
@@ -9,7 +9,6 @@ from datetime import datetime
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_calmar
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -24,10 +23,9 @@ class CalmarHyperOptLoss(IHyperOptLoss):
@staticmethod
def hyperopt_loss_function(
results: DataFrame,
trade_count: int,
min_date: datetime,
max_date: datetime,
config: Config,
starting_balance: float,
*args,
**kwargs,
) -> float:
@@ -36,7 +34,6 @@ class CalmarHyperOptLoss(IHyperOptLoss):
Uses Calmar Ratio calculation.
"""
starting_balance = config["dry_run_wallet"]
calmar_ratio = calculate_calmar(results, min_date, max_date, starting_balance)
# print(expected_returns_mean, max_drawdown, calmar_ratio)
return -calmar_ratio
@@ -31,6 +31,7 @@ class IHyperOptLoss(ABC):
config: Config,
processed: dict[str, DataFrame],
backtest_stats: dict[str, Any],
starting_balance: float,
**kwargs,
) -> float:
"""
@@ -7,7 +7,6 @@ Hyperoptimization.
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_underwater
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -21,7 +20,9 @@ class MaxDrawDownRelativeHyperOptLoss(IHyperOptLoss):
"""
@staticmethod
def hyperopt_loss_function(results: DataFrame, config: Config, *args, **kwargs) -> float:
def hyperopt_loss_function(
results: DataFrame, starting_balance: float, *args, **kwargs
) -> float:
"""
Objective function.
@@ -31,7 +32,7 @@ class MaxDrawDownRelativeHyperOptLoss(IHyperOptLoss):
total_profit = results["profit_abs"].sum()
try:
drawdown_df = calculate_underwater(
results, value_col="profit_abs", starting_balance=config["dry_run_wallet"]
results, value_col="profit_abs", starting_balance=starting_balance
)
max_drawdown = abs(min(drawdown_df["drawdown"]))
relative_drawdown = max(drawdown_df["drawdown_relative"])
@@ -33,7 +33,6 @@ TARGET_TRADE_AMOUNT variable sets the minimum number of trades required to avoid
import numpy as np
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_expectancy, calculate_max_drawdown
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -57,7 +56,7 @@ class MultiMetricHyperOptLoss(IHyperOptLoss):
def hyperopt_loss_function(
results: DataFrame,
trade_count: int,
config: Config,
starting_balance: float,
**kwargs,
) -> float:
total_profit = results["profit_abs"].sum()
@@ -83,7 +82,7 @@ class MultiMetricHyperOptLoss(IHyperOptLoss):
# Calculate drawdown
try:
drawdown = calculate_max_drawdown(
results, starting_balance=config["dry_run_wallet"], value_col="profit_abs"
results, starting_balance=starting_balance, value_col="profit_abs"
)
relative_account_drawdown = drawdown.relative_account_drawdown
except ValueError:
@@ -10,7 +10,6 @@ individual needs.
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_max_drawdown
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -21,12 +20,14 @@ DRAWDOWN_MULT = 0.075
class ProfitDrawDownHyperOptLoss(IHyperOptLoss):
@staticmethod
def hyperopt_loss_function(results: DataFrame, config: Config, *args, **kwargs) -> float:
def hyperopt_loss_function(
results: DataFrame, starting_balance: float, *args, **kwargs
) -> float:
total_profit = results["profit_abs"].sum()
try:
drawdown = calculate_max_drawdown(
results, starting_balance=config["dry_run_wallet"], value_col="profit_abs"
results, starting_balance=starting_balance, value_col="profit_abs"
)
relative_account_drawdown = drawdown.relative_account_drawdown
except ValueError:
@@ -9,7 +9,6 @@ from datetime import datetime
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_sharpe
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -24,10 +23,9 @@ class SharpeHyperOptLoss(IHyperOptLoss):
@staticmethod
def hyperopt_loss_function(
results: DataFrame,
trade_count: int,
min_date: datetime,
max_date: datetime,
config: Config,
starting_balance: float,
*args,
**kwargs,
) -> float:
@@ -36,7 +34,6 @@ class SharpeHyperOptLoss(IHyperOptLoss):
Uses Sharpe Ratio calculation.
"""
starting_balance = config["dry_run_wallet"]
sharp_ratio = calculate_sharpe(results, min_date, max_date, starting_balance)
# print(expected_returns_mean, up_stdev, sharp_ratio)
return -sharp_ratio
@@ -9,7 +9,6 @@ from datetime import datetime
from pandas import DataFrame
from freqtrade.constants import Config
from freqtrade.data.metrics import calculate_sortino
from freqtrade.optimize.hyperopt import IHyperOptLoss
@@ -24,10 +23,9 @@ class SortinoHyperOptLoss(IHyperOptLoss):
@staticmethod
def hyperopt_loss_function(
results: DataFrame,
trade_count: int,
min_date: datetime,
max_date: datetime,
config: Config,
starting_balance: float,
*args,
**kwargs,
) -> float:
@@ -36,7 +34,6 @@ class SortinoHyperOptLoss(IHyperOptLoss):
Uses Sortino Ratio calculation.
"""
starting_balance = config["dry_run_wallet"]
sortino_ratio = calculate_sortino(results, min_date, max_date, starting_balance)
# print(expected_returns_mean, down_stdev, sortino_ratio)
return -sortino_ratio
+8 -2
View File
@@ -374,7 +374,6 @@ class HyperoptTools:
trials = json_normalize(results, max_level=1)
trials["Best"] = ""
trials["Stake currency"] = config["stake_currency"]
base_metrics = [
"Best",
@@ -383,11 +382,13 @@ class HyperoptTools:
"results_metrics.profit_mean",
"results_metrics.profit_median",
"results_metrics.profit_total",
"Stake currency",
"results_metrics.stake_currency",
"results_metrics.profit_total_abs",
"results_metrics.holding_avg",
"results_metrics.trade_count_long",
"results_metrics.trade_count_short",
"results_metrics.max_drawdown_abs",
"results_metrics.max_drawdown_account",
"loss",
"is_initial_point",
"is_best",
@@ -409,6 +410,8 @@ class HyperoptTools:
"Avg duration",
"Trade count long",
"Trade count short",
"Max drawdown",
"Max drawdown percent",
"Objective",
"is_initial_point",
"is_best",
@@ -432,6 +435,9 @@ class HyperoptTools:
trials["Avg profit"] = trials["Avg profit"].apply(
lambda x: f"{x * perc_multi:,.2f}%" if not isna(x) else ""
)
trials["Max drawdown percent"] = trials["Max drawdown percent"].apply(
lambda x: f"{x * perc_multi:,.2f}%" if not isna(x) else ""
)
trials["Objective"] = trials["Objective"].apply(
lambda x: f"{x:,.5f}" if x != 100000 else ""
)
@@ -11,10 +11,7 @@ from freqtrade.optimize.optimize_reports.bt_output import (
text_table_strategy,
text_table_tags,
)
from freqtrade.optimize.optimize_reports.bt_storage import (
store_backtest_analysis_results,
store_backtest_stats,
)
from freqtrade.optimize.optimize_reports.bt_storage import store_backtest_results
from freqtrade.optimize.optimize_reports.optimize_reports import (
generate_all_periodic_breakdown_stats,
generate_backtest_stats,
@@ -4,6 +4,7 @@ from pathlib import Path
from pandas import DataFrame
from freqtrade.constants import LAST_BT_RESULT_FN
from freqtrade.enums.runmode import RunMode
from freqtrade.ft_types import BacktestResultType
from freqtrade.misc import file_dump_joblib, file_dump_json
from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename
@@ -29,21 +30,27 @@ def _generate_filename(recordfilename: Path, appendix: str, suffix: str) -> Path
return filename
def store_backtest_stats(
recordfilename: Path,
def store_backtest_results(
config: dict,
stats: BacktestResultType,
dtappendix: str,
*,
market_change_data: DataFrame | None = None,
analysis_results: dict[str, dict[str, DataFrame]] | None = None,
) -> Path:
"""
Stores backtest results
:param recordfilename: Path object, which can either be a filename or a directory.
Filenames will be appended with a timestamp right before the suffix
while for directories, <directory>/backtest-result-<datetime>.json will be used as filename
Stores backtest results and analysis data
:param config: Configuration dictionary
:param stats: Dataframe containing the backtesting statistics
:param dtappendix: Datetime to use for the filename
:param market_change_data: Dataframe containing market change data
:param analysis_results: Dictionary containing analysis results
"""
# Path object, which can either be a filename or a directory.
# Filenames will be appended with a timestamp right before the suffix
# while for directories, <directory>/backtest-result-<datetime>.json will be used as filename
recordfilename: Path = config["exportfilename"]
filename = _generate_filename(recordfilename, dtappendix, ".json")
# Store metadata separately.
@@ -65,6 +72,21 @@ def store_backtest_stats(
filename_mc, compression_level=9, compression="lz4"
)
if (
config.get("export", "none") == "signals"
and analysis_results is not None
and config.get("runmode", RunMode.OTHER) == RunMode.BACKTEST
):
_store_backtest_analysis_data(
recordfilename, analysis_results["signals"], dtappendix, "signals"
)
_store_backtest_analysis_data(
recordfilename, analysis_results["rejected"], dtappendix, "rejected"
)
_store_backtest_analysis_data(
recordfilename, analysis_results["exited"], dtappendix, "exited"
)
return filename
@@ -86,15 +108,3 @@ def _store_backtest_analysis_data(
file_dump_joblib(filename, data)
return filename
def store_backtest_analysis_results(
recordfilename: Path,
candles: dict[str, dict],
trades: dict[str, dict],
exited: dict[str, dict],
dtappendix: str,
) -> None:
_store_backtest_analysis_data(recordfilename, candles, dtappendix, "signals")
_store_backtest_analysis_data(recordfilename, trades, dtappendix, "rejected")
_store_backtest_analysis_data(recordfilename, exited, dtappendix, "exited")
@@ -18,7 +18,7 @@ from freqtrade.data.metrics import (
calculate_sortino,
)
from freqtrade.ft_types import BacktestResultType
from freqtrade.util import decimals_per_coin, fmt_coin
from freqtrade.util import decimals_per_coin, fmt_coin, get_dry_run_wallet
logger = logging.getLogger(__name__)
@@ -69,7 +69,7 @@ def generate_rejected_signals(
def _generate_result_line(
result: DataFrame, starting_balance: int, first_column: str | list[str]
result: DataFrame, starting_balance: float, first_column: str | list[str]
) -> dict:
"""
Generate one result dict, with "first_column" as key.
@@ -111,7 +111,7 @@ def _generate_result_line(
def generate_pair_metrics(
pairlist: list[str],
stake_currency: str,
starting_balance: int,
starting_balance: float,
results: DataFrame,
skip_nan: bool = False,
) -> list[dict]:
@@ -144,7 +144,7 @@ def generate_pair_metrics(
def generate_tag_metrics(
tag_type: Literal["enter_tag", "exit_reason"] | list[Literal["enter_tag", "exit_reason"]],
starting_balance: int,
starting_balance: float,
results: DataFrame,
skip_nan: bool = False,
) -> list[dict]:
@@ -373,7 +373,7 @@ def generate_strategy_stats(
return {}
config = content["config"]
max_open_trades = min(config["max_open_trades"], len(pairlist))
start_balance = config["dry_run_wallet"]
start_balance = get_dry_run_wallet(config)
stake_currency = config["stake_currency"]
pair_results = generate_pair_metrics(
+3 -3
View File
@@ -39,7 +39,7 @@ class PairLock(ModelBase):
@staticmethod
def query_pair_locks(
pair: str | None, now: datetime, side: str = "*"
pair: str | None, now: datetime, side: str | None = None
) -> ScalarResult["PairLock"]:
"""
Get all currently active locks for this pair
@@ -53,9 +53,9 @@ class PairLock(ModelBase):
]
if pair:
filters.append(PairLock.pair == pair)
if side != "*":
if side is not None and side != "*":
filters.append(or_(PairLock.side == side, PairLock.side == "*"))
else:
elif side is not None:
filters.append(PairLock.side == "*")
return PairLock.session.scalars(select(PairLock).filter(*filters))
+3 -2
View File
@@ -67,13 +67,14 @@ class PairLocks:
@staticmethod
def get_pair_locks(
pair: str | None, now: datetime | None = None, side: str = "*"
pair: str | None, now: datetime | None = None, side: str | None = None
) -> Sequence[PairLock]:
"""
Get all currently active locks for this pair
:param pair: Pair to check for. Returns all current locks if pair is empty
:param now: Datetime object (generated via datetime.now(timezone.utc)).
defaults to datetime.now(timezone.utc)
:param side: Side get locks for, can be 'long', 'short', '*' or None
"""
if not now:
now = datetime.now(timezone.utc)
@@ -88,7 +89,7 @@ class PairLocks:
lock.lock_end_time >= now
and lock.active is True
and (pair is None or lock.pair == pair)
and (lock.side == "*" or lock.side == side)
and (side is None or lock.side == "*" or lock.side == side)
)
]
return locks
+18 -4
View File
@@ -172,12 +172,20 @@ class Order(ModelBase):
@property
def stake_amount(self) -> float:
"""Amount in stake currency used for this order"""
return self.safe_amount * self.safe_price / self.trade.leverage
return float(
FtPrecise(self.safe_amount)
* FtPrecise(self.safe_price)
/ FtPrecise(self.trade.leverage)
)
@property
def stake_amount_filled(self) -> float:
"""Filled Amount in stake currency used for this order"""
return self.safe_filled * self.safe_price / self.trade.leverage
return float(
FtPrecise(self.safe_filled)
* FtPrecise(self.safe_price)
/ FtPrecise(self.trade.leverage)
)
def __repr__(self):
return (
@@ -769,7 +777,9 @@ class LocalTrade:
"""
if liquidation_price is None:
return
self.liquidation_price = liquidation_price
self.liquidation_price = price_to_precision(
liquidation_price, self.price_precision, self.precision_mode_price
)
def set_funding_fees(self, funding_fee: float) -> None:
"""
@@ -1239,7 +1249,11 @@ class LocalTrade:
if current_amount_tr > 0.0:
# Trade is still open
# Leverage not updated, as we don't allow changing leverage through DCA at the moment.
self.open_rate = float(current_stake / current_amount)
self.open_rate = price_to_precision(
float(current_stake / current_amount),
self.price_precision,
self.precision_mode_price,
)
self.amount = current_amount_tr
self.stake_amount = float(current_stake) / (self.leverage or 1.0)
self.fee_open_cost = self.fee_open * float(self.max_stake_amount)
+2 -1
View File
@@ -28,6 +28,7 @@ from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.strategy import IStrategy
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
from freqtrade.util import get_dry_run_wallet
logger = logging.getLogger(__name__)
@@ -706,7 +707,7 @@ def plot_profit(config: Config) -> None:
trades,
config["timeframe"],
config.get("stake_currency", ""),
config.get("available_capital", config["dry_run_wallet"]),
config.get("available_capital", get_dry_run_wallet(config)),
)
store_plot_file(
fig,
@@ -8,7 +8,7 @@ defined period or as coming from ticker
import logging
from datetime import timedelta
from typing import Any
from typing import TypedDict
from cachetools import TTLCache
from pandas import DataFrame
@@ -24,6 +24,11 @@ from freqtrade.util import dt_now, format_ms_time
logger = logging.getLogger(__name__)
class SymbolWithPercentage(TypedDict):
symbol: str
percentage: float | None
class PercentChangePairList(IPairList):
is_pairlist_generator = True
supports_backtesting = SupportsBacktesting.NO
@@ -191,7 +196,6 @@ class PercentChangePairList(IPairList):
for k, v in tickers.items()
if (
self._exchange.get_pair_quote_currency(k) == self._stake_currency
and (self._use_range or v.get("percentage") is not None)
and v["symbol"] in _pairlist
)
]
@@ -212,13 +216,15 @@ class PercentChangePairList(IPairList):
:param tickers: Tickers (from exchange.get_tickers). May be cached.
:return: new whitelist
"""
filtered_tickers: list[dict[str, Any]] = [{"symbol": k} for k in pairlist]
filtered_tickers: list[SymbolWithPercentage] = [
{"symbol": k, "percentage": None} for k in pairlist
]
if self._use_range:
# calculating using lookback_period
self.fetch_percent_change_from_lookback_period(filtered_tickers)
filtered_tickers = self.fetch_percent_change_from_lookback_period(filtered_tickers)
else:
# Fetching 24h change by default from supported exchange tickers
self.fetch_percent_change_from_tickers(filtered_tickers, tickers)
filtered_tickers = self.fetch_percent_change_from_tickers(filtered_tickers, tickers)
if self._min_value is not None:
filtered_tickers = [v for v in filtered_tickers if v["percentage"] > self._min_value]
@@ -228,7 +234,7 @@ class PercentChangePairList(IPairList):
sorted_tickers = sorted(
filtered_tickers,
reverse=self._sort_direction == "desc",
key=lambda t: t["percentage"],
key=lambda t: t["percentage"], # type: ignore
)
# Validate whitelist to only have active market pairs
@@ -240,7 +246,7 @@ class PercentChangePairList(IPairList):
return pairs
def fetch_candles_for_lookback_period(
self, filtered_tickers: list[dict[str, str]]
self, filtered_tickers: list[SymbolWithPercentage]
) -> dict[PairWithTimeframe, DataFrame]:
since_ms = (
int(
@@ -262,7 +268,6 @@ class PercentChangePairList(IPairList):
)
* 1000
)
# todo: utc date output for starting date
self.log_once(
f"Using change range of {self._lookback_period} candles, timeframe: "
f"{self._lookback_timeframe}, starting from {format_ms_time(since_ms)} "
@@ -277,7 +282,9 @@ class PercentChangePairList(IPairList):
candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms)
return candles
def fetch_percent_change_from_lookback_period(self, filtered_tickers: list[dict[str, Any]]):
def fetch_percent_change_from_lookback_period(
self, filtered_tickers: list[SymbolWithPercentage]
) -> list[SymbolWithPercentage]:
# get lookback period in ms, for exchange ohlcv fetch
candles = self.fetch_candles_for_lookback_period(filtered_tickers)
@@ -302,16 +309,23 @@ class PercentChangePairList(IPairList):
filtered_tickers[i]["percentage"] = pct_change
else:
filtered_tickers[i]["percentage"] = 0
return filtered_tickers
def fetch_percent_change_from_tickers(self, filtered_tickers: list[dict[str, Any]], tickers):
for i, p in enumerate(filtered_tickers):
def fetch_percent_change_from_tickers(
self, filtered_tickers: list[SymbolWithPercentage], tickers
) -> list[SymbolWithPercentage]:
valid_tickers: list[SymbolWithPercentage] = []
for p in filtered_tickers:
# Filter out assets
if not self._validate_pair(
p["symbol"], tickers[p["symbol"]] if p["symbol"] in tickers else None
if (
self._validate_pair(
p["symbol"], tickers[p["symbol"]] if p["symbol"] in tickers else None
)
and p["symbol"] != "UNI/USDT"
):
filtered_tickers.remove(p)
else:
filtered_tickers[i]["percentage"] = tickers[p["symbol"]]["percentage"]
p["percentage"] = tickers[p["symbol"]]["percentage"]
valid_tickers.append(p)
return valid_tickers
def _validate_pair(self, pair: str, ticker: Ticker | None) -> bool:
"""
+3 -3
View File
@@ -43,7 +43,7 @@ router = APIRouter()
def __run_backtest_bg(btconfig: Config):
from freqtrade.data.metrics import combined_dataframes_with_rel_mean
from freqtrade.optimize.optimize_reports import generate_backtest_stats, store_backtest_stats
from freqtrade.optimize.optimize_reports import generate_backtest_stats, store_backtest_results
from freqtrade.resolvers import StrategyResolver
asyncio.set_event_loop(asyncio.new_event_loop())
@@ -101,8 +101,8 @@ def __run_backtest_bg(btconfig: Config):
if btconfig.get("export", "none") == "trades":
combined_res = combined_dataframes_with_rel_mean(ApiBG.bt["data"], min_date, max_date)
fn = store_backtest_stats(
btconfig["exportfilename"],
fn = store_backtest_results(
btconfig,
ApiBG.bt["bt"].results,
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
market_change_data=combined_res,
@@ -57,7 +57,7 @@ def pairlists_evaluate(
config_loc = deepcopy(config)
config_loc["stake_currency"] = ""
config_loc["pairs"] = payload.pairs
config_loc["timeframe"] = payload.timeframes
config_loc["timerange"] = payload.timerange
config_loc["days"] = payload.days
config_loc["timeframes"] = payload.timeframes
config_loc["erase"] = payload.erase
+4 -2
View File
@@ -6,7 +6,7 @@ from fastapi.exceptions import HTTPException
from freqtrade import __version__
from freqtrade.data.history import get_datahandler
from freqtrade.enums import CandleType, TradingMode
from freqtrade.enums import CandleType, State, TradingMode
from freqtrade.exceptions import OperationalException
from freqtrade.rpc import RPC
from freqtrade.rpc.api_server.api_schemas import (
@@ -217,7 +217,7 @@ def edge(rpc: RPC = Depends(get_rpc)):
@router.get("/show_config", response_model=ShowConfig, tags=["info"])
def show_config(rpc: RPC | None = Depends(get_rpc_optional), config=Depends(get_config)):
state = ""
state: State | str = ""
strategy_version = None
if rpc:
state = rpc._freqtrade.state
@@ -357,6 +357,7 @@ def pair_history(
config = deepcopy(config)
config.update(
{
"timeframe": timeframe,
"strategy": strategy,
"timerange": timerange,
"freqaimodel": freqaimodel if freqaimodel else config.get("freqaimodel"),
@@ -377,6 +378,7 @@ def pair_history_filtered(
config = deepcopy(config)
config.update(
{
"timeframe": payload.timeframe,
"strategy": payload.strategy,
"timerange": payload.timerange,
"freqaimodel": (
+109 -94
View File
@@ -7,7 +7,7 @@ from abc import abstractmethod
from collections.abc import Generator, Sequence
from datetime import date, datetime, timedelta, timezone
from math import isnan
from typing import Any, cast
from typing import TYPE_CHECKING, Any
import psutil
from dateutil.relativedelta import relativedelta
@@ -32,14 +32,21 @@ from freqtrade.enums import (
)
from freqtrade.exceptions import ExchangeError, PricingError
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs
from freqtrade.exchange.exchange_types import Ticker, Tickers
from freqtrade.exchange.exchange_utils import price_to_precision
from freqtrade.loggers import bufferHandler
from freqtrade.persistence import KeyStoreKeys, KeyValueStore, PairLocks, Trade
from freqtrade.persistence.models import PairLock
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
from freqtrade.rpc.rpc_types import RPCSendMsg
from freqtrade.util import decimals_per_coin, dt_now, dt_ts_def, format_date, shorten_date
from freqtrade.util import (
decimals_per_coin,
dt_from_ts,
dt_now,
dt_ts_def,
format_date,
shorten_date,
)
from freqtrade.util.datetime_helpers import dt_humanize_delta
from freqtrade.wallets import PositionWallet, Wallet
@@ -98,6 +105,10 @@ class RPC:
# Bind _fiat_converter if needed
_fiat_converter: CryptoToFiatConverter | None = None
if TYPE_CHECKING:
from freqtrade.freqtradebot import FreqtradeBot
_freqtrade: FreqtradeBot
def __init__(self, freqtrade) -> None:
"""
@@ -201,7 +212,7 @@ class RPC:
# calculate profit and send message to user
if trade.is_open:
try:
current_rate = self._freqtrade.exchange.get_rate(
current_rate: float = self._freqtrade.exchange.get_rate(
trade.pair, side="exit", is_short=trade.is_short, refresh=False
)
except (ExchangeError, PricingError):
@@ -219,7 +230,7 @@ class RPC:
else:
# Closed trade ...
current_rate = trade.close_rate
current_rate = trade.close_rate or 0.0
current_profit = trade.close_profit or 0.0
current_profit_abs = trade.close_profit_abs or 0.0
@@ -243,7 +254,11 @@ class RPC:
stoploss_entry_dist_ratio = stop_entry.profit_ratio
# calculate distance to stoploss
stoploss_current_dist = trade.stop_loss - current_rate
stoploss_current_dist = price_to_precision(
trade.stop_loss - current_rate,
trade.price_precision,
trade.precision_mode_price,
)
stoploss_current_dist_ratio = stoploss_current_dist / current_rate
trade_dict = trade.to_json()
@@ -264,6 +279,7 @@ class RPC:
stoploss_entry_dist=stoploss_entry_dist,
stoploss_entry_dist_ratio=round(stoploss_entry_dist_ratio, 8),
open_orders=oo_details,
nr_of_successful_entries=trade.nr_of_successful_entries,
)
)
results.append(trade_dict)
@@ -271,83 +287,82 @@ class RPC:
def _rpc_status_table(
self, stake_currency: str, fiat_display_currency: str
) -> tuple[list, list, float]:
trades: list[Trade] = Trade.get_open_trades()
) -> tuple[list, list, float, float]:
"""
:return: list of trades, list of columns, sum of fiat profit
"""
nonspot = self._config.get("trading_mode", TradingMode.SPOT) != TradingMode.SPOT
if not trades:
if not Trade.get_open_trades():
raise RPCException("no active trade")
else:
trades_list = []
fiat_profit_sum = nan
for trade in trades:
# calculate profit and send message to user
try:
current_rate = self._freqtrade.exchange.get_rate(
trade.pair, side="exit", is_short=trade.is_short, refresh=False
)
except (PricingError, ExchangeError):
current_rate = nan
trade_profit = nan
profit_str = f"{nan:.2%}"
else:
if trade.nr_of_successful_entries > 0:
profit = trade.calculate_profit(current_rate)
trade_profit = profit.profit_abs
profit_str = f"{profit.profit_ratio:.2%}"
else:
trade_profit = 0.0
profit_str = f"{0.0:.2f}"
leverage = f"{trade.leverage:.3g}"
direction_str = (
(f"S {leverage}x" if trade.is_short else f"L {leverage}x") if nonspot else ""
trades_list = []
fiat_profit_sum = nan
fiat_total_profit_sum = nan
for trade in self._rpc_trade_status():
# Format profit as a string with the right sign
profit = f"{trade['profit_ratio']:.2%}"
fiat_profit = trade.get("profit_fiat", None)
if fiat_profit is None or isnan(fiat_profit):
fiat_profit = trade.get("profit_abs", 0.0)
if not isnan(fiat_profit):
profit += f" ({fiat_profit:.2f})"
fiat_profit_sum = (
fiat_profit if isnan(fiat_profit_sum) else fiat_profit_sum + fiat_profit
)
total_profit = trade.get("total_profit_fiat", None)
if total_profit is None or isnan(total_profit):
total_profit = trade.get("total_profit_abs", 0.0)
if not isnan(total_profit):
fiat_total_profit_sum = (
total_profit
if isnan(fiat_total_profit_sum)
else fiat_total_profit_sum + total_profit
)
if self._fiat_converter:
fiat_profit = self._fiat_converter.convert_amount(
trade_profit, stake_currency, fiat_display_currency
)
if not isnan(fiat_profit):
profit_str += f" ({fiat_profit:.2f})"
fiat_profit_sum = (
fiat_profit if isnan(fiat_profit_sum) else fiat_profit_sum + fiat_profit
)
else:
profit_str += f" ({trade_profit:.2f})"
fiat_profit_sum = (
trade_profit if isnan(fiat_profit_sum) else fiat_profit_sum + trade_profit
)
active_attempt_side_symbols = [
"*" if (oo and oo.ft_order_side == trade.entry_side) else "**"
for oo in trade.open_orders
]
# Format the active order side symbols
active_order_side = ""
orders = trade.get("orders", [])
if orders:
active_order_side = ".".join(
"*" if (o.get("is_open") and o.get("ft_is_entry")) else "**"
for o in orders
if o.get("is_open")
)
# example: '*.**.**' trying to enter, exit and exit with 3 different orders
active_attempt_side_symbols_str = ".".join(active_attempt_side_symbols)
# Direction string for non-spot
direction_str = ""
if nonspot:
leverage = trade.get("leverage", 1.0)
direction_str = f"{'S' if trade.get('is_short') else 'L'} {leverage:.3g}x"
detail_trade = [
f"{trade.id} {direction_str}",
trade.pair + active_attempt_side_symbols_str,
shorten_date(dt_humanize_delta(trade.open_date_utc)),
profit_str,
]
detail_trade = [
f"{trade['trade_id']} {direction_str}",
f"{trade['pair']}{active_order_side}",
shorten_date(dt_humanize_delta(dt_from_ts(trade["open_timestamp"]))),
profit,
]
if self._config.get("position_adjustment_enable", False):
max_entry_str = ""
if self._config.get("max_entry_position_adjustment", -1) > 0:
max_entry_str = f"/{self._config['max_entry_position_adjustment'] + 1}"
filled_entries = trade.nr_of_successful_entries
detail_trade.append(f"{filled_entries}{max_entry_str}")
trades_list.append(detail_trade)
profitcol = "Profit"
if self._fiat_converter:
profitcol += " (" + fiat_display_currency + ")"
else:
profitcol += " (" + stake_currency + ")"
columns = ["ID L/S" if nonspot else "ID", "Pair", "Since", profitcol]
# Add number of entries if position adjustment is enabled
if self._config.get("position_adjustment_enable", False):
columns.append("# Entries")
return trades_list, columns, fiat_profit_sum
max_entry_str = ""
if self._config.get("max_entry_position_adjustment", -1) > 0:
max_entry_str = f"/{self._config['max_entry_position_adjustment'] + 1}"
filled_entries = trade.get("nr_of_successful_entries", 0)
detail_trade.append(f"{filled_entries}{max_entry_str}")
trades_list.append(detail_trade)
columns = [
"ID L/S" if nonspot else "ID",
"Pair",
"Since",
f"Profit ({fiat_display_currency if self._fiat_converter else stake_currency})",
]
if self._config.get("position_adjustment_enable", False):
columns.append("# Entries")
return trades_list, columns, fiat_profit_sum, fiat_total_profit_sum
def _rpc_timeunit_profit(
self,
@@ -572,8 +587,8 @@ class RPC:
# Doing the sum is not right - overall profit needs to be based on initial capital
profit_all_ratio_sum = sum(profit_all_ratio) if profit_all_ratio else 0.0
starting_balance = self._freqtrade.wallets.get_starting_balance()
profit_closed_ratio_fromstart = 0
profit_all_ratio_fromstart = 0
profit_closed_ratio_fromstart = 0.0
profit_all_ratio_fromstart = 0.0
if starting_balance:
profit_closed_ratio_fromstart = profit_closed_coin_sum / starting_balance
profit_all_ratio_fromstart = profit_all_coin_sum / starting_balance
@@ -670,7 +685,7 @@ class RPC:
}
def __balance_get_est_stake(
self, coin: str, stake_currency: str, amount: float, balance: Wallet, tickers: Tickers
self, coin: str, stake_currency: str, amount: float, balance: Wallet
) -> tuple[float, float]:
est_stake = 0.0
est_bot_stake = 0.0
@@ -681,14 +696,18 @@ class RPC:
est_stake = balance.free
est_bot_stake = amount
else:
pair = self._freqtrade.exchange.get_valid_pair_combination(coin, stake_currency)
rate: float | None = cast(Ticker, tickers.get(pair, {})).get("last", None)
if rate:
if pair.startswith(stake_currency) and not pair.endswith(stake_currency):
rate = 1.0 / rate
est_stake = rate * balance.total
est_bot_stake = rate * amount
try:
rate: float | None = self._freqtrade.exchange.get_conversion_rate(
coin, stake_currency
)
if rate:
est_stake = rate * balance.total
est_bot_stake = rate * amount
return est_stake, est_bot_stake
except (ExchangeError, PricingError) as e:
logger.warning(f"Error {e} getting rate for {coin}")
pass
return est_stake, est_bot_stake
def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> dict:
@@ -696,10 +715,6 @@ class RPC:
currencies: list[dict] = []
total = 0.0
total_bot = 0.0
try:
tickers: Tickers = self._freqtrade.exchange.get_tickers(cached=True)
except ExchangeError:
raise RPCException("Error getting current tickers.")
open_trades: list[Trade] = Trade.get_open_trades()
open_assets: dict[str, Trade] = {t.safe_base_currency: t for t in open_trades}
@@ -715,7 +730,7 @@ class RPC:
coin: str
balance: Wallet
for coin, balance in self._freqtrade.wallets.get_all_balances().items():
if not balance.total:
if not balance.total and not balance.free:
continue
trade = open_assets.get(coin, None)
@@ -726,7 +741,7 @@ class RPC:
try:
est_stake, est_stake_bot = self.__balance_get_est_stake(
coin, stake_currency, trade_amount, balance, tickers
coin, stake_currency, trade_amount, balance
)
except ValueError:
continue
@@ -886,10 +901,10 @@ class RPC:
if amount and amount < trade.amount:
# Partial exit ...
min_exit_stake = self._freqtrade.exchange.get_min_pair_stake_amount(
trade.pair, current_rate, trade.stop_loss_pct
trade.pair, current_rate, trade.stop_loss_pct or 0.0
)
remaining = (trade.amount - amount) * current_rate
if remaining < min_exit_stake:
if min_exit_stake and remaining < min_exit_stake:
raise RPCException(f"Remaining amount of {remaining} would be too small.")
sub_amount = amount
@@ -1229,7 +1244,7 @@ class RPC:
for pair in add:
if pair not in self._freqtrade.pairlists.blacklist:
try:
expand_pairlist([pair], self._freqtrade.exchange.get_markets().keys())
expand_pairlist([pair], list(self._freqtrade.exchange.get_markets().keys()))
self._freqtrade.pairlists.blacklist.append(pair)
except ValueError:
+65 -6
View File
@@ -90,6 +90,7 @@ class TimeunitMappings:
def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]):
"""
Decorator to check if the message comes from the correct chat_id
can only be used with Telegram Class to decorate instance methods.
:param command_handler: Telegram CommandHandler
:return: decorated function
"""
@@ -102,13 +103,21 @@ def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]):
# Reject unauthorized messages
if update.callback_query:
cchat_id = int(update.callback_query.message.chat.id)
ctopic_id = update.callback_query.message.message_thread_id
else:
cchat_id = int(update.message.chat_id)
ctopic_id = update.message.message_thread_id
chat_id = int(self._config["telegram"]["chat_id"])
if cchat_id != chat_id:
logger.info(f"Rejected unauthorized message from: {update.message.chat_id}")
return wrapper
logger.info(f"Rejected unauthorized message from: {cchat_id}")
return None
if (topic_id := self._config["telegram"].get("topic_id")) is not None:
if str(ctopic_id) != topic_id:
# This can be quite common in multi-topic environments.
logger.debug(f"Rejected message from wrong channel: {cchat_id}, {ctopic_id}")
return None
# Rollback session to avoid getting data stored in a transaction.
Trade.rollback()
logger.debug("Executing handler: %s for chat_id: %s", command_handler.__name__, chat_id)
@@ -291,6 +300,7 @@ class Telegram(RPCHandler):
CommandHandler("marketdir", self._changemarketdir),
CommandHandler("order", self._order),
CommandHandler("list_custom_data", self._list_custom_data),
CommandHandler("tg_info", self._tg_info),
]
callbacks = [
CallbackQueryHandler(self._status_table, pattern="update_status_table"),
@@ -848,11 +858,14 @@ class Telegram(RPCHandler):
:return: None
"""
fiat_currency = self._config.get("fiat_display_currency", "")
statlist, head, fiat_profit_sum = self._rpc._rpc_status_table(
statlist, head, fiat_profit_sum, fiat_total_profit_sum = self._rpc._rpc_status_table(
self._config["stake_currency"], fiat_currency
)
show_total = not isnan(fiat_profit_sum) and len(statlist) > 1
show_total_realized = (
not isnan(fiat_total_profit_sum) and len(statlist) > 1 and fiat_profit_sum
) != fiat_total_profit_sum
max_trades_per_msg = 50
"""
Calculate the number of messages of 50 trades per message
@@ -865,12 +878,22 @@ class Telegram(RPCHandler):
if show_total and i == messages_count - 1:
# append total line
trades.append(["Total", "", "", f"{fiat_profit_sum:.2f} {fiat_currency}"])
if show_total_realized:
trades.append(
[
"Total",
"(incl. realized Profits)",
"",
f"{fiat_total_profit_sum:.2f} {fiat_currency}",
]
)
message = tabulate(trades, headers=head, tablefmt="simple")
if show_total and i == messages_count - 1:
# insert separators line between Total
lines = message.split("\n")
message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
offset = 2 if show_total_realized else 1
message = "\n".join(lines[:-offset] + [lines[1]] + lines[-offset:])
await self._send_msg(
f"<pre>{message}</pre>",
parse_mode=ParseMode.HTML,
@@ -1277,7 +1300,7 @@ class Telegram(RPCHandler):
else:
fiat_currency = self._config.get("fiat_display_currency", "")
try:
statlist, _, _ = self._rpc._rpc_status_table(
statlist, _, _, _ = self._rpc._rpc_status_table(
self._config["stake_currency"], fiat_currency
)
except RPCException:
@@ -1806,7 +1829,7 @@ class Telegram(RPCHandler):
"*/fx <trade_id>|all:* `Alias to /forceexit`\n"
f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}"
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
"*/reload_trade <trade_id>:* `Relade trade from exchange Orders`\n"
"*/reload_trade <trade_id>:* `Reload trade from exchange Orders`\n"
"*/cancel_open_order <trade_id>:* `Cancels open orders for trade. "
"Only valid when the trade has open orders.`\n"
"*/coo <trade_id>|all:* `Alias to /cancel_open_order`\n"
@@ -2054,6 +2077,7 @@ class Telegram(RPCHandler):
parse_mode=parse_mode,
reply_markup=reply_markup,
disable_notification=disable_notification,
message_thread_id=self._config["telegram"].get("topic_id"),
)
except NetworkError as network_err:
# Sometimes the telegram server resets the current connection,
@@ -2067,6 +2091,7 @@ class Telegram(RPCHandler):
parse_mode=parse_mode,
reply_markup=reply_markup,
disable_notification=disable_notification,
message_thread_id=self._config["telegram"].get("topic_id"),
)
except TelegramError as telegram_err:
logger.warning("TelegramError: %s! Giving up on that message.", telegram_err.message)
@@ -2112,3 +2137,37 @@ class Telegram(RPCHandler):
"Invalid usage of command /marketdir. \n"
"Usage: */marketdir [short | long | even | none]*"
)
async def _tg_info(self, update: Update, context: CallbackContext) -> None:
"""
Intentionally unauthenticated Handler for /tg_info.
Returns information about the current telegram chat - even if chat_id does not
correspond to this chat.
:param update: message update
:return: None
"""
if not update.message:
return
chat_id = update.message.chat_id
topic_id = update.message.message_thread_id
msg = f"""Freqtrade Bot Info:
```json
{{
"enabled": true,
"token": "********",
"chat_id": "{chat_id}",
{f'"topic_id": "{topic_id}"' if topic_id else ""}
}}
```
"""
try:
await context.bot.send_message(
chat_id=chat_id,
text=msg,
parse_mode=ParseMode.MARKDOWN_V2,
message_thread_id=topic_id,
)
except TelegramError as telegram_err:
logger.warning("TelegramError: %s! Giving up on that message.", telegram_err.message)
+6 -13
View File
@@ -5,7 +5,6 @@ This module defines the interface to apply for strategies
import logging
from abc import ABC, abstractmethod
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from math import isinf, isnan
@@ -141,9 +140,7 @@ class IStrategy(ABC, HyperStrategyMixin):
market_direction: MarketDirection = MarketDirection.NONE
# Global cache dictionary
_cached_grouped_trades_per_pair: dict[
str, OrderedDict[tuple[datetime, datetime], DataFrame]
] = {}
_cached_grouped_trades_per_pair: dict[str, DataFrame] = {}
def __init__(self, config: Config) -> None:
self.config = config
@@ -1163,10 +1160,10 @@ class IStrategy(ABC, HyperStrategyMixin):
logger.warning(f"Empty candle (OHLCV) data for pair {pair}")
return None, None
latest_date = dataframe["date"].max()
latest = dataframe.loc[dataframe["date"] == latest_date].iloc[-1]
latest_date_pd = dataframe["date"].max()
latest = dataframe.loc[dataframe["date"] == latest_date_pd].iloc[-1]
# Explicitly convert to datetime object to ensure the below comparison does not fail
latest_date = latest_date.to_pydatetime()
latest_date: datetime = latest_date_pd.to_pydatetime()
# Check if dataframe is out of date
timeframe_minutes = timeframe_to_minutes(timeframe)
@@ -1604,15 +1601,11 @@ class IStrategy(ABC, HyperStrategyMixin):
if use_public_trades:
trades = self.dp.trades(pair=metadata["pair"], copy=False)
config = self.config
config["timeframe"] = self.timeframe
pair = metadata["pair"]
# TODO: slice trades to size of dataframe for faster backtesting
cached_grouped_trades: OrderedDict[tuple[datetime, datetime], DataFrame] = (
self._cached_grouped_trades_per_pair.get(pair, OrderedDict())
)
cached_grouped_trades: DataFrame | None = self._cached_grouped_trades_per_pair.get(pair)
dataframe, cached_grouped_trades = populate_dataframe_with_trades(
cached_grouped_trades, config, dataframe, trades
cached_grouped_trades, self.config, dataframe, trades
)
# dereference old cache
@@ -216,7 +216,7 @@
"# Get market change (average change of all pairs from start to end of the backtest period)\n",
"print(stats[\"strategy\"][strategy][\"market_change\"])\n",
"# Maximum drawdown ()\n",
"print(stats[\"strategy\"][strategy][\"max_drawdown\"])\n",
"print(stats[\"strategy\"][strategy][\"max_drawdown_abs\"])\n",
"# Maximum drawdown start and end\n",
"print(stats[\"strategy\"][strategy][\"drawdown_start\"])\n",
"print(stats[\"strategy\"][strategy][\"drawdown_end\"])\n",
+2
View File
@@ -11,6 +11,7 @@ from freqtrade.util.datetime_helpers import (
format_ms_time,
shorten_date,
)
from freqtrade.util.dry_run_wallet import get_dry_run_wallet
from freqtrade.util.formatters import decimals_per_coin, fmt_coin, fmt_coin2, round_value
from freqtrade.util.ft_precise import FtPrecise
from freqtrade.util.measure_time import MeasureTime
@@ -35,6 +36,7 @@ __all__ = [
"dt_utc",
"format_date",
"format_ms_time",
"get_dry_run_wallet",
"FtPrecise",
"PeriodicCache",
"shorten_date",
+12
View File
@@ -0,0 +1,12 @@
from freqtrade.constants import Config
def get_dry_run_wallet(config: Config) -> int | float:
"""
Return dry-run wallet balance in stake currency from configuration.
This setup also supports dictionary mode for dry-run-wallet.
"""
if isinstance(_start_cap := config["dry_run_wallet"], float | int):
return _start_cap
else:
return _start_cap.get("stake_currency")
+68 -35
View File
@@ -2,7 +2,6 @@
"""Wallet"""
import logging
from copy import deepcopy
from datetime import datetime, timedelta
from typing import NamedTuple
@@ -41,7 +40,14 @@ class Wallets:
self._exchange = exchange
self._wallets: dict[str, Wallet] = {}
self._positions: dict[str, PositionWallet] = {}
self._start_cap = config["dry_run_wallet"]
self._start_cap: dict[str, float] = {}
self._stake_currency = config["stake_currency"]
if isinstance(_start_cap := config["dry_run_wallet"], float | int):
self._start_cap[self._stake_currency] = _start_cap
else:
self._start_cap = _start_cap
self._last_wallet_refresh: datetime | None = None
self.update()
@@ -66,6 +72,18 @@ class Wallets:
else:
return 0
def get_collateral(self) -> float:
"""
Get total collateral for liquidation price calculation.
"""
if self._config.get("margin_mode") == "cross":
# free includes all balances and, combined with position collateral,
# is used as "wallet balance".
return self.get_free(self._stake_currency) + sum(
pos.collateral for pos in self._positions.values()
)
return self.get_total(self._stake_currency)
def get_owned(self, pair: str, base_currency: str) -> float:
"""
Get currently owned value.
@@ -109,54 +127,66 @@ class Wallets:
for o in trade.open_orders
if o.amount and o.ft_order_side == trade.exit_side
)
curr_wallet_bal = self._start_cap.get(curr, 0)
_wallets[curr] = Wallet(curr, trade.amount - pending, pending, trade.amount)
current_stake = self._start_cap + tot_profit - tot_in_trades
total_stake = current_stake + used_stake
_wallets[curr] = Wallet(
curr,
curr_wallet_bal + trade.amount - pending,
pending,
trade.amount + curr_wallet_bal,
)
else:
tot_in_trades = 0
for position in open_trades:
# size = self._exchange._contracts_to_amount(position.pair, position['contracts'])
size = position.amount
collateral = position.stake_amount
leverage = position.leverage
tot_in_trades += collateral
_positions[position.pair] = PositionWallet(
position.pair,
position=size,
leverage=leverage,
collateral=collateral,
position=position.amount,
leverage=position.leverage,
collateral=position.stake_amount,
side=position.trade_direction,
)
current_stake = self._start_cap + tot_profit - tot_in_trades
used_stake = tot_in_trades
total_stake = current_stake + tot_in_trades
_wallets[self._config["stake_currency"]] = Wallet(
currency=self._config["stake_currency"],
free=current_stake,
used_stake = tot_in_trades
cross_margin = 0.0
if self._config.get("margin_mode") == "cross":
# In cross-margin mode, the total balance is used as collateral.
# This is moved as "free" into the stake currency balance.
# strongly tied to the get_collateral() implementation.
for curr, bal in self._start_cap.items():
if curr == self._stake_currency:
continue
rate = self._exchange.get_conversion_rate(curr, self._stake_currency)
if rate:
cross_margin += bal * rate
current_stake = self._start_cap.get(self._stake_currency, 0) + tot_profit - tot_in_trades
total_stake = current_stake + used_stake
_wallets[self._stake_currency] = Wallet(
currency=self._stake_currency,
free=current_stake + cross_margin,
used=used_stake,
total=total_stake,
)
for currency, bal in self._start_cap.items():
if currency not in _wallets:
_wallets[currency] = Wallet(currency, bal, 0, bal)
self._wallets = _wallets
self._positions = _positions
def _update_live(self) -> None:
balances = self._exchange.get_balances()
_wallets = {}
for currency in balances:
if isinstance(balances[currency], dict):
self._wallets[currency] = Wallet(
_wallets[currency] = Wallet(
currency,
balances[currency].get("free", 0),
balances[currency].get("used", 0),
balances[currency].get("total", 0),
)
# Remove currencies no longer in get_balances output
for currency in deepcopy(self._wallets):
if currency not in balances:
del self._wallets[currency]
positions = self._exchange.fetch_positions()
_parsed_positions = {}
@@ -176,6 +206,7 @@ class Wallets:
side=position["side"],
)
self._positions = _parsed_positions
self._wallets = _wallets
def update(self, require_update: bool = True) -> None:
"""
@@ -244,8 +275,10 @@ class Wallets:
else:
tot_profit = Trade.get_total_closed_profit()
open_stakes = Trade.total_open_trades_stakes()
available_balance = self.get_free(self._config["stake_currency"])
return available_balance - tot_profit + open_stakes
available_balance = self.get_free(self._stake_currency)
return (available_balance - tot_profit + open_stakes) * self._config[
"tradable_balance_ratio"
]
def get_total_stake_amount(self):
"""
@@ -264,9 +297,9 @@ class Wallets:
# Ensure <tradable_balance_ratio>% is used from the overall balance
# Otherwise we'd risk lowering stakes with each open trade.
# (tied up + current free) * ratio) - tied up
available_amount = (
val_tied_up + self.get_free(self._config["stake_currency"])
) * self._config["tradable_balance_ratio"]
available_amount = (val_tied_up + self.get_free(self._stake_currency)) * self._config[
"tradable_balance_ratio"
]
return available_amount
def get_available_stake_amount(self) -> float:
@@ -277,7 +310,7 @@ class Wallets:
(<open_trade stakes> + free amount) * tradable_balance_ratio - <open_trade stakes>
"""
free = self.get_free(self._config["stake_currency"])
free = self.get_free(self._stake_currency)
return min(self.get_total_stake_amount() - Trade.total_open_trades_stakes(), free)
def _calculate_unlimited_stake_amount(
@@ -316,7 +349,7 @@ class Wallets:
f"lower than stake amount ({stake_amount} {self._config['stake_currency']})"
)
return stake_amount
return max(stake_amount, 0)
def get_trade_stake_amount(
self, pair: str, max_open_trades: IntOrInf, edge=None, update: bool = True
@@ -336,8 +369,8 @@ class Wallets:
if edge:
stake_amount = edge.stake_amount(
pair,
self.get_free(self._config["stake_currency"]),
self.get_total(self._config["stake_currency"]),
self.get_free(self._stake_currency),
self.get_total(self._stake_currency),
val_tied_up,
)
else:
+1 -1
View File
@@ -1,7 +1,7 @@
from freqtrade_client.ft_rest_client import FtRestClient
__version__ = "2024.11"
__version__ = "2024.12"
if "dev" in __version__:
from pathlib import Path
+2 -1
View File
@@ -111,6 +111,8 @@ develop = [
"pytest-cov",
"pytest-mock",
"pytest-random-order",
"pytest-timeout",
"pytest-xdist",
"pytest",
"ruff",
"time-machine",
@@ -215,7 +217,6 @@ exclude = [
"build_helpers/*.py",
"ft_client/build/*",
"build/*",
"tests/*",
]
ignore = ["freqtrade/vendor/**"]
pythonPlatform = "All"
+6 -6
View File
@@ -7,11 +7,11 @@
-r docs/requirements-docs.txt
coveralls==4.0.1
ruff==0.8.0
mypy==1.13.0
ruff==0.8.4
mypy==1.14.0
pre-commit==4.0.1
pytest==8.3.3
pytest-asyncio==0.24.0
pytest==8.3.4
pytest-asyncio==0.25.0
pytest-cov==6.0.0
pytest-mock==3.14.0
pytest-random-order==1.1.1
@@ -28,5 +28,5 @@ nbconvert==7.16.4
types-cachetools==5.5.0.20240820
types-filelock==3.2.7
types-requests==2.32.0.20241016
types-tabulate==0.9.0.20240106
types-python-dateutil==2.9.0.20241003
types-tabulate==0.9.0.20241207
types-python-dateutil==2.9.0.20241206
+2 -5
View File
@@ -3,13 +3,10 @@
-r requirements-plot.txt
# Required for freqai
scikit-learn==1.5.2
scikit-learn==1.6.0
joblib==1.4.2
catboost==1.2.7; 'arm' not in platform_machine
# Pin Matplotlib - it's depended on by catboost
# Temporary downgrade of matplotlib due to https://github.com/matplotlib/matplotlib/issues/28551
matplotlib==3.9.2
lightgbm==4.5.0
xgboost==2.0.3
xgboost==2.1.3
tensorboard==2.18.0
datasieve==0.1.7
+1 -1
View File
@@ -3,6 +3,6 @@
# Required for hyperopt
scipy==1.14.1
scikit-learn==1.5.2
scikit-learn==1.6.0
ft-scikit-optimize==0.9.2
filelock==3.16.1
+17 -14
View File
@@ -4,28 +4,31 @@ bottleneck==1.4.2
numexpr==2.10.2
pandas-ta==0.3.14b
ccxt==4.4.35
ccxt==4.4.43
cryptography==42.0.8; platform_machine == 'armv7l'
cryptography==43.0.3; platform_machine != 'armv7l'
cryptography==44.0.0; platform_machine != 'armv7l'
aiohttp==3.10.11
SQLAlchemy==2.0.36
python-telegram-bot==21.7
python-telegram-bot==21.9
# can't be hard-pinned due to telegram-bot pinning httpx with ~
httpx>=0.24.1
humanize==4.11.0
cachetools==5.5.0
requests==2.32.3
urllib3==2.2.3
urllib3==2.3.0
jsonschema==4.23.0
TA-Lib==0.4.32
technical==1.4.4
TA-Lib==0.4.34
technical==1.5.0
tabulate==0.9.0
pycoingecko==3.2.0
jinja2==3.1.4
jinja2==3.1.5
tables==3.10.1
# Pin blosc2 to < 3.0 until piwheel has wheels for 3.x
blosc2==2.7.1; platform_machine == 'armv7l' or python_version < '3.11'
blosc2==3.0.0; platform_machine != 'armv7l' and python_version >= '3.11'
joblib==1.4.2
rich==13.9.4
pyarrow==18.0.0; platform_machine != 'armv7l'
pyarrow==18.1.0; platform_machine != 'armv7l'
# find first, C search in arrays
py_find_1st==1.1.6
@@ -39,12 +42,12 @@ orjson==3.10.12
sdnotify==0.3.2
# API Server
fastapi==0.115.5
pydantic==2.10.1
uvicorn==0.32.1
pyjwt==2.10.0
fastapi==0.115.6
pydantic==2.10.4
uvicorn==0.34.0
pyjwt==2.10.1
aiofiles==24.1.0
psutil==6.1.0
psutil==6.1.1
# Building config files interactively
questionary==2.0.1
@@ -58,7 +61,7 @@ schedule==1.2.2
#WS Messages
websockets==14.1
janus==1.1.0
janus==2.0.0
ast-comments==1.2.2
packaging==24.2
+5 -2
View File
@@ -1532,8 +1532,11 @@ def test_hyperopt_list(mocker, capsys, caplog, tmp_path):
assert csv_file.is_file()
line = csv_file.read_text()
assert (
'Best,1,2,-1.25%,-1.2222,-0.00125625,,-2.51,"3,930.0 m",0.43662' in line
or "Best,1,2,-1.25%,-1.2222,-0.00125625,,-2.51,2 days 17:30:00,2,0,0.43662" in line
'Best,1,2,-1.25%,-1.2222,-0.00125625,BTC,-2.51,"3,930.0 m",-0.00125625,23.00%,0.43662'
in line
or "Best,1,2,-1.25%,-1.2222,-0.00125625,BTC,-2.51,2 days 17:30:00,2,0,-0.00125625,23.00%,"
"0.43662"
in line
)
csv_file.unlink()
+28 -3
View File
@@ -1,6 +1,7 @@
# pragma pylint: disable=missing-docstring
import json
import logging
import platform
import re
from copy import deepcopy
from datetime import datetime, timedelta, timezone
@@ -517,6 +518,30 @@ def patch_gc(mocker) -> None:
mocker.patch("freqtrade.main.gc_set_threshold")
def is_arm() -> bool:
machine = platform.machine()
return "arm" in machine or "aarch64" in machine
def is_mac() -> bool:
machine = platform.system()
return "Darwin" in machine
@pytest.fixture(autouse=True)
def patch_torch_initlogs(mocker) -> None:
if is_mac():
# Mock torch import completely
import sys
import types
module_name = "torch"
mocked_module = types.ModuleType(module_name)
sys.modules[module_name] = mocked_module
else:
mocker.patch("torch._logging._init_logs")
@pytest.fixture(autouse=True)
def user_dir(mocker, tmp_path) -> Path:
user_dir = tmp_path / "user_data"
@@ -600,7 +625,7 @@ def get_default_conf(testdatadir):
"telegram": {
"enabled": False,
"token": "token",
"chat_id": "0",
"chat_id": "1235",
"notification_settings": {},
},
"datadir": Path(testdatadir),
@@ -2212,7 +2237,7 @@ def tickers():
"first": None,
"last": 8603.67,
"change": -0.879,
"percentage": None,
"percentage": -8.95,
"average": None,
"baseVolume": 30414.604298,
"quoteVolume": 259629896.48584127,
@@ -2256,7 +2281,7 @@ def tickers():
"first": None,
"last": 129.28,
"change": 1.795,
"percentage": None,
"percentage": -2.5,
"average": None,
"baseVolume": 59698.79897,
"quoteVolume": 29132399.743954,
+102 -8
View File
@@ -1,13 +1,17 @@
from collections import OrderedDict
import numpy as np
import pandas as pd
import pytest
from freqtrade.constants import DEFAULT_TRADES_COLUMNS
from freqtrade.data.converter import populate_dataframe_with_trades
from freqtrade.data.converter.orderflow import trades_to_volumeprofile_with_total_delta_bid_ask
from freqtrade.data.converter.orderflow import (
ORDERFLOW_ADDED_COLUMNS,
timeframe_to_DateOffset,
trades_to_volumeprofile_with_total_delta_bid_ask,
)
from freqtrade.data.converter.trade_converter import trades_list_to_df
from freqtrade.data.dataprovider import DataProvider
from tests.strategy.strats.strategy_test_v3 import StrategyTestV3
BIN_SIZE_SCALE = 0.5
@@ -37,6 +41,7 @@ def populate_dataframe_with_trades_trades(testdatadir):
@pytest.fixture
def candles(testdatadir):
# TODO: this fixture isn't really necessary and could be removed
return pd.read_json(testdatadir / "orderflow/candles.json").copy()
@@ -102,7 +107,7 @@ def test_public_trades_mock_populate_dataframe_with_trades__check_orderflow(
},
}
# Apply the function to populate the data frame with order flow data
df, _ = populate_dataframe_with_trades(OrderedDict(), config, dataframe, trades)
df, _ = populate_dataframe_with_trades(None, config, dataframe, trades)
# Extract results from the first row of the DataFrame
results = df.iloc[0]
t = results["trades"]
@@ -243,7 +248,7 @@ def test_public_trades_trades_mock_populate_dataframe_with_trades__check_trades(
}
# Populate the DataFrame with trades and order flow data
df, _ = populate_dataframe_with_trades(OrderedDict(), config, dataframe, trades)
df, _ = populate_dataframe_with_trades(None, config, dataframe, trades)
# --- DataFrame and Trade Data Validation ---
@@ -401,9 +406,7 @@ def test_public_trades_config_max_trades(
},
}
df, _ = populate_dataframe_with_trades(
OrderedDict(), default_conf | orderflow_config, dataframe, trades
)
df, _ = populate_dataframe_with_trades(None, default_conf | orderflow_config, dataframe, trades)
assert df.delta.count() == 1
@@ -482,3 +485,94 @@ def test_public_trades_testdata_sanity(
"cost",
"date",
]
def test_analyze_with_orderflow(
default_conf_usdt,
mocker,
populate_dataframe_with_trades_dataframe,
populate_dataframe_with_trades_trades,
):
ohlcv_history = populate_dataframe_with_trades_dataframe
# call without orderflow
strategy = StrategyTestV3(config=default_conf_usdt)
strategy.dp = DataProvider(default_conf_usdt, None, None)
mocker.patch.object(strategy.dp, "trades", return_value=populate_dataframe_with_trades_trades)
import freqtrade.data.converter.orderflow as orderflow_module
spy = mocker.spy(orderflow_module, "trades_to_volumeprofile_with_total_delta_bid_ask")
pair = "ETH/BTC"
df = strategy.advise_indicators(ohlcv_history, {"pair:": pair})
assert len(df) == len(ohlcv_history)
assert "open" in df.columns
assert spy.call_count == 0
# Not expected to run - shouldn't have added orderflow columns
for col in ORDERFLOW_ADDED_COLUMNS:
assert col not in df.columns, f"Column {col} found in df.columns"
default_conf_usdt["exchange"]["use_public_trades"] = True
default_conf_usdt["orderflow"] = {
"cache_size": 5,
"max_candles": 5,
"scale": 0.005,
"imbalance_volume": 0,
"imbalance_ratio": 3,
"stacked_imbalance_range": 3,
}
strategy.config = default_conf_usdt
# First round - builds cache
df1 = strategy.advise_indicators(ohlcv_history, {"pair": pair})
assert len(df1) == len(ohlcv_history)
assert "open" in df1.columns
assert spy.call_count == 5
for col in ORDERFLOW_ADDED_COLUMNS:
assert col in df1.columns, f"Column {col} not found in df.columns"
if col not in ("stacked_imbalances_bid", "stacked_imbalances_ask"):
assert df1[col].count() == 5, f"Column {col} has {df1[col].count()} non-NaN values"
assert len(strategy._cached_grouped_trades_per_pair[pair]) == 5
lastval_trades = df1.at[len(df1) - 1, "trades"]
assert isinstance(lastval_trades, list)
assert len(lastval_trades) == 122
lastval_of = df1.at[len(df1) - 1, "orderflow"]
assert isinstance(lastval_of, dict)
spy.reset_mock()
# Ensure caching works - call the same logic again.
df2 = strategy.advise_indicators(ohlcv_history, {"pair": pair})
assert len(df2) == len(ohlcv_history)
assert "open" in df2.columns
assert spy.call_count == 0
for col in ORDERFLOW_ADDED_COLUMNS:
assert col in df2.columns, f"Round2: Column {col} not found in df.columns"
if col not in ("stacked_imbalances_bid", "stacked_imbalances_ask"):
assert (
df2[col].count() == 5
), f"Round2: Column {col} has {df2[col].count()} non-NaN values"
lastval_trade2 = df2.at[len(df2) - 1, "trades"]
assert isinstance(lastval_trade2, list)
assert len(lastval_trade2) == 122
lastval_of2 = df2.at[len(df2) - 1, "orderflow"]
assert isinstance(lastval_of2, dict)
def test_timeframe_to_DateOffset():
assert timeframe_to_DateOffset("1s") == pd.DateOffset(seconds=1)
assert timeframe_to_DateOffset("1m") == pd.DateOffset(minutes=1)
assert timeframe_to_DateOffset("5m") == pd.DateOffset(minutes=5)
assert timeframe_to_DateOffset("1h") == pd.DateOffset(hours=1)
assert timeframe_to_DateOffset("1d") == pd.DateOffset(days=1)
assert timeframe_to_DateOffset("1w") == pd.DateOffset(weeks=1)
assert timeframe_to_DateOffset("1M") == pd.DateOffset(months=1)
assert timeframe_to_DateOffset("1y") == pd.DateOffset(years=1)
+5
View File
@@ -360,6 +360,11 @@ def test_hdf5datahandler_trades_load(testdatadir):
# assert len([t for t in trades2 if t[0] > timerange.stopts * 1000]) == 0
def test_hdf5datahandler_deprecated(testdatadir, caplog):
get_datahandler(testdatadir, "hdf5")
log_has_re(r"DEPRECATED: The hdf5 dataformat is deprecated.*", caplog)
@pytest.mark.parametrize(
"pair,timeframe,candle_type,candle_append,startdt,enddt",
[
+3 -3
View File
@@ -128,8 +128,8 @@ def test_load_data_with_new_pair_1min(
"""
Test load_pair_history() with 1 min timeframe
"""
mocker.patch(f"{EXMS}.get_historic_ohlcv", return_value=ohlcv_history)
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch.object(exchange, "get_historic_ohlcv", return_value=ohlcv_history)
file = tmp_path / "MEME_BTC-1m.feather"
# do not download a new pair if refresh_pairs isn't set
@@ -306,8 +306,8 @@ def test_load_cached_data_for_updating(testdatadir) -> None:
def test_download_pair_history(
ohlcv_history, mocker, default_conf, tmp_path, candle_type, subdir, file_tail
) -> None:
mocker.patch(f"{EXMS}.get_historic_ohlcv", return_value=ohlcv_history)
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch.object(exchange, "get_historic_ohlcv", return_value=ohlcv_history)
file1_1 = tmp_path / f"{subdir}MEME_BTC-1m{file_tail}.feather"
file1_5 = tmp_path / f"{subdir}MEME_BTC-5m{file_tail}.feather"
file2_1 = tmp_path / f"{subdir}CFI_BTC-1m{file_tail}.feather"
@@ -357,8 +357,8 @@ def test_download_pair_history2(mocker, default_conf, testdatadir, ohlcv_history
"freqtrade.data.history.datahandlers.featherdatahandler.FeatherDataHandler.ohlcv_store",
return_value=None,
)
mocker.patch(f"{EXMS}.get_historic_ohlcv", return_value=ohlcv_history)
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch.object(exchange, "get_historic_ohlcv", return_value=ohlcv_history)
_download_pair_history(
datadir=testdatadir,
exchange=exchange,
+237 -32
View File
@@ -1,14 +1,17 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta
from random import randint
from unittest.mock import MagicMock, PropertyMock
import ccxt
import pandas as pd
import pytest
from freqtrade.enums import CandleType, MarginMode, TradingMode
from freqtrade.exceptions import DependencyException, InvalidOrderException, OperationalException
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_seconds
from freqtrade.persistence import Trade
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has_re
from freqtrade.util.datetime_helpers import dt_from_ts, dt_ts, dt_utc
from tests.conftest import EXMS, get_patched_exchange
from tests.exchange.test_exchange import ccxt_exceptionhandlers
@@ -290,6 +293,7 @@ def test_liquidation_price_binance(
default_conf["trading_mode"] = trading_mode
default_conf["margin_mode"] = margin_mode
default_conf["liquidation_buffer"] = 0.0
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
exchange = get_patched_exchange(mocker, default_conf, exchange="binance")
def get_maint_ratio(pair_, stake_amount):
@@ -731,42 +735,243 @@ def test__set_leverage_binance(mocker, default_conf):
)
@pytest.mark.parametrize("candle_type", [CandleType.MARK, ""])
async def test__async_get_historic_ohlcv_binance(default_conf, mocker, caplog, candle_type):
ohlcv = [
[
int((datetime.now(timezone.utc).timestamp() - 1000) * 1000),
1, # open
2, # high
3, # low
4, # close
5, # volume (in quote currency)
def patch_binance_vision_ohlcv(mocker, start, archive_end, api_end, timeframe):
def make_storage(start: datetime, end: datetime, timeframe: str):
date = pd.date_range(start, end, freq=timeframe.replace("m", "min"))
df = pd.DataFrame(
data=dict(date=date, open=1.0, high=1.0, low=1.0, close=1.0),
)
return df
archive_storage = make_storage(start, archive_end, timeframe)
api_storage = make_storage(start, api_end, timeframe)
ohlcv = [[dt_ts(start), 1, 1, 1, 1]]
# (pair, timeframe, candle_type, ohlcv, True)
candle_history = [None, None, None, ohlcv, None]
def get_historic_ohlcv(
# self,
pair: str,
timeframe: str,
since_ms: int,
candle_type: CandleType,
is_new_pair: bool = False,
until_ms: int | None = None,
):
since = dt_from_ts(since_ms)
until = dt_from_ts(until_ms) if until_ms else api_end + timedelta(seconds=1)
return api_storage.loc[(api_storage["date"] >= since) & (api_storage["date"] < until)]
async def download_archive_ohlcv(
candle_type,
pair,
timeframe,
since_ms,
until_ms,
markets=None,
stop_on_404=False,
):
since = dt_from_ts(since_ms)
until = dt_from_ts(until_ms) if until_ms else archive_end + timedelta(seconds=1)
if since < start:
pass
return archive_storage.loc[
(archive_storage["date"] >= since) & (archive_storage["date"] < until)
]
]
candle_mock = mocker.patch(f"{EXMS}._async_get_candle_history", return_value=candle_history)
api_mock = mocker.patch(f"{EXMS}.get_historic_ohlcv", side_effect=get_historic_ohlcv)
archive_mock = mocker.patch(
"freqtrade.exchange.binance.download_archive_ohlcv", side_effect=download_archive_ohlcv
)
return candle_mock, api_mock, archive_mock
@pytest.mark.parametrize(
"timeframe,is_new_pair,since,until,first_date,last_date,candle_called,archive_called,"
"api_called",
[
(
"1m",
True,
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23, 59),
True,
True,
False,
),
(
"1m",
True,
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 3),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 2, 23, 59),
True,
True,
True,
),
(
"1m",
True,
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 2, 1),
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 2, 0, 59),
True,
False,
True,
),
(
"1m",
False,
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23, 59),
False,
True,
False,
),
(
"1m",
True,
dt_utc(2019, 1, 1),
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23, 59),
True,
True,
False,
),
(
"1m",
False,
dt_utc(2019, 1, 1),
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23, 59),
False,
True,
False,
),
(
"1m",
False,
dt_utc(2019, 1, 1),
dt_utc(2019, 1, 2),
None,
None,
False,
True,
True,
),
(
"1m",
True,
dt_utc(2019, 1, 1),
dt_utc(2019, 1, 2),
None,
None,
True,
False,
False,
),
(
"1m",
False,
dt_utc(2021, 1, 1),
dt_utc(2021, 1, 2),
None,
None,
False,
False,
False,
),
(
"1m",
True,
dt_utc(2021, 1, 1),
dt_utc(2021, 1, 2),
None,
None,
True,
False,
False,
),
(
"1h",
False,
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23),
False,
False,
True,
),
(
"1m",
False,
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 3, 50, 30),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 3, 50),
False,
True,
False,
),
],
)
def test_get_historic_ohlcv_binance(
mocker,
default_conf,
timeframe,
is_new_pair,
since,
until,
first_date,
last_date,
candle_called,
archive_called,
api_called,
):
exchange = get_patched_exchange(mocker, default_conf, exchange="binance")
# Monkey-patch async function
exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv)
pair = "ETH/BTC"
respair, restf, restype, res, _ = await exchange._async_get_historic_ohlcv(
pair, "5m", 1500000000000, is_new_pair=False, candle_type=candle_type
)
assert respair == pair
assert restf == "5m"
assert restype == candle_type
# Call with very old timestamp - causes tons of requests
assert exchange._api_async.fetch_ohlcv.call_count > 400
# assert res == ohlcv
exchange._api_async.fetch_ohlcv.reset_mock()
_, _, _, res, _ = await exchange._async_get_historic_ohlcv(
pair, "5m", 1500000000000, is_new_pair=True, candle_type=candle_type
start = dt_utc(2020, 1, 1)
archive_end = dt_utc(2020, 1, 2)
api_end = dt_utc(2020, 1, 3)
candle_mock, api_mock, archive_mock = patch_binance_vision_ohlcv(
mocker, start=start, archive_end=archive_end, api_end=api_end, timeframe=timeframe
)
# Called twice - one "init" call - and one to get the actual data.
assert exchange._api_async.fetch_ohlcv.call_count == 2
assert res == ohlcv
assert log_has_re(r"Candle-data for ETH/BTC available starting with .*", caplog)
candle_type = CandleType.SPOT
pair = "BTC/USDT"
since_ms = dt_ts(since)
until_ms = dt_ts(until)
df = exchange.get_historic_ohlcv(pair, timeframe, since_ms, candle_type, is_new_pair, until_ms)
if df.empty:
assert first_date is None
assert last_date is None
else:
assert df["date"].iloc[0] == first_date
assert df["date"].iloc[-1] == last_date
assert (
df["date"].diff().iloc[1:] == timedelta(seconds=timeframe_to_seconds(timeframe))
).all()
if candle_called:
candle_mock.assert_called_once()
if archive_called:
archive_mock.assert_called_once()
if api_called:
api_mock.assert_called_once()
@pytest.mark.parametrize(
+337
View File
@@ -0,0 +1,337 @@
import asyncio
import datetime
import io
import re
import sys
import zipfile
from datetime import timedelta
import aiohttp
import pandas as pd
import pytest
from freqtrade.enums import CandleType
from freqtrade.exchange.binance_public_data import (
BadHttpStatus,
Http404,
binance_vision_zip_name,
download_archive_ohlcv,
get_daily_ohlcv,
)
from freqtrade.util.datetime_helpers import dt_ts, dt_utc
@pytest.fixture(scope="module")
def event_loop_policy(request):
if sys.platform == "win32":
return asyncio.WindowsSelectorEventLoopPolicy()
else:
return asyncio.DefaultEventLoopPolicy()
class MockResponse:
"""AioHTTP response mock"""
def __init__(self, content, status, reason=""):
self._content = content
self.status = status
self.reason = reason
async def read(self):
return self._content
async def __aexit__(self, exc_type, exc, tb):
pass
async def __aenter__(self):
return self
# spot klines archive csv file format, the futures/um klines don't have the header line
#
# open_time,open,high,low,close,volume,close_time,quote_volume,count,taker_buy_volume,taker_buy_quote_volume,ignore # noqa: E501
# 1698364800000,34161.6,34182.5,33977.4,34024.2,409953,1698368399999,1202.97118037,15095,192220,564.12041453,0 # noqa: E501
# 1698368400000,34024.2,34060.1,33776.4,33848.4,740960,1698371999999,2183.75671155,23938,368266,1085.17080793,0 # noqa: E501
# 1698372000000,33848.5,34150.0,33815.1,34094.2,390376,1698375599999,1147.73267094,13854,231446,680.60405822,0 # noqa: E501
def make_response_from_url(start_date, end_date):
def make_daily_df(date, timeframe):
start = dt_utc(date.year, date.month, date.day)
end = start + timedelta(days=1)
date_col = pd.date_range(start, end, freq=timeframe.replace("m", "min"), inclusive="left")
cols = (
"open_time,open,high,low,close,volume,close_time,quote_volume,count,taker_buy_volume,"
"taker_buy_quote_volume,ignore"
)
df = pd.DataFrame(columns=cols.split(","), dtype=float)
df["open_time"] = date_col.astype("int64") // 10**6
df["open"] = df["high"] = df["low"] = df["close"] = df["volume"] = 1.0
return df
def make_daily_zip(asset_type_url_segment, symbol, timeframe, date) -> bytes:
df = make_daily_df(date, timeframe)
if asset_type_url_segment == "spot":
header = True
elif asset_type_url_segment == "futures/um":
header = None
else:
raise ValueError
csv = df.to_csv(index=False, header=header)
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w") as zipf:
zipf.writestr(binance_vision_zip_name(symbol, timeframe, date), csv)
return zip_buffer.getvalue()
def make_response(url):
pattern = (
r"https://data.binance.vision/data/(?P<asset_type_url_segment>spot|futures/um)"
r"/daily/klines/(?P<symbol>.*?)/(?P<timeframe>.*?)/(?P=symbol)-(?P=timeframe)-"
r"(?P<date>\d{4}-\d{2}-\d{2}).zip"
)
m = re.match(pattern, url)
if not m:
return MockResponse(content="", status=404)
date = datetime.datetime.strptime(m["date"], "%Y-%m-%d").date()
if date < start_date or date > end_date:
return MockResponse(content="", status=404)
zip_file = make_daily_zip(m["asset_type_url_segment"], m["symbol"], m["timeframe"], date)
return MockResponse(content=zip_file, status=200)
return make_response
@pytest.mark.parametrize(
"candle_type,pair,since,until,first_date,last_date,stop_on_404",
[
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23),
False,
),
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23, 59, 59),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23),
False,
),
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 5),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 3, 23),
False,
),
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2019, 12, 25),
dt_utc(2020, 1, 5),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 3, 23),
False,
),
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2019, 1, 1),
dt_utc(2019, 1, 5),
None,
None,
False,
),
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2021, 1, 1),
dt_utc(2021, 1, 5),
None,
None,
False,
),
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2020, 1, 2),
None,
dt_utc(2020, 1, 2),
dt_utc(2020, 1, 3, 23),
False,
),
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2020, 1, 5),
dt_utc(2020, 1, 1),
None,
None,
False,
),
(
CandleType.FUTURES,
"BTC/USDT:USDT",
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23, 59, 59),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23),
False,
),
(
CandleType.INDEX,
"N/A",
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 1, 23, 59, 59),
None,
None,
False,
),
# stop_on_404 = True
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2019, 12, 25),
dt_utc(2020, 1, 5),
None,
None,
True,
),
(
CandleType.SPOT,
"BTC/USDT",
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 5),
dt_utc(2020, 1, 1),
dt_utc(2020, 1, 3, 23),
True,
),
(
CandleType.FUTURES,
"BTC/USDT:USDT",
dt_utc(2019, 12, 25),
dt_utc(2020, 1, 5),
None,
None,
True,
),
],
)
async def test_download_archive_ohlcv(
mocker, candle_type, pair, since, until, first_date, last_date, stop_on_404
):
history_start = dt_utc(2020, 1, 1).date()
history_end = dt_utc(2020, 1, 3).date()
timeframe = "1h"
since_ms = dt_ts(since)
until_ms = dt_ts(until)
mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
side_effect=make_response_from_url(history_start, history_end),
)
markets = {"BTC/USDT": {"id": "BTCUSDT"}, "BTC/USDT:USDT": {"id": "BTCUSDT"}}
df = await download_archive_ohlcv(
candle_type,
pair,
timeframe,
since_ms=since_ms,
until_ms=until_ms,
markets=markets,
stop_on_404=stop_on_404,
)
if df.empty:
assert first_date is None and last_date is None
else:
assert candle_type in [CandleType.SPOT, CandleType.FUTURES]
assert df["date"].iloc[0] == first_date
assert df["date"].iloc[-1] == last_date
async def test_download_archive_ohlcv_exception(mocker):
timeframe = "1h"
pair = "BTC/USDT"
since_ms = dt_ts(dt_utc(2020, 1, 1))
until_ms = dt_ts(dt_utc(2020, 1, 2))
markets = {"BTC/USDT": {"id": "BTCUSDT"}, "BTC/USDT:USDT": {"id": "BTCUSDT"}}
mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get", side_effect=RuntimeError
)
df = await download_archive_ohlcv(
CandleType.SPOT, pair, timeframe, since_ms=since_ms, until_ms=until_ms, markets=markets
)
assert df.empty
async def test_get_daily_ohlcv(mocker, testdatadir):
symbol = "BTCUSDT"
timeframe = "1h"
date = dt_utc(2024, 10, 28).date()
first_date = dt_utc(2024, 10, 28)
last_date = dt_utc(2024, 10, 28, 23)
async with aiohttp.ClientSession() as session:
spot_path = (
testdatadir / "binance/binance_public_data/spot-klines-BTCUSDT-1h-2024-10-28.zip"
)
get = mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
return_value=MockResponse(spot_path.read_bytes(), 200),
)
df = await get_daily_ohlcv("spot", symbol, timeframe, date, session)
assert get.call_count == 1
assert df["date"].iloc[0] == first_date
assert df["date"].iloc[-1] == last_date
futures_path = (
testdatadir / "binance/binance_public_data/futures-um-klines-BTCUSDT-1h-2024-10-28.zip"
)
get = mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
return_value=MockResponse(futures_path.read_bytes(), 200),
)
df = await get_daily_ohlcv("futures/um", symbol, timeframe, date, session)
assert get.call_count == 1
assert df["date"].iloc[0] == first_date
assert df["date"].iloc[-1] == last_date
get = mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
return_value=MockResponse(b"", 404),
)
with pytest.raises(Http404):
df = await get_daily_ohlcv("spot", symbol, timeframe, date, session, retry_delay=0)
assert get.call_count == 1
get = mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
return_value=MockResponse(b"", 500),
)
mocker.patch("asyncio.sleep")
with pytest.raises(BadHttpStatus):
df = await get_daily_ohlcv("spot", symbol, timeframe, date, session)
assert get.call_count == 4 # 1 + 3 default retries
get = mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
return_value=MockResponse(b"nop", 200),
)
with pytest.raises(zipfile.BadZipFile):
df = await get_daily_ohlcv("spot", symbol, timeframe, date, session)
assert get.call_count == 4 # 1 + 3 default retries
+54 -6
View File
@@ -2006,6 +2006,46 @@ def test_get_tickers(default_conf, mocker, exchange_name, caplog):
assert exchange.get_tickers() == {}
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_get_conversion_rate(default_conf_usdt, mocker, exchange_name):
api_mock = MagicMock()
tick = {
"ETH/USDT": {
"last": 42,
},
"BCH/USDT": {
"last": 41,
},
"ETH/BTC": {
"last": 250,
},
}
tick2 = {
"ADA/USDT:USDT": {
"last": 2.5,
}
}
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
api_mock.fetch_tickers = MagicMock(side_effect=[tick, tick2])
api_mock.fetch_bids_asks = MagicMock(return_value={})
exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange=exchange_name)
# retrieve original ticker
assert exchange.get_conversion_rate("USDT", "USDT") == 1
assert api_mock.fetch_tickers.call_count == 0
assert exchange.get_conversion_rate("ETH", "USDT") == 42
assert exchange.get_conversion_rate("ETH", "USDC") is None
assert exchange.get_conversion_rate("ETH", "BTC") == 250
assert exchange.get_conversion_rate("BTC", "ETH") == 0.004
assert api_mock.fetch_tickers.call_count == 1
api_mock.fetch_tickers.reset_mock()
assert exchange.get_conversion_rate("ADA", "USDT") == 2.5
# Only the call to the "others" market
assert api_mock.fetch_tickers.call_count == 1
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_ticker(default_conf, mocker, exchange_name):
api_mock = MagicMock()
@@ -2091,6 +2131,7 @@ def test___now_is_time_to_refresh(default_conf, mocker, exchange_name, time_mach
@pytest.mark.parametrize("candle_type", ["mark", ""])
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_get_historic_ohlcv(default_conf, mocker, caplog, exchange_name, candle_type):
caplog.set_level(logging.DEBUG)
exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name)
pair = "ETH/BTC"
calls = 0
@@ -2123,7 +2164,7 @@ def test_get_historic_ohlcv(default_conf, mocker, caplog, exchange_name, candle_
assert exchange._async_get_candle_history.call_count == 2
# Returns twice the above OHLCV data after truncating the open candle.
assert len(ret) == 2
assert log_has_re(r"Downloaded data for .* with length .*\.", caplog)
assert log_has_re(r"Downloaded data for .* from ccxt with length .*\.", caplog)
caplog.clear()
@@ -2156,7 +2197,7 @@ async def test__async_get_historic_ohlcv(default_conf, mocker, caplog, exchange_
pair = "ETH/USDT"
respair, restf, _, res, _ = await exchange._async_get_historic_ohlcv(
pair, "5m", 1500000000000, candle_type=candle_type, is_new_pair=False
pair, "5m", 1500000000000, candle_type=candle_type
)
assert respair == pair
assert restf == "5m"
@@ -2168,7 +2209,7 @@ async def test__async_get_historic_ohlcv(default_conf, mocker, caplog, exchange_
end_ts = 1_500_500_000_000
start_ts = 1_500_000_000_000
respair, restf, _, res, _ = await exchange._async_get_historic_ohlcv(
pair, "5m", since_ms=start_ts, candle_type=candle_type, is_new_pair=False, until_ms=end_ts
pair, "5m", since_ms=start_ts, candle_type=candle_type, until_ms=end_ts
)
# Required candles
candles = (end_ts - start_ts) / 300_000
@@ -4078,10 +4119,16 @@ def test_get_valid_pair_combination(default_conf, mocker, markets):
)
ex = Exchange(default_conf)
assert ex.get_valid_pair_combination("ETH", "BTC") == "ETH/BTC"
assert ex.get_valid_pair_combination("BTC", "ETH") == "ETH/BTC"
assert next(ex.get_valid_pair_combination("ETH", "BTC")) == "ETH/BTC"
assert next(ex.get_valid_pair_combination("BTC", "ETH")) == "ETH/BTC"
multicombs = list(ex.get_valid_pair_combination("ETH", "USDT"))
assert len(multicombs) == 2
assert "ETH/USDT" in multicombs
assert "ETH/USDT:USDT" in multicombs
with pytest.raises(ValueError, match=r"Could not combine.* to get a valid pair."):
ex.get_valid_pair_combination("NOPAIR", "ETH")
for x in ex.get_valid_pair_combination("NOPAIR", "ETH"):
pass
@pytest.mark.parametrize(
@@ -6130,6 +6177,7 @@ def test_get_liquidation_price(
default_conf_usdt["exchange"]["name"] = exchange_name
default_conf_usdt["margin_mode"] = margin_mode
mocker.patch("freqtrade.exchange.gate.Gate.validate_ordertypes")
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange=exchange_name)
exchange.get_maintenance_ratio_and_amt = MagicMock(return_value=(0.01, 0.01))
-25
View File
@@ -1,4 +1,3 @@
import platform
import sys
from copy import deepcopy
from pathlib import Path
@@ -20,30 +19,6 @@ def is_py12() -> bool:
return sys.version_info >= (3, 12)
def is_mac() -> bool:
machine = platform.system()
return "Darwin" in machine
def is_arm() -> bool:
machine = platform.machine()
return "arm" in machine or "aarch64" in machine
@pytest.fixture(autouse=True)
def patch_torch_initlogs(mocker) -> None:
if is_mac():
# Mock torch import completely
import sys
import types
module_name = "torch"
mocked_module = types.ModuleType(module_name)
sys.modules[module_name] = mocked_module
else:
mocker.patch("torch._logging._init_logs")
@pytest.fixture(scope="function")
def freqai_conf(default_conf, tmp_path):
freqaiconf = deepcopy(default_conf)
+1 -2
View File
@@ -10,11 +10,10 @@ from freqtrade.configuration import TimeRange
from freqtrade.data.dataprovider import DataProvider
from freqtrade.exceptions import OperationalException
from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
from tests.conftest import get_patched_exchange
from tests.conftest import get_patched_exchange, is_mac
from tests.freqai.conftest import (
get_patched_data_kitchen,
get_patched_freqai_strategy,
is_mac,
make_unfiltered_dataframe,
)
+8 -3
View File
@@ -13,11 +13,16 @@ from freqtrade.freqai.utils import download_all_data_for_training, get_required_
from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import Trade
from freqtrade.plugins.pairlistmanager import PairListManager
from tests.conftest import EXMS, create_mock_trades, get_patched_exchange, log_has_re
from tests.freqai.conftest import (
get_patched_freqai_strategy,
from tests.conftest import (
EXMS,
create_mock_trades,
get_patched_exchange,
is_arm,
is_mac,
log_has_re,
)
from tests.freqai.conftest import (
get_patched_freqai_strategy,
make_rl_config,
mock_pytorch_mlp_model_training_parameters,
)
+18 -2
View File
@@ -374,7 +374,7 @@ def test_total_open_trades_stakes(mocker, default_conf_usdt, ticker_usdt, fee) -
def test_create_trade(
default_conf_usdt, ticker_usdt, limit_order, fee, mocker, is_short, open_rate
) -> None:
patch_RPCManager(mocker)
send_msg_mock = patch_RPCManager(mocker)
patch_exchange(mocker)
mocker.patch.multiple(
EXMS,
@@ -387,6 +387,7 @@ def test_create_trade(
whitelist = deepcopy(default_conf_usdt["exchange"]["pair_whitelist"])
freqtrade = FreqtradeBot(default_conf_usdt)
patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short)
send_msg_mock.reset_mock()
freqtrade.create_trade("ETH/USDT")
trade = Trade.session.scalars(select(Trade)).first()
@@ -402,6 +403,14 @@ def test_create_trade(
limit_order[entry_side(is_short)], "ADA/USDT", entry_side(is_short)
)
trade.update_trade(oobj)
assert send_msg_mock.call_count == 1
entry_msg = send_msg_mock.call_args_list[0][0][0]
assert entry_msg["type"] == RPCMessageType.ENTRY
assert entry_msg["stake_amount"] == trade.stake_amount
assert entry_msg["stake_currency"] == default_conf_usdt["stake_currency"]
assert entry_msg["pair"] == "ETH/USDT"
assert entry_msg["direction"] == ("Short" if is_short else "Long")
assert entry_msg["sub_trade"] is False
assert trade.open_rate == open_rate
assert trade.amount == 30.0
@@ -4021,7 +4030,7 @@ def test_get_real_amount_fees_order(
default_conf_usdt, market_buy_order_usdt_doublefee, fee, mocker
):
tfo_mock = mocker.patch(f"{EXMS}.get_trades_for_order", return_value=[])
mocker.patch(f"{EXMS}.get_valid_pair_combination", return_value="BNB/USDT")
mocker.patch(f"{EXMS}.get_valid_pair_combination", return_value=["BNB/USDT"])
mocker.patch(f"{EXMS}.fetch_ticker", return_value={"last": 200})
trade = Trade(
pair="LTC/USDT",
@@ -5190,6 +5199,13 @@ def test_update_funding_fees(
open_exit_order = limit_order_open[exit_side(is_short)]
bid = 0.11
enter_rate_mock = MagicMock(return_value=bid)
open_order.update(
{
"status": "closed",
"filled": open_order["amount"],
"remaining": 0,
}
)
enter_mm = MagicMock(return_value=open_order)
patch_RPCManager(mocker)
patch_exchange(mocker)
@@ -29,7 +29,7 @@ def test_update_liquidation_prices(mocker, margin_mode, dry_run):
assert trade_mock.set_liquidation_price.call_count == 1
assert wallets.get_total.call_count == (
assert wallets.get_collateral.call_count == (
0 if margin_mode == MarginMode.ISOLATED or not dry_run else 1
)
+5 -3
View File
@@ -371,8 +371,7 @@ def test_backtesting_start(default_conf, mocker, caplog) -> None:
mocker.patch("freqtrade.optimize.backtesting.Backtesting.backtest")
mocker.patch("freqtrade.optimize.backtesting.generate_backtest_stats")
mocker.patch("freqtrade.optimize.backtesting.show_backtest_results")
sbs = mocker.patch("freqtrade.optimize.backtesting.store_backtest_stats")
sbc = mocker.patch("freqtrade.optimize.backtesting.store_backtest_analysis_results")
sbs = mocker.patch("freqtrade.optimize.backtesting.store_backtest_results")
mocker.patch(
"freqtrade.plugins.pairlistmanager.PairListManager.whitelist",
PropertyMock(return_value=["UNITTEST/BTC"]),
@@ -397,7 +396,6 @@ def test_backtesting_start(default_conf, mocker, caplog) -> None:
assert backtesting.strategy.bot_start.call_count == 1
assert backtesting.strategy.bot_loop_start.call_count == 0
assert sbs.call_count == 1
assert sbc.call_count == 1
def test_backtesting_start_no_data(default_conf, mocker, caplog, testdatadir) -> None:
@@ -568,6 +566,9 @@ def test_backtest__enter_trade_futures(default_conf_usdt, fee, mocker) -> None:
mocker.patch(f"{EXMS}.get_fee", fee)
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
mocker.patch(f"{EXMS}.get_max_pair_stake_amount", return_value=float("inf"))
mocker.patch(
"freqtrade.persistence.trade_model.price_to_precision", lambda p, *args, **kwargs: p
)
mocker.patch(f"{EXMS}.get_max_leverage", return_value=100)
mocker.patch("freqtrade.optimize.backtesting.price_to_precision", lambda p, *args: p)
patch_exchange(mocker)
@@ -1842,6 +1843,7 @@ def test_backtest_multi_pair_long_short_switch(
if use_detail:
default_conf_usdt["timeframe_detail"] = "1m"
mocker.patch(f"{EXMS}.price_to_precision", lambda s, x, y, **kwargs: y)
mocker.patch(f"{EXMS}.get_min_pair_stake_amount", return_value=0.00001)
mocker.patch(f"{EXMS}.get_max_pair_stake_amount", return_value=float("inf"))
mocker.patch(f"{EXMS}.get_fee", fee)
+74 -11
View File
@@ -39,13 +39,34 @@ def test_loss_calculation_prefer_correct_trade_count(hyperopt_conf, hyperopt_res
hyperopt_conf.update({"hyperopt_loss": "ShortTradeDurHyperOptLoss"})
hl = HyperOptLossResolver.load_hyperoptloss(hyperopt_conf)
correct = hl.hyperopt_loss_function(
hyperopt_results, 600, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=600,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
over = hl.hyperopt_loss_function(
hyperopt_results, 600 + 100, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=600 + 100,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
under = hl.hyperopt_loss_function(
hyperopt_results, 600 - 100, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=600 - 100,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
assert over > correct
assert under > correct
@@ -58,9 +79,25 @@ def test_loss_calculation_prefer_shorter_trades(hyperopt_conf, hyperopt_results)
hyperopt_conf.update({"hyperopt_loss": "ShortTradeDurHyperOptLoss"})
hl = HyperOptLossResolver.load_hyperoptloss(hyperopt_conf)
longer = hl.hyperopt_loss_function(
hyperopt_results, 100, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=100,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
shorter = hl.hyperopt_loss_function(
results=resultsb,
trade_count=100,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": resultsb["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
shorter = hl.hyperopt_loss_function(resultsb, 100, datetime(2019, 1, 1), datetime(2019, 5, 1))
assert shorter < longer
@@ -73,11 +110,34 @@ def test_loss_calculation_has_limited_profit(hyperopt_conf, hyperopt_results) ->
hyperopt_conf.update({"hyperopt_loss": "ShortTradeDurHyperOptLoss"})
hl = HyperOptLossResolver.load_hyperoptloss(hyperopt_conf)
correct = hl.hyperopt_loss_function(
hyperopt_results, 600, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=hyperopt_results,
trade_count=600,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
over = hl.hyperopt_loss_function(
results=results_over,
trade_count=600,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": results_over["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
over = hl.hyperopt_loss_function(results_over, 600, datetime(2019, 1, 1), datetime(2019, 5, 1))
under = hl.hyperopt_loss_function(
results_under, 600, datetime(2019, 1, 1), datetime(2019, 5, 1)
results=results_under,
trade_count=600,
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=hyperopt_conf,
processed=None,
backtest_stats={"profit_total": results_under["profit_abs"].sum()},
starting_balance=hyperopt_conf["dry_run_wallet"],
)
assert over < correct
assert under > correct
@@ -109,31 +169,34 @@ def test_loss_functions_better_profits(default_conf, hyperopt_results, lossfunct
default_conf.update({"hyperopt_loss": lossfunction})
hl = HyperOptLossResolver.load_hyperoptloss(default_conf)
correct = hl.hyperopt_loss_function(
hyperopt_results,
results=hyperopt_results,
trade_count=len(hyperopt_results),
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=default_conf,
processed=None,
backtest_stats={"profit_total": hyperopt_results["profit_abs"].sum()},
starting_balance=default_conf["dry_run_wallet"],
)
over = hl.hyperopt_loss_function(
results_over,
results=results_over,
trade_count=len(results_over),
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=default_conf,
processed=None,
backtest_stats={"profit_total": results_over["profit_abs"].sum()},
starting_balance=default_conf["dry_run_wallet"],
)
under = hl.hyperopt_loss_function(
results_under,
results=results_under,
trade_count=len(results_under),
min_date=datetime(2019, 1, 1),
max_date=datetime(2019, 5, 1),
config=default_conf,
processed=None,
backtest_stats={"profit_total": results_under["profit_abs"].sum()},
starting_balance=default_conf["dry_run_wallet"],
)
assert over < correct
assert under > correct

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