Merge pull request #11566 from freqtrade/new_release

New release 2025.3
This commit is contained in:
Matthias
2025-03-27 17:56:46 +01:00
committed by GitHub
85 changed files with 6136 additions and 1386 deletions
+4 -4
View File
@@ -16,10 +16,10 @@ repos:
additional_dependencies:
- types-cachetools==5.5.0.20240820
- types-filelock==3.2.7
- types-requests==2.32.0.20241016
- types-requests==2.32.0.20250306
- types-tabulate==0.9.0.20241207
- types-python-dateutil==2.9.0.20241206
- SQLAlchemy==2.0.38
- SQLAlchemy==2.0.39
# stages: [push]
- repo: https://github.com/pycqa/isort
@@ -31,7 +31,7 @@ repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version.
rev: 'v0.9.7'
rev: 'v0.11.2'
hooks:
- id: ruff
- id: ruff-format
@@ -70,6 +70,6 @@ repos:
# Ensure github actions remain safe
- repo: https://github.com/woodruffw/zizmor-pre-commit
rev: v1.4.1
rev: v1.5.2
hooks:
- id: zizmor
+1 -1
View File
@@ -1,6 +1,6 @@
# ![freqtrade](https://raw.githubusercontent.com/freqtrade/freqtrade/develop/docs/assets/freqtrade_poweredby.svg)
[![Freqtrade CI](https://github.com/freqtrade/freqtrade/workflows/Freqtrade%20CI/badge.svg)](https://github.com/freqtrade/freqtrade/actions/)
[![Freqtrade CI](https://github.com/freqtrade/freqtrade/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/freqtrade/freqtrade/actions/)
[![DOI](https://joss.theoj.org/papers/10.21105/joss.04864/status.svg)](https://doi.org/10.21105/joss.04864)
[![Coverage Status](https://coveralls.io/repos/github/freqtrade/freqtrade/badge.svg?branch=develop&service=github)](https://coveralls.io/github/freqtrade/freqtrade?branch=develop)
[![Documentation](https://readthedocs.org/projects/freqtrade/badge/)](https://www.freqtrade.io)
+79 -9
View File
@@ -257,7 +257,8 @@
"enum": [
"day",
"week",
"month"
"month",
"year"
]
}
},
@@ -541,6 +542,10 @@
"description": "Edge configuration.",
"$ref": "#/definitions/edge"
},
"log_config": {
"description": "Logging configuration.",
"$ref": "#/definitions/logging"
},
"freqai": {
"description": "FreqAI configuration.",
"$ref": "#/definitions/freqai"
@@ -612,6 +617,14 @@
"description": "Telegram topic ID - only applicable for group chats",
"type": "string"
},
"authorized_users": {
"description": "Authorized users for the bot.",
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
},
"allow_custom_messages": {
"description": "Allow sending custom messages from the Strategy.",
"type": "boolean",
@@ -1272,6 +1285,30 @@
"allowed_risk"
]
},
"logging": {
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 1
},
"formatters": {
"type": "object"
},
"handlers": {
"type": "object"
},
"root": {
"type": "object"
}
},
"required": [
"version",
"formatters",
"handlers",
"root"
]
},
"external_message_consumer": {
"description": "Configuration for external message consumer.",
"type": "object",
@@ -1366,10 +1403,10 @@
"type": "boolean",
"default": false
},
"keras": {
"description": "Use Keras for model training.",
"type": "boolean",
"default": false
"identifier": {
"description": "A unique ID for the current model. Must be changed when modifying features.",
"type": "string",
"default": "example"
},
"write_metrics_to_disk": {
"description": "Write metrics to disk?",
@@ -1399,16 +1436,49 @@
"type": "number",
"default": 7
},
"identifier": {
"description": "A unique ID for the current model. Must be changed when modifying features.",
"type": "string",
"default": "example"
"live_retrain_hours": {
"description": "Frequency of retraining during dry/live runs.",
"type": "number",
"default": 0
},
"expiration_hours": {
"description": "Avoid making predictions if a model is more than `expiration_hours` old. Defaults to 0 (no expiration).",
"type": "number",
"default": 0
},
"save_backtest_models": {
"description": "Save models to disk when running backtesting.",
"type": "boolean",
"default": false
},
"fit_live_predictions_candles": {
"description": "Number of historical candles to use for computing target (label) statistics from prediction data, instead of from the training dataset.",
"type": "integer"
},
"data_kitchen_thread_count": {
"description": "Designate the number of threads you want to use for data processing (outlier methods, normalization, etc.).",
"type": "integer"
},
"activate_tensorboard": {
"description": "Indicate whether or not to activate tensorboard",
"type": "boolean",
"default": true
},
"wait_for_training_iteration_on_reload": {
"description": "Wait for the next training iteration to complete after /reload or ctrl+c.",
"type": "boolean",
"default": true
},
"continual_learning": {
"description": "Use the final state of the most recently trained model as starting point for the new model, allowing for incremental learning.",
"type": "boolean",
"default": false
},
"keras": {
"description": "Use Keras for model training.",
"type": "boolean",
"default": false
},
"feature_parameters": {
"description": "The parameters used to engineer the feature set",
"type": "object",
+198 -13
View File
@@ -188,30 +188,111 @@ as the watchdog.
## Advanced Logging
Freqtrade uses the default logging module provided by python.
Python allows for extensive [logging configuration](https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig) in this regards - way more than what can be covered here.
Default logging (Colored terminal output) is setup by default if no `log_config` is provided.
Using `--logfile logfile.log` will enable the RotatingFileHandler.
If you're not content with the log format - or with the default settings provided for the RotatingFileHandler, you can customize logging to your liking.
The default configuration looks roughly like the below - with the file handler being provided - but not enabled.
``` json hl_lines="5-7 13-16 27"
{
"log_config": {
"version": 1,
"formatters": {
"basic": {
"format": "%(message)s"
},
"standard": {
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
}
},
"handlers": {
"console": {
"class": "freqtrade.loggers.ft_rich_handler.FtRichHandler",
"formatter": "basic"
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "standard",
// "filename": "someRandomLogFile.log",
"maxBytes": 10485760,
"backupCount": 10
}
},
"root": {
"handlers": [
"console",
// "file"
],
"level": "INFO",
}
}
}
```
!!! Note "highlighted lines"
Highlighted lines in the above code-block define the Rich handler and belong together.
The formatter "standard" and "file" will belong to the FileHandler.
Each handler must use one of the defined formatters (by name) - and it's class must be available and a valid logging class.
To actually use a handler - it must be in the "handlers" section inside the "root" segment.
If this section is left out, freqtrade will provide no output (in the non-configured handler, anyway).
!!! Tip "Explicit log configuration"
We recommend to extract the logging configuration from your main configuration, and provide it to your bot via [multiple configuration files](configuration.md#multiple-configuration-files) functionality. This will avoid unnecessary code duplication.
---
On many Linux systems the bot can be configured to send its log messages to `syslog` or `journald` system services. Logging to a remote `syslog` server is also available on Windows. The special values for the `--logfile` command line option can be used for this.
### Logging to syslog
To send Freqtrade log messages to a local or remote `syslog` service use the `--logfile` command line option with the value in the following format:
To send Freqtrade log messages to a local or remote `syslog` service use the `"log_config"` setup option to configure logging.
* `--logfile syslog:<syslog_address>` -- send log messages to `syslog` service using the `<syslog_address>` as the syslog address.
``` json
{
// ...
"log_config": {
"version": 1,
"formatters": {
"syslog_fmt": {
"format": "%(name)s - %(levelname)s - %(message)s"
}
},
"handlers": {
// Other handlers?
"syslog": {
"class": "logging.handlers.SysLogHandler",
"formatter": "syslog_fmt",
// Use one of the other options above as address instead?
"address": "/dev/log"
}
},
"root": {
"handlers": [
// other handlers
"syslog",
]
}
The syslog address can be either a Unix domain socket (socket filename) or a UDP socket specification, consisting of IP address and UDP port, separated by the `:` character.
}
}
```
So, the following are the examples of possible usages:
[Additional log-handlers](#advanced-logging) may need to be configured to for example also have log output in the console.
* `--logfile syslog:/dev/log` -- log to syslog (rsyslog) using the `/dev/log` socket, suitable for most systems.
* `--logfile syslog` -- same as above, the shortcut for `/dev/log`.
* `--logfile syslog:/var/run/syslog` -- log to syslog (rsyslog) using the `/var/run/syslog` socket. Use this on MacOS.
* `--logfile syslog:localhost:514` -- log to local syslog using UDP socket, if it listens on port 514.
* `--logfile syslog:<ip>:514` -- log to remote syslog at IP address and port 514. This may be used on Windows for remote logging to an external syslog server.
#### Syslog usage
Log messages are send to `syslog` with the `user` facility. So you can see them with the following commands:
* `tail -f /var/log/user`, or
* `tail -f /var/log/user`, or
* install a comprehensive graphical viewer (for instance, 'Log File Viewer' for Ubuntu).
On many systems `syslog` (`rsyslog`) fetches data from `journald` (and vice versa), so both `--logfile syslog` or `--logfile journald` can be used and the messages be viewed with both `journalctl` and a syslog viewer utility. You can combine this in any way which suites you better.
On many systems `syslog` (`rsyslog`) fetches data from `journald` (and vice versa), so both syslog or journald can be used and the messages be viewed with both `journalctl` and a syslog viewer utility. You can combine this in any way which suites you better.
For `rsyslog` the messages from the bot can be redirected into a separate dedicated log file. To achieve this, add
@@ -228,13 +309,69 @@ For `syslog` (`rsyslog`), the reduction mode can be switched on. This will reduc
$RepeatedMsgReduction on
```
#### Syslog addressing
The syslog address can be either a Unix domain socket (socket filename) or a UDP socket specification, consisting of IP address and UDP port, separated by the `:` character.
So, the following are the examples of possible addresses:
* `"address": "/dev/log"` -- log to syslog (rsyslog) using the `/dev/log` socket, suitable for most systems.
* `"address": "/var/run/syslog"` -- log to syslog (rsyslog) using the `/var/run/syslog` socket. Use this on MacOS.
* `"address": "localhost:514"` -- log to local syslog using UDP socket, if it listens on port 514.
* `"address": "<ip>:514"` -- log to remote syslog at IP address and port 514. This may be used on Windows for remote logging to an external syslog server.
??? Info "Deprecated - configure syslog via command line"
`--logfile syslog:<syslog_address>` -- send log messages to `syslog` service using the `<syslog_address>` as the syslog address.
The syslog address can be either a Unix domain socket (socket filename) or a UDP socket specification, consisting of IP address and UDP port, separated by the `:` character.
So, the following are the examples of possible usages:
* `--logfile syslog:/dev/log` -- log to syslog (rsyslog) using the `/dev/log` socket, suitable for most systems.
* `--logfile syslog` -- same as above, the shortcut for `/dev/log`.
* `--logfile syslog:/var/run/syslog` -- log to syslog (rsyslog) using the `/var/run/syslog` socket. Use this on MacOS.
* `--logfile syslog:localhost:514` -- log to local syslog using UDP socket, if it listens on port 514.
* `--logfile syslog:<ip>:514` -- log to remote syslog at IP address and port 514. This may be used on Windows for remote logging to an external syslog server.
### Logging to journald
This needs the `cysystemd` python package installed as dependency (`pip install cysystemd`), which is not available on Windows. Hence, the whole journald logging functionality is not available for a bot running on Windows.
To send Freqtrade log messages to `journald` system service use the `--logfile` command line option with the value in the following format:
To send Freqtrade log messages to `journald` system service, add the following configuration snippet to your configuration.
* `--logfile journald` -- send log messages to `journald`.
``` json
{
// ...
"log_config": {
"version": 1,
"formatters": {
"journald_fmt": {
"format": "%(name)s - %(levelname)s - %(message)s"
}
},
"handlers": {
// Other handlers?
"journald": {
"class": "cysystemd.journal.JournaldLogHandler",
"formatter": "journald_fmt",
}
},
"root": {
"handlers": [
// ..
"journald",
]
}
}
}
```
[Additional log-handlers](#advanced-logging) may need to be configured to for example also have log output in the console.
Log messages are send to `journald` with the `user` facility. So you can see them with the following commands:
@@ -244,3 +381,51 @@ Log messages are send to `journald` with the `user` facility. So you can see the
There are many other options in the `journalctl` utility to filter the messages, see manual pages for this utility.
On many systems `syslog` (`rsyslog`) fetches data from `journald` (and vice versa), so both `--logfile syslog` or `--logfile journald` can be used and the messages be viewed with both `journalctl` and a syslog viewer utility. You can combine this in any way which suites you better.
??? Info "Deprecated - configure journald via command line"
To send Freqtrade log messages to `journald` system service use the `--logfile` command line option with the value in the following format:
`--logfile journald` -- send log messages to `journald`.
### Log format as JSON
You can also configure the default output stream to use JSON format instead.
The "fmt_dict" attribute defines the keys for the json output - as well as the [python logging LogRecord attributes](https://docs.python.org/3/library/logging.html#logrecord-attributes).
The below configuration will change the default output to JSON. The same formatter could however also be used in combination with the `RotatingFileHandler`.
We recommend to keep one format in human readable form.
``` json
{
// ...
"log_config": {
"version": 1,
"formatters": {
"json": {
"()": "freqtrade.loggers.json_formatter.JsonFormatter",
"fmt_dict": {
"timestamp": "asctime",
"level": "levelname",
"logger": "name",
"message": "message"
}
}
},
"handlers": {
// Other handlers?
"jsonStream": {
"class": "logging.StreamHandler",
"formatter": "json"
}
},
"root": {
"handlers": [
// ..
"jsonStream",
]
}
}
}
```
+17
View File
@@ -209,6 +209,7 @@ A backtesting result will look like that:
| Sortino | 1.88 |
| Sharpe | 2.97 |
| Calmar | 6.29 |
| SQN | 2.45 |
| Profit factor | 1.11 |
| Expectancy (Ratio) | -0.15 (-0.05) |
| Avg. stake amount | 0.001 BTC |
@@ -315,6 +316,7 @@ It contains some useful key metrics about performance of your strategy on backte
| Sortino | 1.88 |
| Sharpe | 2.97 |
| Calmar | 6.29 |
| SQN | 2.45 |
| Profit factor | 1.11 |
| Expectancy (Ratio) | -0.15 (-0.05) |
| Avg. stake amount | 0.001 BTC |
@@ -368,6 +370,7 @@ It contains some useful key metrics about performance of your strategy on backte
- `Sortino`: Annualized Sortino ratio.
- `Sharpe`: Annualized Sharpe ratio.
- `Calmar`: Annualized Calmar ratio.
- `SQN`: System Quality Number (SQN) - by Van Tharp.
- `Profit factor`: profit / loss.
- `Avg. stake amount`: Average stake amount, either `stake_amount` or the average when using dynamic stake amount.
- `Total trade volume`: Volume generated on the exchange to reach the above profit.
@@ -432,6 +435,20 @@ To save time, by default backtest will reuse a cached result from within the las
To further analyze your backtest results, freqtrade will export the trades to file by default.
You can then load the trades to perform further analysis as shown in the [data analysis](strategy_analysis_example.md#load-backtest-results-to-pandas-dataframe) backtesting section.
### Backtest output file
The output file freqtrade produces is a zip file containing the following files:
- The backtest report in json format
- the market change data in feather format
- a copy of the strategy file
- a copy of the strategy parameters (if a parameter file was used)
- a sanitized copy of the config file
This will ensure results are reproducible - under the assumption that the same data is available.
Only the strategy file and the config file are included in the zip file, eventual dependencies are not included.
## Assumptions made by backtesting
Since backtesting lacks some detailed information about what happens within a candle, it needs to take a few assumptions:
+4 -3
View File
@@ -2,7 +2,7 @@
usage: freqtrade backtesting-show [-h] [-v] [--no-color] [--logfile FILE] [-V]
[-c PATH] [-d PATH] [--userdir PATH]
[--export-filename PATH] [--show-pair-list]
[--breakdown {day,week,month} [{day,week,month} ...]]
[--breakdown {day,week,month,year} [{day,week,month,year} ...]]
options:
-h, --help show this help message and exit
@@ -11,8 +11,9 @@ options:
`--export` to be set as well. Example: `--export-filen
ame=user_data/backtest_results/backtest_today.json`
--show-pair-list Show backtesting pairlist sorted by profit.
--breakdown {day,week,month} [{day,week,month} ...]
Show backtesting breakdown per [day, week, month].
--breakdown {day,week,month,year} [{day,week,month,year} ...]
Show backtesting breakdown per [day, week, month,
year].
Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
+4 -3
View File
@@ -15,7 +15,7 @@ usage: freqtrade backtesting [-h] [-v] [--no-color] [--logfile FILE] [-V]
[--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]]
[--export {none,trades,signals}]
[--export-filename PATH]
[--breakdown {day,week,month} [{day,week,month} ...]]
[--breakdown {day,week,month,year} [{day,week,month,year} ...]]
[--cache {none,day,week,month}]
[--freqai-backtest-live-models]
@@ -65,8 +65,9 @@ options:
Use this filename for backtest results.Requires
`--export` to be set as well. Example: `--export-filen
ame=user_data/backtest_results/backtest_today.json`
--breakdown {day,week,month} [{day,week,month} ...]
Show backtesting breakdown per [day, week, month].
--breakdown {day,week,month,year} [{day,week,month,year} ...]
Show backtesting breakdown per [day, week, month,
year].
--cache {none,day,week,month}
Load a cached backtest result no older than specified
age (default: day).
+4 -3
View File
@@ -4,7 +4,7 @@ usage: freqtrade hyperopt-show [-h] [-v] [--no-color] [--logfile FILE] [-V]
[--profitable] [-n INT] [--print-json]
[--hyperopt-filename FILENAME] [--no-header]
[--disable-param-export]
[--breakdown {day,week,month} [{day,week,month} ...]]
[--breakdown {day,week,month,year} [{day,week,month,year} ...]]
options:
-h, --help show this help message and exit
@@ -18,8 +18,9 @@ options:
--no-header Do not print epoch details header.
--disable-param-export
Disable automatic hyperopt parameter export.
--breakdown {day,week,month} [{day,week,month} ...]
Show backtesting breakdown per [day, week, month].
--breakdown {day,week,month,year} [{day,week,month,year} ...]
Show backtesting breakdown per [day, week, month,
year].
Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
+1
View File
@@ -282,6 +282,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `dataformat_ohlcv` | Data format to use to store historical candle (OHLCV) data. <br> *Defaults to `feather`*. <br> **Datatype:** String
| `dataformat_trades` | Data format to use to store historical trades data. <br> *Defaults to `feather`*. <br> **Datatype:** String
| `reduce_df_footprint` | Recast all numeric columns to float32/int32, with the objective of reducing ram/disk usage (and decreasing train/inference timing in FreqAI). (Currently only affects FreqAI use-cases) <br> **Datatype:** Boolean. <br> Default: `False`.
| `log_config` | Dictionary containing the log config for python logging. [more info](advanced-setup.md#advanced-logging) <br> **Datatype:** dict. <br> Default: `FtRichHandler`
### Parameters in the strategy
+5
View File
@@ -88,3 +88,8 @@ Setting protections from the configuration via `"protections": [],` has been rem
Using hdf5 as data storage has been deprecated in 2024.12 and was 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 before updating.
## Configuring advanced logging via config
Configuring syslog and journald via `--logfile systemd` and `--logfile journald` respectively has been deprecated in 2025.3.
Please use configuration based [log setup](advanced-setup.md#advanced-logging) instead.
+16 -1
View File
@@ -44,9 +44,24 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged
By default, the `StaticPairList` method is used, which uses a statically defined pair whitelist from the configuration. The pairlist also supports wildcards (in regex-style) - so `.*/BTC` will include all pairs with BTC as a stake.
It uses configuration from `exchange.pair_whitelist` and `exchange.pair_blacklist`.
It uses configuration from `exchange.pair_whitelist` and `exchange.pair_blacklist`, which in the below example, will trade BTC/USDT and ETH/USDT - and will prevent BNB/USDT trading.
Both `pair_*list` parameters support regex - so values like `.*/USDT` would enable trading all pairs that are not in the blacklist.
```json
"exchange": {
"name": "...",
// ...
"pair_whitelist": [
"BTC/USDT",
"ETH/USDT",
// ...
],
"pair_blacklist": [
"BNB/USDT",
// ...
]
},
"pairlists": [
{"method": "StaticPairList"}
],
+1 -1
View File
@@ -1,6 +1,6 @@
![freqtrade](assets/freqtrade_poweredby.svg)
[![Freqtrade CI](https://github.com/freqtrade/freqtrade/workflows/Freqtrade%20CI/badge.svg)](https://github.com/freqtrade/freqtrade/actions/)
[![Freqtrade CI](https://github.com/freqtrade/freqtrade/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/freqtrade/freqtrade/actions/)
[![DOI](https://joss.theoj.org/papers/10.21105/joss.04864/status.svg)](https://doi.org/10.21105/joss.04864)
[![Coverage Status](https://coveralls.io/repos/github/freqtrade/freqtrade/badge.svg?branch=develop&service=github)](https://coveralls.io/github/freqtrade/freqtrade?branch=develop)
[![Maintainability](https://api.codeclimate.com/v1/badges/5737e6d668200b7518ff/maintainability)](https://codeclimate.com/github/freqtrade/freqtrade/maintainability)
+2 -2
View File
@@ -1,7 +1,7 @@
markdown==3.7
mkdocs==1.6.1
mkdocs-material==9.6.5
mkdocs-material==9.6.9
mdx_truly_sane_lists==1.3
pymdown-extensions==10.14.3
jinja2==3.1.5
jinja2==3.1.6
mike==2.1.3
+13
View File
@@ -302,6 +302,19 @@ trades
:param limit: Limits trades to the X last trades. Max 500 trades.
:param offset: Offset by this amount of trades.
list_open_trades_custom_data
Return a dict containing open trades custom-datas
:param key: str, optional - Key of the custom-data
:param limit: Limits trades to X trades.
:param offset: Offset by this amount of trades.
list_custom_data
Return a dict containing custom-datas of a specified trade
:param trade_id: int - ID of the trade
:param key: str, optional - Key of the custom-data
version
Return the version of the bot.
+9 -5
View File
@@ -758,7 +758,7 @@ For performance reasons, it's disabled by default and freqtrade will show a warn
Additional orders also result in additional fees and those orders don't count towards `max_open_trades`.
This callback is also called when there is an open order (either buy or sell) waiting for execution - and will cancel the existing open order to place a new order if the amount, price or direction is different.
This callback is also called when there is an open order (either buy or sell) waiting for execution - and will cancel the existing open order to place a new order if the amount, price or direction is different. Also partially filled orders will be canceled, and will be replaced with the new amount as returned by the callback.
`adjust_trade_position()` is called very frequently for the duration of a trade, so you must keep your implementation as performant as possible.
@@ -770,9 +770,10 @@ Modifications to leverage are not possible, and the stake-amount returned is ass
The combined stake currently allocated to the position is held in `trade.stake_amount`. Therefore `trade.stake_amount` will always be updated on every additional entry and partial exit made through `adjust_trade_position()`.
!!! 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.
On dry and live run, this function will be called every `throttle_process_secs` (default to 5s). If you have a loose logic, (e.g. increase position if RSI of the last candle is below 30), your bot will do extra re-entry every 5 secs until you either it run out of money, 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.
Same thing also can happen with partial exit.
So be sure to have a strict logic and/or check for the last filled order and if an order is already open.
!!! 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.
@@ -876,6 +877,9 @@ class DigDeeperStrategy(IStrategy):
Return None for no action.
Optionally, return a tuple with a 2nd element with an order reason
"""
if trade.has_open_orders:
# Only act if no orders are open
return
if current_profit > 0.05 and trade.nr_of_successful_exits == 0:
# Take half of the profit at +5%
@@ -973,7 +977,7 @@ class AwesomeStrategy(IStrategy):
side: str,
is_entry: bool,
**kwargs,
) -> float:
) -> float | None:
"""
Exit and entry order price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
@@ -995,7 +999,7 @@ class AwesomeStrategy(IStrategy):
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param is_entry: True if the order is an entry order, False if it's an exit order.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
:return float or None: New entry price value if provided
"""
# Limit entry orders to use and follow SMA200 as price target for the first 10 minutes since entry trigger for BTC/USDT pair.
+1
View File
@@ -1122,6 +1122,7 @@ The following list contains some common patterns which should be avoided to prev
- don't use `.iloc[-1]` or any other absolute position in the dataframe within `populate_` functions, as this will be different between dry-run and backtesting. Absolute `iloc` indexing is safe to use in callbacks however - see [Strategy Callbacks](strategy-callbacks.md).
- don't use functions that use all dataframe or column values, e.g. `dataframe['mean_volume'] = dataframe['volume'].mean()`. As backtesting uses the full dataframe, at any point in the dataframe, the `'mean_volume'` series would include data from the future. Use rolling() calculations instead, e.g. `dataframe['volume'].rolling(<window>).mean()`.
- don't use `.resample('1h')`. This uses the left border of the period interval, so moves data from an hour boundary to the start of the hour. Use `.resample('1h', label='right')` instead.
- don't use `.merge()` to combine longer timeframes onto shorter ones. Instead, use the [informative pair](#informative-pairs) helpers. (A plain merge can implicitly cause a lookahead bias as date refers to open date, not close date).
!!! Tip "Identifying problems"
You should always use the two helper commands [lookahead-analysis](lookahead-analysis.md) and [recursive-analysis](recursive-analysis.md), which can each help you figure out problems with your strategy in different ways.
+13
View File
@@ -81,6 +81,19 @@ Without this, the bot will always respond to the general channel in the group if
Similar to the group-id - you can use `/tg_info` from the topic/thread to get the correct topic-id.
#### Authorized users
For groups, it can be useful to limit who can send commands to the bot.
If `"authorized_users": []` is present and empty, no user will be allowed to control the bot.
In the below example, only the user with the id "1234567" is allowed to control the bot - all other users will only be able to receive messages.
```json
"chat_id": "-1001332619709",
"topic_id": "3",
"authorized_users": ["1234567"]
```
## Control telegram noise
Freqtrade provides means to control the verbosity of your telegram bot.
+1
View File
@@ -35,6 +35,7 @@ The following attributes / properties are available for each individual trade -
| `trade_direction` | "long" / "short" | Trade direction in text - long or short. |
| `nr_of_successful_entries` | int | Number of successful (filled) entry orders. |
| `nr_of_successful_exits` | int | Number of successful (filled) exit orders. |
| `has_open_orders` | boolean | Has the trade open orders (excluding stoploss orders). |
## Class methods
+1 -1
View File
@@ -1,6 +1,6 @@
"""Freqtrade bot"""
__version__ = "2025.2"
__version__ = "2025.3"
if "dev" in __version__:
from pathlib import Path
+1 -1
View File
@@ -224,7 +224,7 @@ AVAILABLE_CLI_OPTIONS = {
),
"backtest_breakdown": Arg(
"--breakdown",
help="Show backtesting breakdown per [day, week, month].",
help="Show backtesting breakdown per [day, week, month, year].",
nargs="+",
choices=constants.BACKTEST_BREAKDOWNS,
),
+94 -85
View File
@@ -17,11 +17,11 @@ def start_list_exchanges(args: dict[str, Any]) -> None:
:param args: Cli args from Arguments()
:return: None
"""
from rich.console import Console
from rich.table import Table
from rich.text import Text
from freqtrade.exchange import list_available_exchanges
from freqtrade.loggers.rich_console import get_rich_console
available_exchanges: list[ValidExchangesType] = list_available_exchanges(
args["list_exchanges_all"]
@@ -77,15 +77,16 @@ def start_list_exchanges(args: dict[str, Any]) -> None:
)
# table.add_row(*[exchange[header] for header in headers])
console = Console()
console = get_rich_console()
console.print(table)
def _print_objs_tabular(objs: list, print_colorized: bool) -> None:
from rich.console import Console
from rich.table import Table
from rich.text import Text
from freqtrade.loggers.rich_console import get_rich_console
names = [s["name"] for s in objs]
objs_to_print: list[dict[str, Text | str]] = [
{
@@ -118,10 +119,7 @@ def _print_objs_tabular(objs: list, print_colorized: bool) -> None:
for row in objs_to_print:
table.add_row(*[row[header] for header in objs_to_print[0].keys()])
console = Console(
color_system="auto" if print_colorized else None,
width=200 if "pytest" in sys.modules else None,
)
console = get_rich_console(color_system="auto" if print_colorized else None)
console.print(table)
@@ -219,7 +217,7 @@ def start_list_markets(args: dict[str, Any], pairs_only: bool = False) -> None:
"""
from freqtrade.configuration import setup_utils_configuration
from freqtrade.exchange import market_is_active
from freqtrade.misc import plural
from freqtrade.misc import plural, safe_value_fallback
from freqtrade.resolvers import ExchangeResolver
from freqtrade.util import print_rich_table
@@ -246,88 +244,99 @@ def start_list_markets(args: dict[str, Any], pairs_only: bool = False) -> None:
except Exception as e:
raise OperationalException(f"Cannot get markets. Reason: {e}") from e
else:
summary_str = (
(f"Exchange {exchange.name} has {len(pairs)} ")
+ ("active " if active_only else "")
+ (plural(len(pairs), "pair" if pairs_only else "market"))
+ (
f" with {', '.join(base_currencies)} as base "
f"{plural(len(base_currencies), 'currency', 'currencies')}"
if base_currencies
else ""
)
+ (" and" if base_currencies and quote_currencies else "")
+ (
f" with {', '.join(quote_currencies)} as quote "
f"{plural(len(quote_currencies), 'currency', 'currencies')}"
if quote_currencies
else ""
)
tickers = exchange.get_tickers()
summary_str = (
(f"Exchange {exchange.name} has {len(pairs)} ")
+ ("active " if active_only else "")
+ (plural(len(pairs), "pair" if pairs_only else "market"))
+ (
f" with {', '.join(base_currencies)} as base "
f"{plural(len(base_currencies), 'currency', 'currencies')}"
if base_currencies
else ""
)
+ (" and" if base_currencies and quote_currencies else "")
+ (
f" with {', '.join(quote_currencies)} as quote "
f"{plural(len(quote_currencies), 'currency', 'currencies')}"
if quote_currencies
else ""
)
)
headers = [
"Id",
"Symbol",
"Base",
"Quote",
"Active",
"Spot",
"Margin",
"Future",
"Leverage",
]
headers = [
"Id",
"Symbol",
"Base",
"Quote",
"Active",
"Spot",
"Margin",
"Future",
"Leverage",
"Min Stake",
]
tabular_data = [
{
"Id": v["id"],
"Symbol": v["symbol"],
"Base": v["base"],
"Quote": v["quote"],
"Active": market_is_active(v),
"Spot": "Spot" if exchange.market_is_spot(v) else "",
"Margin": "Margin" if exchange.market_is_margin(v) else "",
"Future": "Future" if exchange.market_is_future(v) else "",
"Leverage": exchange.get_max_leverage(v["symbol"], 20),
}
for _, v in pairs.items()
]
tabular_data = [
{
"Id": v["id"],
"Symbol": v["symbol"],
"Base": v["base"],
"Quote": v["quote"],
"Active": market_is_active(v),
"Spot": "Spot" if exchange.market_is_spot(v) else "",
"Margin": "Margin" if exchange.market_is_margin(v) else "",
"Future": "Future" if exchange.market_is_future(v) else "",
"Leverage": exchange.get_max_leverage(v["symbol"], 20),
"Min Stake": round(
exchange.get_min_pair_stake_amount(
v["symbol"],
safe_value_fallback(tickers.get(v["symbol"], {}), "last", "ask", 0.0),
0.0,
)
or 0.0,
8,
),
}
for _, v in pairs.items()
]
if (
args.get("print_one_column", False)
or args.get("list_pairs_print_json", False)
or args.get("print_csv", False)
):
# Print summary string in the log in case of machine-readable
# regular formats.
logger.info(f"{summary_str}.")
if (
args.get("print_one_column", False)
or args.get("list_pairs_print_json", False)
or args.get("print_csv", False)
):
# Print summary string in the log in case of machine-readable
# regular formats.
logger.info(f"{summary_str}.")
else:
# Print empty string separating leading logs and output in case of
# human-readable formats.
print()
if pairs:
if args.get("print_list", False):
# print data as a list, with human-readable summary
print(f"{summary_str}: {', '.join(pairs.keys())}.")
elif args.get("print_one_column", False):
print("\n".join(pairs.keys()))
elif args.get("list_pairs_print_json", False):
import rapidjson
print(rapidjson.dumps(list(pairs.keys()), default=str))
elif args.get("print_csv", False):
writer = csv.DictWriter(sys.stdout, fieldnames=headers)
writer.writeheader()
writer.writerows(tabular_data)
else:
# Print empty string separating leading logs and output in case of
# human-readable formats.
print()
if pairs:
if args.get("print_list", False):
# print data as a list, with human-readable summary
print(f"{summary_str}: {', '.join(pairs.keys())}.")
elif args.get("print_one_column", False):
print("\n".join(pairs.keys()))
elif args.get("list_pairs_print_json", False):
import rapidjson
print(rapidjson.dumps(list(pairs.keys()), default=str))
elif args.get("print_csv", False):
writer = csv.DictWriter(sys.stdout, fieldnames=headers)
writer.writeheader()
writer.writerows(tabular_data)
else:
print_rich_table(tabular_data, headers, summary_str)
elif not (
args.get("print_one_column", False)
or args.get("list_pairs_print_json", False)
or args.get("print_csv", False)
):
print(f"{summary_str}.")
print_rich_table(tabular_data, headers, summary_str)
elif not (
args.get("print_one_column", False)
or args.get("list_pairs_print_json", False)
or args.get("print_csv", False)
):
print(f"{summary_str}.")
def start_show_trades(args: dict[str, Any]) -> None:
+87 -9
View File
@@ -425,6 +425,10 @@ CONF_SCHEMA = {
"description": "Edge configuration.",
"$ref": "#/definitions/edge",
},
"log_config": {
"description": "Logging configuration.",
"$ref": "#/definitions/logging",
},
"freqai": {
"description": "FreqAI configuration.",
"$ref": "#/definitions/freqai",
@@ -471,6 +475,12 @@ CONF_SCHEMA = {
"description": "Telegram topic ID - only applicable for group chats",
"type": "string",
},
"authorized_users": {
"description": "Authorized users for the bot.",
"type": "array",
"items": {"type": "string"},
"uniqueItems": True,
},
"allow_custom_messages": {
"description": "Allow sending custom messages from the Strategy.",
"type": "boolean",
@@ -877,6 +887,28 @@ CONF_SCHEMA = {
},
"required": ["process_throttle_secs", "allowed_risk"],
},
"logging": {
"type": "object",
"properties": {
"version": {"type": "number", "const": 1},
"formatters": {
"type": "object",
# In theory the below, but can be more flexible
# based on logging.config documentation
# "additionalProperties": {
# "type": "object",
# "properties": {
# "format": {"type": "string"},
# "datefmt": {"type": "string"},
# },
# "required": ["format"],
# },
},
"handlers": {"type": "object"},
"root": {"type": "object"},
},
"required": ["version", "formatters", "handlers", "root"],
},
"external_message_consumer": {
"description": "Configuration for external message consumer.",
"type": "object",
@@ -965,10 +997,13 @@ CONF_SCHEMA = {
"type": "boolean",
"default": False,
},
"keras": {
"description": "Use Keras for model training.",
"type": "boolean",
"default": False,
"identifier": {
"description": (
"A unique ID for the current model. "
"Must be changed when modifying features."
),
"type": "string",
"default": "example",
},
"write_metrics_to_disk": {
"description": "Write metrics to disk?",
@@ -1000,13 +1035,42 @@ CONF_SCHEMA = {
"type": "number",
"default": 7,
},
"identifier": {
"live_retrain_hours": {
"description": "Frequency of retraining during dry/live runs.",
"type": "number",
"default": 0,
},
"expiration_hours": {
"description": (
"A unique ID for the current model. "
"Must be changed when modifying features."
"Avoid making predictions if a model is more than `expiration_hours` "
"old. Defaults to 0 (no expiration)."
),
"type": "string",
"default": "example",
"type": "number",
"default": 0,
},
"save_backtest_models": {
"description": "Save models to disk when running backtesting.",
"type": "boolean",
"default": False,
},
"fit_live_predictions_candles": {
"description": (
"Number of historical candles to use for computing target (label) "
"statistics from prediction data, instead of from the training dataset."
),
"type": "integer",
},
"data_kitchen_thread_count": {
"description": (
"Designate the number of threads you want to use for data processing "
"(outlier methods, normalization, etc.)."
),
"type": "integer",
},
"activate_tensorboard": {
"description": "Indicate whether or not to activate tensorboard",
"type": "boolean",
"default": True,
},
"wait_for_training_iteration_on_reload": {
"description": (
@@ -1015,6 +1079,20 @@ CONF_SCHEMA = {
"type": "boolean",
"default": True,
},
"continual_learning": {
"description": (
"Use the final state of the most recently trained model "
"as starting point for the new model, allowing for "
"incremental learning."
),
"type": "boolean",
"default": False,
},
"keras": {
"description": "Use Keras for model training.",
"type": "boolean",
"default": False,
},
"feature_parameters": {
"description": "The parameters used to engineer the feature set",
"type": "object",
+1 -1
View File
@@ -59,7 +59,7 @@ AVAILABLE_PAIRLISTS = [
"VolatilityFilter",
]
AVAILABLE_DATAHANDLERS = ["json", "jsongz", "feather", "parquet"]
BACKTEST_BREAKDOWNS = ["day", "week", "month"]
BACKTEST_BREAKDOWNS = ["day", "week", "month", "year"]
BACKTEST_CACHE_AGE = ["none", "day", "week", "month"]
BACKTEST_CACHE_DEFAULT = "day"
DRY_RUN_WALLET = 1000
+7 -6
View File
@@ -49,7 +49,7 @@ class DataProvider:
self._pairlists = pairlists
self.__rpc = rpc
self.__cached_pairs: dict[PairWithTimeframe, tuple[DataFrame, datetime]] = {}
self.__slice_index: int | None = None
self.__slice_index: dict[str, int] = {}
self.__slice_date: datetime | None = None
self.__cached_pairs_backtesting: dict[PairWithTimeframe, DataFrame] = {}
@@ -69,13 +69,13 @@ class DataProvider:
self.producers = self._config.get("external_message_consumer", {}).get("producers", [])
self.external_data_enabled = len(self.producers) > 0
def _set_dataframe_max_index(self, limit_index: int):
def _set_dataframe_max_index(self, pair: str, limit_index: int):
"""
Limit analyzed dataframe to max specified index.
Only relevant in backtesting.
:param limit_index: dataframe index.
"""
self.__slice_index = limit_index
self.__slice_index[pair] = limit_index
def _set_dataframe_max_date(self, limit_date: datetime):
"""
@@ -393,9 +393,10 @@ class DataProvider:
df, date = self.__cached_pairs[pair_key]
else:
df, date = self.__cached_pairs[pair_key]
if self.__slice_index is not None:
max_index = self.__slice_index
if (max_index := self.__slice_index.get(pair)) is not None:
df = df.iloc[max(0, max_index - MAX_DATAFRAME_CANDLES) : max_index]
else:
return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc))
return df, date
else:
return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc))
@@ -430,7 +431,7 @@ class DataProvider:
# Don't reset backtesting pairs -
# otherwise they're reloaded each time during hyperopt due to with analyze_per_epoch
# self.__cached_pairs_backtesting = {}
self.__slice_index = 0
self.__slice_index = {}
# Exchange functions
+29
View File
@@ -375,3 +375,32 @@ def calculate_calmar(
# print(expected_returns_mean, max_drawdown, calmar_ratio)
return calmar_ratio
def calculate_sqn(trades: pd.DataFrame, starting_balance: float) -> float:
"""
Calculate System Quality Number (SQN) - Van K. Tharp.
SQN measures systematic trading quality and takes into account both
the number of trades and their standard deviation.
:param trades: DataFrame containing trades (requires column profit_abs)
:param starting_balance: Starting balance of the trading system
:return: SQN value
"""
if len(trades) == 0:
return 0.0
total_profit = trades["profit_abs"] / starting_balance
number_of_trades = len(trades)
# Calculate average trade and standard deviation
average_profits = total_profit.mean()
profits_std = total_profit.std()
if profits_std != 0 and not np.isnan(profits_std):
sqn = math.sqrt(number_of_trades) * (average_profits / profits_std)
else:
# Define negative SQN to indicate this is NOT optimal
sqn = -100.0
return round(sqn, 4)
+52 -5
View File
@@ -11,7 +11,11 @@ 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.binance_public_data import (
concat_safe,
download_archive_ohlcv,
download_archive_trades,
)
from freqtrade.exchange.common import retrier
from freqtrade.exchange.exchange_types import FtHas, Tickers
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
@@ -270,12 +274,12 @@ class Binance(Exchange):
def dry_run_liquidation_price(
self,
pair: str,
open_rate: float, # Entry price of position
open_rate: float,
is_short: bool,
amount: float,
stake_amount: float,
leverage: float,
wallet_balance: float, # Or margin balance
wallet_balance: float,
open_trades: list,
) -> float | None:
"""
@@ -289,8 +293,6 @@ class Binance(Exchange):
:param amount: Absolute value of position size incl. leverage (in base currency)
:param stake_amount: Stake amount - Collateral in settle currency.
:param leverage: Leverage used for this position.
:param trading_mode: SPOT, MARGIN, FUTURES, etc.
:param margin_mode: Either ISOLATED or CROSS
:param wallet_balance: Amount of margin_mode in the wallet being used to trade
Cross-Margin Mode: crossWalletBalance
Isolated-Margin Mode: isolatedWalletBalance
@@ -379,3 +381,48 @@ class Binance(Exchange):
if not t:
return [], "0"
return t, from_id
async def _async_get_trade_history_id(
self, pair: str, until: int, since: int, from_id: str | None = None
) -> tuple[str, list[list]]:
logger.info(f"Fetching trades from Binance, {from_id=}, {since=}, {until=}")
if not self._config["exchange"].get("only_from_ccxt", False):
if from_id is None or not since:
trades = await self._api_async.fetch_trades(
pair,
params={
self._trades_pagination_arg: "0",
},
limit=5,
)
listing_date: int = trades[0]["timestamp"]
since = max(since, listing_date)
_, res = await download_archive_trades(
CandleType.SPOT,
pair,
since_ms=since,
until_ms=until,
markets=self.markets,
)
if not res:
end_time = since
end_id = from_id
else:
end_time = res[-1][0]
end_id = res[-1][1]
if end_time and end_time >= until:
return pair, res
else:
_, res2 = await super()._async_get_trade_history_id(
pair, until=until, since=end_time, from_id=end_id
)
res.extend(res2)
return pair, res
return await super()._async_get_trade_history_id(
pair, until=until, since=since, from_id=from_id
)
File diff suppressed because it is too large Load Diff
+224 -3
View File
@@ -1,5 +1,6 @@
"""
Fetch daily-archived OHLCV data from https://data.binance.vision/
Documentation can be found in https://github.com/binance/binance-public-data
"""
import asyncio
@@ -10,9 +11,11 @@ from io import BytesIO
from typing import Any
import aiohttp
import numpy as np
import pandas as pd
from pandas import DataFrame
from freqtrade.constants import DEFAULT_TRADES_COLUMNS
from freqtrade.enums import CandleType
from freqtrade.misc import chunks
from freqtrade.util.datetime_helpers import dt_from_ts, dt_now
@@ -157,8 +160,8 @@ async def _download_archive_ohlcv(
return concat_safe(dfs)
else:
dfs.append(None)
except BaseException as e:
logger.warning(f"An exception raised: : {e}")
except Exception 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)
@@ -212,6 +215,20 @@ def binance_vision_ohlcv_zip_url(
return url
def binance_vision_trades_zip_url(symbol: str, candle_type: CandleType, date: date) -> str:
"""
example urls:
https://data.binance.vision/data/spot/daily/aggTrades/BTCUSDT/BTCUSDT-aggTrades-2023-10-27.zip
https://data.binance.vision/data/futures/um/daily/aggTrades/BTCUSDT/BTCUSDT-aggTrades-2023-10-27.zip
"""
asset_type_url_segment = candle_type_to_url_segment(candle_type)
url = (
f"https://data.binance.vision/data/{asset_type_url_segment}/daily/aggTrades/{symbol}"
f"/{symbol}-aggTrades-{date.strftime('%Y-%m-%d')}.zip"
)
return url
async def get_daily_ohlcv(
symbol: str,
timeframe: str,
@@ -268,7 +285,11 @@ async def get_daily_ohlcv(
names=["date", "open", "high", "low", "close", "volume"],
header=header,
)
df["date"] = pd.to_datetime(df["date"], unit="ms", utc=True)
df["date"] = pd.to_datetime(
np.where(df["date"] > 1e13, df["date"] // 1000, df["date"]),
unit="ms",
utc=True,
)
return df
elif resp.status == 404:
logger.debug(f"Failed to download {url}")
@@ -280,3 +301,203 @@ async def get_daily_ohlcv(
if isinstance(e, Http404) or retry > retry_count:
logger.debug(f"Failed to get data from {url}: {e}")
raise
async def download_archive_trades(
candle_type: CandleType,
pair: str,
*,
since_ms: int,
until_ms: int | None,
markets: dict[str, Any],
stop_on_404: bool = True,
) -> tuple[str, list[list]]:
try:
symbol = markets[pair]["id"]
last_available_date = dt_now() - timedelta(days=2)
start = dt_from_ts(since_ms)
end = dt_from_ts(until_ms) if until_ms else dt_now()
end = min(end, last_available_date)
if start >= end:
return pair, []
result_list = await _download_archive_trades(
symbol, pair, candle_type, start, end, stop_on_404
)
return pair, result_list
except Exception as e:
logger.warning(
"An exception occurred during fast trades download from Binance, falling back to "
"the slower REST API, this can take a lot more time.",
exc_info=e,
)
return pair, []
def parse_trades_from_zip(csvf):
# https://github.com/binance/binance-public-data/issues/283
first_byte = csvf.read(1)[0]
if chr(first_byte).isdigit():
# spot
header = None
names = [
"id",
"price",
"amount",
"first_trade_id",
"last_trade_id",
"timestamp",
"is_buyer_maker",
"is_best_match",
]
else:
# futures
header = 0
names = [
"id",
"price",
"amount",
"first_trade_id",
"last_trade_id",
"timestamp",
"is_buyer_maker",
]
csvf.seek(0)
df = pd.read_csv(
csvf,
names=names,
header=header,
)
df.loc[:, "cost"] = df["price"] * df["amount"]
# Side is reversed intentionally
# based on ccxt parseTrade logic.
df.loc[:, "side"] = np.where(df["is_buyer_maker"], "sell", "buy")
df.loc[:, "type"] = None
# Convert timestamp to ms
df.loc[:, "timestamp"] = np.where(
df["timestamp"] > 1e13,
df["timestamp"] // 1000,
df["timestamp"],
)
return df.loc[:, DEFAULT_TRADES_COLUMNS].to_records(index=False).tolist()
async def get_daily_trades(
symbol: str,
candle_type: CandleType,
date: date,
session: aiohttp.ClientSession,
retry_count: int = 3,
retry_delay: float = 0.0,
) -> list[list]:
"""
Get daily OHLCV from https://data.binance.vision
See https://github.com/binance/binance-public-data
:symbol: binance symbol name, e.g. BTCUSDT
:candle_type: SPOT or FUTURES
: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 list containing trades in DEFAULT_TRADES_COLUMNS format
"""
url = binance_vision_trades_zip_url(symbol, candle_type, date)
logger.debug(f"download trades 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:
return parse_trades_from_zip(csvf)
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:
logger.info("download Daily_trades raised: %s", e)
retry += 1
if isinstance(e, Http404) or retry > retry_count:
logger.debug(f"Failed to get data from {url}: {e}")
raise
async def _download_archive_trades(
symbol: str,
pair: str,
candle_type: CandleType,
start: date,
end: date,
stop_on_404: bool,
) -> list[list]:
# daily dataframes, `None` indicates missing data in that day (when `stop_on_404` is False)
results: list[list] = []
# 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)), 30):
tasks = [
asyncio.create_task(get_daily_trades(symbol, candle_type, date, session))
for date in dates
]
for task in tasks:
current_day += 1
try:
result = 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 results
except Exception 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 results
else:
# Happy case
results.extend(result)
return results
+11 -9
View File
@@ -166,15 +166,16 @@ class Bybit(Exchange):
PERPETUAL:
bybit:
https://www.bybithelp.com/HelpCenterKnowledge/bybitHC_Article?language=en_US&id=000001067
https://www.bybit.com/en/help-center/article/Liquidation-Price-Calculation-under-Isolated-Mode-Unified-Trading-Account#b
Long:
Liquidation Price = (
Entry Price * (1 - Initial Margin Rate + Maintenance Margin Rate)
- Extra Margin Added/ Contract)
Entry Price - [(Initial Margin - Maintenance Margin)/Contract Quantity]
- (Extra Margin Added/Contract Quantity))
Short:
Liquidation Price = (
Entry Price * (1 + Initial Margin Rate - Maintenance Margin Rate)
+ Extra Margin Added/ Contract)
Entry Price + [(Initial Margin - Maintenance Margin)/Contract Quantity]
+ (Extra Margin Added/Contract Quantity))
Implementation Note: Extra margin is currently not used.
@@ -184,8 +185,6 @@ class Bybit(Exchange):
:param amount: Absolute value of position size incl. leverage (in base currency)
:param stake_amount: Stake amount - Collateral in settle currency.
:param leverage: Leverage used for this position.
:param trading_mode: SPOT, MARGIN, FUTURES, etc.
:param margin_mode: Either ISOLATED or CROSS
:param wallet_balance: Amount of margin_mode in the wallet being used to trade
Cross-Margin Mode: crossWalletBalance
Isolated-Margin Mode: isolatedWalletBalance
@@ -198,13 +197,16 @@ class Bybit(Exchange):
if self.trading_mode == TradingMode.FUTURES and self.margin_mode == MarginMode.ISOLATED:
if market["inverse"]:
raise OperationalException("Freqtrade does not yet support inverse contracts")
initial_margin_rate = 1 / leverage
position_value = amount * open_rate
initial_margin = position_value / leverage
maintenance_margin = position_value * mm_ratio
margin_diff_per_contract = (initial_margin - maintenance_margin) / amount
# See docstring - ignores extra margin!
if is_short:
return open_rate * (1 + initial_margin_rate - mm_ratio)
return open_rate + margin_diff_per_contract
else:
return open_rate * (1 - initial_margin_rate + mm_ratio)
return open_rate - margin_diff_per_contract
else:
raise OperationalException(
+6 -7
View File
@@ -2351,6 +2351,7 @@ class Exchange:
since_ms=since_ms,
until_ms=until_ms,
candle_type=candle_type,
raise_=True,
)
)
logger.debug(f"Downloaded data for {pair} from ccxt with length {len(data)}.")
@@ -2391,7 +2392,7 @@ class Exchange:
if isinstance(res, BaseException):
logger.warning(f"Async code raised an exception: {repr(res)}")
if raise_:
raise
raise res
continue
else:
# Deconstruct tuple if it's not an exception
@@ -2440,8 +2441,8 @@ class Exchange:
return self._exchange_ws.get_ohlcv(pair, timeframe, candle_type, candle_ts)
logger.info(
f"Failed to reuse watch {pair}, {timeframe}, {candle_ts < last_refresh_time},"
f" {candle_ts}, {last_refresh_time}, "
f"Couldn't reuse watch for {pair}, {timeframe}, falling back to REST api. "
f"{candle_ts < last_refresh_time}, {candle_ts}, {last_refresh_time}, "
f"{format_ms_time(candle_ts)}, {format_ms_time(last_refresh_time)} "
)
@@ -3687,12 +3688,12 @@ class Exchange:
def dry_run_liquidation_price(
self,
pair: str,
open_rate: float, # Entry price of position
open_rate: float,
is_short: bool,
amount: float,
stake_amount: float,
leverage: float,
wallet_balance: float, # Or margin balance
wallet_balance: float,
open_trades: list,
) -> float | None:
"""
@@ -3713,8 +3714,6 @@ class Exchange:
:param amount: Absolute value of position size incl. leverage (in base currency)
:param stake_amount: Stake amount - Collateral in settle currency.
:param leverage: Leverage used for this position.
:param trading_mode: SPOT, MARGIN, FUTURES, etc.
:param margin_mode: Either ISOLATED or CROSS
:param wallet_balance: Amount of margin_mode in the wallet being used to trade
Cross-Margin Mode: crossWalletBalance
Isolated-Margin Mode: isolatedWalletBalance
+6 -5
View File
@@ -46,19 +46,20 @@ class BaseEnvironment(gym.Env):
def __init__(
self,
df: DataFrame = DataFrame(),
prices: DataFrame = DataFrame(),
reward_kwargs: dict = {},
*,
df: DataFrame,
prices: DataFrame,
reward_kwargs: dict,
window_size=10,
starting_point=True,
id: str = "baseenv-1", # noqa: A002
seed: int = 1,
config: dict = {},
config: dict,
live: bool = False,
fee: float = 0.0015,
can_short: bool = False,
pair: str = "",
df_raw: DataFrame = DataFrame(),
df_raw: DataFrame,
):
"""
Initializes the training/eval environment.
@@ -488,7 +488,7 @@ def make_env(
seed: int,
train_df: DataFrame,
price: DataFrame,
env_info: dict[str, Any] = {},
env_info: dict[str, Any],
) -> Callable:
"""
Utility function for multiprocessed env.
+12 -8
View File
@@ -214,7 +214,7 @@ class FreqaiDataKitchen:
self,
unfiltered_df: DataFrame,
training_feature_list: list,
label_list: list = list(),
label_list: list | None = None,
training_filter: bool = True,
) -> tuple[DataFrame, DataFrame]:
"""
@@ -244,7 +244,7 @@ class FreqaiDataKitchen:
# we don't care about total row number (total no. datapoints) in training, we only care
# about removing any row with NaNs
# if labels has multiple columns (user wants to train multiple modelEs), we detect here
labels = unfiltered_df.filter(label_list, axis=1)
labels = unfiltered_df.filter(label_list or [], axis=1)
drop_index_labels = pd.isnull(labels).any(axis=1)
drop_index_labels = (
drop_index_labels.replace(True, 1).replace(False, 0).infer_objects(copy=False)
@@ -654,8 +654,8 @@ class FreqaiDataKitchen:
pair: str,
tf: str,
strategy: IStrategy,
corr_dataframes: dict = {},
base_dataframes: dict = {},
corr_dataframes: dict,
base_dataframes: dict,
is_corr_pairs: bool = False,
) -> DataFrame:
"""
@@ -773,10 +773,10 @@ class FreqaiDataKitchen:
def use_strategy_to_populate_indicators( # noqa: C901
self,
strategy: IStrategy,
corr_dataframes: dict = {},
base_dataframes: dict = {},
corr_dataframes: dict[str, DataFrame] | None = None,
base_dataframes: dict[str, dict[str, DataFrame]] | None = None,
pair: str = "",
prediction_dataframe: DataFrame = pd.DataFrame(),
prediction_dataframe: DataFrame | None = None,
do_corr_pairs: bool = True,
) -> DataFrame:
"""
@@ -793,6 +793,10 @@ class FreqaiDataKitchen:
:return:
dataframe: DataFrame = dataframe containing populated indicators
"""
if not corr_dataframes:
corr_dataframes = {}
if not base_dataframes:
base_dataframes = {}
# check if the user is using the deprecated populate_any_indicators function
new_version = inspect.getsource(strategy.populate_any_indicators) == (
@@ -822,7 +826,7 @@ class FreqaiDataKitchen:
if tf not in corr_dataframes[p]:
corr_dataframes[p][tf] = pd.DataFrame()
if not prediction_dataframe.empty:
if prediction_dataframe is not None and not prediction_dataframe.empty:
dataframe = prediction_dataframe.copy()
base_dataframes[self.config["timeframe"]] = dataframe.copy()
else:
+1 -1
View File
@@ -618,7 +618,7 @@ class IFreqaiModel(ABC):
)
unfiltered_dataframe = dk.use_strategy_to_populate_indicators(
strategy, corr_dataframes, base_dataframes, pair
strategy, corr_dataframes=corr_dataframes, base_dataframes=base_dataframes, pair=pair
)
trained_timestamp = new_trained_timerange.stopts
@@ -25,7 +25,7 @@ class PyTorchModelTrainer(PyTorchTrainerInterface):
criterion: nn.Module,
device: str,
data_convertor: PyTorchDataConvertor,
model_meta_data: dict[str, Any] = {},
model_meta_data: dict[str, Any] | None = None,
window_size: int = 1,
tb_logger: Any = None,
**kwargs,
@@ -45,6 +45,8 @@ class PyTorchModelTrainer(PyTorchTrainerInterface):
:param n_epochs: The maximum number batches to use for evaluation.
:param batch_size: The size of the batches to use during training.
"""
if model_meta_data is None:
model_meta_data = {}
self.model = model
self.optimizer = optimizer
self.criterion = criterion
+80 -44
View File
@@ -789,6 +789,7 @@ class FreqtradeBot(LoggingMixin):
return
else:
logger.debug("Max adjustment entries is set to unlimited.")
self.execute_entry(
trade.pair,
stake_amount,
@@ -903,14 +904,14 @@ class FreqtradeBot(LoggingMixin):
msg = (
f"Position adjust: about to create a new order for {pair} with stake_amount: "
f"{stake_amount} for {trade}"
f"{stake_amount} and price: {enter_limit_requested} for {trade}"
if mode == "pos_adjust"
else (
f"Replacing {side} order: about create a new order for {pair} with stake_amount: "
f"{stake_amount} ..."
f"{stake_amount} and price: {enter_limit_requested} ..."
if mode == "replace"
else f"{name} signal found: about create a new trade for {pair} with stake_amount: "
f"{stake_amount} ..."
f"{stake_amount} and price: {enter_limit_requested} ..."
)
)
logger.info(msg)
@@ -1711,47 +1712,68 @@ class FreqtradeBot(LoggingMixin):
cancel_reason = constants.CANCEL_REASON["USER_CANCEL"]
if order_obj.safe_placement_price != adjusted_price:
# cancel existing order if new price is supplied or None
res = self.handle_cancel_order(
order, order_obj, trade, cancel_reason, replacing=replacing
self.handle_replace_order(
order,
order_obj,
trade,
adjusted_price,
is_entry,
cancel_reason,
replacing=replacing,
)
if not res:
self.replace_order_failed(
trade, f"Could not fully cancel order for {trade}, therefore not replacing."
def handle_replace_order(
self,
order: CcxtOrder | None,
order_obj: Order,
trade: Trade,
new_order_price: float | None,
is_entry: bool,
cancel_reason: str,
replacing: bool = False,
) -> None:
"""
Cancel existing order if new price is supplied, and if the cancel is successful,
places a new order with the remaining capital.
"""
if not order:
order = self.exchange.fetch_order(order_obj.order_id, trade.pair)
res = self.handle_cancel_order(order, order_obj, trade, cancel_reason, replacing=replacing)
if not res:
self.replace_order_failed(
trade, f"Could not fully cancel order for {trade}, therefore not replacing."
)
return
if new_order_price:
# place new order only if new price is supplied
try:
if is_entry:
succeeded = self.execute_entry(
pair=trade.pair,
stake_amount=(
order_obj.safe_remaining * order_obj.safe_price / trade.leverage
),
price=new_order_price,
trade=trade,
is_short=trade.is_short,
mode="replace",
)
return
if adjusted_price:
# place new order only if new price is supplied
try:
if is_entry:
succeeded = self.execute_entry(
pair=trade.pair,
stake_amount=(
order_obj.safe_remaining * order_obj.safe_price / trade.leverage
),
price=adjusted_price,
trade=trade,
is_short=trade.is_short,
mode="replace",
)
else:
succeeded = self.execute_trade_exit(
trade,
adjusted_price,
exit_check=ExitCheckTuple(
exit_type=ExitType.CUSTOM_EXIT,
exit_reason=order_obj.ft_order_tag or "order_replaced",
),
ordertype="limit",
sub_trade_amt=order_obj.safe_remaining,
)
if not succeeded:
self.replace_order_failed(
trade, f"Could not replace order for {trade}."
)
except DependencyException as exception:
logger.warning(f"Unable to replace order for {trade.pair}: {exception}")
self.replace_order_failed(trade, f"Could not replace order for {trade}.")
else:
succeeded = self.execute_trade_exit(
trade,
new_order_price,
exit_check=ExitCheckTuple(
exit_type=ExitType.CUSTOM_EXIT,
exit_reason=order_obj.ft_order_tag or "order_replaced",
),
ordertype="limit",
sub_trade_amt=order_obj.safe_remaining,
)
if not succeeded:
self.replace_order_failed(trade, f"Could not replace order for {trade}.")
except DependencyException as exception:
logger.warning(f"Unable to replace order for {trade.pair}: {exception}")
self.replace_order_failed(trade, f"Could not replace order for {trade}.")
def cancel_open_orders_of_trade(
self, trade: Trade, sides: list[str], reason: str, replacing: bool = False
@@ -1901,7 +1923,10 @@ class FreqtradeBot(LoggingMixin):
# to the trade object
self.update_trade_state(trade, order_id, corder)
logger.info(f"Partial {trade.entry_side} order timeout for {trade}.")
logger.info(
f"Partial {trade.entry_side} order timeout for {trade}. Filled: {filled_amount}, "
f"total: {order_obj.ft_amount}"
)
order_obj.ft_cancel_reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}"
self.wallets.update()
@@ -2587,4 +2612,15 @@ class FreqtradeBot(LoggingMixin):
max_custom_price_allowed = proposed_price + (proposed_price * cust_p_max_dist_r)
# Bracket between min_custom_price_allowed and max_custom_price_allowed
return max(min(valid_custom_price, max_custom_price_allowed), min_custom_price_allowed)
final_price = max(
min(valid_custom_price, max_custom_price_allowed), min_custom_price_allowed
)
# Log a warning if the custom price was adjusted by clamping.
if final_price != valid_custom_price:
logger.info(
f"Custom price adjusted from {valid_custom_price} to {final_price} based on "
"custom_price_max_distance_ratio of {cust_p_max_dist_r}."
)
return final_price
+12 -6
View File
@@ -1,4 +1,5 @@
from typing import Any
from copy import deepcopy
from typing import Any, cast
from typing_extensions import TypedDict
@@ -15,11 +16,16 @@ class BacktestResultType(TypedDict):
def get_BacktestResultType_default() -> BacktestResultType:
return {
"metadata": {},
"strategy": {},
"strategy_comparison": [],
}
return cast(
BacktestResultType,
deepcopy(
{
"metadata": {},
"strategy": {},
"strategy_comparison": [],
}
),
)
class BacktestHistoryEntryType(BacktestMetadataType):
+154 -51
View File
@@ -1,15 +1,16 @@
import logging
import logging.config
import os
from copy import deepcopy
from logging import Formatter
from logging.handlers import RotatingFileHandler, SysLogHandler
from pathlib import Path
from rich.console import Console
from typing import Any
from freqtrade.constants import Config
from freqtrade.exceptions import OperationalException
from freqtrade.loggers.buffering_handler import FTBufferingHandler
from freqtrade.loggers.ft_rich_handler import FtRichHandler
from freqtrade.loggers.set_log_levels import set_loggers
from freqtrade.loggers.rich_console import get_rich_console
# from freqtrade.loggers.std_err_stream_handler import FTStdErrStreamHandler
@@ -22,7 +23,8 @@ LOGFORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
bufferHandler = FTBufferingHandler(1000)
bufferHandler.setFormatter(Formatter(LOGFORMAT))
error_console = Console(stderr=True, color_system=None)
error_console = get_rich_console(stderr=True, color_system=None)
def get_existing_handlers(handlertype):
@@ -53,63 +55,140 @@ def setup_logging_pre() -> None:
)
def setup_logging(config: Config) -> None:
"""
Process -v/--verbose, --logfile options
"""
# Log level
verbosity = config["verbosity"]
logging.root.addHandler(bufferHandler)
if config.get("print_colorized", True):
logger.info("Enabling colorized output.")
error_console._color_system = error_console._detect_color_system()
FT_LOGGING_CONFIG = {
"version": 1,
# "incremental": True,
# "disable_existing_loggers": False,
"formatters": {
"basic": {"format": "%(message)s"},
"standard": {
"format": LOGFORMAT,
},
},
"handlers": {
"console": {
"class": "freqtrade.loggers.ft_rich_handler.FtRichHandler",
"formatter": "basic",
},
},
"root": {
"handlers": [
"console",
# "file",
],
"level": "INFO",
},
}
logfile = config.get("logfile")
if logfile:
def _set_log_levels(
log_config: dict[str, Any], verbosity: int = 0, api_verbosity: str = "info"
) -> None:
"""
Set the logging level for the different loggers
"""
if "loggers" not in log_config:
log_config["loggers"] = {}
# Set default levels for third party libraries
third_party_loggers = {
"freqtrade": logging.INFO if verbosity <= 1 else logging.DEBUG,
"requests": logging.INFO if verbosity <= 1 else logging.DEBUG,
"urllib3": logging.INFO if verbosity <= 1 else logging.DEBUG,
"httpcore": logging.INFO if verbosity <= 1 else logging.DEBUG,
"ccxt.base.exchange": logging.INFO if verbosity <= 2 else logging.DEBUG,
"telegram": logging.INFO,
"httpx": logging.WARNING,
"werkzeug": logging.ERROR if api_verbosity == "error" else logging.INFO,
}
# Add third party loggers to the configuration
for logger_name, level in third_party_loggers.items():
if logger_name not in log_config["loggers"]:
log_config["loggers"][logger_name] = {
"level": logging.getLevelName(level),
"propagate": True,
}
def _add_root_handler(log_config: dict[str, Any], handler_name: str):
if handler_name not in log_config["root"]["handlers"]:
log_config["root"]["handlers"].append(handler_name)
def _add_formatter(log_config: dict[str, Any], format_name: str, format_: str):
if format_name not in log_config["formatters"]:
log_config["formatters"][format_name] = {"format": format_}
def _create_log_config(config: Config) -> dict[str, Any]:
# Get log_config from user config or use default
log_config = config.get("log_config", deepcopy(FT_LOGGING_CONFIG))
if logfile := config.get("logfile"):
s = logfile.split(":")
if s[0] == "syslog":
# Address can be either a string (socket filename) for Unix domain socket or
# a tuple (hostname, port) for UDP socket.
# Address can be omitted (i.e. simple 'syslog' used as the value of
# config['logfilename']), which defaults to '/dev/log', applicable for most
# of the systems.
address = (s[1], int(s[2])) if len(s) > 2 else s[1] if len(s) > 1 else "/dev/log"
if handler_sl := get_existing_handlers(SysLogHandler):
logging.root.removeHandler(handler_sl)
handler_sl = SysLogHandler(address=address)
# No datetime field for logging into syslog, to allow syslog
# to perform reduction of repeating messages if this is set in the
# syslog config. The messages should be equal for this.
handler_sl.setFormatter(Formatter("%(name)s - %(levelname)s - %(message)s"))
logging.root.addHandler(handler_sl)
logger.warning(
"DEPRECATED: Configuring syslog logging via command line is deprecated."
"Please use the log_config option in the configuration file instead."
)
# Add syslog handler to the config
log_config["handlers"]["syslog"] = {
"class": "logging.handlers.SysLogHandler",
"formatter": "syslog_format",
"address": (s[1], int(s[2])) if len(s) > 2 else s[1] if len(s) > 1 else "/dev/log",
}
_add_formatter(log_config, "syslog_format", "%(name)s - %(levelname)s - %(message)s")
_add_root_handler(log_config, "syslog")
elif s[0] == "journald": # pragma: no cover
# Check if we have the module available
logger.warning(
"DEPRECATED: Configuring Journald logging via command line is deprecated."
"Please use the log_config option in the configuration file instead."
)
try:
from cysystemd.journal import JournaldLogHandler
from cysystemd.journal import JournaldLogHandler # noqa: F401
except ImportError:
raise OperationalException(
"You need the cysystemd python package be installed in "
"order to use logging to journald."
)
if handler_jd := get_existing_handlers(JournaldLogHandler):
logging.root.removeHandler(handler_jd)
handler_jd = JournaldLogHandler()
# No datetime field for logging into journald, to allow syslog
# to perform reduction of repeating messages if this is set in the
# syslog config. The messages should be equal for this.
handler_jd.setFormatter(Formatter("%(name)s - %(levelname)s - %(message)s"))
logging.root.addHandler(handler_jd)
# Add journald handler to the config
log_config["handlers"]["journald"] = {
"class": "cysystemd.journal.JournaldLogHandler",
"formatter": "journald_format",
}
_add_formatter(log_config, "journald_format", "%(name)s - %(levelname)s - %(message)s")
_add_root_handler(log_config, "journald")
else:
if handler_rf := get_existing_handlers(RotatingFileHandler):
logging.root.removeHandler(handler_rf)
# Regular file logging
# Update existing file handler configuration
if "file" in log_config["handlers"]:
log_config["handlers"]["file"]["filename"] = logfile
else:
log_config["handlers"]["file"] = {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "standard",
"filename": logfile,
"maxBytes": 1024 * 1024 * 10, # 10Mb
"backupCount": 10,
}
_add_root_handler(log_config, "file")
# Dynamically update some handlers
for handler_config in log_config.get("handlers", {}).values():
if handler_config.get("class") == "freqtrade.loggers.ft_rich_handler.FtRichHandler":
handler_config["console"] = error_console
elif handler_config.get("class") == "logging.handlers.RotatingFileHandler":
logfile_path = Path(handler_config["filename"])
try:
logfile_path = Path(logfile)
# Create parent for filehandler
logfile_path.parent.mkdir(parents=True, exist_ok=True)
handler_rf = RotatingFileHandler(
logfile_path,
maxBytes=1024 * 1024 * 10, # 10Mb
backupCount=10,
)
except PermissionError:
raise OperationalException(
f'Failed to create or access log file "{logfile_path.absolute()}". '
@@ -119,10 +198,34 @@ def setup_logging(config: Config) -> None:
"non-root user, delete and recreate the directories you need, and then try "
"again."
)
handler_rf.setFormatter(Formatter(LOGFORMAT))
logging.root.addHandler(handler_rf)
return log_config
def setup_logging(config: Config) -> None:
"""
Process -v/--verbose, --logfile options
"""
verbosity = config["verbosity"]
if os.environ.get("PYTEST_VERSION") is None or config.get("ft_tests_force_logging"):
log_config = _create_log_config(config)
_set_log_levels(
log_config, verbosity, config.get("api_server", {}).get("verbosity", "info")
)
logging.config.dictConfig(log_config)
# Add buffer handler to root logger
if bufferHandler not in logging.root.handlers:
logging.root.addHandler(bufferHandler)
# Set color system for console output
if config.get("print_colorized", True):
logger.info("Enabling colorized output.")
error_console._color_system = error_console._detect_color_system()
logging.info("Logfile configured")
# Set verbosity levels
logging.root.setLevel(logging.INFO if verbosity < 1 else logging.DEBUG)
set_loggers(verbosity, config.get("api_server", {}).get("verbosity", "info"))
logger.info("Verbosity set to %s", verbosity)
+74
View File
@@ -0,0 +1,74 @@
import json
import logging
class JsonFormatter(logging.Formatter):
"""
Formatter that outputs JSON strings after parsing the LogRecord.
@param dict fmt_dict: Key: logging format attribute pairs. Defaults to {"message": "message"}.
@param str time_format: time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S"
@param str msec_format: Microsecond formatting. Appended at the end. Default: "%s.%03dZ"
"""
def __init__(
self,
fmt_dict: dict | None = None,
time_format: str = "%Y-%m-%dT%H:%M:%S",
msec_format: str = "%s.%03dZ",
):
self.fmt_dict = (
fmt_dict
if fmt_dict is not None
else {
"timestamp": "asctime",
"level": "levelname",
"logger": "name",
"message": "message",
}
)
self.default_time_format = time_format
self.default_msec_format = msec_format
self.datefmt = None
def usesTime(self) -> bool:
"""
Look for the attribute in the format dict values instead of the fmt string.
"""
return "asctime" in self.fmt_dict.values()
def formatMessage(self, record) -> str:
raise NotImplementedError()
def formatMessageDict(self, record) -> dict:
"""
Return a dictionary of the relevant LogRecord attributes instead of a string.
KeyError is raised if an unknown attribute is provided in the fmt_dict.
"""
return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()}
def format(self, record) -> str:
"""
Mostly the same as the parent's class method, the difference being that a dict is
manipulated and dumped as JSON instead of a string.
"""
record.message = record.getMessage()
if self.usesTime():
record.asctime = self.formatTime(record, self.datefmt)
message_dict = self.formatMessageDict(record)
if record.exc_info:
# Cache the traceback text to avoid converting it multiple times
# (it's constant anyway)
if not record.exc_text:
record.exc_text = self.formatException(record.exc_info)
if record.exc_text:
message_dict["exc_info"] = record.exc_text
if record.stack_info:
message_dict["stack_info"] = self.formatStack(record.stack_info)
return json.dumps(message_dict, default=str)
+26
View File
@@ -0,0 +1,26 @@
import sys
from shutil import get_terminal_size
from rich.console import Console
def console_width() -> int | None:
"""
Get the width of the console
"""
if any(module in ["pytest", "ipykernel"] for module in sys.modules):
return 200
width, _ = get_terminal_size((1, 24))
# Fall back to 200 if terminal size is not available.
# This is determined by assuming an insane width of 1char, which is unlikely.
w = None if width > 1 else 200
return w
def get_rich_console(**kwargs) -> Console:
"""
Get a rich console with default settings
"""
kwargs["width"] = kwargs.get("width", console_width())
return Console(**kwargs)
-19
View File
@@ -4,25 +4,6 @@ import logging
logger = logging.getLogger(__name__)
def set_loggers(verbosity: int = 0, api_verbosity: str = "info") -> None:
"""
Set the logging level for third party libraries
:param verbosity: Verbosity level. amount of `-v` passed to the command line
:return: None
"""
for logger_name in ("requests", "urllib3", "httpcore"):
logging.getLogger(logger_name).setLevel(logging.INFO if verbosity <= 1 else logging.DEBUG)
logging.getLogger("ccxt.base.exchange").setLevel(
logging.INFO if verbosity <= 2 else logging.DEBUG
)
logging.getLogger("telegram").setLevel(logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("werkzeug").setLevel(
logging.ERROR if api_verbosity == "error" else logging.INFO
)
__BIAS_TESTER_LOGGERS = [
"freqtrade.resolvers",
"freqtrade.strategy.hyper",
+7 -4
View File
@@ -603,7 +603,7 @@ class Backtesting:
# This should not be reached...
return row[OPEN_IDX]
def _get_adjust_trade_entry_for_candle(
def _check_adjust_trade_for_candle(
self, trade: LocalTrade, row: tuple, current_time: datetime
) -> LocalTrade:
current_rate: float = row[OPEN_IDX]
@@ -714,7 +714,7 @@ class Backtesting:
exchange=self.exchange,
wallets=self.wallets,
stake_currency=self.config["stake_currency"],
dry_run=self.config["dry_run"],
dry_run=True,
)
if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount):
self._call_adjust_stop(current_date, trade, order.ft_price)
@@ -871,7 +871,7 @@ class Backtesting:
# Check if we need to adjust our current positions
if self.strategy.position_adjustment_enable:
trade = self._get_adjust_trade_entry_for_candle(trade, row, current_time)
trade = self._check_adjust_trade_for_candle(trade, row, current_time)
if trade.is_open:
enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX]
@@ -1552,7 +1552,9 @@ class Backtesting:
row_index += 1
indexes[pair] = row_index
is_last_row = current_time == end_date
self.dataprovider._set_dataframe_max_index(self.required_startup + row_index)
self.dataprovider._set_dataframe_max_index(
pair, self.required_startup + row_index
)
trade_dir = self.check_for_trade_entry(row)
pair_tradedir_cache[pair] = trade_dir
@@ -1790,6 +1792,7 @@ class Backtesting:
dt_appendix,
market_change_data=combined_res,
analysis_results=self.analysis_results,
strategy_files={s.get_strategy_name(): s.__file__ for s in self.strategylist},
)
# Results may be mixed up now. Sort them so they follow --strategy-list order.
@@ -132,18 +132,18 @@ def text_table_periodic_breakdown(
"""
headers = [
period.capitalize(),
"Trades",
f"Tot Profit {stake_currency}",
"Wins",
"Draws",
"Losses",
"Profit Factor",
"Win Draw Loss Win%",
]
output = [
[
d["date"],
d.get("trades", "N/A"),
fmt_coin(d["profit_abs"], stake_currency, False),
d["wins"],
d["draws"],
d["loses"],
round(d["profit_factor"], 2) if "profit_factor" in d else "N/A",
generate_wins_draws_losses(d["wins"], d["draws"], d.get("losses", d.get("loses", 0))),
]
for d in days_breakdown_stats
]
@@ -312,6 +312,7 @@ def text_table_add_metrics(strat_results: dict) -> None:
("Sortino", f"{strat_results['sortino']:.2f}" if "sortino" in strat_results else "N/A"),
("Sharpe", f"{strat_results['sharpe']:.2f}" if "sharpe" in strat_results else "N/A"),
("Calmar", f"{strat_results['calmar']:.2f}" if "calmar" in strat_results else "N/A"),
("SQN", f"{strat_results['sqn']:.2f}" if "sqn" in strat_results else "N/A"),
(
"Profit factor",
(
@@ -6,6 +6,7 @@ from zipfile import ZIP_DEFLATED, ZipFile
from pandas import DataFrame
from freqtrade.configuration import sanitize_config
from freqtrade.constants import LAST_BT_RESULT_FN
from freqtrade.enums.runmode import RunMode
from freqtrade.ft_types import BacktestResultType
@@ -52,6 +53,7 @@ def store_backtest_results(
*,
market_change_data: DataFrame | None = None,
analysis_results: dict[str, dict[str, DataFrame]] | None = None,
strategy_files: dict[str, str] | None = None,
) -> Path:
"""
Stores backtest results and analysis data in a zip file, with metadata stored separately
@@ -85,6 +87,32 @@ def store_backtest_results(
dump_json_to_file(stats_buf, stats_copy)
zipf.writestr(json_filename.name, stats_buf.getvalue())
config_buf = StringIO()
dump_json_to_file(config_buf, sanitize_config(config["original_config"]))
zipf.writestr(f"{base_filename.stem}_config.json", config_buf.getvalue())
for strategy_name, strategy_file in (strategy_files or {}).items():
# Store the strategy file and its parameters
strategy_buf = BytesIO()
strategy_path = Path(strategy_file)
if not strategy_path.is_file():
logger.warning(f"Strategy file '{strategy_path}' does not exist. Skipping.")
continue
with strategy_path.open("rb") as strategy_file_obj:
strategy_buf.write(strategy_file_obj.read())
strategy_buf.seek(0)
zipf.writestr(f"{base_filename.stem}_{strategy_name}.py", strategy_buf.getvalue())
strategy_params = strategy_path.with_suffix(".json")
if strategy_params.is_file():
strategy_params_buf = BytesIO()
with strategy_params.open("rb") as strategy_params_obj:
strategy_params_buf.write(strategy_params_obj.read())
strategy_params_buf.seek(0)
zipf.writestr(
f"{base_filename.stem}_{strategy_name}.json",
strategy_params_buf.getvalue(),
)
# Add market change data if present
if market_change_data is not None:
market_change_name = f"{base_filename.stem}_market_change.feather"
@@ -16,8 +16,9 @@ from freqtrade.data.metrics import (
calculate_max_drawdown,
calculate_sharpe,
calculate_sortino,
calculate_sqn,
)
from freqtrade.ft_types import BacktestResultType
from freqtrade.ft_types import BacktestResultType, get_BacktestResultType_default
from freqtrade.util import decimals_per_coin, fmt_coin, get_dry_run_wallet
@@ -211,6 +212,8 @@ def _get_resample_from_period(period: str) -> str:
return "1W-MON"
if period == "month":
return "1ME"
if period == "year":
return "1YE"
raise ValueError(f"Period {period} is not supported.")
@@ -228,8 +231,11 @@ def generate_periodic_breakdown_stats(
profit_abs = day["profit_abs"].sum().round(10)
wins = sum(day["profit_abs"] > 0)
draws = sum(day["profit_abs"] == 0)
loses = sum(day["profit_abs"] < 0)
trades = wins + draws + loses
losses = sum(day["profit_abs"] < 0)
trades = wins + draws + losses
winning_profit = day.loc[day["profit_abs"] > 0, "profit_abs"].sum()
losing_profit = day.loc[day["profit_abs"] < 0, "profit_abs"].sum()
profit_factor = winning_profit / abs(losing_profit) if losing_profit else 0.0
stats.append(
{
"date": name.strftime("%d/%m/%Y"),
@@ -237,8 +243,9 @@ def generate_periodic_breakdown_stats(
"profit_abs": profit_abs,
"wins": wins,
"draws": draws,
"loses": loses,
"winrate": wins / trades if trades else 0.0,
"losses": losses,
"trades": trades,
"profit_factor": round(profit_factor, 8),
}
)
return stats
@@ -468,6 +475,7 @@ def generate_strategy_stats(
"sortino": calculate_sortino(results, min_date, max_date, start_balance),
"sharpe": calculate_sharpe(results, min_date, max_date, start_balance),
"calmar": calculate_calmar(results, min_date, max_date, start_balance),
"sqn": calculate_sqn(results, start_balance),
"profit_factor": profit_factor,
"backtest_start": min_date.strftime(DATETIME_PRINT_FORMAT),
"backtest_start_ts": int(min_date.timestamp() * 1000),
@@ -579,11 +587,7 @@ def generate_backtest_stats(
:param max_date: Backtest end date
:return: Dictionary containing results per strategy and a strategy summary.
"""
result: BacktestResultType = {
"metadata": {},
"strategy": {},
"strategy_comparison": [],
}
result: BacktestResultType = get_BacktestResultType_default()
market_change = calculate_market_change(btdata, "close")
metadata = {}
pairlist = list(btdata.keys())
+25 -3
View File
@@ -124,6 +124,7 @@ def migrate_trades_and_orders_table(
funding_fees = get_column_def(cols, "funding_fees", "0.0")
funding_fee_running = get_column_def(cols, "funding_fee_running", "null")
max_stake_amount = get_column_def(cols, "max_stake_amount", "stake_amount")
record_version = get_column_def(cols, "record_version", "1")
# If ticker-interval existed use that, else null.
if has_column(cols, "ticker_interval"):
@@ -180,7 +181,7 @@ def migrate_trades_and_orders_table(
trading_mode, leverage, liquidation_price, is_short,
interest_rate, funding_fees, funding_fee_running, realized_profit,
amount_precision, price_precision, precision_mode, precision_mode_price, contract_size,
max_stake_amount
max_stake_amount, record_version
)
select id, lower(exchange), pair, {base_currency} base_currency,
{stake_currency} stake_currency,
@@ -210,7 +211,8 @@ def migrate_trades_and_orders_table(
{realized_profit} realized_profit,
{amount_precision} amount_precision, {price_precision} price_precision,
{precision_mode} precision_mode, {precision_mode_price} precision_mode_price,
{contract_size} contract_size, {max_stake_amount} max_stake_amount
{contract_size} contract_size, {max_stake_amount} max_stake_amount,
{record_version} record_version
from {trade_back_name}
"""
)
@@ -329,6 +331,25 @@ def fix_old_dry_orders(engine):
connection.execute(stmt)
def fix_wrong_max_stake_amount(engine):
"""
Fix max_stake_amount for leveraged closed trades
This caused record_version to be bumped to 2.
"""
with engine.begin() as connection:
stmt = (
update(Trade)
.where(
Trade.record_version < 2,
Trade.leverage > 1,
Trade.is_open.is_(False),
Trade.max_stake_amount != 0,
)
.values(max_stake_amount=Trade.max_stake_amount / Trade.leverage, record_version=2)
)
connection.execute(stmt)
def check_migrate(engine, decl_base, previous_tables) -> None:
"""
Checks if migration is necessary and migrates if necessary
@@ -350,7 +371,7 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
# if ('orders' not in previous_tables
# or not has_column(cols_orders, 'funding_fee')):
migrating = False
if not has_column(cols_trades, "precision_mode_price"):
if not has_column(cols_trades, "record_version"):
# if not has_column(cols_orders, "ft_order_tag"):
migrating = True
logger.info(
@@ -383,6 +404,7 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
set_sqlite_to_wal(engine)
fix_old_dry_orders(engine)
fix_wrong_max_stake_amount(engine)
if migrating:
logger.info("Database migration finished.")
+8 -2
View File
@@ -464,6 +464,8 @@ class LocalTrade:
# Used to keep running funding fees - between the last filled order and now
# Shall not be used for calculations!
funding_fee_running: float | None = None
# v 2 -> correct max_stake_amount calculation for leveraged trades
record_version: int = 2
@property
def stoploss_or_liquidation(self) -> float:
@@ -1243,7 +1245,7 @@ class LocalTrade:
total_stake += self._calc_open_trade_value(tmp_amount, price)
max_stake_amount += tmp_amount * price
self.funding_fees = funding_fees
self.max_stake_amount = float(max_stake_amount)
self.max_stake_amount = float(max_stake_amount) / (self.leverage or 1.0)
if close_profit:
self.close_profit = close_profit
@@ -1351,8 +1353,10 @@ class LocalTrade:
def get_custom_data(self, key: str, default: Any = None) -> Any:
"""
Get custom data for this trade
Get custom data for this trade.
:param key: key of the custom data
:param default: value to return if no data is found
"""
data = CustomDataWrapper.get_custom_data(trade_id=self.id, key=key)
if data:
@@ -1752,6 +1756,8 @@ class Trade(ModelBase, LocalTrade):
Float(), nullable=True, default=None
)
record_version: Mapped[int] = mapped_column(Integer, nullable=False, default=2) # type: ignore
def __init__(self, **kwargs):
from_json = kwargs.pop("__FROM_JSON", None)
super().__init__(**kwargs)
+3
View File
@@ -108,6 +108,9 @@ def __run_backtest_bg(btconfig: Config):
ApiBG.bt["bt"].results,
datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
market_change_data=combined_res,
strategy_files={
s.get_strategy_name(): s.__file__ for s in ApiBG.bt["bt"].strategylist
},
)
ApiBG.bt["bt"].results["metadata"][strategy_name]["filename"] = str(fn.stem)
ApiBG.bt["bt"].results["metadata"][strategy_name]["strategy"] = strategy_name
@@ -110,13 +110,17 @@ def handleExchangePayload(payload: ExchangeModePayloadMixin, config_loc: Config)
Handle exchange and trading mode payload.
Updates the configuration with the payload values.
"""
from freqtrade.configuration.directory_operations import create_datadir
if payload.exchange:
config_loc["exchange"]["name"] = payload.exchange
config_loc.update({"datadir": create_datadir(config_loc, None)})
if payload.trading_mode:
config_loc["trading_mode"] = payload.trading_mode
config_loc["candle_type_def"] = CandleType.get_default(
config_loc.get("trading_mode", "spot") or "spot"
)
if payload.margin_mode:
config_loc["margin_mode"] = payload.margin_mode
+13
View File
@@ -637,3 +637,16 @@ class Health(BaseModel):
bot_start_ts: int | None = None
bot_startup: datetime | None = None
bot_startup_ts: int | None = None
class CustomDataEntry(BaseModel):
key: str
type: str
value: Any
created_at: datetime
updated_at: datetime | None = None
class ListCustomData(BaseModel):
trade_id: int
custom_data: list[CustomDataEntry]
+51 -4
View File
@@ -29,6 +29,7 @@ from freqtrade.rpc.api_server.api_schemas import (
FreqAIModelListResponse,
Health,
HyperoptLossListResponse,
ListCustomData,
Locks,
LocksPayload,
Logs,
@@ -153,21 +154,33 @@ def stats(rpc: RPC = Depends(get_rpc)):
@router.get("/daily", response_model=DailyWeeklyMonthly, tags=["info"])
def daily(timescale: int = 7, rpc: RPC = Depends(get_rpc), config=Depends(get_config)):
def daily(
timescale: int = Query(7, ge=1, description="Number of days to fetch data for"),
rpc: RPC = Depends(get_rpc),
config=Depends(get_config),
):
return rpc._rpc_timeunit_profit(
timescale, config["stake_currency"], config.get("fiat_display_currency", "")
)
@router.get("/weekly", response_model=DailyWeeklyMonthly, tags=["info"])
def weekly(timescale: int = 4, rpc: RPC = Depends(get_rpc), config=Depends(get_config)):
def weekly(
timescale: int = Query(4, ge=1, description="Number of weeks to fetch data for"),
rpc: RPC = Depends(get_rpc),
config=Depends(get_config),
):
return rpc._rpc_timeunit_profit(
timescale, config["stake_currency"], config.get("fiat_display_currency", ""), "weeks"
)
@router.get("/monthly", response_model=DailyWeeklyMonthly, tags=["info"])
def monthly(timescale: int = 3, rpc: RPC = Depends(get_rpc), config=Depends(get_config)):
def monthly(
timescale: int = Query(3, ge=1, description="Number of months to fetch data for"),
rpc: RPC = Depends(get_rpc),
config=Depends(get_config),
):
return rpc._rpc_timeunit_profit(
timescale, config["stake_currency"], config.get("fiat_display_currency", ""), "months"
)
@@ -184,7 +197,11 @@ def status(rpc: RPC = Depends(get_rpc)):
# Using the responsemodel here will cause a ~100% increase in response time (from 1s to 2s)
# on big databases. Correct response model: response_model=TradeResponse,
@router.get("/trades", tags=["info", "trading"])
def trades(limit: int = 500, offset: int = 0, rpc: RPC = Depends(get_rpc)):
def trades(
limit: int = Query(500, ge=1, description="Maximum number of different trades to return data"),
offset: int = Query(0, ge=0, description="Number of trades to skip for pagination"),
rpc: RPC = Depends(get_rpc),
):
return rpc._rpc_trade_history(limit, offset=offset, order_by_id=True)
@@ -213,6 +230,36 @@ def trade_reload(tradeid: int, rpc: RPC = Depends(get_rpc)):
return rpc._rpc_trade_status([tradeid])[0]
@router.get("/trades/open/custom-data", response_model=list[ListCustomData], tags=["trading"])
def list_open_trades_custom_data(
key: str | None = Query(None, description="Optional key to filter data"),
limit: int = Query(100, ge=1, description="Maximum number of different trades to return data"),
offset: int = Query(0, ge=0, description="Number of trades to skip for pagination"),
rpc: RPC = Depends(get_rpc),
):
"""
Fetch custom data for all open trades.
If a key is provided, it will be used to filter data accordingly.
Pagination is implemented via the `limit` and `offset` parameters.
"""
try:
return rpc._rpc_list_custom_data(key=key, limit=limit, offset=offset)
except RPCException as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/trades/{trade_id}/custom-data", response_model=list[ListCustomData], tags=["trading"])
def list_custom_data(trade_id: int, key: str | None = Query(None), rpc: RPC = Depends(get_rpc)):
"""
Fetch custom data for a specific trade.
If a key is provided, it will be used to filter data accordingly.
"""
try:
return rpc._rpc_list_custom_data(trade_id, key=key)
except RPCException as e:
raise HTTPException(status_code=404, detail=str(e))
# TODO: Missing response model
@router.get("/edge", tags=["info"])
def edge(rpc: RPC = Depends(get_rpc)):
+64 -25
View File
@@ -33,7 +33,7 @@ from freqtrade.exceptions import ExchangeError, PricingError
from freqtrade.exchange import Exchange, timeframe_to_minutes, timeframe_to_msecs
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 import CustomDataWrapper, 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
@@ -1115,31 +1115,70 @@ class RPC:
"cancel_order_count": c_count,
}
def _rpc_list_custom_data(self, trade_id: int, key: str | None) -> list[dict[str, Any]]:
# Query for trade
trade = Trade.get_trades(trade_filter=[Trade.id == trade_id]).first()
if trade is None:
return []
# Query custom_data
custom_data = []
if key:
data = trade.get_custom_data(key=key)
if data:
custom_data = [data]
def _rpc_list_custom_data(
self, trade_id: int | None = None, key: str | None = None, limit: int = 100, offset: int = 0
) -> list[dict[str, Any]]:
"""
Fetch custom data for a specific trade, or all open trades if `trade_id` is not provided.
Pagination is applied via `limit` and `offset`.
Returns an array of dictionaries, each containing:
- "trade_id": the ID of the trade (int)
- "custom_data": a list of custom data dicts, each with the fields:
"id", "key", "type", "value", "created_at", "updated_at"
"""
trades: Sequence[Trade]
if trade_id is None:
# Get all open trades
trades = Trade.session.scalars(
Trade.get_trades_query([Trade.is_open.is_(True)])
.order_by(Trade.id)
.limit(limit)
.offset(offset)
).all()
else:
custom_data = trade.get_all_custom_data()
return [
{
"id": data_entry.id,
"ft_trade_id": data_entry.ft_trade_id,
"cd_key": data_entry.cd_key,
"cd_type": data_entry.cd_type,
"cd_value": data_entry.cd_value,
"created_at": data_entry.created_at,
"updated_at": data_entry.updated_at,
}
for data_entry in custom_data
]
trades = Trade.get_trades(trade_filter=[Trade.id == trade_id]).all()
if not trades:
raise RPCException(
f"No trade found for trade_id: {trade_id}" if trade_id else "No open trades found."
)
results = []
for trade in trades:
# Depending on whether a specific key is provided, retrieve custom data accordingly.
if key:
data = trade.get_custom_data_entry(key=key)
# If data exists, wrap it in a list so the output remains consistent.
custom_data = [data] if data else []
else:
custom_data = trade.get_all_custom_data()
# Format and Append result for the trade if any custom data was found.
if custom_data:
formatted_custom_data = [
{
"key": data_entry.cd_key,
"type": data_entry.cd_type,
"value": CustomDataWrapper._convert_custom_data(data_entry).value,
"created_at": data_entry.created_at,
"updated_at": data_entry.updated_at,
}
for data_entry in custom_data
]
results.append({"trade_id": trade.id, "custom_data": formatted_custom_data})
# Handle case when there is no custom data found across trades.
if not results:
message_details = ""
if key:
message_details += f"with key '{key}' "
message_details += (
f"found for Trade ID: {trade_id}." if trade_id else "found for any open trades."
)
raise RPCException(f"No custom-data {message_details}")
return results
def _rpc_performance(self) -> list[dict[str, Any]]:
"""
+27 -17
View File
@@ -25,6 +25,7 @@ from telegram import (
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
Message,
ReplyKeyboardMarkup,
Update,
)
@@ -96,17 +97,17 @@ def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]):
"""
@wraps(command_handler)
async def wrapper(self, *args, **kwargs):
async def wrapper(self, *args, **kwargs) -> None:
"""Decorator logic"""
update = kwargs.get("update") or args[0]
# 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
message: Message = (
update.message if update.callback_query is None else update.callback_query.message
)
cchat_id: int = int(message.chat_id)
ctopic_id: int | None = message.message_thread_id
from_user_id: str = str(update.effective_user.id if update.effective_user else "")
chat_id = int(self._config["telegram"]["chat_id"])
if cchat_id != chat_id:
@@ -118,6 +119,10 @@ def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]):
logger.debug(f"Rejected message from wrong channel: {cchat_id}, {ctopic_id}")
return None
authorized = self._config["telegram"].get("authorized_users", None)
if authorized is not None and from_user_id not in authorized:
logger.info(f"Unauthorized user tried to control the bot: {from_user_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)
@@ -1976,16 +1981,17 @@ class Telegram(RPCHandler):
results = self._rpc._rpc_list_custom_data(trade_id, key)
messages = []
if len(results) > 0:
messages.append("Found custom-data entr" + ("ies: " if len(results) > 1 else "y: "))
for result in results:
trade_custom_data = results[0]["custom_data"]
messages.append(
"Found custom-data entr" + ("ies: " if len(trade_custom_data) > 1 else "y: ")
)
for custom_data in trade_custom_data:
lines = [
f"*Key:* `{result['cd_key']}`",
f"*ID:* `{result['id']}`",
f"*Trade ID:* `{result['ft_trade_id']}`",
f"*Type:* `{result['cd_type']}`",
f"*Value:* `{result['cd_value']}`",
f"*Create Date:* `{format_date(result['created_at'])}`",
f"*Update Date:* `{format_date(result['updated_at'])}`",
f"*Key:* `{custom_data['key']}`",
f"*Type:* `{custom_data['type']}`",
f"*Value:* `{custom_data['value']}`",
f"*Create Date:* `{format_date(custom_data['created_at'])}`",
f"*Update Date:* `{format_date(custom_data['updated_at'])}`",
]
# Filter empty lines using list-comprehension
messages.append("\n".join([line for line in lines if line]))
@@ -2153,6 +2159,9 @@ class Telegram(RPCHandler):
return
chat_id = update.message.chat_id
topic_id = update.message.message_thread_id
user_id = (
update.effective_user.id if topic_id is not None and update.effective_user else None
)
msg = f"""Freqtrade Bot Info:
```json
@@ -2160,7 +2169,8 @@ class Telegram(RPCHandler):
"enabled": true,
"token": "********",
"chat_id": "{chat_id}",
{f'"topic_id": "{topic_id}"' if topic_id else ""}
{f'"topic_id": "{topic_id}",' if topic_id else ""}
{f'//"authorized_users": ["{user_id}"]' if topic_id and user_id else ""}
}}
```
"""
+7 -6
View File
@@ -132,6 +132,7 @@ class IStrategy(ABC, HyperStrategyMixin):
stake_currency: str
# container variable for strategy source code
__source__: str = ""
__file__: str = ""
# Definition of plot_config. See plotting documentation for more details.
plot_config: dict = {}
@@ -664,7 +665,7 @@ class IStrategy(ABC, HyperStrategyMixin):
entry_tag: str | None,
side: str,
**kwargs,
) -> float:
) -> float | None:
"""
Entry price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
@@ -685,7 +686,7 @@ class IStrategy(ABC, HyperStrategyMixin):
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
:return float or None: New entry price value if provided
"""
return current_order_rate
@@ -701,7 +702,7 @@ class IStrategy(ABC, HyperStrategyMixin):
entry_tag: str | None,
side: str,
**kwargs,
) -> float:
) -> float | None:
"""
Exit price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
@@ -722,7 +723,7 @@ class IStrategy(ABC, HyperStrategyMixin):
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
:return float or None: New exit price value if provided
"""
return current_order_rate
@@ -739,7 +740,7 @@ class IStrategy(ABC, HyperStrategyMixin):
side: str,
is_entry: bool,
**kwargs,
) -> float:
) -> float | None:
"""
Exit and entry order price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
@@ -761,7 +762,7 @@ class IStrategy(ABC, HyperStrategyMixin):
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param is_entry: True if the order is an entry order, False if it's an exit order.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
:return float or None: New entry price value if provided
"""
if is_entry:
return self.adjust_entry_price(
@@ -52,7 +52,7 @@ def adjust_order_price(
side: str,
is_entry: bool,
**kwargs,
) -> float:
) -> float | None:
"""
Exit and entry order price re-adjustment logic, returning the user desired limit price.
This only executes when a order was already placed, still open (unfilled fully or partially)
@@ -74,8 +74,7 @@ def adjust_order_price(
:param side: 'long' or 'short' - indicating the direction of the proposed trade
:param is_entry: True if the order is an entry order, False if it's an exit order.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New entry price value if provided
:return float or None: New entry price value if provided
"""
return current_order_rate
+2 -1
View File
@@ -7,7 +7,6 @@ from rich.progress import (
TimeRemainingColumn,
)
from freqtrade.loggers import error_console
from freqtrade.util.rich_progress import CustomProgress
@@ -21,6 +20,8 @@ def get_progress_tracker(**kwargs) -> CustomProgress:
"""
Get progress Bar with custom columns.
"""
from freqtrade.loggers import error_console
return CustomProgress(
TextColumn("[progress.description]{task.description}"),
BarColumn(bar_width=None),
+4 -12
View File
@@ -1,12 +1,12 @@
import sys
from collections.abc import Sequence
from typing import Any, TypeAlias
from pandas import DataFrame
from rich.console import Console
from rich.table import Column, Table
from rich.text import Text
from freqtrade.loggers.rich_console import get_rich_console
TextOrString: TypeAlias = str | Text
@@ -38,11 +38,7 @@ def print_rich_table(
row_to_add: list[str | Text] = [r if isinstance(r, Text) else str(r) for r in row]
table.add_row(*row_to_add)
width = None
if any(module in ["pytest", "ipykernel"] for module in sys.modules):
width = 200
console = Console(width=width)
console = get_rich_console()
console.print(table)
@@ -74,9 +70,5 @@ def print_df_rich_table(
row = [_format_value(x, floatfmt=".3f") for x in value_list]
table.add_row(*row)
width = None
if any(module in ["pytest", "ipykernel"] for module in sys.modules):
width = 200
console = Console(width=width)
console = get_rich_console()
console.print(table)
+1 -1
View File
@@ -197,7 +197,7 @@ class Wallets:
# Position is not open ...
continue
size = self._exchange._contracts_to_amount(symbol, position["contracts"])
collateral = safe_value_fallback(position, "collateral", "initialMargin", 0.0)
collateral = safe_value_fallback(position, "initialMargin", "collateral", 0.0)
leverage = position.get("leverage")
_parsed_positions[symbol] = PositionWallet(
symbol,
+1 -1
View File
@@ -1,7 +1,7 @@
from freqtrade_client.ft_rest_client import FtRestClient
__version__ = "2025.2"
__version__ = "2025.3"
if "dev" in __version__:
from pathlib import Path
@@ -269,6 +269,36 @@ class FtRestClient:
params["offset"] = offset
return self._get("trades", params)
def list_open_trades_custom_data(self, key=None, limit=100, offset=0):
"""List open trades custom-data of the running bot.
:param key: str, optional - Key of the custom-data
:param limit: limit of trades
:param offset: trades offset for pagination
:return: json object
"""
params = {}
params["limit"] = limit
params["offset"] = offset
if key is not None:
params["key"] = key
return self._get("trades/open/custom-data", params=params)
def list_custom_data(self, trade_id, key=None):
"""List custom-data of the running bot for a specific trade.
:param trade_id: ID of the trade
:param key: str, optional - Key of the custom-data
:return: JSON object
"""
params = {}
params["trade_id"] = trade_id
if key is not None:
params["key"] = key
return self._get(f"trades/{trade_id}/custom-data", params=params)
def trade(self, trade_id):
"""Return specific trade
+1 -3
View File
@@ -29,7 +29,7 @@ classifiers = [
dependencies = [
# from requirements.txt
"ccxt>=4.3.24",
"ccxt>=4.4.60",
"SQLAlchemy>=2.0.6",
"python-telegram-bot>=20.1",
"humanize>=4.0.0",
@@ -287,8 +287,6 @@ max-complexity = 12
[tool.ruff.lint.per-file-ignores]
"freqtrade/freqai/**/*.py" = [
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
"B006", # Bugbear - mutable default argument
"B008", # bugbear - Do not perform function calls in argument defaults
]
"tests/**/*.py" = [
"S101", # allow assert in tests
+5 -5
View File
@@ -7,17 +7,17 @@
-r docs/requirements-docs.txt
coveralls==4.0.1
ruff==0.9.7
ruff==0.11.2
mypy==1.15.0
pre-commit==4.1.0
pytest==8.3.4
pre-commit==4.2.0
pytest==8.3.5
pytest-asyncio==0.25.3
pytest-cov==6.0.0
pytest-mock==3.14.0
pytest-random-order==1.1.1
pytest-timeout==2.3.1
pytest-xdist==3.6.1
isort==6.0.0
isort==6.0.1
# For datetime mocking
time-machine==2.16.0
@@ -27,6 +27,6 @@ nbconvert==7.16.6
# mypy types
types-cachetools==5.5.0.20240820
types-filelock==3.2.7
types-requests==2.32.0.20241016
types-requests==2.32.0.20250306
types-tabulate==0.9.0.20241207
types-python-dateutil==2.9.0.20241206
+1 -1
View File
@@ -5,4 +5,4 @@
scipy==1.15.2
scikit-learn==1.6.1
ft-scikit-optimize==0.9.2
filelock==3.17.0
filelock==3.18.0
+1 -1
View File
@@ -1,4 +1,4 @@
# Include all requirements to run the bot.
-r requirements.txt
plotly==6.0.0
plotly==6.0.1
+7 -7
View File
@@ -4,11 +4,11 @@ bottleneck==1.4.2
numexpr==2.10.2
pandas-ta==0.3.14b
ccxt==4.4.62
cryptography==44.0.1
ccxt==4.4.69
cryptography==44.0.2
aiohttp==3.9.5
SQLAlchemy==2.0.38
python-telegram-bot==21.10
SQLAlchemy==2.0.39
python-telegram-bot==22.0
# can't be hard-pinned due to telegram-bot pinning httpx with ~
httpx>=0.24.1
humanize==4.12.1
@@ -20,7 +20,7 @@ TA-Lib==0.4.38
technical==1.5.0
tabulate==0.9.0
pycoingecko==3.2.0
jinja2==3.1.5
jinja2==3.1.6
joblib==1.4.2
rich==13.9.4
pyarrow==19.0.1; platform_machine != 'armv7l'
@@ -37,7 +37,7 @@ orjson==3.10.15
sdnotify==0.3.2
# API Server
fastapi==0.115.8
fastapi==0.115.12
pydantic==2.10.6
uvicorn==0.34.0
pyjwt==2.10.1
@@ -55,7 +55,7 @@ pytz==2025.1
schedule==1.2.2
#WS Messages
websockets==15.0
websockets==15.0.1
janus==2.0.0
ast-comments==1.2.2
+4 -1
View File
@@ -1,8 +1,10 @@
import subprocess
import time
from tests.conftest import is_arm, is_mac
MAXIMUM_STARTUP_TIME = 0.5
MAXIMUM_STARTUP_TIME = 0.7 if is_mac() and not is_arm() else 0.5
def test_startup_time():
@@ -14,4 +16,5 @@ def test_startup_time():
elapsed = time.time() - start
assert elapsed < MAXIMUM_STARTUP_TIME, (
"The startup time is too long, try to use lazy import in the command entry function"
f" (maximum {MAXIMUM_STARTUP_TIME}s, got {elapsed}s)"
)
+9
View File
@@ -549,6 +549,14 @@ def user_dir(mocker, tmp_path) -> Path:
return user_dir
@pytest.fixture()
def keep_log_config_loggers(mocker):
# Mock the _handle_existing_loggers function to prevent it from disabling all loggers.
# This is necessary to keep all loggers active, and avoid random failures if
# this file is ran before the test_rest_client file.
mocker.patch("logging.config._handle_existing_loggers")
@pytest.fixture(autouse=True)
def patch_coingecko(mocker) -> None:
"""
@@ -644,6 +652,7 @@ def get_default_conf(testdatadir):
"trading_mode": "spot",
"margin_mode": "",
"candle_type_def": CandleType.SPOT,
"original_config": {},
}
return configuration
+37
View File
@@ -30,6 +30,7 @@ from freqtrade.data.metrics import (
calculate_max_drawdown,
calculate_sharpe,
calculate_sortino,
calculate_sqn,
calculate_underwater,
combine_dataframes_with_mean,
combined_dataframes_with_rel_mean,
@@ -457,6 +458,42 @@ def test_calculate_calmar(testdatadir):
assert pytest.approx(calmar) == 559.040508
def test_calculate_sqn(testdatadir):
filename = testdatadir / "backtest_results/backtest-result.json"
bt_data = load_backtest_data(filename)
sqn = calculate_sqn(DataFrame(), 0)
assert sqn == 0.0
sqn = calculate_sqn(
bt_data,
0.01,
)
assert isinstance(sqn, float)
assert pytest.approx(sqn) == 3.2991
@pytest.mark.parametrize(
"profits,starting_balance,expected_sqn,description",
[
([1.0, -0.5, 2.0, -1.0, 0.5, 1.5, -0.5, 1.0], 100, 1.3229, "Mixed profits/losses"),
([], 100, 0.0, "Empty dataframe"),
([1.0, 0.5, 2.0, 1.5, 0.8], 100, 4.3657, "All winning trades"),
([-1.0, -0.5, -2.0, -1.5, -0.8], 100, -4.3657, "All losing trades"),
([1.0], 100, -100, "Single trade"),
],
)
def test_calculate_sqn_cases(profits, starting_balance, expected_sqn, description):
"""
Test SQN calculation with various scenarios:
"""
trades = DataFrame({"profit_abs": profits})
sqn = calculate_sqn(trades, starting_balance=starting_balance)
assert isinstance(sqn, float)
assert pytest.approx(sqn, rel=1e-4) == expected_sqn
@pytest.mark.parametrize(
"start,end,days, expected",
[
+4 -4
View File
@@ -408,20 +408,20 @@ def test_get_analyzed_dataframe(mocker, default_conf, ohlcv_history):
# Test backtest mode
default_conf["runmode"] = RunMode.BACKTEST
dp._set_dataframe_max_index(1)
dp._set_dataframe_max_index("XRP/BTC", 1)
dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe)
assert len(dataframe) == 1
dp._set_dataframe_max_index(2)
dp._set_dataframe_max_index("XRP/BTC", 2)
dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe)
assert len(dataframe) == 2
dp._set_dataframe_max_index(3)
dp._set_dataframe_max_index("XRP/BTC", 3)
dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe)
assert len(dataframe) == 3
dp._set_dataframe_max_index(500)
dp._set_dataframe_max_index("XRP/BTC", 500)
dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe)
assert len(dataframe) == len(ohlcv_history)
+52
View File
@@ -6,6 +6,7 @@ import ccxt
import pandas as pd
import pytest
from freqtrade.data.converter.trade_converter import trades_dict_to_list
from freqtrade.enums import CandleType, MarginMode, TradingMode
from freqtrade.exceptions import DependencyException, InvalidOrderException, OperationalException
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_seconds
@@ -1002,6 +1003,7 @@ def test_get_maintenance_ratio_and_amt_binance(
async def test__async_get_trade_history_id_binance(default_conf_usdt, mocker, fetch_trades_result):
default_conf_usdt["exchange"]["only_from_ccxt"] = True
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="binance")
async def mock_get_trade_hist(pair, *args, **kwargs):
@@ -1056,3 +1058,53 @@ async def test__async_get_trade_history_id_binance(default_conf_usdt, mocker, fe
# Clean up event loop to avoid warnings
exchange.close()
async def test__async_get_trade_history_id_binance_fast(
default_conf_usdt, mocker, fetch_trades_result
):
default_conf_usdt["exchange"]["only_from_ccxt"] = False
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="binance")
async def mock_get_trade_hist(pair, *args, **kwargs):
if "since" in kwargs:
pass
# older than initial call
# if kwargs["since"] < 1565798399752:
# return []
# else:
# # Don't expect to get here
# raise ValueError("Unexpected call")
# # return fetch_trades_result[:-2]
elif kwargs.get("params", {}).get(exchange._trades_pagination_arg) == "0":
# Return first 3
return fetch_trades_result[:-2]
# elif kwargs.get("params", {}).get(exchange._trades_pagination_arg) in (
# fetch_trades_result[-3]["id"],
# 1565798399752,
# ):
# # Return 2
# return fetch_trades_result[-3:-1]
# else:
# # Return last 2
# return fetch_trades_result[-2:]
pair = "ETH/BTC"
mocker.patch(
"freqtrade.exchange.binance.download_archive_trades",
return_value=(pair, trades_dict_to_list(fetch_trades_result[-2:])),
)
exchange._api_async.fetch_trades = MagicMock(side_effect=mock_get_trade_hist)
ret = await exchange._async_get_trade_history(
pair,
since=fetch_trades_result[0]["timestamp"],
until=fetch_trades_result[-1]["timestamp"] - 1,
)
assert ret[0] == pair
assert isinstance(ret[1], list)
# Clean up event loop to avoid warnings
exchange.close()
+157
View File
@@ -14,11 +14,15 @@ from freqtrade.enums import CandleType
from freqtrade.exchange.binance_public_data import (
BadHttpStatus,
Http404,
binance_vision_trades_zip_url,
binance_vision_zip_name,
download_archive_ohlcv,
download_archive_trades,
get_daily_ohlcv,
get_daily_trades,
)
from freqtrade.util.datetime_helpers import dt_ts, dt_utc
from ft_client.test_client.test_rest_client import log_has_re
@pytest.fixture(scope="module")
@@ -337,3 +341,156 @@ async def test_get_daily_ohlcv(mocker, testdatadir):
with pytest.raises(zipfile.BadZipFile):
df = await get_daily_ohlcv(symbol, timeframe, CandleType.SPOT, date, session)
assert get.call_count == 4 # 1 + 3 default retries
async def test_download_archive_trades(mocker, caplog):
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.get_daily_trades", return_value=[[2, 3]])
pair1, res = await download_archive_trades(
CandleType.SPOT, pair, since_ms=since_ms, until_ms=until_ms, markets=markets
)
assert pair1 == pair
assert res == [[2, 3], [2, 3]]
mocker.patch(
"freqtrade.exchange.binance_public_data.get_daily_trades",
side_effect=Http404("xxx", dt_utc(2020, 1, 1), "http://example.com/something"),
)
pair1, res = await download_archive_trades(
CandleType.SPOT, pair, since_ms=since_ms, until_ms=until_ms, markets=markets
)
assert pair1 == pair
assert res == []
# exit on day 1
assert log_has_re("Fast download is unavailable", caplog)
# Test fail on day 2
caplog.clear()
mocker.patch(
"freqtrade.exchange.binance_public_data.get_daily_trades",
side_effect=[
[[2, 3]],
[[2, 3]],
Http404("xxx", dt_utc(2020, 1, 2), "http://example.com/something"),
[[2, 3]],
],
)
# Download 3 days
until_ms = dt_ts(dt_utc(2020, 1, 3))
pair1, res = await download_archive_trades(
CandleType.SPOT, pair, since_ms=since_ms, until_ms=until_ms, markets=markets
)
assert pair1 == pair
assert res == [[2, 3], [2, 3]]
assert log_has_re(r"Binance fast download .*stopped", caplog)
async def test_download_archive_trades_exception(mocker, caplog):
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
)
pair1, res = await download_archive_trades(
CandleType.SPOT, pair, since_ms=since_ms, until_ms=until_ms, markets=markets
)
assert pair1 == pair
assert res == []
mocker.patch(
"freqtrade.exchange.binance_public_data._download_archive_trades", side_effect=RuntimeError
)
await download_archive_trades(
CandleType.SPOT, pair, since_ms=since_ms, until_ms=until_ms, markets=markets
)
assert pair1 == pair
assert res == []
assert log_has_re("An exception occurred during fast trades download", caplog)
async def test_binance_vision_trades_zip_url():
url = binance_vision_trades_zip_url("BTCUSDT", CandleType.SPOT, dt_utc(2023, 10, 27))
assert (
url == "https://data.binance.vision/data/spot/daily/aggTrades/"
"BTCUSDT/BTCUSDT-aggTrades-2023-10-27.zip"
)
url = binance_vision_trades_zip_url("BTCUSDT", CandleType.FUTURES, dt_utc(2023, 10, 28))
assert (
url == "https://data.binance.vision/data/futures/um/daily/aggTrades/"
"BTCUSDT/BTCUSDT-aggTrades-2023-10-28.zip"
)
async def test_get_daily_trades(mocker, testdatadir):
symbol = "PEPEUSDT"
symbol_futures = "APEUSDT"
date = dt_utc(2024, 10, 28).date()
first_date = 1729987202368
last_date = 1730073596350
async with aiohttp.ClientSession() as session:
spot_path = (
testdatadir / "binance/binance_public_data/spot-PEPEUSDT-aggTrades-2024-10-27.zip"
)
get = mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
return_value=MockResponse(spot_path.read_bytes(), 200),
)
res = await get_daily_trades(symbol, CandleType.SPOT, date, session)
assert get.call_count == 1
assert res[0][0] == first_date
assert res[-1][0] == last_date
futures_path = (
testdatadir / "binance/binance_public_data/futures-APEUSDT-aggTrades-2024-10-18.zip"
)
get = mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
return_value=MockResponse(futures_path.read_bytes(), 200),
)
res_fut = await get_daily_trades(symbol_futures, CandleType.FUTURES, date, session)
assert get.call_count == 1
assert res_fut[0][0] == 1729209603958
assert res_fut[-1][0] == 1729295981272
get = mocker.patch(
"freqtrade.exchange.binance_public_data.aiohttp.ClientSession.get",
return_value=MockResponse(b"", 404),
)
with pytest.raises(Http404):
await get_daily_trades(symbol, CandleType.SPOT, 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):
await get_daily_trades(symbol, CandleType.SPOT, 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):
await get_daily_trades(symbol, CandleType.SPOT, date, session)
assert get.call_count == 4 # 1 + 3 default retries
+44 -39
View File
@@ -2177,13 +2177,11 @@ def test_get_historic_ohlcv(default_conf, mocker, caplog, exchange_name, candle_
caplog.clear()
async def mock_get_candle_hist_error(pair, *args, **kwargs):
raise TimeoutError()
exchange._async_get_candle_history = MagicMock(side_effect=mock_get_candle_hist_error)
ret = exchange.get_historic_ohlcv(
pair, "5m", dt_ts(dt_now() - timedelta(seconds=since)), candle_type=candle_type
)
exchange._async_get_candle_history = get_mock_coro(side_effect=TimeoutError())
with pytest.raises(TimeoutError):
exchange.get_historic_ohlcv(
pair, "5m", dt_ts(dt_now() - timedelta(seconds=since)), candle_type=candle_type
)
assert log_has_re(r"Async code raised an exception: .*", caplog)
@@ -2373,6 +2371,8 @@ def test_refresh_latest_trades(
caplog.set_level(logging.DEBUG)
use_trades_conf = default_conf
use_trades_conf["exchange"]["use_public_trades"] = True
use_trades_conf["exchange"]["only_from_ccxt"] = True
use_trades_conf["datadir"] = tmp_path
use_trades_conf["orderflow"] = {"max_candles": 1500}
exchange = get_patched_exchange(mocker, use_trades_conf)
@@ -3365,6 +3365,7 @@ async def test__async_fetch_trades_contract_size(
async def test__async_get_trade_history_id(
default_conf, mocker, exchange_name, fetch_trades_result
):
default_conf["exchange"]["only_from_ccxt"] = True
exchange = get_patched_exchange(mocker, default_conf, exchange=exchange_name)
if exchange._trades_pagination != "id":
exchange.close()
@@ -6076,44 +6077,47 @@ def test_get_liquidation_price1(mocker, default_conf):
@pytest.mark.parametrize("liquidation_buffer", [0.0])
@pytest.mark.parametrize(
"is_short,trading_mode,exchange_name,margin_mode,leverage,open_rate,amount,expected_liq",
"is_short,trading_mode,exchange_name,margin_mode,leverage,open_rate,amount,mramt,expected_liq",
[
(False, "spot", "binance", "", 5.0, 10.0, 1.0, None),
(True, "spot", "binance", "", 5.0, 10.0, 1.0, None),
(False, "spot", "gate", "", 5.0, 10.0, 1.0, None),
(True, "spot", "gate", "", 5.0, 10.0, 1.0, None),
(False, "spot", "okx", "", 5.0, 10.0, 1.0, None),
(True, "spot", "okx", "", 5.0, 10.0, 1.0, None),
(False, "spot", "binance", "", 5.0, 10.0, 1.0, (0.01, 0.01), None),
(True, "spot", "binance", "", 5.0, 10.0, 1.0, (0.01, 0.01), None),
(False, "spot", "gate", "", 5.0, 10.0, 1.0, (0.01, 0.01), None),
(True, "spot", "gate", "", 5.0, 10.0, 1.0, (0.01, 0.01), None),
(False, "spot", "okx", "", 5.0, 10.0, 1.0, (0.01, 0.01), None),
(True, "spot", "okx", "", 5.0, 10.0, 1.0, (0.01, 0.01), None),
# Binance, short
(True, "futures", "binance", "isolated", 5.0, 10.0, 1.0, 11.89108910891089),
(True, "futures", "binance", "isolated", 3.0, 10.0, 1.0, 13.211221122079207),
(True, "futures", "binance", "isolated", 5.0, 8.0, 1.0, 9.514851485148514),
(True, "futures", "binance", "isolated", 5.0, 10.0, 0.6, 11.897689768976898),
(True, "futures", "binance", "isolated", 5.0, 10.0, 1.0, (0.01, 0.01), 11.89108910891089),
(True, "futures", "binance", "isolated", 3.0, 10.0, 1.0, (0.01, 0.01), 13.211221122079207),
(True, "futures", "binance", "isolated", 5.0, 8.0, 1.0, (0.01, 0.01), 9.514851485148514),
(True, "futures", "binance", "isolated", 5.0, 10.0, 0.6, (0.01, 0.01), 11.897689768976898),
# Binance, long
(False, "futures", "binance", "isolated", 5, 10, 1.0, 8.070707070707071),
(False, "futures", "binance", "isolated", 5, 8, 1.0, 6.454545454545454),
(False, "futures", "binance", "isolated", 3, 10, 1.0, 6.723905723905723),
(False, "futures", "binance", "isolated", 5, 10, 0.6, 8.063973063973064),
(False, "futures", "binance", "isolated", 5, 10, 1.0, (0.01, 0.01), 8.070707070707071),
(False, "futures", "binance", "isolated", 5, 8, 1.0, (0.01, 0.01), 6.454545454545454),
(False, "futures", "binance", "isolated", 3, 10, 1.0, (0.01, 0.01), 6.723905723905723),
(False, "futures", "binance", "isolated", 5, 10, 0.6, (0.01, 0.01), 8.063973063973064),
# Gate/okx, short
(True, "futures", "gate", "isolated", 5, 10, 1.0, 11.87413417771621),
(True, "futures", "gate", "isolated", 5, 10, 2.0, 11.87413417771621),
(True, "futures", "gate", "isolated", 3, 10, 1.0, 13.193482419684678),
(True, "futures", "gate", "isolated", 5, 8, 1.0, 9.499307342172967),
(True, "futures", "okx", "isolated", 3, 10, 1.0, 13.193482419684678),
(True, "futures", "gate", "isolated", 5, 10, 1.0, (0.01, 0.01), 11.87413417771621),
(True, "futures", "gate", "isolated", 5, 10, 2.0, (0.01, 0.01), 11.87413417771621),
(True, "futures", "gate", "isolated", 3, 10, 1.0, (0.01, 0.01), 13.193482419684678),
(True, "futures", "gate", "isolated", 5, 8, 1.0, (0.01, 0.01), 9.499307342172967),
(True, "futures", "okx", "isolated", 3, 10, 1.0, (0.01, 0.01), 13.193482419684678),
# Gate/okx, long
(False, "futures", "gate", "isolated", 5.0, 10.0, 1.0, 8.085708510208207),
(False, "futures", "gate", "isolated", 3.0, 10.0, 1.0, 6.738090425173506),
(False, "futures", "okx", "isolated", 3.0, 10.0, 1.0, 6.738090425173506),
(False, "futures", "gate", "isolated", 5.0, 10.0, 1.0, (0.01, 0.01), 8.085708510208207),
(False, "futures", "gate", "isolated", 3.0, 10.0, 1.0, (0.01, 0.01), 6.738090425173506),
(False, "futures", "okx", "isolated", 3.0, 10.0, 1.0, (0.01, 0.01), 6.738090425173506),
# bybit, long
(False, "futures", "bybit", "isolated", 1.0, 10.0, 1.0, 0.1),
(False, "futures", "bybit", "isolated", 3.0, 10.0, 1.0, 6.7666666),
(False, "futures", "bybit", "isolated", 5.0, 10.0, 1.0, 8.1),
(False, "futures", "bybit", "isolated", 10.0, 10.0, 1.0, 9.1),
(False, "futures", "bybit", "isolated", 1.0, 10.0, 1.0, (0.01, 0.01), 0.1),
(False, "futures", "bybit", "isolated", 3.0, 10.0, 1.0, (0.01, 0.01), 6.7666666),
(False, "futures", "bybit", "isolated", 5.0, 10.0, 1.0, (0.01, 0.01), 8.1),
(False, "futures", "bybit", "isolated", 10.0, 10.0, 1.0, (0.01, 0.01), 9.1),
# From the bybit example - without additional margin
(False, "futures", "bybit", "isolated", 50.0, 40000.0, 1.0, (0.005, None), 39400),
(False, "futures", "bybit", "isolated", 50.0, 20000.0, 1.0, (0.005, None), 19700),
# bybit, short
(True, "futures", "bybit", "isolated", 1.0, 10.0, 1.0, 19.9),
(True, "futures", "bybit", "isolated", 3.0, 10.0, 1.0, 13.233333),
(True, "futures", "bybit", "isolated", 5.0, 10.0, 1.0, 11.9),
(True, "futures", "bybit", "isolated", 10.0, 10.0, 1.0, 10.9),
(True, "futures", "bybit", "isolated", 1.0, 10.0, 1.0, (0.01, 0.01), 19.9),
(True, "futures", "bybit", "isolated", 3.0, 10.0, 1.0, (0.01, 0.01), 13.233333),
(True, "futures", "bybit", "isolated", 5.0, 10.0, 1.0, (0.01, 0.01), 11.9),
(True, "futures", "bybit", "isolated", 10.0, 10.0, 1.0, (0.01, 0.01), 10.9),
],
)
def test_get_liquidation_price(
@@ -6126,6 +6130,7 @@ def test_get_liquidation_price(
leverage,
open_rate,
amount,
mramt,
expected_liq,
liquidation_buffer,
):
@@ -6189,7 +6194,7 @@ def test_get_liquidation_price(
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))
exchange.get_maintenance_ratio_and_amt = MagicMock(return_value=mramt)
exchange.name = exchange_name
# default_conf_usdt.update({
# "dry_run": False,
@@ -12,7 +12,6 @@ import pytest
from freqtrade.enums import CandleType
from freqtrade.exchange.exchange_utils import timeframe_to_prev_date
from freqtrade.loggers.set_log_levels import set_loggers
from freqtrade.util.datetime_helpers import dt_now
from tests.conftest import log_has_re
from tests.exchange_online.conftest import EXCHANGE_WS_FIXTURE_TYPE
@@ -50,7 +49,6 @@ class TestCCXTExchangeWs:
assert res[pair_tf] is not None
df1 = res[pair_tf]
caplog.set_level(logging.DEBUG)
set_loggers(1)
assert df1.iloc[-1]["date"] == curr_candle
# Wait until the next candle (might be up to 1 minute).
+6 -2
View File
@@ -150,7 +150,9 @@ def test_get_pair_data_for_features_with_prealoaded_data(mocker, freqai_conf):
freqai.dd.load_all_pair_histories(timerange, freqai.dk)
_, base_df = freqai.dd.get_base_and_corr_dataframes(timerange, "LTC/BTC", freqai.dk)
df = freqai.dk.get_pair_data_for_features("LTC/BTC", "5m", strategy, base_dataframes=base_df)
df = freqai.dk.get_pair_data_for_features(
"LTC/BTC", "5m", strategy, {}, base_dataframes=base_df
)
assert df is base_df["5m"]
assert not df.empty
@@ -170,7 +172,9 @@ def test_get_pair_data_for_features_without_preloaded_data(mocker, freqai_conf):
freqai.dd.load_all_pair_histories(timerange, freqai.dk)
base_df = {"5m": pd.DataFrame()}
df = freqai.dk.get_pair_data_for_features("LTC/BTC", "5m", strategy, base_dataframes=base_df)
df = freqai.dk.get_pair_data_for_features(
"LTC/BTC", "5m", strategy, {}, base_dataframes=base_df
)
assert df is not base_df["5m"]
assert not df.empty
+2 -2
View File
@@ -701,9 +701,9 @@ def test_process_trade_creation(
assert pytest.approx(trade.amount) == 0
assert pytest.approx(trade.amount_requested) == 60 / ticker_usdt.return_value[ticker_side]
assert log_has(
assert log_has_re(
f"{'Short' if is_short else 'Long'} signal found: about create a new trade for ETH/USDT "
"with stake_amount: 60.0 ...",
r"with stake_amount: 60.0 and price: .*",
caplog,
)
mocker.patch("freqtrade.freqtradebot.FreqtradeBot._check_and_execute_exit")
+3 -5
View File
@@ -1543,8 +1543,7 @@ def test_backtest_multi_pair(default_conf, fee, mocker, tres, pair, testdatadir)
assert len(evaluate_result_multi(results["results"], "5m", 3)) == 0
# Cached data correctly removed amounts
offset = 1 if tres == 0 else 0
removed_candles = len(data[pair]) - offset
removed_candles = len(data[pair]) - 1
assert len(backtesting.dataprovider.get_analyzed_dataframe(pair, "5m")[0]) == removed_candles
assert (
len(backtesting.dataprovider.get_analyzed_dataframe("NXT/BTC", "5m")[0])
@@ -1663,8 +1662,7 @@ def test_backtest_multi_pair_detail(
assert len(evaluate_result_multi(results["results"], "5m", 3)) == 0
# Cached data correctly removed amounts
offset = 1 if tres == 0 else 0
removed_candles = len(data[pair]) - offset
removed_candles = len(data[pair]) - 1
assert len(backtesting.dataprovider.get_analyzed_dataframe(pair, "5m")[0]) == removed_candles
assert (
len(backtesting.dataprovider.get_analyzed_dataframe("NXT/USDT", "5m")[0])
@@ -1793,7 +1791,7 @@ def test_backtest_multi_pair_detail_simplified(
assert len(evaluate_result_multi(results["results"], "1m", 3)) == 0
# # Cached data correctly removed amounts
offset = 1 if tres == 0 else 0
offset = 1
removed_candles = len(data[pair]) - offset
assert len(backtesting.dataprovider.get_analyzed_dataframe(pair, "1h")[0]) == removed_candles
assert (
@@ -162,7 +162,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera
backtesting.strategy.adjust_trade_position = MagicMock(return_value=None)
assert pytest.approx(trade.liquidation_price) == (0.10278333 if leverage == 1 else 1.2122249)
trade = backtesting._get_adjust_trade_entry_for_candle(trade, row_enter, current_time)
trade = backtesting._check_adjust_trade_for_candle(trade, row_enter, current_time)
assert trade
assert pytest.approx(trade.stake_amount) == 100.0
assert pytest.approx(trade.amount) == 47.61904762 * leverage
@@ -170,7 +170,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera
# Increase position by 100
backtesting.strategy.adjust_trade_position = MagicMock(return_value=(100, "PartIncrease"))
trade = backtesting._get_adjust_trade_entry_for_candle(trade, row_enter, current_time)
trade = backtesting._check_adjust_trade_for_candle(trade, row_enter, current_time)
liq_price = 0.1038916 if leverage == 1 else 1.2127791
assert trade
@@ -184,7 +184,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera
backtesting.strategy.adjust_trade_position = MagicMock(return_value=-500)
current_time = row_exit[0].to_pydatetime()
trade = backtesting._get_adjust_trade_entry_for_candle(trade, row_exit, current_time)
trade = backtesting._check_adjust_trade_for_candle(trade, row_exit, current_time)
assert trade
assert pytest.approx(trade.stake_amount) == 200.0
@@ -195,7 +195,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera
# Reduce position by 50
backtesting.strategy.adjust_trade_position = MagicMock(return_value=(-100, "partDecrease"))
trade = backtesting._get_adjust_trade_entry_for_candle(trade, row_exit, current_time)
trade = backtesting._check_adjust_trade_for_candle(trade, row_exit, current_time)
assert trade
assert pytest.approx(trade.stake_amount) == 100.0
@@ -208,7 +208,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera
# Adjust below minimum
backtesting.strategy.adjust_trade_position = MagicMock(return_value=-99)
trade = backtesting._get_adjust_trade_entry_for_candle(trade, row_exit, current_time)
trade = backtesting._check_adjust_trade_for_candle(trade, row_exit, current_time)
assert trade
assert pytest.approx(trade.stake_amount) == 100.0
@@ -220,5 +220,5 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, levera
# Adjust to close trade
backtesting.strategy.adjust_trade_position = MagicMock(return_value=-trade.stake_amount)
trade = backtesting._get_adjust_trade_entry_for_candle(trade, row_exit, current_time)
trade = backtesting._check_adjust_trade_for_candle(trade, row_exit, current_time)
assert trade.is_open is False
+47 -8
View File
@@ -1,5 +1,6 @@
import json
import re
import shutil
from datetime import timedelta
from pathlib import Path
from shutil import copyfile
@@ -41,7 +42,7 @@ from freqtrade.optimize.optimize_reports.optimize_reports import (
from freqtrade.resolvers.strategy_resolver import StrategyResolver
from freqtrade.util import dt_ts
from freqtrade.util.datetime_helpers import dt_from_ts, dt_utc
from tests.conftest import CURRENT_TEST_STRATEGY
from tests.conftest import CURRENT_TEST_STRATEGY, log_has_re
from tests.data.test_history import _clean_test_file
@@ -253,8 +254,9 @@ def test_store_backtest_results(testdatadir, mocker):
dump_mock = mocker.patch("freqtrade.optimize.optimize_reports.bt_storage.file_dump_json")
zip_mock = mocker.patch("freqtrade.optimize.optimize_reports.bt_storage.ZipFile")
data = {"metadata": {}, "strategy": {}, "strategy_comparison": []}
store_backtest_results({"exportfilename": testdatadir}, data, "2022_01_01_15_05_13")
store_backtest_results(
{"exportfilename": testdatadir, "original_config": {}}, data, "2022_01_01_15_05_13"
)
assert dump_mock.call_count == 2
assert zip_mock.call_count == 1
@@ -264,7 +266,9 @@ def test_store_backtest_results(testdatadir, mocker):
dump_mock.reset_mock()
zip_mock.reset_mock()
filename = testdatadir / "testresult.json"
store_backtest_results({"exportfilename": filename}, data, "2022_01_01_15_05_13")
store_backtest_results(
{"exportfilename": filename, "original_config": {}}, data, "2022_01_01_15_05_13"
)
assert dump_mock.call_count == 2
assert zip_mock.call_count == 1
assert isinstance(dump_mock.call_args_list[0][0][0], Path)
@@ -272,9 +276,16 @@ def test_store_backtest_results(testdatadir, mocker):
assert str(dump_mock.call_args_list[0][0][0]).startswith(str(testdatadir / "testresult"))
def test_store_backtest_results_real(tmp_path):
def test_store_backtest_results_real(tmp_path, caplog):
data = {"metadata": {}, "strategy": {}, "strategy_comparison": []}
store_backtest_results({"exportfilename": tmp_path}, data, "2022_01_01_15_05_13")
config = {
"exportfilename": tmp_path,
"original_config": {},
}
store_backtest_results(
config, data, "2022_01_01_15_05_13", strategy_files={"DefStrat": "NoFile"}
)
assert log_has_re(r"Strategy file .* does not exist\. Skipping\.", caplog)
zip_file = tmp_path / "backtest-result-2022_01_01_15_05_13.zip"
assert zip_file.is_file()
@@ -287,8 +298,19 @@ def test_store_backtest_results_real(tmp_path):
fn = get_latest_backtest_filename(tmp_path)
assert fn == "backtest-result-2022_01_01_15_05_13.zip"
strategy_test_dir = Path(__file__).parent.parent / "strategy" / "strats"
shutil.copy(strategy_test_dir / "strategy_test_v3.py", tmp_path)
params_file = tmp_path / "strategy_test_v3.json"
with params_file.open("w") as f:
f.write("""{"strategy_name": "TurtleStrategyX5","params":{}}""")
store_backtest_results(
{"exportfilename": tmp_path}, data, "2024_01_01_15_05_25", market_change_data=pd.DataFrame()
config,
data,
"2024_01_01_15_05_25",
market_change_data=pd.DataFrame(),
strategy_files={"DefStrat": str(tmp_path / "strategy_test_v3.py")},
)
zip_file = tmp_path / "backtest-result-2024_01_01_15_05_25.zip"
assert zip_file.is_file()
@@ -298,6 +320,22 @@ def test_store_backtest_results_real(tmp_path):
with ZipFile(zip_file, "r") as zipf:
assert "backtest-result-2024_01_01_15_05_25.json" in zipf.namelist()
assert "backtest-result-2024_01_01_15_05_25_market_change.feather" in zipf.namelist()
assert "backtest-result-2024_01_01_15_05_25_config.json" in zipf.namelist()
# strategy file is copied to the zip file
assert "backtest-result-2024_01_01_15_05_25_DefStrat.py" in zipf.namelist()
# compare the content of the strategy file
with zipf.open("backtest-result-2024_01_01_15_05_25_DefStrat.py") as strategy_file:
strategy_content = strategy_file.read()
with (strategy_test_dir / "strategy_test_v3.py").open("rb") as original_file:
original_content = original_file.read()
assert strategy_content == original_content
assert "backtest-result-2024_01_01_15_05_25_DefStrat.py" in zipf.namelist()
with zipf.open("backtest-result-2024_01_01_15_05_25_DefStrat.json") as pf:
params_content = pf.read()
with params_file.open("rb") as original_file:
original_content = original_file.read()
assert params_content == original_content
assert (tmp_path / LAST_BT_RESULT_FN).is_file()
# Last file reference should be updated
@@ -313,6 +351,7 @@ def test_write_read_backtest_candles(tmp_path):
"exportfilename": tmp_path,
"export": "signals",
"runmode": "backtest",
"original_config": {},
}
# test directory exporting
sample_date = "2022_01_01_15_05_13"
@@ -587,7 +626,7 @@ def test_generate_periodic_breakdown_stats(testdatadir):
day = res[0]
assert "date" in day
assert "draws" in day
assert "loses" in day
assert "losses" in day
assert "wins" in day
assert "profit_abs" in day
+4 -3
View File
@@ -577,7 +577,7 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers, proxy_coin, marg
"symbol": "ETH/USDT:USDT",
"timestamp": None,
"datetime": None,
"initialMargin": 0.0,
"initialMargin": 20,
"initialMarginPercentage": None,
"maintenanceMargin": 0.0,
"maintenanceMarginPercentage": 0.005,
@@ -590,8 +590,9 @@ def test_rpc_balance_handle(default_conf_usdt, mocker, tickers, proxy_coin, marg
"marginRatio": None,
"liquidationPrice": 0.0,
"markPrice": 2896.41,
"collateral": 20,
"marginType": "isolated",
# Collateral is in USDT - and can be higher than position size in cross mode
"collateral": 50,
"marginType": "cross",
"side": "short",
"percentage": None,
}
+206 -1
View File
@@ -24,7 +24,7 @@ from freqtrade.enums import CandleType, RunMode, State, TradingMode
from freqtrade.exceptions import DependencyException, ExchangeError, OperationalException
from freqtrade.loggers import setup_logging, setup_logging_pre
from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import Trade
from freqtrade.persistence import CustomDataWrapper, Trade
from freqtrade.rpc import RPC
from freqtrade.rpc.api_server import ApiServer
from freqtrade.rpc.api_server.api_auth import create_token, get_user_from_token
@@ -802,6 +802,211 @@ def test_api_trade_single(botclient, mocker, fee, ticker, markets, is_short):
assert rc.json()["is_short"] == is_short
@pytest.mark.usefixtures("init_persistence")
def test_api_custom_data_single_trade(botclient, fee):
Trade.reset_trades()
CustomDataWrapper.reset_custom_data()
create_mock_trades_usdt(fee, use_db=True)
trade1 = Trade.get_trades_proxy()[0]
assert trade1.get_all_custom_data() == []
trade1.set_custom_data("test_str", "test_value")
trade1.set_custom_data("test_int", 0)
trade1.set_custom_data("test_float", 1.54)
trade1.set_custom_data("test_bool", True)
trade1.set_custom_data("test_dict", {"test": "vl"})
trade1.set_custom_data("test_int", 1)
_, client = botclient
# CASE 1 Checking all custom data of trade 1
rc = client_get(client, f"{BASE_URI}/trades/1/custom-data")
assert_response(rc)
# Validate response JSON structure
response_json = rc.json()
assert len(response_json) == 1
res_cust_data = response_json[0]["custom_data"]
expected_data_td_1 = [
{"key": "test_str", "type": "str", "value": "test_value"},
{"key": "test_int", "type": "int", "value": 1},
{"key": "test_float", "type": "float", "value": 1.54},
{"key": "test_bool", "type": "bool", "value": True},
{"key": "test_dict", "type": "dict", "value": {"test": "vl"}},
]
# Ensure response contains exactly the expected number of entries
assert len(res_cust_data) == len(expected_data_td_1), (
f"Expected {len(expected_data_td_1)} entries, but got {len(res_cust_data)}.\n"
)
# Validate each expected entry
for expected in expected_data_td_1:
matched_item = None
for item in res_cust_data:
if item["key"] == expected["key"]:
matched_item = item
break
assert matched_item is not None, (
f"Missing expected entry for key '{expected['key']}'\nExpected: {expected}\n"
)
# Validate individual fields and print only incorrect values
mismatches = []
for field in ["key", "type", "value"]:
if matched_item[field] != expected[field]:
mismatches.append(f"{field}: Expected {expected[field]}, Got {matched_item[field]}")
assert not mismatches, f"Error in entry '{expected['key']}':\n" + "\n".join(mismatches)
# CASE 2 Checking specific existing key custom data of trade 1
rc = client_get(client, f"{BASE_URI}/trades/1/custom-data?key=test_dict")
assert_response(rc, 200)
# CASE 3 Checking specific not existing key custom data of trade 1
rc = client_get(client, f"{BASE_URI}/trades/1/custom-data&key=test")
assert_response(rc, 404)
# CASE 4 Trying to get custom-data from not existing trade
rc = client_get(client, f"{BASE_URI}/trades/13/custom-data")
assert_response(rc, 404)
assert rc.json()["detail"] == "No trade found for trade_id: 13"
@pytest.mark.usefixtures("init_persistence")
def test_api_custom_data_multiple_open_trades(botclient, fee):
use_db = True
Trade.use_db = use_db
Trade.reset_trades()
CustomDataWrapper.reset_custom_data()
create_mock_trades(fee, False, use_db)
trades = Trade.get_trades_proxy()
assert len(trades) == 6
assert isinstance(trades[0], Trade)
trades = Trade.get_trades_proxy(is_open=True)
assert len(trades) == 4
create_mock_trades_usdt(fee, use_db=True)
trade1 = Trade.get_trades_proxy(is_open=True)[0]
trade2 = Trade.get_trades_proxy(is_open=True)[1]
# Initially, no custom data should be present.
assert trade1.get_all_custom_data() == []
assert trade2.get_all_custom_data() == []
# Set custom data for the two open trades.
trade1.set_custom_data("test_str", "test_value_t1")
trade1.set_custom_data("test_float", 1.54)
trade1.set_custom_data("test_dict", {"test_t1": "vl_t1"})
trade2.set_custom_data("test_str", "test_value_t2")
trade2.set_custom_data("test_float", 1.55)
trade2.set_custom_data("test_dict", {"test_t2": "vl_t2"})
_, client = botclient
# CASE 1: Checking all custom data for both trades.
rc = client_get(client, f"{BASE_URI}/trades/open/custom-data")
assert_response(rc)
response_json = rc.json()
# Expecting two trade entries in the response
assert len(response_json) == 2, f"Expected 2 trade entries, but got {len(response_json)}.\n"
# Define expected custom data for each trade.
# The keys now use the actual trade_ids from the custom data.
expected_custom_data = {
1: [
{
"key": "test_str",
"type": "str",
"value": "test_value_t1",
},
{
"key": "test_float",
"type": "float",
"value": 1.54,
},
{
"key": "test_dict",
"type": "dict",
"value": {"test_t1": "vl_t1"},
},
],
4: [
{
"key": "test_str",
"type": "str",
"value": "test_value_t2",
},
{
"key": "test_float",
"type": "float",
"value": 1.55,
},
{
"key": "test_dict",
"type": "dict",
"value": {"test_t2": "vl_t2"},
},
],
}
# Iterate over each trade's data in the response and validate entries.
for trade_entry in response_json:
trade_id = trade_entry.get("trade_id")
assert trade_id in expected_custom_data, f"\nUnexpected trade_id: {trade_id}"
custom_data_list = trade_entry.get("custom_data")
expected_data = expected_custom_data[trade_id]
assert len(custom_data_list) == len(expected_data), (
f"Error for trade_id {trade_id}: "
f"Expected {len(expected_data)} entries, but got {len(custom_data_list)}.\n"
)
# For each expected entry, check that the response contains the correct entry.
for expected in expected_data:
matched_item = None
for item in custom_data_list:
if item["key"] == expected["key"]:
matched_item = item
break
assert matched_item is not None, (
f"For trade_id {trade_id}, "
f"missing expected entry for key '{expected['key']}'\n"
f"Expected: {expected}\n"
)
# Validate key fields.
mismatches = []
for field in ["key", "type", "value"]:
if matched_item[field] != expected[field]:
mismatches.append(
f"{field}: Expected {expected[field]}, Got {matched_item[field]}"
)
# Check for field presence of created_at and updated_at without comparing values.
for field in ["created_at", "updated_at"]:
if field not in matched_item:
mismatches.append(f"Missing field: {field}")
assert not mismatches, (
f"Error in entry '{expected['key']}' for trade_id {trade_id}:\n"
+ "\n".join(mismatches)
)
@pytest.mark.parametrize("is_short", [True, False])
def test_api_delete_trade(botclient, mocker, fee, markets, is_short):
ftbot, client = botclient
+59 -20
View File
@@ -6,7 +6,7 @@ import asyncio
import logging
import re
import threading
from datetime import datetime, timedelta, timezone
from datetime import timedelta
from functools import reduce
from random import choice, randint
from string import ascii_uppercase
@@ -16,7 +16,7 @@ import pytest
import time_machine
from pandas import DataFrame
from sqlalchemy import select
from telegram import Chat, Message, ReplyKeyboardMarkup, Update
from telegram import Chat, Message, ReplyKeyboardMarkup, Update, User
from telegram.error import BadRequest, NetworkError, TelegramError
from freqtrade import __version__
@@ -67,7 +67,12 @@ def default_conf(default_conf) -> dict:
@pytest.fixture
def update():
message = Message(0, datetime.now(timezone.utc), Chat(1235, 0))
message = Message(
0,
dt_now(),
Chat(1235, 0),
from_user=User(5432, "test", is_bot=False),
)
_update = Update(0, message=message)
return _update
@@ -232,8 +237,12 @@ async def test_authorized_only(default_conf, mocker, caplog, update) -> None:
async def test_authorized_only_unauthorized(default_conf, mocker, caplog) -> None:
patch_exchange(mocker)
caplog.set_level(logging.DEBUG)
chat = Chat(0xDEADBEEF, 0)
message = Message(randint(1, 100), datetime.now(timezone.utc), chat)
message = Message(
randint(1, 100),
dt_now(),
Chat(0xDEADBEEF, 0),
from_user=User(5432, "test", is_bot=False),
)
update = Update(randint(1, 100), message=message)
default_conf["telegram"]["enabled"] = False
@@ -249,6 +258,42 @@ async def test_authorized_only_unauthorized(default_conf, mocker, caplog) -> Non
assert not log_has("Exception occurred within Telegram module", caplog)
async def test_authorized_users(default_conf, mocker, caplog, update) -> None:
patch_exchange(mocker)
caplog.set_level(logging.DEBUG)
default_conf["telegram"]["enabled"] = False
default_conf["telegram"]["authorized_users"] = ["5432"]
bot = FreqtradeBot(default_conf)
rpc = RPC(bot)
dummy = DummyCls(rpc, default_conf)
await dummy.dummy_handler(update=update, context=MagicMock())
assert dummy.state["called"] is True
assert log_has("Executing handler: dummy_handler for chat_id: 1235", caplog)
caplog.clear()
# Test empty case
default_conf["telegram"]["authorized_users"] = []
dummy1 = DummyCls(rpc, default_conf)
await dummy1.dummy_handler(update=update, context=MagicMock())
assert dummy1.state["called"] is False
assert log_has_re(r"Unauthorized user tried to .*5432", caplog)
caplog.clear()
# Test wrong user
default_conf["telegram"]["authorized_users"] = ["1234"]
dummy1 = DummyCls(rpc, default_conf)
await dummy1.dummy_handler(update=update, context=MagicMock())
assert dummy1.state["called"] is False
assert log_has_re(r"Unauthorized user tried to .*5432", caplog)
caplog.clear()
# Test reverse case again
default_conf["telegram"]["authorized_users"] = ["5432"]
dummy1 = DummyCls(rpc, default_conf)
await dummy1.dummy_handler(update=update, context=MagicMock())
assert dummy1.state["called"] is True
assert not log_has_re(r"Unauthorized user tried to .*5432", caplog)
async def test_authorized_only_exception(default_conf, mocker, caplog, update) -> None:
patch_exchange(mocker)
@@ -638,7 +683,7 @@ async def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time
assert msg_mock.call_count == 1
assert "Daily Profit over the last 2 days</b>:" in msg_mock.call_args_list[0][0][0]
assert "Day " in msg_mock.call_args_list[0][0][0]
assert str(datetime.now(timezone.utc).date()) in msg_mock.call_args_list[0][0][0]
assert str(dt_now().date()) in msg_mock.call_args_list[0][0][0]
assert " 6.83 USDT" in msg_mock.call_args_list[0][0][0]
assert " 7.51 USD" in msg_mock.call_args_list[0][0][0]
assert "(2)" in msg_mock.call_args_list[0][0][0]
@@ -651,11 +696,8 @@ async def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time
await telegram._daily(update=update, context=context)
assert msg_mock.call_count == 1
assert "Daily Profit over the last 7 days</b>:" in msg_mock.call_args_list[0][0][0]
assert str(datetime.now(timezone.utc).date()) in msg_mock.call_args_list[0][0][0]
assert (
str((datetime.now(timezone.utc) - timedelta(days=5)).date())
in msg_mock.call_args_list[0][0][0]
)
assert str(dt_now().date()) in msg_mock.call_args_list[0][0][0]
assert str((dt_now() - timedelta(days=5)).date()) in msg_mock.call_args_list[0][0][0]
assert " 6.83 USDT" in msg_mock.call_args_list[0][0][0]
assert " 7.51 USD" in msg_mock.call_args_list[0][0][0]
assert "(2)" in msg_mock.call_args_list[0][0][0]
@@ -725,7 +767,7 @@ async def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, tim
in msg_mock.call_args_list[0][0][0]
)
assert "Monday " in msg_mock.call_args_list[0][0][0]
today = datetime.now(timezone.utc).date()
today = dt_now().date()
first_iso_day_of_current_week = today - timedelta(days=today.weekday())
assert str(first_iso_day_of_current_week) in msg_mock.call_args_list[0][0][0]
assert " 2.74 USDT" in msg_mock.call_args_list[0][0][0]
@@ -793,7 +835,7 @@ async def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, ti
assert msg_mock.call_count == 1
assert "Monthly Profit over the last 2 months</b>:" in msg_mock.call_args_list[0][0][0]
assert "Month " in msg_mock.call_args_list[0][0][0]
today = datetime.now(timezone.utc).date()
today = dt_now().date()
current_month = f"{today.year}-{today.month:02} "
assert current_month in msg_mock.call_args_list[0][0][0]
assert " 2.74 USDT" in msg_mock.call_args_list[0][0][0]
@@ -898,7 +940,7 @@ async def test_telegram_profit_handle(
trade.orders.append(oobj)
trade.update_trade(oobj)
trade.close_date = datetime.now(timezone.utc)
trade.close_date = dt_now()
trade.is_open = False
Trade.commit()
@@ -2861,9 +2903,7 @@ async def test_telegram_list_custom_data(default_conf_usdt, update, ticker, fee,
context.args = ["1"]
await telegram._list_custom_data(update=update, context=context)
assert msg_mock.call_count == 1
assert (
"Didn't find any custom-data entries for Trade ID: `1`" in msg_mock.call_args_list[0][0][0]
)
assert "No custom-data found for Trade ID: 1." in msg_mock.call_args_list[0][0][0]
msg_mock.reset_mock()
# Add some custom data
@@ -2876,11 +2916,10 @@ async def test_telegram_list_custom_data(default_conf_usdt, update, ticker, fee,
assert msg_mock.call_count == 3
assert "Found custom-data entries: " in msg_mock.call_args_list[0][0][0]
assert (
"*Key:* `test_int`\n*ID:* `1`\n*Trade ID:* `1`\n*Type:* `int`\n*Value:* `1`\n*Create Date:*"
"*Key:* `test_int`\n*Type:* `int`\n*Value:* `1`\n*Create Date:*"
) in msg_mock.call_args_list[1][0][0]
assert (
"*Key:* `test_dict`\n*ID:* `2`\n*Trade ID:* `1`\n*Type:* `dict`\n"
'*Value:* `{"test": "dict"}`\n*Create Date:* `'
"*Key:* `test_dict`\n*Type:* `dict`\n*Value:* `{'test': 'dict'}`\n*Create Date:* `"
) in msg_mock.call_args_list[2][0][0]
msg_mock.reset_mock()
+3 -1
View File
@@ -603,7 +603,7 @@ def test_cli_verbose_with_params(default_conf, mocker, caplog) -> None:
patched_configuration_load_config_file(mocker, default_conf)
# Prevent setting loggers
mocker.patch("freqtrade.loggers.set_loggers", MagicMock)
mocker.patch("freqtrade.loggers.logging.config.dictConfig", MagicMock)
arglist = ["trade", "-vvv"]
args = Arguments(arglist).get_parsed_arg()
@@ -614,7 +614,9 @@ def test_cli_verbose_with_params(default_conf, mocker, caplog) -> None:
assert log_has("Verbosity set to 3", caplog)
@pytest.mark.usefixtures("keep_log_config_loggers")
def test_set_logfile(default_conf, mocker, tmp_path):
default_conf["ft_tests_force_logging"] = True
patched_configuration_load_config_file(mocker, default_conf)
f = tmp_path / "test_file.log"
assert not f.is_file()
+73 -7
View File
@@ -1,4 +1,5 @@
import logging
import re
import sys
import pytest
@@ -7,7 +8,6 @@ from freqtrade.exceptions import OperationalException
from freqtrade.loggers import (
FTBufferingHandler,
FtRichHandler,
set_loggers,
setup_logging,
setup_logging_pre,
)
@@ -17,6 +17,7 @@ from freqtrade.loggers.set_log_levels import (
)
@pytest.mark.usefixtures("keep_log_config_loggers")
def test_set_loggers() -> None:
# Reset Logging to Debug, otherwise this fails randomly as it's set globally
logging.getLogger("requests").setLevel(logging.DEBUG)
@@ -27,8 +28,11 @@ def test_set_loggers() -> None:
previous_value1 = logging.getLogger("requests").level
previous_value2 = logging.getLogger("ccxt.base.exchange").level
previous_value3 = logging.getLogger("telegram").level
set_loggers()
config = {
"verbosity": 1,
"ft_tests_force_logging": True,
}
setup_logging(config)
value1 = logging.getLogger("requests").level
assert previous_value1 is not value1
@@ -41,15 +45,17 @@ def test_set_loggers() -> None:
value3 = logging.getLogger("telegram").level
assert previous_value3 is not value3
assert value3 is logging.INFO
set_loggers(verbosity=2)
config["verbosity"] = 2
setup_logging(config)
assert logging.getLogger("requests").level is logging.DEBUG
assert logging.getLogger("ccxt.base.exchange").level is logging.INFO
assert logging.getLogger("telegram").level is logging.INFO
assert logging.getLogger("werkzeug").level is logging.INFO
set_loggers(verbosity=3, api_verbosity="error")
config["verbosity"] = 3
config["api_server"] = {"verbosity": "error"}
setup_logging(config)
assert logging.getLogger("requests").level is logging.DEBUG
assert logging.getLogger("ccxt.base.exchange").level is logging.DEBUG
@@ -58,12 +64,14 @@ def test_set_loggers() -> None:
@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
@pytest.mark.usefixtures("keep_log_config_loggers")
def test_set_loggers_syslog():
logger = logging.getLogger()
orig_handlers = logger.handlers
logger.handlers = []
config = {
"ft_tests_force_logging": True,
"verbosity": 2,
"logfile": "syslog:/dev/log",
}
@@ -82,12 +90,14 @@ def test_set_loggers_syslog():
@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
@pytest.mark.usefixtures("keep_log_config_loggers")
def test_set_loggers_Filehandler(tmp_path):
logger = logging.getLogger()
orig_handlers = logger.handlers
logger.handlers = []
logfile = tmp_path / "logs/ft_logfile.log"
config = {
"ft_tests_force_logging": True,
"verbosity": 2,
"logfile": str(logfile),
}
@@ -108,6 +118,7 @@ def test_set_loggers_Filehandler(tmp_path):
@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
@pytest.mark.usefixtures("keep_log_config_loggers")
def test_set_loggers_Filehandler_without_permission(tmp_path):
logger = logging.getLogger()
orig_handlers = logger.handlers
@@ -117,6 +128,7 @@ def test_set_loggers_Filehandler_without_permission(tmp_path):
tmp_path.chmod(0o400)
logfile = tmp_path / "logs/ft_logfile.log"
config = {
"ft_tests_force_logging": True,
"verbosity": 2,
"logfile": str(logfile),
}
@@ -131,12 +143,14 @@ def test_set_loggers_Filehandler_without_permission(tmp_path):
@pytest.mark.skip(reason="systemd is not installed on every system, so we're not testing this.")
def test_set_loggers_journald(mocker):
@pytest.mark.usefixtures("keep_log_config_loggers")
def test_set_loggers_journald():
logger = logging.getLogger()
orig_handlers = logger.handlers
logger.handlers = []
config = {
"ft_tests_force_logging": True,
"verbosity": 2,
"logfile": "journald",
}
@@ -150,12 +164,14 @@ def test_set_loggers_journald(mocker):
logger.handlers = orig_handlers
@pytest.mark.usefixtures("keep_log_config_loggers")
def test_set_loggers_journald_importerror(import_fails):
logger = logging.getLogger()
orig_handlers = logger.handlers
logger.handlers = []
config = {
"ft_tests_force_logging": True,
"verbosity": 2,
"logfile": "journald",
}
@@ -164,6 +180,56 @@ def test_set_loggers_journald_importerror(import_fails):
logger.handlers = orig_handlers
@pytest.mark.usefixtures("keep_log_config_loggers")
def test_set_loggers_json_format(capsys):
logger = logging.getLogger()
orig_handlers = logger.handlers
logger.handlers = []
config = {
"ft_tests_force_logging": True,
"verbosity": 2,
"log_config": {
"version": 1,
"formatters": {
"json": {
"()": "freqtrade.loggers.json_formatter.JsonFormatter",
"fmt_dict": {
"timestamp": "asctime",
"level": "levelname",
"logger": "name",
"message": "message",
},
}
},
"handlers": {
"json": {
"class": "logging.StreamHandler",
"formatter": "json",
}
},
"root": {
"handlers": ["json"],
"level": "DEBUG",
},
},
}
setup_logging_pre()
setup_logging(config)
assert len(logger.handlers) == 2
assert [x for x in logger.handlers if type(x).__name__ == "StreamHandler"]
assert [x for x in logger.handlers if isinstance(x, FTBufferingHandler)]
logger.info("Test message")
captured = capsys.readouterr()
assert re.search(r'{"timestamp": ".*"Test message".*', captured.err)
# reset handlers to not break pytest
logger.handlers = orig_handlers
def test_reduce_verbosity():
setup_logging_pre()
reduce_verbosity_for_bias_tester()