From 2a088814464bb4579036d631286fc40d11acf679 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:03:23 +0200 Subject: [PATCH 01/28] chore: update example configs for api_key wording --- config_examples/config_binance.example.json | 2 +- config_examples/config_freqai.example.json | 4 ++-- config_examples/config_full.example.json | 4 ++-- config_examples/config_kraken.example.json | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/config_examples/config_binance.example.json b/config_examples/config_binance.example.json index b62a72c3b06..aad6c992740 100644 --- a/config_examples/config_binance.example.json +++ b/config_examples/config_binance.example.json @@ -31,7 +31,7 @@ }, "exchange": { "name": "binance", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", "ccxt_config": {}, "ccxt_async_config": { diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index a23b3f60c7a..91897bc787d 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -17,8 +17,8 @@ }, "exchange": { "name": "binance", - "key": "", - "secret": "", + "api_key": "your_exchange_api_key", + "secret": "your_exchange_secret", "ccxt_config": {}, "ccxt_async_config": {}, "pair_whitelist": [ diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 94726813ff6..7e15f5d30c1 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -111,9 +111,9 @@ ], "exchange": { "name": "binance", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", - "password": "", + "password": "your_exchange_password_if_necessary", "log_responses": false, // "unknown_fee_rate": 1, "ccxt_config": {}, diff --git a/config_examples/config_kraken.example.json b/config_examples/config_kraken.example.json index c9f79134daf..f3dde3b756a 100644 --- a/config_examples/config_kraken.example.json +++ b/config_examples/config_kraken.example.json @@ -31,8 +31,8 @@ }, "exchange": { "name": "kraken", - "key": "your_exchange_key", - "secret": "your_exchange_key", + "api_key": "your_exchange_api_key", + "secret": "your_exchange_secret", "ccxt_config": {}, "ccxt_async_config": { }, From b1c7f27c87152807ee2d8a709e9eb57f614bdc00 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:04:43 +0200 Subject: [PATCH 02/28] docs: update documentation to use api_key (over key) --- docs/configuration.md | 4 ++-- docs/exchanges.md | 16 ++++++++-------- docs/utils.md | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 1aecb8d220b..bd5342142f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -699,7 +699,7 @@ creating trades on the exchange. ```json "exchange": { "name": "binance", - "key": "key", + "api_key": "api_key", "secret": "secret", ... } @@ -749,7 +749,7 @@ API Keys are usually only required for live trading (trading for real money, bot { "exchange": { "name": "binance", - "key": "af8ddd35195e9dc500b9a6f799f6f5c93d89193b", + "api_key": "af8ddd35195e9dc500b9a6f799f6f5c93d89193b", "secret": "08a9dc6db3d7b53e1acebd9275677f4b0a04f1a5", //"password": "", // Optional, not needed by all exchanges) // ... diff --git a/docs/exchanges.md b/docs/exchanges.md index 02c278f2b26..2bdf9517d87 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -25,7 +25,7 @@ A exchange configuration for "binance" would look as follows: ```json "exchange": { "name": "binance", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", "ccxt_config": {}, "ccxt_async_config": {}, @@ -40,7 +40,7 @@ In case of problems related to rate-limits (usually DDOS Exceptions in your logs ```json "exchange": { "name": "kraken", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", "ccxt_config": {"enableRateLimit": true}, "ccxt_async_config": { @@ -95,7 +95,7 @@ They can however also be configured via configuration file. Since json doesn't s ``` json // ... - "key": "", + "api_key": "", "secret": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBABACAFQA<...>s8KX8=\n-----END PRIVATE KEY-----" // ... ``` @@ -224,7 +224,7 @@ Kraken Futures uses the exchange id `krakenfutures` and supports isolated future ```jsonc "exchange": { "name": "krakenfutures", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret" }, "trading_mode": "futures", @@ -250,7 +250,7 @@ Kucoin requires a passphrase for each api key, you will therefore need to add th ```json "exchange": { "name": "kucoin", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", "password": "your_exchange_api_key_password", // ... @@ -283,7 +283,7 @@ OKX requires a passphrase for each api key, you will therefore need to add this ```json "exchange": { "name": "okx", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", "password": "your_exchange_api_key_password", // ... @@ -364,7 +364,7 @@ Bitget requires a passphrase for each api key, you will therefore need to add th ```json "exchange": { "name": "bitget", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", "password": "your_exchange_api_key_password", // ... @@ -511,7 +511,7 @@ If your account is required to use an operatorId, you can set it in the configur ``` json "exchange": { "name": "bitvavo", - "key": "", + "api_key": "", "secret": "", "ccxt_config": { "options": { diff --git a/docs/utils.md b/docs/utils.md index 0ad901e98ea..66b64bf6673 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -70,7 +70,7 @@ Your combined configuration is: "stake_currency": "USDT", "exchange": { "name": "binance", - "key": "REDACTED", + "api_key": "REDACTED", "secret": "REDACTED", "ccxt_config": {}, "ccxt_async_config": {}, From f8ad75c41cb6288eea6c2b1be32bb800e06287a2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:05:14 +0200 Subject: [PATCH 03/28] chore: update config schema to include api_key --- build_helpers/schema.json | 10 +++++++++- freqtrade/config_schema/config_schema.py | 8 ++++++++ freqtrade/constants.py | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/build_helpers/schema.json b/build_helpers/schema.json index bc1985b17e5..4c9bac511d6 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -1273,8 +1273,16 @@ "description": "Name of the exchange.", "type": "string" }, + "api_key": { + "description": "API key for the exchange. Recommended to be set via environment variable FREQTRADE__EXCHANGE__API_KEY", + "type": [ + "string", + "null" + ], + "default": null + }, "key": { - "description": "API key for the exchange. Recommended to be set via environment variable FREQTRADE__EXCHANGE__KEY", + "description": "API key for the exchange. Recommended to be set via environment variable FREQTRADE__EXCHANGE__KEYDeprecated, use api_key instead.", "type": [ "string", "null" diff --git a/freqtrade/config_schema/config_schema.py b/freqtrade/config_schema/config_schema.py index aaee625d302..f9a0d05def8 100644 --- a/freqtrade/config_schema/config_schema.py +++ b/freqtrade/config_schema/config_schema.py @@ -936,9 +936,17 @@ "type": "object", "properties": { "name": {"description": "Name of the exchange.", "type": "string"}, + "api_key": { + "description": ( + f"API key for the exchange. {__VIA_ENV} FREQTRADE__EXCHANGE__API_KEY" + ), + "type": ["string", "null"], + "default": None, + }, "key": { "description": ( f"API key for the exchange. {__VIA_ENV} FREQTRADE__EXCHANGE__KEY" + "Deprecated, use api_key instead." ), "type": ["string", "null"], "default": None, diff --git a/freqtrade/constants.py b/freqtrade/constants.py index dafbd411e6b..b2f6517bd9c 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -196,7 +196,7 @@ "dry_run": True, "exchange": { "name": "", - "key": None, + "api_key": None, "secret": None, "pair_whitelist": [], "ccxt_async_config": {}, From ed367cbae7f46db259e22bcb7bb2a3ee3ef07b7f Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:06:27 +0200 Subject: [PATCH 04/28] test: update test setups for api_key wording --- tests/conftest.py | 4 ++-- tests/testdata/testconfigs/main_test_config.json | 2 +- tests/testdata/testconfigs/test_base_config.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7bf1dc5a6af..62bd9040116 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -639,7 +639,7 @@ def get_default_conf(testdatadir): }, "exchange": { "name": "binance", - "key": "key", + "api_key": "key", "enable_ws": False, "secret": "secret", "pair_whitelist": ["ETH/BTC", "LTC/BTC", "XRP/BTC", "NEO/BTC"], @@ -685,7 +685,7 @@ def get_default_conf_usdt(testdatadir): "exchange": { "name": "binance", "enabled": True, - "key": "key", + "api_key": "key", "enable_ws": False, "secret": "secret", "pair_whitelist": [ diff --git a/tests/testdata/testconfigs/main_test_config.json b/tests/testdata/testconfigs/main_test_config.json index d4ce5b176df..25d415fd2ad 100644 --- a/tests/testdata/testconfigs/main_test_config.json +++ b/tests/testdata/testconfigs/main_test_config.json @@ -30,7 +30,7 @@ }, "exchange": { "name": "binance", - "key": "your_exchange_key", + "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", "ccxt_config": {}, "ccxt_async_config": {}, diff --git a/tests/testdata/testconfigs/test_base_config.json b/tests/testdata/testconfigs/test_base_config.json index 40a114150f7..ad09c8a0287 100644 --- a/tests/testdata/testconfigs/test_base_config.json +++ b/tests/testdata/testconfigs/test_base_config.json @@ -3,7 +3,7 @@ "dry_run": false, "exchange": { "name": "", - "key": "", + "api_key": "", "secret": "", "pair_whitelist": [], "ccxt_async_config": { From 21c8ed8156e035295b13437cc3f0edb8c948539c Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:08:54 +0200 Subject: [PATCH 05/28] chore: api_key wording for new-config --- freqtrade/configuration/deploy_config.py | 6 +++--- freqtrade/templates/subtemplates/exchange_binance.j2 | 2 +- freqtrade/templates/subtemplates/exchange_bittrex.j2 | 2 +- freqtrade/templates/subtemplates/exchange_gateio.j2 | 2 +- freqtrade/templates/subtemplates/exchange_generic.j2 | 2 +- freqtrade/templates/subtemplates/exchange_huobi.j2 | 2 +- freqtrade/templates/subtemplates/exchange_kraken.j2 | 2 +- freqtrade/templates/subtemplates/exchange_kucoin.j2 | 4 ++-- freqtrade/templates/subtemplates/exchange_okex.j2 | 4 ++-- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/freqtrade/configuration/deploy_config.py b/freqtrade/configuration/deploy_config.py index 071ee0b1179..5401750d62b 100644 --- a/freqtrade/configuration/deploy_config.py +++ b/freqtrade/configuration/deploy_config.py @@ -137,8 +137,8 @@ def ask_user_config() -> dict[str, Any]: }, { "type": "password", - "name": "exchange_key", - "message": "Insert Exchange Key", + "name": "exchange_api_key", + "message": "Insert Exchange API Key", "when": lambda x: not x["dry_run"], }, { @@ -149,7 +149,7 @@ def ask_user_config() -> dict[str, Any]: }, { "type": "password", - "name": "exchange_key_password", + "name": "exchange_api_key_password", "message": "Insert Exchange API Key password", "when": lambda x: not x["dry_run"] and x["exchange_name"] in ("kucoin", "okx"), }, diff --git a/freqtrade/templates/subtemplates/exchange_binance.j2 b/freqtrade/templates/subtemplates/exchange_binance.j2 index dc2272119b2..4b60709b171 100644 --- a/freqtrade/templates/subtemplates/exchange_binance.j2 +++ b/freqtrade/templates/subtemplates/exchange_binance.j2 @@ -1,6 +1,6 @@ "exchange": { "name": "{{ exchange_name | lower }}", - "key": "{{ exchange_key }}", + "api_key": "{{ exchange_api_key }}", "secret": "{{ exchange_secret }}", "ccxt_config": {}, "ccxt_async_config": {}, diff --git a/freqtrade/templates/subtemplates/exchange_bittrex.j2 b/freqtrade/templates/subtemplates/exchange_bittrex.j2 index 118e8104e48..23f93ff93bc 100644 --- a/freqtrade/templates/subtemplates/exchange_bittrex.j2 +++ b/freqtrade/templates/subtemplates/exchange_bittrex.j2 @@ -7,7 +7,7 @@ }, "exchange": { "name": "{{ exchange_name | lower }}", - "key": "{{ exchange_key }}", + "api_key": "{{ exchange_api_key }}", "secret": "{{ exchange_secret }}", "ccxt_config": {}, "ccxt_async_config": {}, diff --git a/freqtrade/templates/subtemplates/exchange_gateio.j2 b/freqtrade/templates/subtemplates/exchange_gateio.j2 index 63dff052fbe..fcc702586dd 100644 --- a/freqtrade/templates/subtemplates/exchange_gateio.j2 +++ b/freqtrade/templates/subtemplates/exchange_gateio.j2 @@ -1,6 +1,6 @@ "exchange": { "name": "{{ exchange_name | lower }}", - "key": "{{ exchange_key }}", + "api_key": "{{ exchange_api_key }}", "secret": "{{ exchange_secret }}", "unknown_fee_rate": 1, "ccxt_config": {}, diff --git a/freqtrade/templates/subtemplates/exchange_generic.j2 b/freqtrade/templates/subtemplates/exchange_generic.j2 index 01f63763887..bfb75d074c4 100644 --- a/freqtrade/templates/subtemplates/exchange_generic.j2 +++ b/freqtrade/templates/subtemplates/exchange_generic.j2 @@ -1,6 +1,6 @@ "exchange": { "name": "{{ exchange_name | lower }}", - "key": "{{ exchange_key }}", + "api_key": "{{ exchange_api_key }}", "secret": "{{ exchange_secret }}", "ccxt_config": {}, "ccxt_async_config": {}, diff --git a/freqtrade/templates/subtemplates/exchange_huobi.j2 b/freqtrade/templates/subtemplates/exchange_huobi.j2 index 3cb521785e5..d6bdadd4952 100644 --- a/freqtrade/templates/subtemplates/exchange_huobi.j2 +++ b/freqtrade/templates/subtemplates/exchange_huobi.j2 @@ -1,6 +1,6 @@ "exchange": { "name": "{{ exchange_name | lower }}", - "key": "{{ exchange_key }}", + "api_key": "{{ exchange_api_key }}", "secret": "{{ exchange_secret }}", "ccxt_config": {}, "ccxt_async_config": {}, diff --git a/freqtrade/templates/subtemplates/exchange_kraken.j2 b/freqtrade/templates/subtemplates/exchange_kraken.j2 index 7dd646720b7..08ba9aab45e 100644 --- a/freqtrade/templates/subtemplates/exchange_kraken.j2 +++ b/freqtrade/templates/subtemplates/exchange_kraken.j2 @@ -1,7 +1,7 @@ "download_trades": true, "exchange": { "name": "kraken", - "key": "{{ exchange_key }}", + "api_key": "{{ exchange_api_key }}", "secret": "{{ exchange_secret }}", "ccxt_config": {}, "ccxt_async_config": {}, diff --git a/freqtrade/templates/subtemplates/exchange_kucoin.j2 b/freqtrade/templates/subtemplates/exchange_kucoin.j2 index b797dda41a2..dc109848e0e 100644 --- a/freqtrade/templates/subtemplates/exchange_kucoin.j2 +++ b/freqtrade/templates/subtemplates/exchange_kucoin.j2 @@ -1,8 +1,8 @@ "exchange": { "name": "{{ exchange_name | lower }}", - "key": "{{ exchange_key }}", + "api_key": "{{ exchange_api_key }}", "secret": "{{ exchange_secret }}", - "password": "{{ exchange_key_password }}", + "password": "{{ exchange_api_key_password }}", "ccxt_config": {}, "ccxt_async_config": {}, "pair_whitelist": [ diff --git a/freqtrade/templates/subtemplates/exchange_okex.j2 b/freqtrade/templates/subtemplates/exchange_okex.j2 index b797dda41a2..dc109848e0e 100644 --- a/freqtrade/templates/subtemplates/exchange_okex.j2 +++ b/freqtrade/templates/subtemplates/exchange_okex.j2 @@ -1,8 +1,8 @@ "exchange": { "name": "{{ exchange_name | lower }}", - "key": "{{ exchange_key }}", + "api_key": "{{ exchange_api_key }}", "secret": "{{ exchange_secret }}", - "password": "{{ exchange_key_password }}", + "password": "{{ exchange_api_key_password }}", "ccxt_config": {}, "ccxt_async_config": {}, "pair_whitelist": [ From f67dec911c4d31eb97df8c3c5430714ab750b9d2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:20:37 +0200 Subject: [PATCH 06/28] test: improve test setup --- tests/test_configuration.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index dc8e1974dc9..878d5fd9b54 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -169,7 +169,7 @@ def test_load_config_max_open_trades_zero(default_conf, mocker, caplog) -> None: def test_load_config_combine_dicts(default_conf, mocker, caplog) -> None: conf1 = deepcopy(default_conf) conf2 = deepcopy(default_conf) - del conf1["exchange"]["key"] + del conf1["exchange"]["api_key"] del conf1["exchange"]["secret"] del conf2["exchange"]["name"] conf2["exchange"]["pair_whitelist"] += ["NANO/BTC"] @@ -192,7 +192,7 @@ def test_load_config_combine_dicts(default_conf, mocker, caplog) -> None: exchange_conf = default_conf["exchange"] assert validated_conf["exchange"]["name"] == exchange_conf["name"] - assert validated_conf["exchange"]["key"] == exchange_conf["key"] + assert validated_conf["exchange"]["api_key"] == exchange_conf["api_key"] assert validated_conf["exchange"]["secret"] == exchange_conf["secret"] assert validated_conf["exchange"]["pair_whitelist"] != conf1["exchange"]["pair_whitelist"] assert validated_conf["exchange"]["pair_whitelist"] == conf2["exchange"]["pair_whitelist"] @@ -203,7 +203,7 @@ def test_load_config_combine_dicts(default_conf, mocker, caplog) -> None: def test_from_config(default_conf, mocker, caplog) -> None: conf1 = deepcopy(default_conf) conf2 = deepcopy(default_conf) - del conf1["exchange"]["key"] + del conf1["exchange"]["api_key"] del conf1["exchange"]["secret"] del conf2["exchange"]["name"] conf2["exchange"]["pair_whitelist"] += ["NANO/BTC"] @@ -218,7 +218,7 @@ def test_from_config(default_conf, mocker, caplog) -> None: exchange_conf = default_conf["exchange"] assert validated_conf["exchange"]["name"] == exchange_conf["name"] - assert validated_conf["exchange"]["key"] == exchange_conf["key"] + assert validated_conf["exchange"]["api_key"] == exchange_conf["api_key"] assert validated_conf["exchange"]["secret"] == exchange_conf["secret"] assert validated_conf["exchange"]["pair_whitelist"] != conf1["exchange"]["pair_whitelist"] assert validated_conf["exchange"]["pair_whitelist"] == conf2["exchange"]["pair_whitelist"] @@ -1116,31 +1116,29 @@ def test_load_config_stoploss_exchange_limit_ratio(all_conf) -> None: @pytest.mark.parametrize( - "keys", + "base,key,expected", [ - ("exchange", "key", None), + ("exchange", "api_key", None), ("exchange", "secret", None), ("exchange", "password", None), ], ) -def test_load_config_default_subkeys(all_conf, keys) -> None: +def test_load_config_default_subkeys(all_conf, base, key, expected) -> None: """ Test for parameters with default values in sub-paths so they can be omitted in the config and the default value should is added to the config. """ - # Get first level key - key = keys[0] # get second level key - subkey = keys[1] + subkey = key - del all_conf[key][subkey] + del all_conf[base][subkey] - assert subkey not in all_conf[key] + assert subkey not in all_conf[base] validate_config_schema(all_conf) - assert subkey in all_conf[key] - assert all_conf[key][subkey] == keys[2] + assert subkey in all_conf[base] + assert all_conf[base][subkey] == expected def test_pairlist_resolving(): From 0ce6e6d8f3710d1349a305f3e0d04238953c4ccb Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:22:35 +0200 Subject: [PATCH 07/28] test: update test wording to "api_key" instead of "key" --- tests/freqtradebot/test_freqtradebot.py | 6 +++--- tests/test_configuration.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/freqtradebot/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py index 0176c33ac59..7b931f471a4 100644 --- a/tests/freqtradebot/test_freqtradebot.py +++ b/tests/freqtradebot/test_freqtradebot.py @@ -188,15 +188,15 @@ def test_load_strategy_no_keys(default_conf_usdt, mocker, runmode, caplog) -> No strategy_config = freqtrade.strategy.config assert id(strategy_config["exchange"]) == id(conf["exchange"]) # Keys have been removed and are not passed to the exchange - assert strategy_config["exchange"]["key"] is None + assert strategy_config["exchange"]["api_key"] is None assert strategy_config["exchange"]["secret"] is None assert erm.call_count == 1 ex_conf = erm.call_args_list[0][1]["exchange_config"] assert id(ex_conf) != id(conf["exchange"]) # Keys are still present - assert ex_conf["key"] is not None - assert ex_conf["key"] == default_conf_usdt["exchange"]["key"] + assert ex_conf["api_key"] is not None + assert ex_conf["api_key"] == default_conf_usdt["exchange"]["api_key"] assert ex_conf["secret"] is not None assert ex_conf["secret"] == default_conf_usdt["exchange"]["secret"] diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 878d5fd9b54..b76029510c2 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1591,19 +1591,19 @@ def test_setup_freqai_backtesting(mocker, default_conf) -> None: def test_sanitize_config(default_conf_usdt): - assert default_conf_usdt["exchange"]["key"] != "REDACTED" + assert default_conf_usdt["exchange"]["api_key"] != "REDACTED" res = sanitize_config(default_conf_usdt) # Didn't modify original dict - assert default_conf_usdt["exchange"]["key"] != "REDACTED" + assert default_conf_usdt["exchange"]["api_key"] != "REDACTED" assert "accountId" not in default_conf_usdt["exchange"] - assert res["exchange"]["key"] == "REDACTED" + assert res["exchange"]["api_key"] == "REDACTED" assert res["exchange"]["secret"] == "REDACTED" # Didn't add a non-existing key assert "accountId" not in res["exchange"] res = sanitize_config(default_conf_usdt, show_sensitive=True) - assert res["exchange"]["key"] == default_conf_usdt["exchange"]["key"] + assert res["exchange"]["api_key"] == default_conf_usdt["exchange"]["api_key"] assert res["exchange"]["secret"] == default_conf_usdt["exchange"]["secret"] @@ -1611,11 +1611,11 @@ def test_remove_exchange_credentials(default_conf) -> None: conf = deepcopy(default_conf) remove_exchange_credentials(conf["exchange"], False) - assert conf["exchange"]["key"] is not None + assert conf["exchange"]["api_key"] is not None assert conf["exchange"]["secret"] is not None remove_exchange_credentials(conf["exchange"], True) - assert conf["exchange"]["key"] is None + assert conf["exchange"]["api_key"] is None assert conf["exchange"]["secret"] is None assert conf["exchange"].get("password") is None assert conf["exchange"].get("uid") is None From cd7c8080681159bbb5cebbc9e7ea1b75c90effdc Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:43:41 +0200 Subject: [PATCH 08/28] chore: minor correctness fixes --- build_helpers/schema.json | 8 +++----- config_examples/config_full.example.json | 2 +- docs/configuration.md | 2 +- freqtrade/config_schema/config_schema.py | 4 +--- tests/commands/test_build_config.py | 2 +- tests/exchange_online/conftest.py | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/build_helpers/schema.json b/build_helpers/schema.json index 4c9bac511d6..9d7a95c9777 100644 --- a/build_helpers/schema.json +++ b/build_helpers/schema.json @@ -1278,16 +1278,14 @@ "type": [ "string", "null" - ], - "default": null + ] }, "key": { - "description": "API key for the exchange. Recommended to be set via environment variable FREQTRADE__EXCHANGE__KEYDeprecated, use api_key instead.", + "description": "API key for the exchange. Recommended to be set via environment variable FREQTRADE__EXCHANGE__KEY Deprecated, use api_key instead.", "type": [ "string", "null" - ], - "default": null + ] }, "secret": { "description": "API secret for the exchange. Recommended to be set via environment variable FREQTRADE__EXCHANGE__SECRET", diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 7e15f5d30c1..ce9d057adff 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -113,7 +113,7 @@ "name": "binance", "api_key": "your_exchange_api_key", "secret": "your_exchange_secret", - "password": "your_exchange_password_if_necessary", + "password": "", "log_responses": false, // "unknown_fee_rate": 1, "ccxt_config": {}, diff --git a/docs/configuration.md b/docs/configuration.md index bd5342142f3..229258b71e2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -217,7 +217,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `max_entry_position_adjustment` | Maximum additional order(s) for each open trade on top of the first entry Order. Set it to `-1` for unlimited additional orders. [More information here](strategy-callbacks.md#adjust-trade-position).
[Strategy Override](#parameters-in-the-strategy).
*Defaults to `-1`.*
**Datatype:** Positive Integer or -1 | | **Exchange** | `exchange.name` | **Required.** Name of the exchange class to use.
**Datatype:** String -| `exchange.key` | API key to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `exchange.api_key` | API key to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.secret` | API secret to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.password` | API password to use for the exchange. Only required when you are in production mode and for exchanges that use password for API requests.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.uid` | API uid to use for the exchange. Only required when you are in production mode and for exchanges that use uid for API requests.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String diff --git a/freqtrade/config_schema/config_schema.py b/freqtrade/config_schema/config_schema.py index f9a0d05def8..2036a3f5037 100644 --- a/freqtrade/config_schema/config_schema.py +++ b/freqtrade/config_schema/config_schema.py @@ -941,15 +941,13 @@ f"API key for the exchange. {__VIA_ENV} FREQTRADE__EXCHANGE__API_KEY" ), "type": ["string", "null"], - "default": None, }, "key": { "description": ( f"API key for the exchange. {__VIA_ENV} FREQTRADE__EXCHANGE__KEY" - "Deprecated, use api_key instead." + " Deprecated, use api_key instead." ), "type": ["string", "null"], - "default": None, }, "secret": { "description": ( diff --git a/tests/commands/test_build_config.py b/tests/commands/test_build_config.py index 9d4a57db172..c721adbb22a 100644 --- a/tests/commands/test_build_config.py +++ b/tests/commands/test_build_config.py @@ -51,7 +51,7 @@ def test_start_new_config(mocker, caplog, exchange): "trading_mode": "spot", "margin_mode": "", "exchange_name": exchange, - "exchange_key": "sampleKey", + "exchange_api_key": "sampleKey", "exchange_secret": "Samplesecret", "telegram": False, "telegram_token": "asdf1244", diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 08ff88ac201..b25ca8b9fba 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -773,7 +773,7 @@ class TestExchangeOnlineSetup(TypedDict): def exchange_conf(): config = get_default_conf_usdt((Path(__file__).parent / "testdata").resolve()) config["exchange"]["pair_whitelist"] = [] - config["exchange"]["apiKey"] = None + config["exchange"]["api_key"] = None config["exchange"]["secret"] = None config["dry_run"] = False config["entry_pricing"]["use_order_book"] = True From f76b2fab478fd550961f74a96f0b37b0eb6ab261 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:54:38 +0200 Subject: [PATCH 09/28] test: improved test coverage --- tests/commands/test_build_config.py | 1 + tests/test_configuration.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/test_build_config.py b/tests/commands/test_build_config.py index c721adbb22a..c175dbb0f28 100644 --- a/tests/commands/test_build_config.py +++ b/tests/commands/test_build_config.py @@ -75,6 +75,7 @@ def test_start_new_config(mocker, caplog, exchange): parse_mode=rapidjson.PM_COMMENTS | rapidjson.PM_TRAILING_COMMAS, ) assert result["exchange"]["name"] == exchange + assert result["exchange"]["api_key"] == "sampleKey" assert result["timeframe"] == "15m" diff --git a/tests/test_configuration.py b/tests/test_configuration.py index b76029510c2..59516f6cd27 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1118,7 +1118,6 @@ def test_load_config_stoploss_exchange_limit_ratio(all_conf) -> None: @pytest.mark.parametrize( "base,key,expected", [ - ("exchange", "api_key", None), ("exchange", "secret", None), ("exchange", "password", None), ], From 7805c12185abef1aad156b6b5312cdf66b4cac29 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 15:40:23 +0200 Subject: [PATCH 10/28] fix: slow cache leak when using short timeframes in pairlists closes #13421 --- freqtrade/exchange/exchange.py | 29 ++++++++++++------- .../plugins/pairlist/PercentChangePairList.py | 2 +- .../plugins/pairlist/VolatilityFilter.py | 6 ++-- freqtrade/plugins/pairlist/VolumePairList.py | 4 ++- .../plugins/pairlist/rangestabilityfilter.py | 6 ++-- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index b33690a168e..34dde733bf5 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -2923,37 +2923,44 @@ async def gather_coroutines(coro): return results_df def refresh_ohlcv_with_cache( - self, pairs: list[PairWithTimeframe], since_ms: int + self, pairs: list[PairWithTimeframe], lookback_period: int ) -> dict[PairWithTimeframe, DataFrame]: """ Refresh ohlcv data for all pairs in needed_pairs if necessary. - Caches data with expiring per timeframe. - Should only be used for pairlists which need "on time" expirarion, and no longer cache. + Caches data per (timeframe, lookback_period), expiring with each new candle. + Should only be used for pairlists which need "on time" expiration, and no longer cache. + :param pairs: List of pairs, timeframes to refresh + :param lookback_period: Amount of candles to fetch, counted back from the start + of the current (incomplete) candle. """ timeframes = {p[1] for p in pairs} for timeframe in timeframes: - if (timeframe, since_ms) not in self._expiring_candle_cache: + if (timeframe, lookback_period) not in self._expiring_candle_cache: timeframe_in_sec = timeframe_to_seconds(timeframe) # Initialise cache - self._expiring_candle_cache[(timeframe, since_ms)] = PeriodicCache( + self._expiring_candle_cache[(timeframe, lookback_period)] = PeriodicCache( ttl=timeframe_in_sec, maxsize=1000 ) # Get candles from cache candles = { - c: self._expiring_candle_cache[(c[1], since_ms)].get(c, None) + c: self._expiring_candle_cache[(c[1], lookback_period)].get(c, None) for c in pairs - if c in self._expiring_candle_cache[(c[1], since_ms)] + if c in self._expiring_candle_cache[(c[1], lookback_period)] } pairs_to_download = [p for p in pairs if p not in candles] - if pairs_to_download: - candles_new = self.refresh_latest_ohlcv( - pairs_to_download, since_ms=since_ms, cache=False + for timeframe in timeframes: + tf_pairs = [p for p in pairs_to_download if p[1] == timeframe] + if not tf_pairs: + continue + since_ms = dt_ts(timeframe_to_prev_date(timeframe)) - lookback_period * ( + timeframe_to_msecs(timeframe) ) + candles_new = self.refresh_latest_ohlcv(tf_pairs, since_ms=since_ms, cache=False) for c, val in candles_new.items(): candles[c] = val - self._expiring_candle_cache[(c[1], since_ms)][c] = val + self._expiring_candle_cache[(c[1], lookback_period)][c] = val return candles def _now_is_time_to_refresh(self, pair: str, timeframe: str, candle_type: CandleType) -> bool: diff --git a/freqtrade/plugins/pairlist/PercentChangePairList.py b/freqtrade/plugins/pairlist/PercentChangePairList.py index 0f63db77f23..40f1ce21e8c 100644 --- a/freqtrade/plugins/pairlist/PercentChangePairList.py +++ b/freqtrade/plugins/pairlist/PercentChangePairList.py @@ -277,7 +277,7 @@ def fetch_candles_for_lookback_period( (p, self._lookback_timeframe, self._def_candletype) for p in [s["symbol"] for s in filtered_tickers] ] - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms) + candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, self._lookback_period + 1) return candles def fetch_percent_change_from_lookback_period( diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 942e596f592..726cc4ba61a 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -4,7 +4,6 @@ import logging import sys -from datetime import timedelta import numpy as np from pandas import DataFrame @@ -14,7 +13,7 @@ from freqtrade.exchange.exchange_types import Tickers from freqtrade.misc import plural from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting -from freqtrade.util import FtTTLCache, dt_floor_day, dt_now, dt_ts +from freqtrade.util import FtTTLCache logger = logging.getLogger(__name__) @@ -109,8 +108,7 @@ def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: (p, "1d", self._def_candletype) for p in pairlist if p not in self._pair_cache ] - since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days)) - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms) + candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, self._days + 1) resulting_pairlist: list[str] = [] volatilitys: dict[str, float] = {} diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 72b8dbec3e8..ee98b040f46 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -258,7 +258,9 @@ def filter_pairlist(self, pairlist: list[str], tickers: dict) -> list[str]: for p in [s["symbol"] for s in filtered_tickers] ] - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms) + candles = self._exchange.refresh_ohlcv_with_cache( + needed_pairs, self._lookback_period + 1 + ) for i, p in enumerate(filtered_tickers): contract_size = self._exchange.markets[p["symbol"]].get("contractSize", 1.0) or 1.0 diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 40ba0fc1e1e..e963dee2a37 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -3,7 +3,6 @@ """ import logging -from datetime import timedelta from pandas import DataFrame @@ -12,7 +11,7 @@ from freqtrade.exchange.exchange_types import Tickers from freqtrade.misc import plural from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting -from freqtrade.util import FtTTLCache, dt_floor_day, dt_now, dt_ts +from freqtrade.util import FtTTLCache logger = logging.getLogger(__name__) @@ -106,8 +105,7 @@ def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: (p, "1d", self._def_candletype) for p in pairlist if p not in self._pair_cache ] - since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days + 1)) - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms) + candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, self._days + 1) resulting_pairlist: list[str] = [] pct_changes: dict[str, float] = {} From 1792480a78732b92b71cb1b8d1078c3799fd5826 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 15:42:10 +0200 Subject: [PATCH 11/28] test: update test to test for cache growth --- tests/exchange/test_exchange.py | 50 ++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 5685655fe52..ac70971c5ce 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -31,6 +31,7 @@ Kraken, date_minus_candles, market_is_active, + timeframe_to_msecs, timeframe_to_prev_date, ) from freqtrade.exchange.common import ( @@ -3019,24 +3020,30 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): assert len(exchange._expiring_candle_cache) == 0 - res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) - assert ohlcv_mock.call_count == 1 - assert ohlcv_mock.call_args_list[0][0][0] == pairs - assert len(ohlcv_mock.call_args_list[0][0][0]) == 5 + res = exchange.refresh_ohlcv_with_cache(pairs, 5) + # One download call per timeframe + assert ohlcv_mock.call_count == 3 + requested = [p for call in ohlcv_mock.call_args_list for p in call[0][0]] + assert set(requested) == set(pairs) + assert len(requested) == 5 + for call in ohlcv_mock.call_args_list: + timeframe = call[0][0][0][1] + expected_since = dt_ts(timeframe_to_prev_date(timeframe, start)) - 5 * timeframe_to_msecs( + timeframe + ) + assert call[1]["since_ms"] == expected_since assert len(res) == 5 # length of 3 - as we have 3 different timeframes assert len(exchange._expiring_candle_cache) == 3 ohlcv_mock.reset_mock() - res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) + res = exchange.refresh_ohlcv_with_cache(pairs, 5) assert ohlcv_mock.call_count == 0 assert len(res) == 5 # # re-run with one additional pair - res = exchange.refresh_ohlcv_with_cache( - pairs + [("NEW/PAIR", "1d", CandleType.SPOT)], start.timestamp() - ) + res = exchange.refresh_ohlcv_with_cache(pairs + [("NEW/PAIR", "1d", CandleType.SPOT)], 5) assert ohlcv_mock.call_count == 1 assert len(res) == 6 @@ -3044,7 +3051,7 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): time_machine.move_to(start + timedelta(minutes=6), tick=False) ohlcv_mock.reset_mock() - res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) + res = exchange.refresh_ohlcv_with_cache(pairs, 5) assert ohlcv_mock.call_count == 1 assert len(ohlcv_mock.call_args_list[0][0][0]) == 1 assert len(res) == 5 @@ -3053,21 +3060,32 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): time_machine.move_to(start + timedelta(hours=2), tick=False) ohlcv_mock.reset_mock() - res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) - assert ohlcv_mock.call_count == 1 - assert len(ohlcv_mock.call_args_list[0][0][0]) == 2 + res = exchange.refresh_ohlcv_with_cache(pairs, 5) + assert ohlcv_mock.call_count == 2 + requested = [p for call in ohlcv_mock.call_args_list for p in call[0][0]] + assert len(requested) == 2 assert len(res) == 5 # Expire all caches time_machine.move_to(start + timedelta(days=1, hours=2), tick=False) ohlcv_mock.reset_mock() - res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp()) - assert ohlcv_mock.call_count == 1 - assert len(ohlcv_mock.call_args_list[0][0][0]) == 5 - assert ohlcv_mock.call_args_list[0][0][0] == pairs + res = exchange.refresh_ohlcv_with_cache(pairs, 5) + assert ohlcv_mock.call_count == 3 + requested = [p for call in ohlcv_mock.call_args_list for p in call[0][0]] + assert set(requested) == set(pairs) assert len(res) == 5 + # Cache keys must remain stable over time + assert len(exchange._expiring_candle_cache) == 3 + # Expired entries are evicted when the cache is written to again - + # NEW/PAIR was never re-requested, so only the 3 base pairs remain. + assert exchange._expiring_candle_cache[("1d", 5)].currsize == 3 + + # A different lookback period uses separate caches + res = exchange.refresh_ohlcv_with_cache(pairs, 6) + assert len(exchange._expiring_candle_cache) == 6 + def test_refresh_latest_ohlcv_funding_rate(mocker, default_conf_usdt, caplog) -> None: ohlcv = generate_test_data_raw("1h", 24, "2025-01-02 12:00:00+00:00") From 0cef9a38cf465061f6846a8cf103886ae597264f Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 15:52:43 +0200 Subject: [PATCH 12/28] chore: Improved method call ergonomics --- freqtrade/exchange/exchange.py | 13 +++++++------ freqtrade/plugins/pairlist/PercentChangePairList.py | 4 +++- freqtrade/plugins/pairlist/VolatilityFilter.py | 2 +- freqtrade/plugins/pairlist/VolumePairList.py | 2 +- freqtrade/plugins/pairlist/rangestabilityfilter.py | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 34dde733bf5..caa1b04e4b1 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -2923,15 +2923,16 @@ async def gather_coroutines(coro): return results_df def refresh_ohlcv_with_cache( - self, pairs: list[PairWithTimeframe], lookback_period: int + self, pairs: list[PairWithTimeframe], *, lookback_period: int ) -> dict[PairWithTimeframe, DataFrame]: """ Refresh ohlcv data for all pairs in needed_pairs if necessary. Caches data per (timeframe, lookback_period), expiring with each new candle. Should only be used for pairlists which need "on time" expiration, and no longer cache. :param pairs: List of pairs, timeframes to refresh - :param lookback_period: Amount of candles to fetch, counted back from the start - of the current (incomplete) candle. + :param lookback_period: Amount of candles to fetch. + Downloads lookback_period + 1 candles, as measuring a change over N candles + requires N + 1 candles of data. """ timeframes = {p[1] for p in pairs} @@ -2954,9 +2955,9 @@ def refresh_ohlcv_with_cache( tf_pairs = [p for p in pairs_to_download if p[1] == timeframe] if not tf_pairs: continue - since_ms = dt_ts(timeframe_to_prev_date(timeframe)) - lookback_period * ( - timeframe_to_msecs(timeframe) - ) + since_ms = dt_ts(timeframe_to_prev_date(timeframe)) - ( + lookback_period + 1 + ) * timeframe_to_msecs(timeframe) candles_new = self.refresh_latest_ohlcv(tf_pairs, since_ms=since_ms, cache=False) for c, val in candles_new.items(): candles[c] = val diff --git a/freqtrade/plugins/pairlist/PercentChangePairList.py b/freqtrade/plugins/pairlist/PercentChangePairList.py index 40f1ce21e8c..96633d55815 100644 --- a/freqtrade/plugins/pairlist/PercentChangePairList.py +++ b/freqtrade/plugins/pairlist/PercentChangePairList.py @@ -277,7 +277,9 @@ def fetch_candles_for_lookback_period( (p, self._lookback_timeframe, self._def_candletype) for p in [s["symbol"] for s in filtered_tickers] ] - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, self._lookback_period + 1) + candles = self._exchange.refresh_ohlcv_with_cache( + needed_pairs, lookback_period=self._lookback_period + ) return candles def fetch_percent_change_from_lookback_period( diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 726cc4ba61a..d77ef6ba434 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -108,7 +108,7 @@ def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: (p, "1d", self._def_candletype) for p in pairlist if p not in self._pair_cache ] - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, self._days + 1) + candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, lookback_period=self._days) resulting_pairlist: list[str] = [] volatilitys: dict[str, float] = {} diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index ee98b040f46..d5fff980a9d 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -259,7 +259,7 @@ def filter_pairlist(self, pairlist: list[str], tickers: dict) -> list[str]: ] candles = self._exchange.refresh_ohlcv_with_cache( - needed_pairs, self._lookback_period + 1 + needed_pairs, lookback_period=self._lookback_period ) for i, p in enumerate(filtered_tickers): diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index e963dee2a37..4b6d2999cb7 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -105,7 +105,7 @@ def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: (p, "1d", self._def_candletype) for p in pairlist if p not in self._pair_cache ] - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, self._days + 1) + candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, lookback_period=self._days) resulting_pairlist: list[str] = [] pct_changes: dict[str, float] = {} From 125b83ed0d96661f6fa29f2519fc4a7a2d15b9e4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 15:53:11 +0200 Subject: [PATCH 13/28] test: adapt exchange test for new variant --- tests/exchange/test_exchange.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index ac70971c5ce..c005e4a98d5 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -3020,7 +3020,7 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): assert len(exchange._expiring_candle_cache) == 0 - res = exchange.refresh_ohlcv_with_cache(pairs, 5) + res = exchange.refresh_ohlcv_with_cache(pairs, lookback_period=5) # One download call per timeframe assert ohlcv_mock.call_count == 3 requested = [p for call in ohlcv_mock.call_args_list for p in call[0][0]] @@ -3028,7 +3028,7 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): assert len(requested) == 5 for call in ohlcv_mock.call_args_list: timeframe = call[0][0][0][1] - expected_since = dt_ts(timeframe_to_prev_date(timeframe, start)) - 5 * timeframe_to_msecs( + expected_since = dt_ts(timeframe_to_prev_date(timeframe, start)) - 6 * timeframe_to_msecs( timeframe ) assert call[1]["since_ms"] == expected_since @@ -3038,12 +3038,14 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): assert len(exchange._expiring_candle_cache) == 3 ohlcv_mock.reset_mock() - res = exchange.refresh_ohlcv_with_cache(pairs, 5) + res = exchange.refresh_ohlcv_with_cache(pairs, lookback_period=5) assert ohlcv_mock.call_count == 0 assert len(res) == 5 # # re-run with one additional pair - res = exchange.refresh_ohlcv_with_cache(pairs + [("NEW/PAIR", "1d", CandleType.SPOT)], 5) + res = exchange.refresh_ohlcv_with_cache( + pairs + [("NEW/PAIR", "1d", CandleType.SPOT)], lookback_period=5 + ) assert ohlcv_mock.call_count == 1 assert len(res) == 6 @@ -3051,7 +3053,7 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): time_machine.move_to(start + timedelta(minutes=6), tick=False) ohlcv_mock.reset_mock() - res = exchange.refresh_ohlcv_with_cache(pairs, 5) + res = exchange.refresh_ohlcv_with_cache(pairs, lookback_period=5) assert ohlcv_mock.call_count == 1 assert len(ohlcv_mock.call_args_list[0][0][0]) == 1 assert len(res) == 5 @@ -3060,7 +3062,7 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): time_machine.move_to(start + timedelta(hours=2), tick=False) ohlcv_mock.reset_mock() - res = exchange.refresh_ohlcv_with_cache(pairs, 5) + res = exchange.refresh_ohlcv_with_cache(pairs, lookback_period=5) assert ohlcv_mock.call_count == 2 requested = [p for call in ohlcv_mock.call_args_list for p in call[0][0]] assert len(requested) == 2 @@ -3070,7 +3072,7 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): time_machine.move_to(start + timedelta(days=1, hours=2), tick=False) ohlcv_mock.reset_mock() - res = exchange.refresh_ohlcv_with_cache(pairs, 5) + res = exchange.refresh_ohlcv_with_cache(pairs, lookback_period=5) assert ohlcv_mock.call_count == 3 requested = [p for call in ohlcv_mock.call_args_list for p in call[0][0]] assert set(requested) == set(pairs) @@ -3083,7 +3085,7 @@ def ohlcv_side_effect(requested_pairs, *args, **kwargs): assert exchange._expiring_candle_cache[("1d", 5)].currsize == 3 # A different lookback period uses separate caches - res = exchange.refresh_ohlcv_with_cache(pairs, 6) + res = exchange.refresh_ohlcv_with_cache(pairs, lookback_period=6) assert len(exchange._expiring_candle_cache) == 6 From 2c4373a0dcaae0461648b4ae580f483588cd7b3a Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 16:15:34 +0200 Subject: [PATCH 14/28] refactor: use existing helpers to calculate since_ms --- freqtrade/exchange/exchange.py | 4 +-- .../plugins/pairlist/PercentChangePairList.py | 27 +++--------------- freqtrade/plugins/pairlist/VolumePairList.py | 28 ++++--------------- 3 files changed, 10 insertions(+), 49 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index caa1b04e4b1..303f7617d53 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -2955,9 +2955,7 @@ def refresh_ohlcv_with_cache( tf_pairs = [p for p in pairs_to_download if p[1] == timeframe] if not tf_pairs: continue - since_ms = dt_ts(timeframe_to_prev_date(timeframe)) - ( - lookback_period + 1 - ) * timeframe_to_msecs(timeframe) + since_ms = dt_ts(date_minus_candles(timeframe, lookback_period + 1)) candles_new = self.refresh_latest_ohlcv(tf_pairs, since_ms=since_ms, cache=False) for c, val in candles_new.items(): candles[c] = val diff --git a/freqtrade/plugins/pairlist/PercentChangePairList.py b/freqtrade/plugins/pairlist/PercentChangePairList.py index 96633d55815..94cb1c3d911 100644 --- a/freqtrade/plugins/pairlist/PercentChangePairList.py +++ b/freqtrade/plugins/pairlist/PercentChangePairList.py @@ -7,17 +7,16 @@ """ import logging -from datetime import timedelta from typing import TypedDict from pandas import DataFrame from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe from freqtrade.exceptions import OperationalException -from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date +from freqtrade.exchange import date_minus_candles, timeframe_to_minutes from freqtrade.exchange.exchange_types import Ticker, Tickers from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting -from freqtrade.util import FtTTLCache, dt_now, format_ms_time +from freqtrade.util import FtTTLCache, dt_ts, format_ms_time logger = logging.getLogger(__name__) @@ -247,26 +246,8 @@ def filter_pairlist(self, pairlist: list[str], tickers: dict) -> list[str]: def fetch_candles_for_lookback_period( self, filtered_tickers: list[SymbolWithPercentage] ) -> dict[PairWithTimeframe, DataFrame]: - since_ms = ( - int( - timeframe_to_prev_date( - self._lookback_timeframe, - dt_now() - + timedelta( - minutes=-(self._lookback_period * self._tf_in_min) - self._tf_in_min - ), - ).timestamp() - ) - * 1000 - ) - to_ms = ( - int( - timeframe_to_prev_date( - self._lookback_timeframe, dt_now() - timedelta(minutes=self._tf_in_min) - ).timestamp() - ) - * 1000 - ) + since_ms = dt_ts(date_minus_candles(self._lookback_timeframe, self._lookback_period + 1)) + to_ms = dt_ts(date_minus_candles(self._lookback_timeframe, 1)) self.log_once( f"Using change range of {self._lookback_period} candles, timeframe: " f"{self._lookback_timeframe}, starting from {format_ms_time(since_ms)} " diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index d5fff980a9d..95b8acc5186 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -5,15 +5,14 @@ """ import logging -from datetime import timedelta from typing import Any, Literal from freqtrade.constants import DOCS_LINK, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException -from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date +from freqtrade.exchange import date_minus_candles, timeframe_to_minutes from freqtrade.exchange.exchange_types import Tickers from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting -from freqtrade.util import FtTTLCache, dt_now, format_ms_time +from freqtrade.util import FtTTLCache, dt_ts, format_ms_time logger = logging.getLogger(__name__) @@ -225,27 +224,10 @@ def filter_pairlist(self, pairlist: list[str], tickers: dict) -> list[str]: filtered_tickers: list[dict[str, Any]] = [{"symbol": k} for k in pairlist] # get lookback period in ms, for exchange ohlcv fetch - since_ms = ( - int( - timeframe_to_prev_date( - self._lookback_timeframe, - dt_now() - + timedelta( - minutes=-(self._lookback_period * self._tf_in_min) - self._tf_in_min - ), - ).timestamp() - ) - * 1000 - ) - - to_ms = ( - int( - timeframe_to_prev_date( - self._lookback_timeframe, dt_now() - timedelta(minutes=self._tf_in_min) - ).timestamp() - ) - * 1000 + since_ms = dt_ts( + date_minus_candles(self._lookback_timeframe, self._lookback_period + 1) ) + to_ms = dt_ts(date_minus_candles(self._lookback_timeframe, 1)) self.log_once( f"Using volume range of {self._lookback_period} candles, timeframe: " From 7307983f120b84f7a4c6a0da8264ac5418bf5a76 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 20:11:15 +0200 Subject: [PATCH 15/28] feat: support lookback_period for RangeStabilityFilter closes #13460 --- .../plugins/pairlist/rangestabilityfilter.py | 86 ++++++++++++++----- 1 file changed, 66 insertions(+), 20 deletions(-) diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 4b6d2999cb7..f64f8bf730f 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -23,7 +23,9 @@ class RangeStabilityFilter(IPairList): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self._days = self._pairlistconfig.get("lookback_days", 10) + _lookback_days = self._pairlistconfig.get("lookback_days", 0) + self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d") + _lookback_period: int | None = self._pairlistconfig.get("lookback_period", None) self._min_rate_of_change = self._pairlistconfig.get("min_rate_of_change", 0.01) self._max_rate_of_change = self._pairlistconfig.get("max_rate_of_change") self._refresh_period = self._pairlistconfig.get("refresh_period", 86400) @@ -32,17 +34,42 @@ def __init__(self, *args, **kwargs) -> None: self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1000, ttl=self._refresh_period) - candle_limit = self._exchange.ohlcv_candle_limit("1d", self._def_candletype) - if self._days < 1: - raise OperationalException("RangeStabilityFilter requires lookback_days to be >= 1") - if self._days > candle_limit: + if (_lookback_days > 0) and ((_lookback_period or 0) > 0): raise OperationalException( - "RangeStabilityFilter requires lookback_days to not " + "Ambiguous configuration: lookback_days and lookback_period both set in pairlist " + "config. Please set lookback_days only or lookback_period and lookback_timeframe " + "and restart the bot." + ) + if "lookback_days" in self._pairlistconfig and _lookback_days < 1: + raise OperationalException(f"{self.name} requires lookback_days to be >= 1") + + # overwrite lookback timeframe and period when lookback_days is set + if _lookback_days > 0: + self._lookback_timeframe = "1d" + _lookback_period = _lookback_days + if _lookback_period is None: + logger.warning( + f"DEPRECATED: Using {self.name} without lookback_days or lookback_period is " + "deprecated and will result in an error in a future version. " + "Please set either lookback_days or lookback_period and lookback_timeframe. " + "Falling back to lookback_days: 10." + ) + _lookback_period = 10 + self._lookback_period: int = _lookback_period + + candle_limit = self._exchange.ohlcv_candle_limit( + self._lookback_timeframe, self._def_candletype + ) + if self._lookback_period < 1: + raise OperationalException(f"{self.name} requires lookback_period to be >= 1") + if self._lookback_period > candle_limit: + raise OperationalException( + f"{self.name} requires lookback_period to not " f"exceed exchange max request size ({candle_limit})" ) if self._sort_direction not in [None, "asc", "desc"]: raise OperationalException( - "RangeStabilityFilter requires sort_direction to be " + f"{self.name} requires sort_direction to be " "either None (undefined), 'asc' or 'desc'" ) @@ -56,7 +83,8 @@ def short_desc(self) -> str: return ( f"{self.name} - Filtering pairs with rate of change below " f"{self._min_rate_of_change}{max_rate_desc} over the " - f"last {plural(self._days, 'day')}." + f"last {self._lookback_period} {plural(self._lookback_period, 'candle')} of " + f"{self._lookback_timeframe}." ) @staticmethod @@ -68,9 +96,21 @@ def available_parameters() -> dict[str, PairlistParameter]: return { "lookback_days": { "type": "number", - "default": 10, + "default": 0, "description": "Lookback Days", - "help": "Number of days to look back at.", + "help": "Number of days to look back at. Implies a lookback_timeframe of 1d.", + }, + "lookback_timeframe": { + "type": "string", + "default": "1d", + "description": "Lookback Timeframe", + "help": "Timeframe to use for lookback.", + }, + "lookback_period": { + "type": "number", + "default": 10, + "description": "Lookback Period", + "help": "Number of periods to look back at.", }, "min_rate_of_change": { "type": "number", @@ -102,18 +142,22 @@ def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: :return: new allowlist """ needed_pairs: ListPairsWithTimeframes = [ - (p, "1d", self._def_candletype) for p in pairlist if p not in self._pair_cache + (p, self._lookback_timeframe, self._def_candletype) + for p in pairlist + if p not in self._pair_cache ] - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, lookback_period=self._days) + candles = self._exchange.refresh_ohlcv_with_cache( + needed_pairs, lookback_period=self._lookback_period + ) resulting_pairlist: list[str] = [] pct_changes: dict[str, float] = {} for p in pairlist: - daily_candles = candles.get((p, "1d", self._def_candletype), None) + pair_candles = candles.get((p, self._lookback_timeframe, self._def_candletype), None) - pct_change = self._calculate_rate_of_change(p, daily_candles) + pct_change = self._calculate_rate_of_change(p, pair_candles) if pct_change is not None: if self._validate_pair_loc(p, pct_change): @@ -130,13 +174,13 @@ def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: ) return resulting_pairlist - def _calculate_rate_of_change(self, pair: str, daily_candles: DataFrame) -> float | None: + def _calculate_rate_of_change(self, pair: str, pair_candles: DataFrame) -> float | None: # Check symbol in cache if (pct_change := self._pair_cache.get(pair, None)) is not None: return pct_change - if daily_candles is not None and not daily_candles.empty: - highest_high = daily_candles["high"].max() - lowest_low = daily_candles["low"].min() + if pair_candles is not None and not pair_candles.empty: + highest_high = pair_candles["high"].max() + lowest_low = pair_candles["low"].min() pct_change = ((highest_high - lowest_low) / lowest_low) if lowest_low > 0 else 0 self._pair_cache[pair] = pct_change return pct_change @@ -155,7 +199,8 @@ def _validate_pair_loc(self, pair: str, pct_change: float) -> bool: if pct_change < self._min_rate_of_change: self.log_once( f"Removed {pair} from whitelist, because rate of change " - f"over {self._days} {plural(self._days, 'day')} is {pct_change:.3f}, " + f"over {self._lookback_period} {plural(self._lookback_period, 'candle')} of " + f"{self._lookback_timeframe} is {pct_change:.3f}, " f"which is below the threshold of {self._min_rate_of_change}.", logger.info, ) @@ -164,7 +209,8 @@ def _validate_pair_loc(self, pair: str, pct_change: float) -> bool: if pct_change > self._max_rate_of_change: self.log_once( f"Removed {pair} from whitelist, because rate of change " - f"over {self._days} {plural(self._days, 'day')} is {pct_change:.3f}, " + f"over {self._lookback_period} {plural(self._lookback_period, 'candle')} of " + f"{self._lookback_timeframe} is {pct_change:.3f}, " f"which is above the threshold of {self._max_rate_of_change}.", logger.info, ) From 6b4dc52ea72506ac492d7816b5d69a75f04d2510 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 20:17:29 +0200 Subject: [PATCH 16/28] feat: support lookback_period for VolatilityFilter --- .../plugins/pairlist/VolatilityFilter.py | 85 ++++++++++++++----- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index d77ef6ba434..e0a90cfd175 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -29,7 +29,9 @@ class VolatilityFilter(IPairList): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - self._days = self._pairlistconfig.get("lookback_days", 10) + _lookback_days = self._pairlistconfig.get("lookback_days", 0) + self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d") + _lookback_period: int | None = self._pairlistconfig.get("lookback_period", None) self._min_volatility = self._pairlistconfig.get("min_volatility", 0) self._max_volatility = self._pairlistconfig.get("max_volatility", sys.maxsize) self._refresh_period = self._pairlistconfig.get("refresh_period", 1440) @@ -38,17 +40,40 @@ def __init__(self, *args, **kwargs) -> None: self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1000, ttl=self._refresh_period) - candle_limit = self._exchange.ohlcv_candle_limit("1d", self._def_candletype) - if self._days < 1: - raise OperationalException("VolatilityFilter requires lookback_days to be >= 1") - if self._days > candle_limit: + if (_lookback_days > 0) and _lookback_period and (_lookback_period > 0): raise OperationalException( - "VolatilityFilter requires lookback_days to not " + "Ambiguous configuration: lookback_days and lookback_period both set in pairlist " + "config. Please set lookback_days only or lookback_period and lookback_timeframe " + "and restart the bot." + ) + + # overwrite lookback timeframe and period when lookback_days is set + if "lookback_days" in self._pairlistconfig: + self._lookback_timeframe = "1d" + _lookback_period = _lookback_days + if _lookback_period is None: + logger.warning( + f"DEPRECATED: Using {self.name} without lookback_days or lookback_period is " + "deprecated and will result in an error in a future version. " + "Please set either lookback_days or lookback_period and lookback_timeframe. " + "Falling back to lookback_days: 10." + ) + _lookback_period = 10 + self._lookback_period: int = _lookback_period + + candle_limit = self._exchange.ohlcv_candle_limit( + self._lookback_timeframe, self._def_candletype + ) + if self._lookback_period < 1: + raise OperationalException(f"{self.name} requires lookback_period to be >= 1") + if self._lookback_period > candle_limit: + raise OperationalException( + f"{self.name} requires lookback_period to not " f"exceed exchange max request size ({candle_limit})" ) if self._sort_direction not in [None, "asc", "desc"]: raise OperationalException( - "VolatilityFilter requires sort_direction to be " + f"{self.name} requires sort_direction to be " "either None (undefined), 'asc' or 'desc'" ) @@ -58,8 +83,9 @@ def short_desc(self) -> str: """ return ( f"{self.name} - Filtering pairs with volatility range " - f"{self._min_volatility}-{self._max_volatility} " - f" the last {self._days} {plural(self._days, 'day')}." + f"{self._min_volatility}-{self._max_volatility} over the " + f"last {self._lookback_period} {plural(self._lookback_period, 'candle')} of " + f"{self._lookback_timeframe}." ) @staticmethod @@ -71,9 +97,21 @@ def available_parameters() -> dict[str, PairlistParameter]: return { "lookback_days": { "type": "number", - "default": 10, + "default": None, "description": "Lookback Days", - "help": "Number of days to look back at.", + "help": "Number of days to look back at. Implies a lookback_timeframe of 1d.", + }, + "lookback_timeframe": { + "type": "string", + "default": "1d", + "description": "Lookback Timeframe", + "help": "Timeframe to use for lookback.", + }, + "lookback_period": { + "type": "number", + "default": 10, + "description": "Lookback Period", + "help": "Number of periods to look back at.", }, "min_volatility": { "type": "number", @@ -105,17 +143,21 @@ def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: :return: new allowlist """ needed_pairs: ListPairsWithTimeframes = [ - (p, "1d", self._def_candletype) for p in pairlist if p not in self._pair_cache + (p, self._lookback_timeframe, self._def_candletype) + for p in pairlist + if p not in self._pair_cache ] - candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, lookback_period=self._days) + candles = self._exchange.refresh_ohlcv_with_cache( + needed_pairs, lookback_period=self._lookback_period + ) resulting_pairlist: list[str] = [] volatilitys: dict[str, float] = {} for p in pairlist: - daily_candles = candles.get((p, "1d", self._def_candletype), None) + pair_candles = candles.get((p, self._lookback_timeframe, self._def_candletype), None) - volatility_avg = self._calculate_volatility(p, daily_candles) + volatility_avg = self._calculate_volatility(p, pair_candles) if volatility_avg is not None: if self._validate_pair_loc(p, volatility_avg): @@ -134,16 +176,18 @@ def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: ) return resulting_pairlist - def _calculate_volatility(self, pair: str, daily_candles: DataFrame) -> float | None: + def _calculate_volatility(self, pair: str, pair_candles: DataFrame) -> float | None: # Check symbol in cache if (volatility_avg := self._pair_cache.get(pair, None)) is not None: return volatility_avg - if daily_candles is not None and not daily_candles.empty: - returns = np.log(daily_candles["close"].shift(1) / daily_candles["close"]) + if pair_candles is not None and not pair_candles.empty: + returns = np.log(pair_candles["close"].shift(1) / pair_candles["close"]) returns.fillna(0, inplace=True) - volatility_series = returns.rolling(window=self._days).std() * np.sqrt(self._days) + volatility_series = returns.rolling(window=self._lookback_period).std() * np.sqrt( + self._lookback_period + ) volatility_avg = volatility_series.mean() self._pair_cache[pair] = volatility_avg @@ -164,7 +208,8 @@ def _validate_pair_loc(self, pair: str, volatility_avg: float) -> bool: else: self.log_once( f"Removed {pair} from whitelist, because volatility " - f"over {self._days} {plural(self._days, 'day')} " + f"over {self._lookback_period} {plural(self._lookback_period, 'candle')} of " + f"{self._lookback_timeframe} " f"is: {volatility_avg:.3f} " f"which is not in the configured range of " f"{self._min_volatility}-{self._max_volatility}.", From dadbcb7075aba05b3e6dfa0c518c82abb939f569 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 20:17:44 +0200 Subject: [PATCH 17/28] docs: update documentation for Lookback and Rangestability filter changes --- docs/includes/pairlists.md | 46 +++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index c34ace15c28..2ba564d540f 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -635,7 +635,9 @@ If `DOGE/BTC` maximum bid is 0.00000026 and minimum ask is 0.00000027, the ratio #### RangeStabilityFilter -Removes pairs where the difference between lowest low and highest high over `lookback_days` days is below `min_rate_of_change` or above `max_rate_of_change`. Since this is a filter that requires additional data, the results are cached for `refresh_period`. +Removes pairs where the difference between lowest low and highest high over `lookback_period` candles of `lookback_timeframe` (defaults to `1d`) is below `min_rate_of_change` or above `max_rate_of_change`. Since this is a filter that requires additional data, the results are cached for `refresh_period`. + +For convenience, `lookback_days` can be used instead, which implies daily candles (equivalent to setting `lookback_period` with a `lookback_timeframe` of `1d`). One of `lookback_days` or `lookback_period` must be set - setting both is ambiguous and will result in an error. Setting neither is deprecated and currently falls back to a lookback of 10 days - this fallback will be removed in a future version. In the below example: If the trading range over the last 10 days is <1% or >99%, remove the pair from the whitelist. @@ -644,7 +646,8 @@ If the trading range over the last 10 days is <1% or >99%, remove the pair from "pairlists": [ { "method": "RangeStabilityFilter", - "lookback_days": 10, + "lookback_timeframe": "1d", + "lookback_period": 10, "min_rate_of_change": 0.01, "max_rate_of_change": 0.99, "refresh_period": 86400 @@ -652,6 +655,21 @@ If the trading range over the last 10 days is <1% or >99%, remove the pair from ] ``` +The same filter based on a trading range of 72 1h candles (3 days) would look as follows: + +```json +"pairlists": [ + { + "method": "RangeStabilityFilter", + "lookback_timeframe": "1h", + "lookback_period": 72, + "min_rate_of_change": 0.01, + "max_rate_of_change": 0.99, + "refresh_period": 3600 + } +] +``` + Adding `"sort_direction": "asc"` or `"sort_direction": "desc"` enables sorting for this pairlist. !!! Tip @@ -660,9 +678,11 @@ Adding `"sort_direction": "asc"` or `"sort_direction": "desc"` enables sorting f #### VolatilityFilter -Volatility is the degree of historical variation of a pairs over time, it is measured by the standard deviation of logarithmic daily returns. Returns are assumed to be normally distributed, although actual distribution might be different. In a normal distribution, 68% of observations fall within one standard deviation and 95% of observations fall within two standard deviations. Assuming a volatility of 0.05 means that the expected returns for 20 out of 30 days is expected to be less than 5% (one standard deviation). Volatility is a positive ratio of the expected deviation of return and can be greater than 1.00. Please refer to the wikipedia definition of [`volatility`](https://en.wikipedia.org/wiki/Volatility_(finance)). +Volatility is the degree of historical variation of a pairs over time, it is measured by the standard deviation of logarithmic candle-to-candle returns. Returns are assumed to be normally distributed, although actual distribution might be different. In a normal distribution, 68% of observations fall within one standard deviation and 95% of observations fall within two standard deviations. Assuming a volatility of 0.05 means that the expected returns for 20 out of 30 days is expected to be less than 5% (one standard deviation). Volatility is a positive ratio of the expected deviation of return and can be greater than 1.00. Please refer to the wikipedia definition of [`volatility`](https://en.wikipedia.org/wiki/Volatility_(finance)). -This filter removes pairs if the average volatility over a `lookback_days` days is below `min_volatility` or above `max_volatility`. Since this is a filter that requires additional data, the results are cached for `refresh_period`. +This filter removes pairs if the average volatility over `lookback_period` candles of `lookback_timeframe` (defaults to `1d`) is below `min_volatility` or above `max_volatility`. Since this is a filter that requires additional data, the results are cached for `refresh_period`. + +For convenience, `lookback_days` can be used instead, which implies daily candles (equivalent to setting `lookback_period` with a `lookback_timeframe` of `1d`). One of `lookback_days` or `lookback_period` must be set - setting both is ambiguous and will result in an error. Setting neither is deprecated and currently falls back to a lookback of 10 days - this fallback will be removed in a future version. This filter can be used to narrow down your pairs to a certain volatility or avoid very volatile pairs. @@ -673,7 +693,8 @@ If the volatility over the last 10 days is not in the range of 0.05-0.50, remove "pairlists": [ { "method": "VolatilityFilter", - "lookback_days": 10, + "lookback_timeframe": "1d", + "lookback_period": 10, "min_volatility": 0.05, "max_volatility": 0.50, "refresh_period": 86400 @@ -681,6 +702,21 @@ If the volatility over the last 10 days is not in the range of 0.05-0.50, remove ] ``` +The same filter based on the volatility of 72 1h candles (3 days) would look as follows: + +```json +"pairlists": [ + { + "method": "VolatilityFilter", + "lookback_timeframe": "1h", + "lookback_period": 72, + "min_volatility": 0.05, + "max_volatility": 0.50, + "refresh_period": 3600 + } +] +``` + Adding `"sort_direction": "asc"` or `"sort_direction": "desc"` enables sorting mode for this pairlist. ### Full example of Pairlist Handlers From a92f7e83f09682108925052dde5b5b857d4cf451 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Aug 2026 20:19:18 +0200 Subject: [PATCH 18/28] test: update tests for Volatility and RangeStability filters --- tests/plugins/test_pairlist.py | 150 ++++++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 10 deletions(-) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 3126a955055..2329f8d5472 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1150,16 +1150,26 @@ def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None: assert log_has("PerformanceFilter is not available in this mode.", caplog) -def test_VolatilityFilter_error(mocker, whitelist_conf) -> None: - volatility_filter = {"method": "VolatilityFilter", "lookback_days": -1} +def test_VolatilityFilter_error(mocker, whitelist_conf, caplog) -> None: + volatility_filter = {"method": "VolatilityFilter"} whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] mocker.patch(f"{EXMS}.exchange_has", MagicMock(return_value=True)) exchange_mock = MagicMock() exchange_mock.ohlcv_candle_limit = MagicMock(return_value=1000) + # Missing lookback configuration is deprecated + PairListManager(exchange_mock, whitelist_conf, MagicMock()) + assert log_has_re( + r"DEPRECATED: Using VolatilityFilter without lookback_days or lookback_period.*", caplog + ) + + volatility_filter = {"method": "VolatilityFilter", "lookback_days": -1} + whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] + with pytest.raises( - OperationalException, match=r"VolatilityFilter requires lookback_days to be >= 1*" + OperationalException, + match=r"VolatilityFilter requires lookback_period to be >= 1", ): PairListManager(exchange_mock, whitelist_conf, MagicMock()) @@ -1167,11 +1177,38 @@ def test_VolatilityFilter_error(mocker, whitelist_conf) -> None: whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] with pytest.raises( OperationalException, - match=r"VolatilityFilter requires lookback_days to not exceed exchange max", + match=r"VolatilityFilter requires lookback_period to not exceed exchange max", + ): + PairListManager(exchange_mock, whitelist_conf, MagicMock()) + + volatility_filter = {"method": "VolatilityFilter", "lookback_period": 2000} + whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] + with pytest.raises( + OperationalException, + match=r"VolatilityFilter requires lookback_period to not exceed exchange max", + ): + PairListManager(exchange_mock, whitelist_conf, MagicMock()) + + volatility_filter = {"method": "VolatilityFilter", "lookback_period": -1} + whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] + with pytest.raises( + OperationalException, match=r"VolatilityFilter requires lookback_period to be >= 1" + ): + PairListManager(exchange_mock, whitelist_conf, MagicMock()) + + volatility_filter = {"method": "VolatilityFilter", "lookback_days": 10, "lookback_period": 10} + whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] + with pytest.raises( + OperationalException, + match=r"Ambiguous configuration: lookback_days and lookback_period both set in pairlist", ): PairListManager(exchange_mock, whitelist_conf, MagicMock()) - volatility_filter = {"method": "VolatilityFilter", "sort_direction": "Random"} + volatility_filter = { + "method": "VolatilityFilter", + "lookback_days": 10, + "sort_direction": "Random", + } whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] with pytest.raises( OperationalException, @@ -1646,10 +1683,10 @@ def test_OffsetFilter_error(mocker, whitelist_conf) -> None: PairListManager(MagicMock, whitelist_conf) -def test_rangestabilityfilter_checks(mocker, default_conf, markets, tickers): +def test_rangestabilityfilter_checks(mocker, default_conf, markets, tickers, caplog): default_conf["pairlists"] = [ {"method": "VolumePairList", "number_assets": 10}, - {"method": "RangeStabilityFilter", "lookback_days": 99999}, + {"method": "RangeStabilityFilter"}, ] mocker.patch.multiple( @@ -1659,9 +1696,21 @@ def test_rangestabilityfilter_checks(mocker, default_conf, markets, tickers): get_tickers=tickers, ) + # Missing lookback configuration is deprecated + get_patched_freqtradebot(mocker, default_conf) + assert log_has_re( + r"DEPRECATED: Using RangeStabilityFilter without lookback_days or lookback_period.*", + caplog, + ) + + default_conf["pairlists"] = [ + {"method": "VolumePairList", "number_assets": 10}, + {"method": "RangeStabilityFilter", "lookback_days": 99999}, + ] + with pytest.raises( OperationalException, - match=r"RangeStabilityFilter requires lookback_days to not exceed " + match=r"RangeStabilityFilter requires lookback_period to not exceed " r"exchange max request size \([0-9]+\)", ): get_patched_freqtradebot(mocker, default_conf) @@ -1676,6 +1725,29 @@ def test_rangestabilityfilter_checks(mocker, default_conf, markets, tickers): ): get_patched_freqtradebot(mocker, default_conf) + default_conf["pairlists"] = [ + {"method": "VolumePairList", "number_assets": 10}, + {"method": "RangeStabilityFilter", "lookback_timeframe": "1h", "lookback_period": 99999}, + ] + + with pytest.raises( + OperationalException, + match=r"RangeStabilityFilter requires lookback_period to not exceed " + r"exchange max request size \([0-9]+\)", + ): + get_patched_freqtradebot(mocker, default_conf) + + default_conf["pairlists"] = [ + {"method": "VolumePairList", "number_assets": 10}, + {"method": "RangeStabilityFilter", "lookback_days": 10, "lookback_period": 10}, + ] + + with pytest.raises( + OperationalException, + match=r"Ambiguous configuration: lookback_days and lookback_period both set in pairlist", + ): + get_patched_freqtradebot(mocker, default_conf) + default_conf["pairlists"] = [ {"method": "VolumePairList", "number_assets": 10}, {"method": "RangeStabilityFilter", "sort_direction": "something"}, @@ -1747,6 +1819,51 @@ def test_rangestabilityfilter_caching( assert freqtrade.exchange.refresh_latest_ohlcv.call_count == previous_call_count +@pytest.mark.parametrize( + "pairlistconfig", + [ + { + "method": "RangeStabilityFilter", + "lookback_timeframe": "1h", + "lookback_period": 24, + "min_rate_of_change": 0, + }, + { + "method": "VolatilityFilter", + "lookback_timeframe": "1h", + "lookback_period": 24, + "min_volatility": 0, + }, + ], +) +def test_range_volatility_filter_lookback_timeframe( + mocker, markets, default_conf, tickers, pairlistconfig +): + default_conf["pairlists"] = [ + {"method": "VolumePairList", "number_assets": 10}, + pairlistconfig, + ] + df = generate_test_data("1h", 30, "2022-01-13 12:00:00+00:00", random_seed=42) + ohlcv_data = { + (pair, "1h", CandleType.SPOT): df + for pair in ["ETH/BTC", "TKN/BTC", "LTC/BTC", "XRP/BTC", "HOT/BTC", "BLK/BTC"] + } + refresh_mock = MagicMock(return_value=ohlcv_data) + mocker.patch.multiple( + EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers, + refresh_latest_ohlcv=refresh_mock, + ) + freqtrade = get_patched_freqtradebot(mocker, default_conf) + freqtrade.pairlists.refresh_pairlist() + assert len(freqtrade.pairlists.whitelist) == 5 + assert refresh_mock.call_count == 1 + # Candles must be requested in the configured lookback timeframe + assert all(p[1] == "1h" for p in refresh_mock.call_args_list[0][0][0]) + + def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplog): default_conf["pairlists"] = [ {"method": "VolumePairList", "number_assets": 10}, @@ -1860,7 +1977,7 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo {"method": "RangeStabilityFilter", "lookback_days": 10, "min_rate_of_change": 0.01}, ( "[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate " - "of change below 0.01 over the last days.'}]" + "of change below 0.01 over the last 10 candles of 1d.'}]" ), None, ), @@ -1873,7 +1990,20 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo }, ( "[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate " - "of change below 0.01 and above 0.99 over the last days.'}]" + "of change below 0.01 and above 0.99 over the last 10 candles of 1d.'}]" + ), + None, + ), + ( + { + "method": "RangeStabilityFilter", + "lookback_timeframe": "1h", + "lookback_period": 72, + "min_rate_of_change": 0.01, + }, + ( + "[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate " + "of change below 0.01 over the last 72 candles of 1h.'}]" ), None, ), From 165c0f8207cd84375609bea34d1106ad72be989e Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 10:56:29 +0200 Subject: [PATCH 19/28] fix: don't fallback when lookback_timeframe is set --- .../plugins/pairlist/VolatilityFilter.py | 4 ++++ .../plugins/pairlist/rangestabilityfilter.py | 4 ++++ tests/plugins/test_pairlist.py | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index e0a90cfd175..a34d43a3aba 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -52,6 +52,10 @@ def __init__(self, *args, **kwargs) -> None: self._lookback_timeframe = "1d" _lookback_period = _lookback_days if _lookback_period is None: + if "lookback_timeframe" in self._pairlistconfig: + raise OperationalException( + f"{self.name} requires lookback_period to be set when using lookback_timeframe." + ) logger.warning( f"DEPRECATED: Using {self.name} without lookback_days or lookback_period is " "deprecated and will result in an error in a future version. " diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index f64f8bf730f..08fc6e9869b 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -48,6 +48,10 @@ def __init__(self, *args, **kwargs) -> None: self._lookback_timeframe = "1d" _lookback_period = _lookback_days if _lookback_period is None: + if "lookback_timeframe" in self._pairlistconfig: + raise OperationalException( + f"{self.name} requires lookback_period to be set when using lookback_timeframe." + ) logger.warning( f"DEPRECATED: Using {self.name} without lookback_days or lookback_period is " "deprecated and will result in an error in a future version. " diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 2329f8d5472..f0715286d34 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1164,6 +1164,16 @@ def test_VolatilityFilter_error(mocker, whitelist_conf, caplog) -> None: r"DEPRECATED: Using VolatilityFilter without lookback_days or lookback_period.*", caplog ) + # The fallback does not apply once a lookback_timeframe is given + volatility_filter = {"method": "VolatilityFilter", "lookback_timeframe": "1h"} + whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] + + with pytest.raises( + OperationalException, + match=r"VolatilityFilter requires lookback_period to be set when using lookback_timeframe", + ): + PairListManager(exchange_mock, whitelist_conf, MagicMock()) + volatility_filter = {"method": "VolatilityFilter", "lookback_days": -1} whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] @@ -1703,6 +1713,19 @@ def test_rangestabilityfilter_checks(mocker, default_conf, markets, tickers, cap caplog, ) + # The fallback does not apply once a lookback_timeframe is given + default_conf["pairlists"] = [ + {"method": "VolumePairList", "number_assets": 10}, + {"method": "RangeStabilityFilter", "lookback_timeframe": "1h"}, + ] + + with pytest.raises( + OperationalException, + match=r"RangeStabilityFilter requires lookback_period to be set when using " + r"lookback_timeframe", + ): + get_patched_freqtradebot(mocker, default_conf) + default_conf["pairlists"] = [ {"method": "VolumePairList", "number_assets": 10}, {"method": "RangeStabilityFilter", "lookback_days": 99999}, From d5a3e41c51cf112dfd72f56651a00c2345b8256c Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 12:22:00 +0200 Subject: [PATCH 20/28] test: add test for "volumepairlist no range" configs --- tests/plugins/test_pairlist.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index f0715286d34..da44543c37f 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1536,6 +1536,26 @@ def test__whitelist_for_active_markets_empty(mocker, whitelist_conf, pairlist, t pairlist_handler._whitelist_for_active_markets(["ETH/BTC"]) +@pytest.mark.parametrize( + "lookback_conf", + [ + {}, + {"lookback_days": 0}, + {"lookback_period": 0}, + {"lookback_days": 0, "lookback_period": 0}, + {"lookback_timeframe": "1d"}, + ], +) +def test_volumepairlist_no_range(mocker, whitelist_conf, lookback_conf): + # An explicit lookback of 0 must not enable the lookback range + whitelist_conf["pairlists"][0].update(lookback_conf) + + mocker.patch(f"{EXMS}.exchange_has", MagicMock(return_value=True)) + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + pairlist_handler = freqtrade.pairlists._pairlist_handlers[0] + assert pairlist_handler._use_range is False + assert pairlist_handler.needstickers is True + def test_volumepairlist_invalid_sortvalue(mocker, whitelist_conf): whitelist_conf["pairlists"][0].update({"sort_key": "asdf"}) From a35c3ad685d51d40e8bd1964433391cf06ddb166 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 12:26:00 +0200 Subject: [PATCH 21/28] refactor: extract lookback_days pairlist resolution --- freqtrade/plugins/pairlist/IPairList.py | 68 +++++++++++++++++++ .../plugins/pairlist/PercentChangePairList.py | 27 +------- .../plugins/pairlist/VolatilityFilter.py | 38 +---------- freqtrade/plugins/pairlist/VolumePairList.py | 28 +------- .../plugins/pairlist/rangestabilityfilter.py | 42 +----------- 5 files changed, 75 insertions(+), 128 deletions(-) diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index fc3908fbe34..ff7a25745c9 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -144,6 +144,74 @@ def refresh_period_parameter() -> dict[str, PairlistParameter]: } } + def _init_lookback_config( + self, *, required: bool = False, deprecated_fallback: int = 0 + ) -> None: + """ + Resolve the lookback configuration (`lookback_days`, `lookback_timeframe` and + `lookback_period`) into `self._lookback_timeframe` and `self._lookback_period`, + and validate it against the exchange's max request size. + `lookback_days` is a convenience alias for `lookback_period` on daily candles. + :param required: Whether this handler needs a lookback range. Handlers with an optional + lookback end up with a period of 0, disabling the lookback range. + :param deprecated_fallback: Number of days to fall back to when `required` is set, but + neither `lookback_days` nor `lookback_period` is configured. + Deprecated - will be removed in a future version. + """ + lookback_days: int = self._pairlistconfig.get("lookback_days", 0) or 0 + lookback_period: int | None = self._pairlistconfig.get("lookback_period", None) + self._lookback_timeframe: str = self._pairlistconfig.get("lookback_timeframe", "1d") + + if lookback_days > 0 and (lookback_period or 0) > 0: + raise OperationalException( + "Ambiguous configuration: lookback_days and lookback_period both set in pairlist " + "config. Please set lookback_days only or lookback_period and lookback_timeframe " + "and restart the bot." + ) + # 0 means "no lookback" - only acceptable if the lookback is optional + min_days = 1 if required else 0 + if "lookback_days" in self._pairlistconfig and lookback_days < min_days: + raise OperationalException(f"{self.name} requires lookback_days to be >= {min_days}") + + # lookback_days implies daily candles + if lookback_days > 0: + self._lookback_timeframe = "1d" + lookback_period = lookback_days + + if lookback_period is None and required: + if "lookback_timeframe" in self._pairlistconfig: + raise OperationalException( + f"{self.name} requires lookback_period to be set when using lookback_timeframe." + ) + if not deprecated_fallback: + raise OperationalException( + f"{self.name} requires either lookback_days or lookback_period to be set." + ) + logger.warning( + f"DEPRECATED: Using {self.name} without lookback_days or lookback_period is " + "deprecated and will result in an error in a future version. " + "Please set either lookback_days or lookback_period and lookback_timeframe. " + f"Falling back to lookback_days: {deprecated_fallback}." + ) + lookback_period = deprecated_fallback + + self._lookback_period: int = lookback_period or 0 + + min_period = 1 if required else 0 + if self._lookback_period < min_period: + raise OperationalException( + f"{self.name} requires lookback_period to be >= {min_period}" + ) + + candle_limit = self._exchange.ohlcv_candle_limit( + self._lookback_timeframe, self._config["candle_type_def"] + ) + if self._lookback_period > candle_limit: + raise OperationalException( + f"{self.name} requires lookback_period to not " + f"exceed exchange max request size ({candle_limit})" + ) + @abstractmethod def short_desc(self) -> str: """ diff --git a/freqtrade/plugins/pairlist/PercentChangePairList.py b/freqtrade/plugins/pairlist/PercentChangePairList.py index 94cb1c3d911..4665322fcba 100644 --- a/freqtrade/plugins/pairlist/PercentChangePairList.py +++ b/freqtrade/plugins/pairlist/PercentChangePairList.py @@ -46,23 +46,10 @@ def __init__(self, *args, **kwargs) -> None: self._max_value = self._pairlistconfig.get("max_value", None) self._refresh_period = self._pairlistconfig.get("refresh_period", 1800) self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period) - self._lookback_days = self._pairlistconfig.get("lookback_days", 0) - self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d") - self._lookback_period = self._pairlistconfig.get("lookback_period", 0) self._sort_direction: str | None = self._pairlistconfig.get("sort_direction", "desc") self._def_candletype = self._config["candle_type_def"] - if (self._lookback_days > 0) and (self._lookback_period > 0): - raise OperationalException( - "Ambiguous configuration: lookback_days and lookback_period both set in pairlist " - "config. Please set lookback_days only or lookback_period and lookback_timeframe " - "and restart the bot." - ) - - # overwrite lookback timeframe and days when lookback_days is set - if self._lookback_days > 0: - self._lookback_timeframe = "1d" - self._lookback_period = self._lookback_days + self._init_lookback_config() # get timeframe in minutes and seconds self._tf_in_min = timeframe_to_minutes(self._lookback_timeframe) @@ -88,16 +75,6 @@ def __init__(self, *args, **kwargs) -> None: "or switch to using candles and restart the bot." ) - candle_limit = self._exchange.ohlcv_candle_limit( - self._lookback_timeframe, self._def_candletype - ) - - if self._lookback_period > candle_limit: - raise OperationalException( - "ChangeFilter requires lookback_period to not " - f"exceed exchange max request size ({candle_limit})" - ) - @property def needstickers(self) -> bool: """ @@ -148,7 +125,7 @@ def available_parameters() -> dict[str, PairlistParameter]: **IPairList.refresh_period_parameter(), "lookback_days": { "type": "number", - "default": 0, + "default": None, "description": "Lookback Days", "help": "Number of days to look back at.", }, diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index a34d43a3aba..55d8541ed32 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -29,9 +29,6 @@ class VolatilityFilter(IPairList): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - _lookback_days = self._pairlistconfig.get("lookback_days", 0) - self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d") - _lookback_period: int | None = self._pairlistconfig.get("lookback_period", None) self._min_volatility = self._pairlistconfig.get("min_volatility", 0) self._max_volatility = self._pairlistconfig.get("max_volatility", sys.maxsize) self._refresh_period = self._pairlistconfig.get("refresh_period", 1440) @@ -40,41 +37,8 @@ def __init__(self, *args, **kwargs) -> None: self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1000, ttl=self._refresh_period) - if (_lookback_days > 0) and _lookback_period and (_lookback_period > 0): - raise OperationalException( - "Ambiguous configuration: lookback_days and lookback_period both set in pairlist " - "config. Please set lookback_days only or lookback_period and lookback_timeframe " - "and restart the bot." - ) - - # overwrite lookback timeframe and period when lookback_days is set - if "lookback_days" in self._pairlistconfig: - self._lookback_timeframe = "1d" - _lookback_period = _lookback_days - if _lookback_period is None: - if "lookback_timeframe" in self._pairlistconfig: - raise OperationalException( - f"{self.name} requires lookback_period to be set when using lookback_timeframe." - ) - logger.warning( - f"DEPRECATED: Using {self.name} without lookback_days or lookback_period is " - "deprecated and will result in an error in a future version. " - "Please set either lookback_days or lookback_period and lookback_timeframe. " - "Falling back to lookback_days: 10." - ) - _lookback_period = 10 - self._lookback_period: int = _lookback_period + self._init_lookback_config(required=True, deprecated_fallback=10) - candle_limit = self._exchange.ohlcv_candle_limit( - self._lookback_timeframe, self._def_candletype - ) - if self._lookback_period < 1: - raise OperationalException(f"{self.name} requires lookback_period to be >= 1") - if self._lookback_period > candle_limit: - raise OperationalException( - f"{self.name} requires lookback_period to not " - f"exceed exchange max request size ({candle_limit})" - ) if self._sort_direction not in [None, "asc", "desc"]: raise OperationalException( f"{self.name} requires sort_direction to be " diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 95b8acc5186..f1ea3c99c67 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -41,22 +41,9 @@ def __init__(self, *args, **kwargs) -> None: self._max_value: float | None = self._pairlistconfig.get("max_value", None) self._refresh_period = self._pairlistconfig.get("refresh_period", 1800) self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period) - self._lookback_days: int = self._pairlistconfig.get("lookback_days", 0) - self._lookback_timeframe: str = self._pairlistconfig.get("lookback_timeframe", "1d") - self._lookback_period: int = self._pairlistconfig.get("lookback_period", 0) self._def_candletype = self._config["candle_type_def"] - if (self._lookback_days > 0) and (self._lookback_period > 0): - raise OperationalException( - "Ambiguous configuration: lookback_days and lookback_period both set in pairlist " - "config. Please set lookback_days only or lookback_period and lookback_timeframe " - "and restart the bot." - ) - - # overwrite lookback timeframe and days when lookback_days is set - if self._lookback_days > 0: - self._lookback_timeframe = "1d" - self._lookback_period = self._lookback_days + self._init_lookback_config() # get timeframe in minutes and seconds self._tf_in_min = timeframe_to_minutes(self._lookback_timeframe) @@ -87,17 +74,6 @@ def __init__(self, *args, **kwargs) -> None: if not self._validate_keys(self._sort_key): raise OperationalException(f"key {self._sort_key} not in {SORT_VALUES}") - candle_limit = self._exchange.ohlcv_candle_limit( - self._lookback_timeframe, self._def_candletype - ) - if self._lookback_period < 0: - raise OperationalException("VolumeFilter requires lookback_period to be >= 0") - if self._lookback_period > candle_limit: - raise OperationalException( - "VolumeFilter requires lookback_period to not " - f"exceed exchange max request size ({candle_limit})" - ) - @property def needstickers(self) -> bool: """ @@ -151,7 +127,7 @@ def available_parameters() -> dict[str, PairlistParameter]: **IPairList.refresh_period_parameter(), "lookback_days": { "type": "number", - "default": 0, + "default": None, "description": "Lookback Days", "help": "Number of days to look back at.", }, diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 08fc6e9869b..7b792a07ee8 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -23,9 +23,6 @@ class RangeStabilityFilter(IPairList): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - _lookback_days = self._pairlistconfig.get("lookback_days", 0) - self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d") - _lookback_period: int | None = self._pairlistconfig.get("lookback_period", None) self._min_rate_of_change = self._pairlistconfig.get("min_rate_of_change", 0.01) self._max_rate_of_change = self._pairlistconfig.get("max_rate_of_change") self._refresh_period = self._pairlistconfig.get("refresh_period", 86400) @@ -34,43 +31,8 @@ def __init__(self, *args, **kwargs) -> None: self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1000, ttl=self._refresh_period) - if (_lookback_days > 0) and ((_lookback_period or 0) > 0): - raise OperationalException( - "Ambiguous configuration: lookback_days and lookback_period both set in pairlist " - "config. Please set lookback_days only or lookback_period and lookback_timeframe " - "and restart the bot." - ) - if "lookback_days" in self._pairlistconfig and _lookback_days < 1: - raise OperationalException(f"{self.name} requires lookback_days to be >= 1") - - # overwrite lookback timeframe and period when lookback_days is set - if _lookback_days > 0: - self._lookback_timeframe = "1d" - _lookback_period = _lookback_days - if _lookback_period is None: - if "lookback_timeframe" in self._pairlistconfig: - raise OperationalException( - f"{self.name} requires lookback_period to be set when using lookback_timeframe." - ) - logger.warning( - f"DEPRECATED: Using {self.name} without lookback_days or lookback_period is " - "deprecated and will result in an error in a future version. " - "Please set either lookback_days or lookback_period and lookback_timeframe. " - "Falling back to lookback_days: 10." - ) - _lookback_period = 10 - self._lookback_period: int = _lookback_period + self._init_lookback_config(required=True, deprecated_fallback=10) - candle_limit = self._exchange.ohlcv_candle_limit( - self._lookback_timeframe, self._def_candletype - ) - if self._lookback_period < 1: - raise OperationalException(f"{self.name} requires lookback_period to be >= 1") - if self._lookback_period > candle_limit: - raise OperationalException( - f"{self.name} requires lookback_period to not " - f"exceed exchange max request size ({candle_limit})" - ) if self._sort_direction not in [None, "asc", "desc"]: raise OperationalException( f"{self.name} requires sort_direction to be " @@ -100,7 +62,7 @@ def available_parameters() -> dict[str, PairlistParameter]: return { "lookback_days": { "type": "number", - "default": 0, + "default": None, "description": "Lookback Days", "help": "Number of days to look back at. Implies a lookback_timeframe of 1d.", }, From a0a382169b8de9925148b34a7f6bb72418e45184 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 12:26:16 +0200 Subject: [PATCH 22/28] test: necessary adjustments to pairlist tests --- tests/plugins/test_pairlist.py | 13 ++++++++++--- tests/plugins/test_percentchangepairlist.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index da44543c37f..3e6551ba426 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1079,13 +1079,13 @@ def test_VolumePairList_range( freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) elif volumefilter_result == "lookback_period_negative": with pytest.raises( - OperationalException, match=r"VolumeFilter requires lookback_period to be >= 0" + OperationalException, match=r"VolumePairList requires lookback_period to be >= 0" ): freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) elif volumefilter_result == "lookback_exceeds_exchange_request_size": with pytest.raises( OperationalException, - match=r"VolumeFilter requires lookback_period to not exceed " + match=r"VolumePairList requires lookback_period to not exceed " r"exchange max request size \([0-9]+\)", ): freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) @@ -1179,7 +1179,7 @@ def test_VolatilityFilter_error(mocker, whitelist_conf, caplog) -> None: with pytest.raises( OperationalException, - match=r"VolatilityFilter requires lookback_period to be >= 1", + match=r"VolatilityFilter requires lookback_days to be >= 1", ): PairListManager(exchange_mock, whitelist_conf, MagicMock()) @@ -1556,6 +1556,13 @@ def test_volumepairlist_no_range(mocker, whitelist_conf, lookback_conf): assert pairlist_handler._use_range is False assert pairlist_handler.needstickers is True + whitelist_conf["pairlists"][0].update({"lookback_days": -1}) + with pytest.raises( + OperationalException, match=r"VolumePairList requires lookback_days to be >= 0" + ): + get_patched_freqtradebot(mocker, whitelist_conf) + + def test_volumepairlist_invalid_sortvalue(mocker, whitelist_conf): whitelist_conf["pairlists"][0].update({"sort_key": "asdf"}) diff --git a/tests/plugins/test_percentchangepairlist.py b/tests/plugins/test_percentchangepairlist.py index bf362d4fbf6..7bdb65b4023 100644 --- a/tests/plugins/test_percentchangepairlist.py +++ b/tests/plugins/test_percentchangepairlist.py @@ -106,7 +106,7 @@ def test_volume_change_pair_list_init_wrong_lookback_period(mocker, rpl_config): with pytest.raises( OperationalException, - match=r"ChangeFilter requires lookback_period to not exceed" + match=r"PercentChangePairList requires lookback_period to not exceed" r" exchange max request size \(\d+\)", ): get_patched_freqtradebot(mocker, rpl_config) From bfc74f1cde0709c142114fc91f0fa662a44b2c41 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 12:34:32 +0200 Subject: [PATCH 23/28] fix: disallow ambiguous config lookback_days shouldn't silently overwrite lookback_timeframe. --- docs/includes/pairlists.md | 6 +++--- freqtrade/plugins/pairlist/IPairList.py | 7 ++++++- tests/plugins/test_pairlist.py | 12 ++++++++++++ tests/plugins/test_percentchangepairlist.py | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 2ba564d540f..4bdb9ce0d43 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -181,7 +181,7 @@ More sophisticated approach can be used, by using `lookback_timeframe` for candl * `max_value`: Sets a maximum percentage change threshold. Pairs with a percentage change above this value will be filtered out. * `sort_direction`: Specifies the order in which pairs are sorted based on their percentage change. Accepts two values: `asc` for ascending order and `desc` for descending order. * `refresh_period`: Defines the interval (in seconds) at which the pairlist will be refreshed. The default is 1800 seconds (30 minutes). -* `lookback_days`: Number of days to look back. When `lookback_days` is selected, the `lookback_timeframe` is defaulted to 1 day. +* `lookback_days`: Number of days to look back. `lookback_days` implies a `lookback_timeframe` of 1 day - combining it with a different `lookback_timeframe` will result in an error. * `lookback_timeframe`: Timeframe to use for the lookback period. * `lookback_period`: Number of periods to look back at. @@ -637,7 +637,7 @@ If `DOGE/BTC` maximum bid is 0.00000026 and minimum ask is 0.00000027, the ratio Removes pairs where the difference between lowest low and highest high over `lookback_period` candles of `lookback_timeframe` (defaults to `1d`) is below `min_rate_of_change` or above `max_rate_of_change`. Since this is a filter that requires additional data, the results are cached for `refresh_period`. -For convenience, `lookback_days` can be used instead, which implies daily candles (equivalent to setting `lookback_period` with a `lookback_timeframe` of `1d`). One of `lookback_days` or `lookback_period` must be set - setting both is ambiguous and will result in an error. Setting neither is deprecated and currently falls back to a lookback of 10 days - this fallback will be removed in a future version. +For convenience, `lookback_days` can be used instead, which implies daily candles (equivalent to setting `lookback_period` with a `lookback_timeframe` of `1d`). One of `lookback_days` or `lookback_period` must be set - setting both is ambiguous and will result in an error, as does combining `lookback_days` with a `lookback_timeframe` other than `1d`. Setting neither is deprecated and currently falls back to a lookback of 10 days - this fallback will be removed in a future version. In the below example: If the trading range over the last 10 days is <1% or >99%, remove the pair from the whitelist. @@ -682,7 +682,7 @@ Volatility is the degree of historical variation of a pairs over time, it is mea This filter removes pairs if the average volatility over `lookback_period` candles of `lookback_timeframe` (defaults to `1d`) is below `min_volatility` or above `max_volatility`. Since this is a filter that requires additional data, the results are cached for `refresh_period`. -For convenience, `lookback_days` can be used instead, which implies daily candles (equivalent to setting `lookback_period` with a `lookback_timeframe` of `1d`). One of `lookback_days` or `lookback_period` must be set - setting both is ambiguous and will result in an error. Setting neither is deprecated and currently falls back to a lookback of 10 days - this fallback will be removed in a future version. +For convenience, `lookback_days` can be used instead, which implies daily candles (equivalent to setting `lookback_period` with a `lookback_timeframe` of `1d`). One of `lookback_days` or `lookback_period` must be set - setting both is ambiguous and will result in an error, as does combining `lookback_days` with a `lookback_timeframe` other than `1d`. Setting neither is deprecated and currently falls back to a lookback of 10 days - this fallback will be removed in a future version. This filter can be used to narrow down your pairs to a certain volatility or avoid very volatile pairs. diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index ff7a25745c9..8b301766435 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -175,7 +175,12 @@ def _init_lookback_config( # lookback_days implies daily candles if lookback_days > 0: - self._lookback_timeframe = "1d" + if self._lookback_timeframe != "1d": + raise OperationalException( + "Ambiguous configuration: lookback_days implies a lookback_timeframe of 1d, " + f"but lookback_timeframe is set to {self._lookback_timeframe}. Please set " + "lookback_period instead of lookback_days and restart the bot." + ) lookback_period = lookback_days if lookback_period is None and required: diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 3e6551ba426..414efe204e9 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1798,6 +1798,18 @@ def test_rangestabilityfilter_checks(mocker, default_conf, markets, tickers, cap ): get_patched_freqtradebot(mocker, default_conf) + default_conf["pairlists"] = [ + {"method": "VolumePairList", "number_assets": 10}, + {"method": "RangeStabilityFilter", "lookback_days": 10, "lookback_timeframe": "1h"}, + ] + + with pytest.raises( + OperationalException, + match=r"Ambiguous configuration: lookback_days implies a lookback_timeframe of 1d, " + r"but lookback_timeframe is set to 1h\..*", + ): + get_patched_freqtradebot(mocker, default_conf) + default_conf["pairlists"] = [ {"method": "VolumePairList", "number_assets": 10}, {"method": "RangeStabilityFilter", "sort_direction": "something"}, diff --git a/tests/plugins/test_percentchangepairlist.py b/tests/plugins/test_percentchangepairlist.py index 7bdb65b4023..352945ea902 100644 --- a/tests/plugins/test_percentchangepairlist.py +++ b/tests/plugins/test_percentchangepairlist.py @@ -93,6 +93,25 @@ def test_volume_change_pair_list_init_wrong_lookback_period(mocker, rpl_config): ): get_patched_freqtradebot(mocker, rpl_config) + rpl_config["pairlists"] = [ + { + "method": "PercentChangePairList", + "number_assets": 2, + "sort_key": "percentage", + "min_value": 0, + "refresh_period": 86400, + "lookback_days": 10, + "lookback_timeframe": "1h", + } + ] + + with pytest.raises( + OperationalException, + match=r"Ambiguous configuration: lookback_days implies a lookback_timeframe " + r"of 1d, but lookback_timeframe is set to 1h\..*", + ): + get_patched_freqtradebot(mocker, rpl_config) + rpl_config["pairlists"] = [ { "method": "PercentChangePairList", From c5508287a147e68089f46b4f7ac3eba5c2001935 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 13:30:28 +0200 Subject: [PATCH 24/28] refactor: extract pairlist lookback parameter config --- freqtrade/plugins/pairlist/IPairList.py | 27 +++++++++++++++++++ .../plugins/pairlist/PercentChangePairList.py | 19 +------------ .../plugins/pairlist/VolatilityFilter.py | 19 +------------ freqtrade/plugins/pairlist/VolumePairList.py | 19 +------------ .../plugins/pairlist/rangestabilityfilter.py | 19 +------------ 5 files changed, 31 insertions(+), 72 deletions(-) diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index 8b301766435..99a1f347bdf 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -144,6 +144,33 @@ def refresh_period_parameter() -> dict[str, PairlistParameter]: } } + @staticmethod + def lookback_parameters(default_period: int = 0) -> dict[str, PairlistParameter]: + """ + Lookback parameters, for Pairlist Handlers supporting a lookback range. + :param default_period: Default lookback_period - 0 when the lookback range is optional. + """ + return { + "lookback_days": { + "type": "number", + "default": None, + "description": "Lookback Days", + "help": "Number of days to look back at. Implies a lookback_timeframe of 1d.", + }, + "lookback_timeframe": { + "type": "string", + "default": "1d", + "description": "Lookback Timeframe", + "help": "Timeframe to use for lookback.", + }, + "lookback_period": { + "type": "number", + "default": default_period, + "description": "Lookback Period", + "help": "Number of periods to look back at.", + }, + } + def _init_lookback_config( self, *, required: bool = False, deprecated_fallback: int = 0 ) -> None: diff --git a/freqtrade/plugins/pairlist/PercentChangePairList.py b/freqtrade/plugins/pairlist/PercentChangePairList.py index 4665322fcba..c29881c7e6e 100644 --- a/freqtrade/plugins/pairlist/PercentChangePairList.py +++ b/freqtrade/plugins/pairlist/PercentChangePairList.py @@ -123,24 +123,7 @@ def available_parameters() -> dict[str, PairlistParameter]: "help": "Sort Pairlist ascending or descending by rate of change.", }, **IPairList.refresh_period_parameter(), - "lookback_days": { - "type": "number", - "default": None, - "description": "Lookback Days", - "help": "Number of days to look back at.", - }, - "lookback_timeframe": { - "type": "string", - "default": "1d", - "description": "Lookback Timeframe", - "help": "Timeframe to use for lookback.", - }, - "lookback_period": { - "type": "number", - "default": 0, - "description": "Lookback Period", - "help": "Number of periods to look back at.", - }, + **IPairList.lookback_parameters(), } def gen_pairlist(self, tickers: Tickers) -> list[str]: diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 55d8541ed32..5cd34cd2b5c 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -63,24 +63,6 @@ def description() -> str: @staticmethod def available_parameters() -> dict[str, PairlistParameter]: return { - "lookback_days": { - "type": "number", - "default": None, - "description": "Lookback Days", - "help": "Number of days to look back at. Implies a lookback_timeframe of 1d.", - }, - "lookback_timeframe": { - "type": "string", - "default": "1d", - "description": "Lookback Timeframe", - "help": "Timeframe to use for lookback.", - }, - "lookback_period": { - "type": "number", - "default": 10, - "description": "Lookback Period", - "help": "Number of periods to look back at.", - }, "min_volatility": { "type": "number", "default": 0, @@ -101,6 +83,7 @@ def available_parameters() -> dict[str, PairlistParameter]: "help": "Sort Pairlist ascending or descending by volatility.", }, **IPairList.refresh_period_parameter(), + **IPairList.lookback_parameters(default_period=10), } def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index f1ea3c99c67..2bb347a5e3d 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -125,24 +125,7 @@ def available_parameters() -> dict[str, PairlistParameter]: "help": "Maximum value to use for filtering the pairlist.", }, **IPairList.refresh_period_parameter(), - "lookback_days": { - "type": "number", - "default": None, - "description": "Lookback Days", - "help": "Number of days to look back at.", - }, - "lookback_timeframe": { - "type": "string", - "default": "", - "description": "Lookback Timeframe", - "help": "Timeframe to use for lookback.", - }, - "lookback_period": { - "type": "number", - "default": 0, - "description": "Lookback Period", - "help": "Number of periods to look back at.", - }, + **IPairList.lookback_parameters(), } def gen_pairlist(self, tickers: Tickers) -> list[str]: diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 7b792a07ee8..c4ee844f2bf 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -60,24 +60,6 @@ def description() -> str: @staticmethod def available_parameters() -> dict[str, PairlistParameter]: return { - "lookback_days": { - "type": "number", - "default": None, - "description": "Lookback Days", - "help": "Number of days to look back at. Implies a lookback_timeframe of 1d.", - }, - "lookback_timeframe": { - "type": "string", - "default": "1d", - "description": "Lookback Timeframe", - "help": "Timeframe to use for lookback.", - }, - "lookback_period": { - "type": "number", - "default": 10, - "description": "Lookback Period", - "help": "Number of periods to look back at.", - }, "min_rate_of_change": { "type": "number", "default": 0.01, @@ -98,6 +80,7 @@ def available_parameters() -> dict[str, PairlistParameter]: "help": "Sort Pairlist ascending or descending by rate of change.", }, **IPairList.refresh_period_parameter(), + **IPairList.lookback_parameters(default_period=10), } def filter_pairlist(self, pairlist: list[str], tickers: Tickers) -> list[str]: From b740ad756dbf95dd1aa67685cc81b9a393d43e51 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 13:30:35 +0200 Subject: [PATCH 25/28] test: ensure correct default applies to pairlists --- tests/plugins/test_pairlist.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 414efe204e9..36a5e9fb5da 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1536,6 +1536,29 @@ def test__whitelist_for_active_markets_empty(mocker, whitelist_conf, pairlist, t pairlist_handler._whitelist_for_active_markets(["ETH/BTC"]) +@pytest.mark.parametrize( + "pairlist_conf", + [ + {"method": "VolumePairList", "number_assets": 10}, + {"method": "PercentChangePairList", "number_assets": 10}, + {"method": "VolatilityFilter"}, + {"method": "RangeStabilityFilter"}, + ], +) +def test_lookback_parameters_defaults(mocker, whitelist_conf, pairlist_conf): + # The lookback defaults must match what the handler resolves to without config + whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, pairlist_conf] + + mocker.patch(f"{EXMS}.exchange_has", MagicMock(return_value=True)) + exchange = get_patched_exchange(mocker, whitelist_conf) + handler = PairListManager(exchange, whitelist_conf, MagicMock())._pairlist_handlers[1] + params = handler.available_parameters() + + assert handler._lookback_timeframe == params["lookback_timeframe"]["default"] + assert handler._lookback_period == params["lookback_period"]["default"] + assert params["lookback_days"]["default"] is None + + @pytest.mark.parametrize( "lookback_conf", [ From 93dc807d2db4ba5416f2563c5e6d59dfc2b2a7d5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 13:54:39 +0200 Subject: [PATCH 26/28] chore: improve errors for improved UX --- freqtrade/plugins/pairlist/IPairList.py | 9 ++++++--- tests/plugins/test_pairlist.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index 99a1f347bdf..b8952ed82a9 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -189,14 +189,17 @@ def _init_lookback_config( lookback_period: int | None = self._pairlistconfig.get("lookback_period", None) self._lookback_timeframe: str = self._pairlistconfig.get("lookback_timeframe", "1d") - if lookback_days > 0 and (lookback_period or 0) > 0: + has_period = (lookback_period or 0) > 0 + + if lookback_days > 0 and has_period: raise OperationalException( "Ambiguous configuration: lookback_days and lookback_period both set in pairlist " "config. Please set lookback_days only or lookback_period and lookback_timeframe " "and restart the bot." ) - # 0 means "no lookback" - only acceptable if the lookback is optional - min_days = 1 if required else 0 + # 0 means "no lookback" - only acceptable if the lookback is optional, + # or if lookback_period provides the range instead. + min_days = 1 if required and not has_period else 0 if "lookback_days" in self._pairlistconfig and lookback_days < min_days: raise OperationalException(f"{self.name} requires lookback_days to be >= {min_days}") diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 36a5e9fb5da..61096285ad0 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1214,6 +1214,17 @@ def test_VolatilityFilter_error(mocker, whitelist_conf, caplog) -> None: ): PairListManager(exchange_mock, whitelist_conf, MagicMock()) + # An explicit "lookback_days: 0" must not invalidate a configured lookback_period + volatility_filter = { + "method": "VolatilityFilter", + "lookback_days": 0, + "lookback_timeframe": "1h", + "lookback_period": 5, + } + whitelist_conf["pairlists"] = [{"method": "StaticPairList"}, volatility_filter] + handler = PairListManager(exchange_mock, whitelist_conf, MagicMock())._pairlist_handlers[1] + assert (handler._lookback_timeframe, handler._lookback_period) == ("1h", 5) + volatility_filter = { "method": "VolatilityFilter", "lookback_days": 10, From 3cfe7926442dce943054dd8e61dd647036d32a13 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 13:58:59 +0200 Subject: [PATCH 27/28] feat: warn about odd configurations this will avoid surprises on the user side --- freqtrade/plugins/pairlist/IPairList.py | 9 +++++++++ tests/plugins/test_pairlist.py | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index b8952ed82a9..5c5ff3a5a55 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -238,6 +238,15 @@ def _init_lookback_config( f"{self.name} requires lookback_period to be >= {min_period}" ) + if self._lookback_period == 0 and "lookback_timeframe" in self._pairlistconfig: + # Required handlers raise above - for optional ones, the timeframe has no effect. + logger.warning( + f"{self.name} is configured with lookback_timeframe " + f"{self._lookback_timeframe}, but without lookback_period - " + "the lookback range is disabled and the timeframe has no effect. " + "Please set lookback_period to enable it." + ) + candle_limit = self._exchange.ohlcv_candle_limit( self._lookback_timeframe, self._config["candle_type_def"] ) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 61096285ad0..29df21cb587 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1580,7 +1580,7 @@ def test_lookback_parameters_defaults(mocker, whitelist_conf, pairlist_conf): {"lookback_timeframe": "1d"}, ], ) -def test_volumepairlist_no_range(mocker, whitelist_conf, lookback_conf): +def test_volumepairlist_no_range(mocker, whitelist_conf, lookback_conf, caplog): # An explicit lookback of 0 must not enable the lookback range whitelist_conf["pairlists"][0].update(lookback_conf) @@ -1590,6 +1590,10 @@ def test_volumepairlist_no_range(mocker, whitelist_conf, lookback_conf): assert pairlist_handler._use_range is False assert pairlist_handler.needstickers is True + # A lookback_timeframe without lookback_period has no effect - warn about it + warning = r"VolumePairList is configured with lookback_timeframe .* the lookback range" + assert log_has_re(warning, caplog) == ("lookback_timeframe" in lookback_conf) + whitelist_conf["pairlists"][0].update({"lookback_days": -1}) with pytest.raises( OperationalException, match=r"VolumePairList requires lookback_days to be >= 0" From ba634ac4044b5d702db14baa7b9f9166308f6e46 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 17 Aug 2026 14:24:20 +0200 Subject: [PATCH 28/28] feat: align wording for pairlist lookback ranges --- .../plugins/pairlist/PercentChangePairList.py | 12 +++++------- freqtrade/plugins/pairlist/VolatilityFilter.py | 8 ++++---- freqtrade/plugins/pairlist/VolumePairList.py | 16 +++++----------- .../plugins/pairlist/rangestabilityfilter.py | 12 ++++++------ tests/plugins/test_pairlist.py | 6 +++--- 5 files changed, 23 insertions(+), 31 deletions(-) diff --git a/freqtrade/plugins/pairlist/PercentChangePairList.py b/freqtrade/plugins/pairlist/PercentChangePairList.py index c29881c7e6e..04f061a326a 100644 --- a/freqtrade/plugins/pairlist/PercentChangePairList.py +++ b/freqtrade/plugins/pairlist/PercentChangePairList.py @@ -13,10 +13,11 @@ from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe from freqtrade.exceptions import OperationalException -from freqtrade.exchange import date_minus_candles, timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes from freqtrade.exchange.exchange_types import Ticker, Tickers +from freqtrade.misc import plural from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting -from freqtrade.util import FtTTLCache, dt_ts, format_ms_time +from freqtrade.util import FtTTLCache logger = logging.getLogger(__name__) @@ -206,12 +207,9 @@ def filter_pairlist(self, pairlist: list[str], tickers: dict) -> list[str]: def fetch_candles_for_lookback_period( self, filtered_tickers: list[SymbolWithPercentage] ) -> dict[PairWithTimeframe, DataFrame]: - since_ms = dt_ts(date_minus_candles(self._lookback_timeframe, self._lookback_period + 1)) - to_ms = dt_ts(date_minus_candles(self._lookback_timeframe, 1)) self.log_once( - f"Using change range of {self._lookback_period} candles, timeframe: " - f"{self._lookback_timeframe}, starting from {format_ms_time(since_ms)} " - f"till {format_ms_time(to_ms)}", + f"Using change range of {self._lookback_period} x {self._lookback_timeframe} " + f"{plural(self._lookback_period, 'candle')}.", logger.info, ) needed_pairs: ListPairsWithTimeframes = [ diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 5cd34cd2b5c..b2ee820f674 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -52,8 +52,8 @@ def short_desc(self) -> str: return ( f"{self.name} - Filtering pairs with volatility range " f"{self._min_volatility}-{self._max_volatility} over the " - f"last {self._lookback_period} {plural(self._lookback_period, 'candle')} of " - f"{self._lookback_timeframe}." + f"last {self._lookback_period} x {self._lookback_timeframe} " + f"{plural(self._lookback_period, 'candle')}." ) @staticmethod @@ -159,8 +159,8 @@ def _validate_pair_loc(self, pair: str, volatility_avg: float) -> bool: else: self.log_once( f"Removed {pair} from whitelist, because volatility " - f"over {self._lookback_period} {plural(self._lookback_period, 'candle')} of " - f"{self._lookback_timeframe} " + f"over {self._lookback_period} x {self._lookback_timeframe} " + f"{plural(self._lookback_period, 'candle')} " f"is: {volatility_avg:.3f} " f"which is not in the configured range of " f"{self._min_volatility}-{self._max_volatility}.", diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 2bb347a5e3d..132dafe777e 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -9,10 +9,11 @@ from freqtrade.constants import DOCS_LINK, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException -from freqtrade.exchange import date_minus_candles, timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes from freqtrade.exchange.exchange_types import Tickers +from freqtrade.misc import plural from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting -from freqtrade.util import FtTTLCache, dt_ts, format_ms_time +from freqtrade.util import FtTTLCache logger = logging.getLogger(__name__) @@ -182,16 +183,9 @@ def filter_pairlist(self, pairlist: list[str], tickers: dict) -> list[str]: # Create bare minimum from tickers structure. filtered_tickers: list[dict[str, Any]] = [{"symbol": k} for k in pairlist] - # get lookback period in ms, for exchange ohlcv fetch - since_ms = dt_ts( - date_minus_candles(self._lookback_timeframe, self._lookback_period + 1) - ) - to_ms = dt_ts(date_minus_candles(self._lookback_timeframe, 1)) - self.log_once( - f"Using volume range of {self._lookback_period} candles, timeframe: " - f"{self._lookback_timeframe}, starting from {format_ms_time(since_ms)} " - f"till {format_ms_time(to_ms)}", + f"Using volume range of {self._lookback_period} x {self._lookback_timeframe} " + f"{plural(self._lookback_period, 'candle')}.", logger.info, ) needed_pairs: ListPairsWithTimeframes = [ diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index c4ee844f2bf..c39812aad3e 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -49,8 +49,8 @@ def short_desc(self) -> str: return ( f"{self.name} - Filtering pairs with rate of change below " f"{self._min_rate_of_change}{max_rate_desc} over the " - f"last {self._lookback_period} {plural(self._lookback_period, 'candle')} of " - f"{self._lookback_timeframe}." + f"last {self._lookback_period} x {self._lookback_timeframe} " + f"{plural(self._lookback_period, 'candle')}." ) @staticmethod @@ -148,8 +148,8 @@ def _validate_pair_loc(self, pair: str, pct_change: float) -> bool: if pct_change < self._min_rate_of_change: self.log_once( f"Removed {pair} from whitelist, because rate of change " - f"over {self._lookback_period} {plural(self._lookback_period, 'candle')} of " - f"{self._lookback_timeframe} is {pct_change:.3f}, " + f"over {self._lookback_period} x {self._lookback_timeframe} " + f"{plural(self._lookback_period, 'candle')} is {pct_change:.3f}, " f"which is below the threshold of {self._min_rate_of_change}.", logger.info, ) @@ -158,8 +158,8 @@ def _validate_pair_loc(self, pair: str, pct_change: float) -> bool: if pct_change > self._max_rate_of_change: self.log_once( f"Removed {pair} from whitelist, because rate of change " - f"over {self._lookback_period} {plural(self._lookback_period, 'candle')} of " - f"{self._lookback_timeframe} is {pct_change:.3f}, " + f"over {self._lookback_period} x {self._lookback_timeframe} " + f"{plural(self._lookback_period, 'candle')} is {pct_change:.3f}, " f"which is above the threshold of {self._max_rate_of_change}.", logger.info, ) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 29df21cb587..c76a7362863 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -2077,7 +2077,7 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo {"method": "RangeStabilityFilter", "lookback_days": 10, "min_rate_of_change": 0.01}, ( "[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate " - "of change below 0.01 over the last 10 candles of 1d.'}]" + "of change below 0.01 over the last 10 x 1d candles.'}]" ), None, ), @@ -2090,7 +2090,7 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo }, ( "[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate " - "of change below 0.01 and above 0.99 over the last 10 candles of 1d.'}]" + "of change below 0.01 and above 0.99 over the last 10 x 1d candles.'}]" ), None, ), @@ -2103,7 +2103,7 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo }, ( "[{'RangeStabilityFilter': 'RangeStabilityFilter - Filtering pairs with rate " - "of change below 0.01 over the last 72 candles of 1h.'}]" + "of change below 0.01 over the last 72 x 1h candles.'}]" ), None, ),