Merge branch 'develop' into pr/dev-starlight/12506

This commit is contained in:
Matthias
2025-11-27 06:37:17 +01:00
60 changed files with 5575 additions and 3391 deletions
+24 -16
View File
@@ -74,15 +74,17 @@ jobs:
run: |
pytest --random-order --cov=freqtrade --cov=freqtrade_client --cov-config=.coveragerc
- name: Coveralls
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
- name: Cleanup codecov dirty state files
if: (runner.os == 'Linux' && matrix.python-version == '3.12' && matrix.os == 'ubuntu-24.04')
env:
# Coveralls token. Not used as secret due to github not providing secrets to forked repositories
COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu
run: |
# Allow failure for coveralls
uv pip install coveralls
coveralls || true
# See https://github.com/codecov/codecov-action/issues/1851
rm -rf codecov codecov.SHA256SUM codecov.SHA256SUM.sig
- name: Run json schema extract
# This should be kept before the repository check to ensure that the schema is up-to-date
@@ -273,10 +275,7 @@ jobs:
# Notify only once - when CI completes (and after deploy) in case it's successful
notify-complete:
needs: [
tests,
docs-check,
mypy-version-check,
pre-commit,
build,
build-linux-online
]
runs-on: ubuntu-22.04
@@ -304,11 +303,23 @@ jobs:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
build:
if: always()
name: "Build"
needs: [ tests, docs-check, mypy-version-check, pre-commit ]
needs: [
tests,
docs-check,
mypy-version-check,
pre-commit,
]
runs-on: ubuntu-22.04
steps:
- name: Decide whether the needed jobs succeeded or failed
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
with:
jobs: ${{ toJSON(needs) }}
- uses: actions/checkout@v5
with:
persist-credentials: false
@@ -403,10 +414,7 @@ jobs:
docker-build:
name: "Docker Build and Deploy"
needs: [
tests,
docs-check,
mypy-version-check,
pre-commit
build,
]
if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade'
uses: ./.github/workflows/docker-build.yml
+1
View File
@@ -14,6 +14,7 @@ permissions: {}
jobs:
zizmor:
name: Run zizmor 🌈
runs-on: ubuntu-latest
permissions:
security-events: write
+2 -2
View File
@@ -30,7 +30,7 @@ repos:
- types-filelock==3.2.7
- types-requests==2.32.4.20250913
- types-tabulate==0.9.0.20241207
- types-python-dateutil==2.9.0.20251008
- types-python-dateutil==2.9.0.20251115
- scipy-stubs==1.16.3.0
- SQLAlchemy==2.0.44
# stages: [push]
@@ -44,7 +44,7 @@ repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version.
rev: 'v0.14.4'
rev: 'v0.14.6'
hooks:
- id: ruff
- id: ruff-format
+6
View File
@@ -1,4 +1,5 @@
import subprocess # noqa: S404, RUF100
import sys
from pathlib import Path
@@ -62,4 +63,9 @@ def extract_command_partials():
if __name__ == "__main__":
if sys.version_info < (3, 13): # pragma: no cover
sys.exit(
"argparse output changed in Python 3.13+. "
"To keep command partials up to date, please run this script with Python 3.13+."
)
extract_command_partials()
+3
View File
@@ -2,11 +2,14 @@
usage: freqtrade list-timeframes [-h] [-v] [--no-color] [--logfile FILE] [-V]
[-c PATH] [-d PATH] [--userdir PATH]
[--exchange EXCHANGE] [-1]
[--trading-mode {spot,margin,futures}]
options:
-h, --help show this help message and exit
--exchange EXCHANGE Exchange name. Only valid if no config is provided.
-1, --one-column Print output in one column.
--trading-mode, --tradingmode {spot,margin,futures}
Select Trading mode
Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
+1 -1
View File
@@ -417,7 +417,7 @@ This filter allows freqtrade to ignore pairs until they have been listed for at
Removes pairs that will be delisted on the exchange maximum `max_days_from_now` days from now (defaults to `0` which remove all future delisted pairs no matter how far from now). Currently this filter only supports following exchanges:
!!! Note "Available exchanges"
Delist filter is only available on Binance, where Binance Futures will work for both dry and live modes, while Binance Spot is limited to live mode (for technical reasons).
Delist filter is available on Bybit Futures, Bitget Futures and Binance, where Binance Futures will work for both dry and live modes, while Binance Spot is limited to live mode (for technical reasons).
!!! Warning "Backtesting"
`DelistFilter` does not support backtesting mode.
+2 -2
View File
@@ -1,7 +1,7 @@
markdown==3.10
mkdocs==1.6.1
mkdocs-material==9.6.23
mkdocs-material==9.7.0
mdx_truly_sane_lists==1.3
pymdown-extensions==10.16.1
pymdown-extensions==10.17.1
jinja2==3.1.6
mike==2.1.3
+1 -1
View File
@@ -104,7 +104,7 @@ ARGS_BACKTEST_SHOW = [
ARGS_LIST_EXCHANGES = ["print_one_column", "list_exchanges_all", "trading_mode", "dex_exchanges"]
ARGS_LIST_TIMEFRAMES = ["exchange", "print_one_column"]
ARGS_LIST_TIMEFRAMES = ["exchange", "print_one_column", "trading_mode"]
ARGS_LIST_PAIRS = [
"exchange",
+8 -4
View File
@@ -388,8 +388,10 @@ def refresh_backtest_ohlcv_data(
for timeframe in timeframes:
# Get fast candles via parallel method on first loop through per timeframe
# and candle type. Downloads all the pairs in the list and stores them.
# Also skips if only 1 pair/timeframe combination is scheduled for download.
if (
not no_parallel_download
and (len(pairs) + len(timeframes)) > 2
and exchange.get_option("download_data_parallel_quick", True)
and (
((pair, timeframe, candle_type) not in fast_candles)
@@ -474,7 +476,7 @@ def _download_all_pairs_history_parallel(
:return: Candle pairs with timeframes
"""
candles: dict[PairWithTimeframe, DataFrame] = {}
since = 0
since: int | None = None
if timerange:
if timerange.starttype == "date":
since = timerange.startts * 1000
@@ -482,10 +484,12 @@ def _download_all_pairs_history_parallel(
candle_limit = exchange.ohlcv_candle_limit(timeframe, candle_type)
one_call_min_time_dt = dt_ts(date_minus_candles(timeframe, candle_limit))
# check if we can get all candles in one go, if so then we can download them in parallel
if since > one_call_min_time_dt:
if since is None or since > one_call_min_time_dt:
logger.info(
f"Downloading parallel candles for {timeframe} for all pairs "
f"since {format_ms_time(since)}"
f"Downloading parallel candles for {timeframe} for all pairs"
f" since {format_ms_time(since)}"
if since
else "."
)
needed_pairs: ListPairsWithTimeframes = [
(p, timeframe, candle_type) for p in [p for p in pairs]
+21 -6
View File
@@ -143,6 +143,20 @@ def _calc_drawdown_series(
max_drawdown_df["drawdown_relative"] = (
max_drawdown_df["high_value"] - max_drawdown_df["cumulative"]
) / max_drawdown_df["high_value"]
# Add zero row at start to account for edge-cases with no winning / losing trades - so high/low
# will be 0.0 in such cases.
zero_row = pd.DataFrame(
{
"cumulative": [0.0],
"high_value": [0.0],
"drawdown": [0.0],
"drawdown_relative": [0.0],
"date": [profit_results.loc[0, date_col]],
}
)
max_drawdown_df = pd.concat([zero_row, max_drawdown_df], ignore_index=True)
return max_drawdown_df
@@ -215,6 +229,7 @@ def calculate_max_drawdown(
max_drawdown_df = _calc_drawdown_series(
profit_results, date_col=date_col, value_col=value_col, starting_balance=starting_balance
)
# max_drawdown_df has an extra zero row at the start
# Calculate maximum drawdown
idxmin = (
@@ -223,15 +238,15 @@ def calculate_max_drawdown(
else max_drawdown_df["drawdown"].idxmin()
)
high_idx = max_drawdown_df.iloc[: idxmin + 1]["high_value"].idxmax()
high_date = profit_results.loc[high_idx, date_col]
low_date = profit_results.loc[idxmin, date_col]
high_val = max_drawdown_df.loc[high_idx, "cumulative"]
low_val = max_drawdown_df.loc[idxmin, "cumulative"]
max_drawdown_rel = max_drawdown_df.loc[idxmin, "drawdown_relative"]
high_date = profit_results.at[max(high_idx - 1, 0), date_col]
low_date = profit_results.at[max(idxmin - 1, 0), date_col]
high_val = max_drawdown_df.at[high_idx, "cumulative"]
low_val = max_drawdown_df.at[idxmin, "cumulative"]
max_drawdown_rel = max_drawdown_df.at[idxmin, "drawdown_relative"]
# Calculate current drawdown
current_high_idx = max_drawdown_df["high_value"].iloc[:-1].idxmax()
current_high_date = profit_results.loc[current_high_idx, date_col]
current_high_date = profit_results.at[max(current_high_idx - 1, 0), date_col]
current_high_value = max_drawdown_df.iloc[-1]["high_value"]
current_cumulative = max_drawdown_df.iloc[-1]["cumulative"]
current_drawdown_abs = current_high_value - current_cumulative
+2 -2
View File
@@ -5,7 +5,6 @@ from datetime import UTC, datetime
from pathlib import Path
import ccxt
from cachetools import TTLCache
from pandas import DataFrame
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
@@ -21,6 +20,7 @@ from freqtrade.exchange.common import retrier
from freqtrade.exchange.exchange_types import FtHas, Tickers
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
from freqtrade.misc import deep_merge_dicts, json_load
from freqtrade.util import FtTTLCache
from freqtrade.util.datetime_helpers import dt_from_ts, dt_ts
@@ -76,7 +76,7 @@ class Binance(Exchange):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._spot_delist_schedule_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
self._spot_delist_schedule_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
def get_proxy_coin(self) -> str:
"""
File diff suppressed because it is too large Load Diff
+36 -3
View File
@@ -1,10 +1,10 @@
import logging
from datetime import timedelta
from datetime import datetime, timedelta
import ccxt
from freqtrade.constants import BuySell
from freqtrade.enums import CandleType, MarginMode, TradingMode
from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode
from freqtrade.exceptions import (
DDosProtection,
OperationalException,
@@ -14,7 +14,7 @@ from freqtrade.exceptions import (
from freqtrade.exchange import Exchange
from freqtrade.exchange.common import API_RETRY_COUNT, retrier
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
from freqtrade.util.datetime_helpers import dt_now, dt_ts
from freqtrade.util import dt_from_ts, dt_now, dt_ts
logger = logging.getLogger(__name__)
@@ -37,6 +37,7 @@ class Bitget(Exchange):
_ft_has_futures: FtHas = {
"mark_ohlcv_timeframe": "4h",
"funding_fee_candle_limit": 100,
"has_delisting": True,
}
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
@@ -236,3 +237,35 @@ class Bitget(Exchange):
raise OperationalException(
"Freqtrade currently only supports isolated futures for bitget"
)
def check_delisting_time(self, pair: str) -> datetime | None:
"""
Check if the pair gonna be delisted.
By default, it returns None.
:param pair: Market symbol
:return: Datetime if the pair gonna be delisted, None otherwise
"""
if self._config["runmode"] in OPTIMIZE_MODES:
return None
if self.trading_mode == TradingMode.FUTURES:
return self._check_delisting_futures(pair)
return None
def _check_delisting_futures(self, pair: str) -> datetime | None:
delivery_time = self.markets.get(pair, {}).get("info", {}).get("limitOpenTime", None)
if delivery_time:
if isinstance(delivery_time, str) and (delivery_time != ""):
delivery_time = int(delivery_time)
if not isinstance(delivery_time, int) or delivery_time <= 0:
return None
max_delivery = dt_ts() + (
14 * 24 * 60 * 60 * 1000
) # Assume exchange don't announce delisting more than 14 days in advance
if delivery_time < max_delivery:
return dt_from_ts(delivery_time)
return None
+35 -1
View File
@@ -4,12 +4,13 @@ from datetime import datetime, timedelta
import ccxt
from freqtrade.constants import BuySell
from freqtrade.enums import MarginMode, PriceType, TradingMode
from freqtrade.enums import OPTIMIZE_MODES, MarginMode, PriceType, TradingMode
from freqtrade.exceptions import DDosProtection, ExchangeError, OperationalException, TemporaryError
from freqtrade.exchange import Exchange
from freqtrade.exchange.common import retrier
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
from freqtrade.misc import deep_merge_dicts
from freqtrade.util import dt_from_ts, dt_ts
logger = logging.getLogger(__name__)
@@ -54,6 +55,7 @@ class Bybit(Exchange):
"exchange_has_overrides": {
"fetchOrder": True,
},
"has_delisting": True,
}
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
@@ -294,3 +296,35 @@ class Bybit(Exchange):
self.cache_leverage_tiers(tiers, self._config["stake_currency"])
return tiers
def check_delisting_time(self, pair: str) -> datetime | None:
"""
Check if the pair gonna be delisted.
By default, it returns None.
:param pair: Market symbol
:return: Datetime if the pair gonna be delisted, None otherwise
"""
if self._config["runmode"] in OPTIMIZE_MODES:
return None
if self.trading_mode == TradingMode.FUTURES:
return self._check_delisting_futures(pair)
return None
def _check_delisting_futures(self, pair: str) -> datetime | None:
delivery_time = self.markets.get(pair, {}).get("info", {}).get("deliveryTime", 0)
if delivery_time:
if isinstance(delivery_time, str) and (delivery_time != ""):
delivery_time = int(delivery_time)
if not isinstance(delivery_time, int) or delivery_time <= 0:
return None
max_delivery = dt_ts() + (
14 * 24 * 60 * 60 * 1000
) # Assume exchange don't announce delisting more than 14 days in advance
if delivery_time < max_delivery:
return dt_from_ts(delivery_time)
return None
+23 -14
View File
@@ -16,7 +16,6 @@ from typing import Any, Literal, TypeGuard, TypeVar
import ccxt
import ccxt.pro as ccxt_pro
from cachetools import TTLCache
from ccxt import TICK_SIZE
from dateutil import parser
from pandas import DataFrame, concat
@@ -107,9 +106,8 @@ from freqtrade.misc import (
file_load_json,
safe_value_fallback2,
)
from freqtrade.util import dt_from_ts, dt_now
from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now
from freqtrade.util.datetime_helpers import dt_humanize_delta, dt_ts, format_ms_time
from freqtrade.util.periodic_cache import PeriodicCache
logger = logging.getLogger(__name__)
@@ -230,13 +228,13 @@ class Exchange:
self._cache_lock = Lock()
# Cache for 10 minutes ...
self._fetch_tickers_cache: TTLCache = TTLCache(maxsize=4, ttl=60 * 10)
self._fetch_tickers_cache: FtTTLCache = FtTTLCache(maxsize=4, ttl=60 * 10)
# Cache values for 300 to avoid frequent polling of the exchange for prices
# Caching only applies to RPC methods, so prices for open trades are still
# refreshed once every iteration.
# Shouldn't be too high either, as it'll freeze UI updates in case of open orders.
self._exit_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
self._entry_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
self._exit_rate_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
self._entry_rate_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
# Holds candles
self._klines: dict[PairWithTimeframe, DataFrame] = {}
@@ -430,7 +428,15 @@ class Exchange:
@property
def timeframes(self) -> list[str]:
return list((self._api.timeframes or {}).keys())
market_type = (
"spot"
if self.trading_mode != TradingMode.FUTURES
else self._ft_has["ccxt_futures_name"]
)
timeframes = self._api.options.get("timeframes", {}).get(market_type)
if timeframes is None:
timeframes = self._api.timeframes
return list((timeframes or {}).keys())
@property
def markets(self) -> dict[str, Any]:
@@ -1295,7 +1301,7 @@ class Exchange:
return order
def fetch_dry_run_order(self, order_id) -> CcxtOrder:
def fetch_dry_run_order(self, order_id: str) -> CcxtOrder:
"""
Return dry-run order
Only call if running in dry-run mode.
@@ -1307,11 +1313,12 @@ class Exchange:
except KeyError as e:
from freqtrade.persistence import Order
order = Order.order_by_id(order_id)
if order:
ccxt_order = order.to_ccxt_object(self._ft_has["stop_price_prop"])
self._dry_run_open_orders[order_id] = ccxt_order
return ccxt_order
order_obj = Order.order_by_id(order_id)
if order_obj:
order = order_obj.to_ccxt_object(self._ft_has["stop_price_prop"])
order = self.check_dry_limit_order_filled(order)
self._dry_run_open_orders[order_id] = order
return order
# Gracefully handle errors with dry-run orders.
raise InvalidOrderException(
f"Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}"
@@ -2155,7 +2162,9 @@ class Exchange:
name = side.capitalize()
strat_name = "entry_pricing" if side == "entry" else "exit_pricing"
cache_rate: TTLCache = self._entry_rate_cache if side == "entry" else self._exit_rate_cache
cache_rate: FtTTLCache = (
self._entry_rate_cache if side == "entry" else self._exit_rate_cache
)
if not refresh:
with self._cache_lock:
rate = cache_rate.get(pair)
+2 -2
View File
@@ -63,7 +63,7 @@ from freqtrade.rpc.rpc_types import (
from freqtrade.strategy.interface import IStrategy
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
from freqtrade.util import FtPrecise, MeasureTime, PeriodicCache, dt_from_ts, dt_now
from freqtrade.util.migrations.binance_mig import migrate_binance_futures_names
from freqtrade.util.migrations import migrate_live_content
from freqtrade.wallets import Wallets
@@ -229,7 +229,7 @@ class FreqtradeBot(LoggingMixin):
Called on startup and after reloading the bot - triggers notifications and
performs startup tasks
"""
migrate_binance_futures_names(self.config)
migrate_live_content(self.config, self.exchange)
set_startup_time()
self.rpc.startup_messages(self.config, self.pairlists, self.protections)
+4 -2
View File
@@ -1,6 +1,8 @@
from collections.abc import Callable
from cachetools import TTLCache, cached
from cachetools import cached
from freqtrade.util import FtTTLCache
class LoggingMixin:
@@ -18,7 +20,7 @@ class LoggingMixin:
"""
self.logger = logger
self.refresh_period = refresh_period
self._log_cache: TTLCache = TTLCache(maxsize=1024, ttl=self.refresh_period)
self._log_cache: FtTTLCache = FtTTLCache(maxsize=1024, ttl=self.refresh_period)
def log_once(self, message: str, logmethod: Callable, force_show: bool = False) -> None:
"""
+3 -1
View File
@@ -39,6 +39,7 @@ class RecursiveAnalysis(BaseAnalysis):
self.dict_recursive: dict[str, Any] = dict()
self.pair_to_used: str | None = None
self._strat_scc: int | None = None
# For recursive bias check
# analyzes two data frames with processed indicators and shows differences between them.
@@ -151,7 +152,8 @@ class RecursiveAnalysis(BaseAnalysis):
backtesting._set_strategy(backtesting.strategylist[0])
strat = backtesting.strategy
self._strat_scc = strat.startup_candle_count
if self._strat_scc is None:
self._strat_scc = strat.startup_candle_count
if self._strat_scc < 1:
raise ConfigurationError(
+6 -3
View File
@@ -126,6 +126,7 @@ class Backtesting:
self.config["dry_run"] = True
self.price_pair_prec: dict[str, Series] = {}
self.available_pairs: list[str] = []
self.run_ids: dict[str, str] = {}
self.strategylist: list[IStrategy] = []
self.all_bt_content: dict[str, BacktestContentType] = {}
@@ -176,7 +177,8 @@ class Backtesting:
self._validate_pairlists_for_backtesting()
self.dataprovider.add_pairlisthandler(self.pairlists)
self.pairlists.refresh_pairlist()
self.dynamic_pairlist: bool = self.config.get("enable_dynamic_pairlist", False)
self.pairlists.refresh_pairlist(only_first=self.dynamic_pairlist)
if len(self.pairlists.whitelist) == 0:
raise OperationalException("No pair in whitelist.")
@@ -211,7 +213,6 @@ class Backtesting:
self._can_short = self.trading_mode != TradingMode.SPOT
self._position_stacking: bool = self.config.get("position_stacking", False)
self.enable_protections: bool = self.config.get("enable_protections", False)
self.dynamic_pairlist: bool = self.config.get("enable_dynamic_pairlist", False)
migrate_data(config, self.exchange)
self.init_backtest()
@@ -335,10 +336,12 @@ class Backtesting:
self.progress.set_new_value(1)
self._load_bt_data_detail()
self.price_pair_prec = {}
for pair in self.pairlists.whitelist:
if pair in data:
# Load price precision logic
self.price_pair_prec[pair] = get_tick_size_over_time(data[pair])
self.available_pairs.append(pair)
return data, self.timerange
def _load_bt_data_detail(self) -> None:
@@ -1587,7 +1590,7 @@ class Backtesting:
self.check_abort()
if self.dynamic_pairlist and self.pairlists:
self.pairlists.refresh_pairlist()
self.pairlists.refresh_pairlist(pairs=self.available_pairs)
pairs = self.pairlists.whitelist
# Reset open trade count for this candle
+2
View File
@@ -755,6 +755,8 @@ class LocalTrade:
"precision_mode": self.precision_mode,
"precision_mode_price": self.precision_mode_price,
"contract_size": self.contract_size,
"nr_of_successful_entries": self.nr_of_successful_entries,
"nr_of_successful_exits": self.nr_of_successful_exits,
"has_open_orders": self.has_open_orders,
"orders": orders_json,
}
@@ -7,11 +7,10 @@ Provides dynamic pair list based on Market Cap
import logging
import math
from cachetools import TTLCache
from freqtrade.exceptions import OperationalException
from freqtrade.exchange.exchange_types import Tickers
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
from freqtrade.util import FtTTLCache
from freqtrade.util.coin_gecko import FtCoinGeckoApi
@@ -38,7 +37,7 @@ class MarketCapPairList(IPairList):
self._max_rank = self._pairlistconfig.get("max_rank", 30)
self._refresh_period = self._pairlistconfig.get("refresh_period", 86400)
self._categories = self._pairlistconfig.get("categories", [])
self._marketcap_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
self._marketcap_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
_coingecko_config = self._config.get("coingecko", {})
@@ -10,7 +10,6 @@ import logging
from datetime import timedelta
from typing import TypedDict
from cachetools import TTLCache
from pandas import DataFrame
from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe
@@ -18,7 +17,7 @@ from freqtrade.exceptions import OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
from freqtrade.exchange.exchange_types import Ticker, Tickers
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
from freqtrade.util import dt_now, format_ms_time
from freqtrade.util import FtTTLCache, dt_now, format_ms_time
logger = logging.getLogger(__name__)
@@ -47,7 +46,7 @@ class PercentChangePairList(IPairList):
self._min_value = self._pairlistconfig.get("min_value", None)
self._max_value = self._pairlistconfig.get("max_value", None)
self._refresh_period = self._pairlistconfig.get("refresh_period", 1800)
self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
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)
+3 -3
View File
@@ -10,7 +10,6 @@ from typing import Any
import rapidjson
import requests
from cachetools import TTLCache
from freqtrade import __version__
from freqtrade.configuration.load_config import CONFIG_PARSE_MODE
@@ -18,6 +17,7 @@ from freqtrade.exceptions import OperationalException
from freqtrade.exchange.exchange_types import Tickers
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.util import FtTTLCache
logger = logging.getLogger(__name__)
@@ -48,7 +48,7 @@ class RemotePairList(IPairList):
self._number_pairs = self._pairlistconfig["number_assets"]
self._refresh_period: int = self._pairlistconfig.get("refresh_period", 1800)
self._keep_pairlist_on_failure = self._pairlistconfig.get("keep_pairlist_on_failure", True)
self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
self._pair_cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=self._refresh_period)
self._pairlist_url = self._pairlistconfig.get("pairlist_url", "")
self._read_timeout = self._pairlistconfig.get("read_timeout", 60)
self._bearer_token = self._pairlistconfig.get("bearer_token", "")
@@ -159,7 +159,7 @@ class RemotePairList(IPairList):
)
self._refresh_period = remote_refresh_period
self._pair_cache = TTLCache(maxsize=1, ttl=remote_refresh_period)
self._pair_cache = FtTTLCache(maxsize=1, ttl=remote_refresh_period)
self._init_done = True
@@ -7,7 +7,6 @@ import sys
from datetime import timedelta
import numpy as np
from cachetools import TTLCache
from pandas import DataFrame
from freqtrade.constants import ListPairsWithTimeframes
@@ -15,7 +14,7 @@ from freqtrade.exceptions import OperationalException
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 dt_floor_day, dt_now, dt_ts
from freqtrade.util import FtTTLCache, dt_floor_day, dt_now, dt_ts
logger = logging.getLogger(__name__)
@@ -38,7 +37,7 @@ class VolatilityFilter(IPairList):
self._def_candletype = self._config["candle_type_def"]
self._sort_direction: str | None = self._pairlistconfig.get("sort_direction", None)
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
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:
+2 -4
View File
@@ -8,14 +8,12 @@ import logging
from datetime import timedelta
from typing import Any, Literal
from cachetools import TTLCache
from freqtrade.constants import ListPairsWithTimeframes
from freqtrade.exceptions import OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
from freqtrade.exchange.exchange_types import Tickers
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
from freqtrade.util import dt_now, format_ms_time
from freqtrade.util import FtTTLCache, dt_now, format_ms_time
logger = logging.getLogger(__name__)
@@ -43,7 +41,7 @@ class VolumePairList(IPairList):
self._min_value = self._pairlistconfig.get("min_value", 0)
self._max_value = self._pairlistconfig.get("max_value", None)
self._refresh_period = self._pairlistconfig.get("refresh_period", 1800)
self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period)
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)
@@ -5,7 +5,6 @@ Rate of change pairlist filter
import logging
from datetime import timedelta
from cachetools import TTLCache
from pandas import DataFrame
from freqtrade.constants import ListPairsWithTimeframes
@@ -13,7 +12,7 @@ from freqtrade.exceptions import OperationalException
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 dt_floor_day, dt_now, dt_ts
from freqtrade.util import FtTTLCache, dt_floor_day, dt_now, dt_ts
logger = logging.getLogger(__name__)
@@ -32,7 +31,7 @@ class RangeStabilityFilter(IPairList):
self._def_candletype = self._config["candle_type_def"]
self._sort_direction: str | None = self._pairlistconfig.get("sort_direction", None)
self._pair_cache: TTLCache = TTLCache(maxsize=1000, ttl=self._refresh_period)
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:
+26 -8
View File
@@ -5,7 +5,7 @@ PairList manager class
import logging
from functools import partial
from cachetools import LRUCache, TTLCache, cached
from cachetools import LRUCache, cached
from freqtrade.constants import Config, ListPairsWithTimeframes
from freqtrade.data.dataprovider import DataProvider
@@ -17,6 +17,7 @@ from freqtrade.mixins import LoggingMixin
from freqtrade.plugins.pairlist.IPairList import IPairList, SupportsBacktesting
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.resolvers import PairListResolver
from freqtrade.util import FtTTLCache
logger = logging.getLogger(__name__)
@@ -129,12 +130,24 @@ class PairListManager(LoggingMixin):
"""List of short_desc for each Pairlist Handler"""
return [{p.name: p.short_desc()} for p in self._pairlist_handlers]
@cached(TTLCache(maxsize=1, ttl=1800))
@cached(FtTTLCache(maxsize=1, ttl=1800))
def _get_cached_tickers(self) -> Tickers:
return self._exchange.get_tickers()
def refresh_pairlist(self) -> None:
"""Run pairlist through all configured Pairlist Handlers."""
def refresh_pairlist(self, only_first: bool = False, pairs: list[str] | None = None) -> None:
"""
Run pairlist through all configured Pairlist Handlers.
:param only_first: If True, only run the first PairList handler (the generator)
and skip all subsequent filters. Used during backtesting startup to ensure
historic data is loaded for the complete universe of pairs that the
generator can produce (even if later filters would reduce the list size).
Prevents missing data when a filter returns a variable number of pairs
across refresh cycles.
:param pairs: Optional list of pairs to intersect with the generated pairlist.
Only pairs present both in the generated list and this parameter are kept.
Used in backtesting to filter out pairs with no available data.
"""
# Tickers should be cached to avoid calling the exchange on each call.
tickers: dict = {}
if self._tickers_needed:
@@ -143,10 +156,15 @@ class PairListManager(LoggingMixin):
# Generate the pairlist with first Pairlist Handler in the chain
pairlist = self._pairlist_handlers[0].gen_pairlist(tickers)
# Process all Pairlist Handlers in the chain
# except for the first one, which is the generator.
for pairlist_handler in self._pairlist_handlers[1:]:
pairlist = pairlist_handler.filter_pairlist(pairlist, tickers)
# Optional intersection with an explicit list of pairs (used in backtesting)
if pairs is not None:
pairlist = [p for p in pairlist if p in pairs]
if not only_first:
# Process all Pairlist Handlers in the chain
# except for the first one, which is the generator.
for pairlist_handler in self._pairlist_handlers[1:]:
pairlist = pairlist_handler.filter_pairlist(pairlist, tickers)
# Validation against blacklist happens after the chain of Pairlist Handlers
# to ensure blacklist is respected.
+2
View File
@@ -340,6 +340,8 @@ class TradeSchema(BaseModel):
min_rate: float | None = None
max_rate: float | None = None
nr_of_successful_entries: int
nr_of_successful_exits: int
has_open_orders: bool
orders: list[OrderSchema]
+1 -1
View File
@@ -37,7 +37,7 @@ class ApiBG:
# Generic background jobs
# TODO: Change this to TTLCache
# TODO: Change this to FtTTLCache
jobs: dict[str, JobsContainer] = {}
# Pairlist evaluate things
pairlist_running: bool = False
+2 -2
View File
@@ -7,11 +7,11 @@ import logging
from datetime import datetime
from typing import Any
from cachetools import TTLCache
from requests.exceptions import RequestException
from freqtrade.constants import SUPPORTED_FIAT, Config
from freqtrade.mixins.logging_mixin import LoggingMixin
from freqtrade.util import FtTTLCache
from freqtrade.util.coin_gecko import FtCoinGeckoApi
@@ -54,7 +54,7 @@ class CryptoToFiatConverter(LoggingMixin):
def __init__(self, config: Config) -> None:
# Timeout: 6h
self._pair_price: TTLCache = TTLCache(maxsize=500, ttl=6 * 60 * 60)
self._pair_price: FtTTLCache = FtTTLCache(maxsize=500, ttl=6 * 60 * 60)
_coingecko_config = config.get("coingecko", {})
self._coingecko = FtCoinGeckoApi(
+2 -1
View File
@@ -47,6 +47,7 @@ from freqtrade.util import (
dt_ts,
dt_ts_def,
format_date,
format_pct,
shorten_date,
)
from freqtrade.wallets import PositionWallet, Wallet
@@ -302,7 +303,7 @@ class RPC:
fiat_total_profit_sum = nan
for trade in self._rpc_trade_status():
# Format profit as a string with the right sign
profit = f"{trade['profit_ratio']:.2%}"
profit = f"{format_pct(trade['profit_ratio'])}"
fiat_profit = trade.get("profit_fiat", None)
if fiat_profit is None or isnan(fiat_profit):
fiat_profit = trade.get("profit_abs", 0.0)
+61 -60
View File
@@ -48,6 +48,7 @@ from freqtrade.util import (
fmt_coin,
fmt_coin2,
format_date,
format_pct,
round_value,
)
@@ -481,7 +482,7 @@ class Telegram(RPCHandler):
if is_final_exit:
profit_prefix = "Sub "
cp_extra = (
f"*Final Profit:* `{msg['final_profit_ratio']:.2%} "
f"*Final Profit:* `{format_pct(msg['final_profit_ratio'])} "
f"({msg['cumulative_profit']:.8f} {msg['quote_currency']}{cp_fiat})`\n"
)
else:
@@ -497,7 +498,7 @@ class Telegram(RPCHandler):
f"{exit_wording} {msg['pair']} (#{msg['trade_id']})\n"
f"{self._add_analyzed_candle(msg['pair'])}"
f"*{f'{profit_prefix}Profit' if is_fill else f'Unrealized {profit_prefix}Profit'}:* "
f"`{msg['profit_ratio']:.2%}{profit_extra}`\n"
f"`{format_pct(msg['profit_ratio'])}{profit_extra}`\n"
f"{cp_extra}"
f"{enter_tag}"
f"*Exit Reason:* `{msg['exit_reason']}`\n"
@@ -670,14 +671,14 @@ class Telegram(RPCHandler):
# TODO: This calculation ignores fees.
price_to_1st_entry = (cur_entry_average - first_avg) / first_avg
if is_open:
lines.append("({})".format(dt_humanize_delta(order["order_filled_date"])))
lines.append(f"({dt_humanize_delta(order['order_filled_date'])})")
lines.append(
f"*Amount:* {round_value(cur_entry_amount, 8)} "
f"({fmt_coin(order['cost'], quote_currency)})"
)
lines.append(
f"*Average {wording} Price:* {round_value(cur_entry_average, 8)} "
f"({price_to_1st_entry:.2%} from 1st entry rate)"
f"({format_pct(price_to_1st_entry)} from 1st entry rate)"
)
lines.append(f"*Order Filled:* {order['order_filled_date']}")
@@ -701,7 +702,7 @@ class Telegram(RPCHandler):
results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
for r in results:
lines = ["*Order List for Trade #*`{trade_id}`"]
lines = [f"*Order List for Trade #*`{r['trade_id']}`"]
lines_detail = self._prepare_order_details(
r["orders"], r["quote_currency"], r["is_open"]
@@ -720,10 +721,10 @@ class Telegram(RPCHandler):
if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
msg += line + "\n"
else:
await self._send_msg(msg.format(**r))
msg = "*Order List for Trade #*`{trade_id}` - continued\n" + line + "\n"
await self._send_msg(msg)
msg = f"*Order List for Trade #*`{r['trade_id']}` - continued\n" + line + "\n"
await self._send_msg(msg.format(**r))
await self._send_msg(msg)
@authorized_only
async def _status(self, update: Update, context: CallbackContext) -> None:
@@ -757,15 +758,7 @@ class Telegram(RPCHandler):
max_entries = self._config.get("max_entry_position_adjustment", -1)
for r in results:
r["open_date_hum"] = dt_humanize_delta(r["open_date"])
r["num_entries"] = len([o for o in r["orders"] if o["ft_is_entry"]])
r["num_exits"] = len(
[
o
for o in r["orders"]
if not o["ft_is_entry"] and not o["ft_order_side"] == "stoploss"
]
)
r["exit_reason"] = r.get("exit_reason", "")
r["stake_amount_r"] = fmt_coin(r["stake_amount"], r["quote_currency"])
r["max_stake_amount_r"] = fmt_coin(
r["max_stake_amount"] or r["stake_amount"], r["quote_currency"]
@@ -774,26 +767,25 @@ class Telegram(RPCHandler):
r["realized_profit_r"] = fmt_coin(r["realized_profit"], r["quote_currency"])
r["total_profit_abs_r"] = fmt_coin(r["total_profit_abs"], r["quote_currency"])
lines = [
"*Trade ID:* `{trade_id}`" + (" `(since {open_date_hum})`" if r["is_open"] else ""),
"*Current Pair:* {pair}",
f"*Trade ID:* `{r['trade_id']}`"
+ (f" `(since {r['open_date_hum']})`" if r["is_open"] else ""),
f"*Current Pair:* {r['pair']}",
(
f"*Direction:* {'`Short`' if r.get('is_short') else '`Long`'}"
+ " ` ({leverage}x)`"
if r.get("leverage")
else ""
+ (f" ` ({r['leverage']}x)`" if r.get("leverage") else "")
),
"*Amount:* `{amount} ({stake_amount_r})`",
"*Total invested:* `{max_stake_amount_r}`" if position_adjust else "",
"*Enter Tag:* `{enter_tag}`" if r["enter_tag"] else "",
"*Exit Reason:* `{exit_reason}`" if r["exit_reason"] else "",
f"*Amount:* `{r['amount']} ({r['stake_amount_r']})`",
f"*Total invested:* `{r['max_stake_amount_r']}`" if position_adjust else "",
f"*Enter Tag:* `{r['enter_tag']}`" if r["enter_tag"] else "",
f"*Exit Reason:* `{r['exit_reason']}`" if r.get("exit_reason") else "",
]
if position_adjust:
max_buy_str = f"/{max_entries + 1}" if (max_entries > 0) else ""
lines.extend(
[
"*Number of Entries:* `{num_entries}" + max_buy_str + "`",
"*Number of Exits:* `{num_exits}`",
f"*Number of Entries:* `{r['nr_of_successful_entries']}{max_buy_str}`",
f"*Number of Exits:* `{r['nr_of_successful_exits']}`",
]
)
@@ -801,53 +793,62 @@ class Telegram(RPCHandler):
[
f"*Open Rate:* `{round_value(r['open_rate'], 8)}`",
f"*Close Rate:* `{round_value(r['close_rate'], 8)}`" if r["close_rate"] else "",
"*Open Date:* `{open_date}`",
"*Close Date:* `{close_date}`" if r["close_date"] else "",
f"*Open Date:* `{r['open_date']}`",
f"*Close Date:* `{r['close_date']}`" if r["close_date"] else "",
(
f" \n*Current Rate:* `{round_value(r['current_rate'], 8)}`"
if r["is_open"]
else ""
),
("*Unrealized Profit:* " if r["is_open"] else "*Close Profit: *")
+ "`{profit_ratio:.2%}` `({profit_abs_r})`",
+ f"`{format_pct(r['profit_ratio'])}` `({r['profit_abs_r']})`",
]
)
if r["is_open"]:
if r.get("realized_profit"):
lines.extend(
[
"*Realized Profit:* `{realized_profit_ratio:.2%} "
"({realized_profit_r})`",
"*Total Profit:* `{total_profit_ratio:.2%} ({total_profit_abs_r})`",
]
if (
r.get("realized_profit") is not None
and r.get("realized_profit_ratio") is not None
):
lines.append(
f"*Realized Profit:* `{format_pct(r['realized_profit_ratio'])} "
f"({r['realized_profit_r']})`"
)
if r.get("total_profit_ratio") is not None:
lines.append(
f"*Total Profit:* `{format_pct(r['total_profit_ratio'])} "
f"({r['total_profit_abs_r']})`"
)
# Append empty line to improve readability
lines.append(" ")
# Adding liquidation only if it is not None
if liquidation := r.get("liquidation_price"):
lines.append(f"*Liquidation:* `{round_value(liquidation, 8)}`")
if (
r["stop_loss_abs"] != r["initial_stop_loss_abs"]
and r["initial_stop_loss_ratio"] is not None
):
# Adding initial stoploss only if it is different from stoploss
lines.append(
"*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
"`({initial_stop_loss_ratio:.2%})`"
f"*Initial Stoploss:* `{r['initial_stop_loss_abs']:.8f}` "
f"`({format_pct(r['initial_stop_loss_ratio'])})`"
)
# Adding stoploss and stoploss percentage only if it is not None
lines.append(
f"*Stoploss:* `{round_value(r['stop_loss_abs'], 8)}` "
+ ("`({stop_loss_ratio:.2%})`" if r["stop_loss_ratio"] else "")
+ (f"`({format_pct(r['stop_loss_ratio'])})`" if r["stop_loss_ratio"] else "")
)
lines.append(
f"*Stoploss distance:* `{round_value(r['stoploss_current_dist'], 8)}` "
"`({stoploss_current_dist_ratio:.2%})`"
f"`({format_pct(r['stoploss_current_dist_ratio'])})`"
)
if r.get("open_orders"):
if open_orders := r.get("open_orders"):
lines.append(
"*Open Order:* `{open_orders}`"
+ ("- `{exit_order_status}`" if r["exit_order_status"] else "")
f"*Open Order:* `{open_orders}`"
+ (f"- `{r['exit_order_status']}`" if r["exit_order_status"] else "")
)
await self.__send_status_msg(lines, r)
@@ -863,10 +864,10 @@ class Telegram(RPCHandler):
if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
msg += line + "\n"
else:
await self._send_msg(msg.format(**r))
msg = "*Trade ID:* `{trade_id}` - continued\n" + line + "\n"
await self._send_msg(msg)
msg = f"*Trade ID:* `{r['trade_id']}` - continued\n" + line + "\n"
await self._send_msg(msg.format(**r))
await self._send_msg(msg)
@authorized_only
async def _status_table(self, update: Update, context: CallbackContext) -> None:
@@ -953,7 +954,7 @@ class Telegram(RPCHandler):
f"{period['date']:{val.dateformat}} ({period['trade_count']})",
f"{fmt_coin(period['abs_profit'], stats['stake_currency'])}",
f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}",
f"{period['rel_profit']:.2%}",
f"{format_pct(period['rel_profit'])}",
]
for period in stats["data"]
],
@@ -1069,7 +1070,7 @@ class Telegram(RPCHandler):
markdown_msg = (
f"{closed_roi_label}\n"
f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} "
f"({profit_closed_ratio_mean:.2%}) "
f"({format_pct(profit_closed_ratio_mean)}) "
f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"{fiat_closed_trades}"
)
@@ -1082,7 +1083,7 @@ class Telegram(RPCHandler):
markdown_msg += (
f"{all_roi_label}\n"
f"∙ `{fmt_coin(profit_all_coin, stake_cur)} "
f"({profit_all_ratio_mean:.2%}) "
f"({format_pct(profit_all_ratio_mean)}) "
f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
f"{fiat_all_trades}"
f"*Total Trade Count:* `{trade_count}`\n"
@@ -1091,7 +1092,7 @@ class Telegram(RPCHandler):
f"`{first_trade_date}`\n"
f"*Latest Trade opened:* `{latest_trade_date}`\n"
f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\n"
f"*Winrate:* `{winrate:.2%}`\n"
f"*Winrate:* `{format_pct(winrate)}`\n"
f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`"
)
@@ -1099,16 +1100,16 @@ class Telegram(RPCHandler):
markdown_msg += (
f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} "
f"({best_pair_profit_ratio:.2%})`\n"
f"({format_pct(best_pair_profit_ratio)})`\n"
f"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n"
f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
f"*Max Drawdown:* `{format_pct(stats['max_drawdown'])} "
f"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n"
f" from `{stats['max_drawdown_start']} "
f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n"
f" to `{stats['max_drawdown_end']} "
f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n"
f"*Current Drawdown:* `{stats['current_drawdown']:.2%} "
f"*Current Drawdown:* `{format_pct(stats['current_drawdown'])} "
f"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n"
f" from `{stats['current_drawdown_start']} "
f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n"
@@ -1561,7 +1562,7 @@ class Telegram(RPCHandler):
dt_humanize_delta(dt_from_ts(trade["close_timestamp"])),
f"{trade['pair']} (#{trade['trade_id']}"
f"{(' ' + ('S' if trade['is_short'] else 'L')) if nonspot else ''})",
f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})",
f"{format_pct(trade['close_profit'])} ({trade['close_profit_abs']})",
]
for trade in trades["trades"]
],
@@ -1625,7 +1626,7 @@ class Telegram(RPCHandler):
stat_line = (
f"{i + 1}.\t <code>{trade['pair']}\t"
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_ratio']:.2%}) "
f"({format_pct(trade['profit_ratio'])}) "
f"({trade['count']})</code>\n"
)
@@ -1662,7 +1663,7 @@ class Telegram(RPCHandler):
stat_line = (
f"{i + 1}.\t `{trade['enter_tag']}\t"
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_ratio']:.2%}) "
f"({format_pct(trade['profit_ratio'])}) "
f"({trade['count']})`\n"
)
@@ -1699,7 +1700,7 @@ class Telegram(RPCHandler):
stat_line = (
f"{i + 1}.\t `{trade['exit_reason']}\t"
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_ratio']:.2%}) "
f"({format_pct(trade['profit_ratio'])}) "
f"({trade['count']})`\n"
)
@@ -1736,7 +1737,7 @@ class Telegram(RPCHandler):
stat_line = (
f"{i + 1}.\t `{trade['mix_tag']}\t"
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_ratio']:.2%}) "
f"({format_pct(trade['profit_ratio'])}) "
f"({trade['count']})`\n"
)
+1 -1
View File
@@ -34,7 +34,7 @@
"bids_to_ask_delta": 1
}
},
"exit_pricing":{
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
+4
View File
@@ -18,9 +18,11 @@ from freqtrade.util.formatters import (
fmt_coin,
fmt_coin2,
format_duration,
format_pct,
round_value,
)
from freqtrade.util.ft_precise import FtPrecise
from freqtrade.util.ft_ttlcache import FtTTLCache
from freqtrade.util.measure_time import MeasureTime
from freqtrade.util.periodic_cache import PeriodicCache
from freqtrade.util.progress_tracker import ( # noqa F401
@@ -44,6 +46,7 @@ __all__ = [
"format_date",
"format_ms_time",
"format_ms_time_det",
"format_pct",
"get_dry_run_wallet",
"FtPrecise",
"PeriodicCache",
@@ -57,4 +60,5 @@ __all__ = [
"print_rich_table",
"print_df_rich_table",
"CustomProgress",
"FtTTLCache",
]
+16
View File
@@ -1,5 +1,7 @@
from datetime import timedelta
from numpy import isnan
from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN
@@ -29,6 +31,8 @@ def round_value(value: float, decimals: int, keep_trailing_zeros=False) -> str:
:param keep_trailing_zeros: Keep trailing zeros "222.200" vs. "222.2"
:return: Rounded value as string
"""
if isnan(value):
return "N/A"
val = f"{value:.{decimals}f}"
if not keep_trailing_zeros:
val = strip_trailing_zeros(val)
@@ -80,3 +84,15 @@ def format_duration(td: timedelta) -> str:
h, r = divmod(td.seconds, 3600)
m, _ = divmod(r, 60)
return f"{d}d {h:02d}:{m:02d}"
def format_pct(value: float | None) -> str:
"""
Format a float value as percentage string with 2 decimals
None and NaN values are formatted as "N/A"
:param value: Float value to format
:return: Formatted percentage string
"""
if value is None or isnan(value):
return "N/A"
return f"{value:.2%}"
+12
View File
@@ -0,0 +1,12 @@
import time
from cachetools import TTLCache
class FtTTLCache(TTLCache):
"""
A TTLCache with a different default timer to allow for easier mocking in tests.
"""
def __init__(self, maxsize, ttl, timer=time.time, getsizeof=None):
super().__init__(maxsize=maxsize, ttl=ttl, timer=timer, getsizeof=getsizeof)
+2 -2
View File
@@ -2,7 +2,7 @@ import logging
import time
from collections.abc import Callable
from cachetools import TTLCache
from freqtrade.util import FtTTLCache
logger = logging.getLogger(__name__)
@@ -27,7 +27,7 @@ class MeasureTime:
"""
self._callback = callback
self._time_limit = time_limit
self.__cache: TTLCache = TTLCache(maxsize=1, ttl=ttl)
self.__cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=ttl)
def __enter__(self):
self._start = time.time()
+13 -2
View File
@@ -1,12 +1,23 @@
from freqtrade.exchange import Exchange
from freqtrade.util.migrations.binance_mig import migrate_binance_futures_data
from freqtrade.util.migrations.binance_mig import (
migrate_binance_futures_data,
migrate_binance_futures_names,
)
from freqtrade.util.migrations.funding_rate_mig import migrate_funding_fee_timeframe
def migrate_data(config, exchange: Exchange | None = None):
def migrate_data(config, exchange: Exchange | None = None) -> None:
"""
Migrate persisted data from old formats to new formats
"""
migrate_binance_futures_data(config)
migrate_funding_fee_timeframe(config, exchange)
def migrate_live_content(config, exchange: Exchange | None = None) -> None:
"""
Migrate database content from old formats to new formats
Used for dry/live mode.
"""
migrate_binance_futures_names(config)
+4
View File
@@ -14,6 +14,10 @@ logger = logging.getLogger(__name__)
def migrate_binance_futures_names(config: Config):
"""
Migrate binance futures names in both database and data files.
This is needed because ccxt naming changed from "BTC/USDT" to "BTC/USDT:USDT"
"""
if not (
config.get("trading_mode", TradingMode.SPOT) == TradingMode.FUTURES
and config["exchange"]["name"] == "binance"
+1
View File
@@ -183,6 +183,7 @@ skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*", "**/user_data/*"
known_first_party = ["freqtrade_client"]
[tool.pytest.ini_options]
# TODO: should be migrated to [tool.pytest] as support for this was added in 9.0
log_format = "%(asctime)s %(levelname)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
+6 -6
View File
@@ -6,11 +6,11 @@
-r requirements-freqai-rl.txt
-r docs/requirements-docs.txt
ruff==0.14.3
ruff==0.14.5
mypy==1.18.2
pre-commit==4.3.0
pytest==8.4.2
pytest-asyncio==1.2.0
pre-commit==4.4.0
pytest==9.0.1
pytest-asyncio==1.3.0
pytest-cov==7.0.0
pytest-mock==3.15.1
pytest-random-order==1.2.0
@@ -18,7 +18,7 @@ pytest-timeout==2.4.0
pytest-xdist==3.8.0
isort==7.0.0
# For datetime mocking
time-machine==2.19.0
time-machine==3.0.0
# Convert jupyter notebooks to markdown documents
nbconvert==7.16.6
@@ -29,4 +29,4 @@ types-cachetools==6.2.0.20251022
types-filelock==3.2.7
types-requests==2.32.4.20250913
types-tabulate==0.9.0.20241207
types-python-dateutil==2.9.0.20251008
types-python-dateutil==2.9.0.20251115
+1 -1
View File
@@ -2,7 +2,7 @@
-r requirements-freqai.txt
# Required for freqai-rl
torch==2.9.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
torch==2.9.1; sys_platform != 'darwin' or platform_machine != 'x86_64'
gymnasium==1.2.2
# SB3 >=2.5.0 depends on torch 2.3.0 - which implies it dropped support x86 macos
stable_baselines3==2.7.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
+1 -1
View File
@@ -5,5 +5,5 @@
scipy==1.16.3
scikit-learn==1.7.2
filelock==3.20.0
optuna==4.5.0
optuna==4.6.0
cmaes==0.12.0
+1 -1
View File
@@ -1,4 +1,4 @@
# Include all requirements to run the bot.
-r requirements.txt
plotly==6.4.0
plotly==6.5.0
+5 -5
View File
@@ -1,4 +1,4 @@
numpy==2.3.4
numpy==2.3.5
pandas==2.3.3
bottleneck==1.6.0
numexpr==2.14.1
@@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16
ta-lib==0.6.8
technical==1.5.3
ccxt==4.5.17
ccxt==4.5.20
cryptography==46.0.3
aiohttp==3.13.2
SQLAlchemy==2.0.44
@@ -15,10 +15,10 @@ python-telegram-bot==22.5
# can't be hard-pinned due to telegram-bot pinning httpx with ~
httpx>=0.24.1
humanize==4.14.0
cachetools==6.2.1
cachetools==6.2.2
requests==2.32.5
urllib3==2.5.0
certifi==2025.10.5
certifi==2025.11.12
jsonschema==4.25.1
tabulate==0.9.0
pycoingecko==3.2.0
@@ -37,7 +37,7 @@ orjson==3.11.4
sdnotify==0.3.2
# API Server
fastapi==0.121.0
fastapi==0.121.3
pydantic==2.12.4
uvicorn==0.38.0
pyjwt==2.10.1
+48
View File
@@ -198,6 +198,8 @@ def test_list_timeframes(mocker, capsys):
"1h": "hour",
"1d": "day",
}
api_mock.options = {}
patch_exchange(mocker, api_mock=api_mock, exchange="bybit")
args = [
"list-timeframes",
@@ -286,6 +288,52 @@ def test_list_timeframes(mocker, capsys):
assert re.search(r"^1h$", captured.out, re.MULTILINE)
assert re.search(r"^1d$", captured.out, re.MULTILINE)
api_mock.options = {
"timeframes": {
"spot": {"1m": "1m", "5m": "5m", "15m": "15m"},
"swap": {"1m": "1m", "15m": "15m", "1h": "1h"},
}
}
args = [
"list-timeframes",
"--exchange",
"binance",
]
start_list_timeframes(get_args(args))
captured = capsys.readouterr()
assert re.match(
"Timeframes available for the exchange `Binance`: 1m, 5m, 15m",
captured.out,
)
args = [
"list-timeframes",
"--exchange",
"binance",
"--trading-mode",
"spot",
]
start_list_timeframes(get_args(args))
captured = capsys.readouterr()
assert re.match(
"Timeframes available for the exchange `Binance`: 1m, 5m, 15m",
captured.out,
)
args = [
"list-timeframes",
"--exchange",
"binance",
"--trading-mode",
"futures",
]
start_list_timeframes(get_args(args))
captured = capsys.readouterr()
assert re.match(
"Timeframes available for the exchange `Binance`: 1m, 15m, 1h",
captured.out,
)
def test_list_markets(mocker, markets_static, capsys):
api_mock = MagicMock()
+2
View File
@@ -303,6 +303,7 @@ def mock_order_usdt_6(is_short: bool):
"side": entry_side(is_short),
"type": "limit",
"price": 10.0,
"cost": 20.0,
"amount": 2.0,
"filled": 2.0,
"remaining": 0.0,
@@ -317,6 +318,7 @@ def mock_order_usdt_6_exit(is_short: bool):
"side": exit_side(is_short),
"type": "limit",
"price": 12.0,
"cost": 24.0,
"amount": 2.0,
"filled": 0.0,
"remaining": 2.0,
+6
View File
@@ -575,12 +575,18 @@ def test_calculate_max_drawdown2():
# No losing trade ...
drawdown = calculate_max_drawdown(df, date_col="open_date", value_col="profit")
assert drawdown.drawdown_abs == 0.0
assert drawdown.low_value == 0.0
assert drawdown.current_high_value >= 0.0
assert drawdown.current_drawdown_abs == 0.0
df1 = DataFrame(zip(values[:5], dates[:5], strict=False), columns=["profit", "open_date"])
df1.loc[:, "profit"] = df1["profit"] * -1
# No winning trade ...
drawdown = calculate_max_drawdown(df1, date_col="open_date", value_col="profit")
assert drawdown.drawdown_abs == 0.055545
assert drawdown.high_value == 0.0
assert drawdown.current_high_value == 0.0
assert drawdown.current_drawdown_abs == 0.055545
@pytest.mark.parametrize(
+20 -2
View File
@@ -582,6 +582,23 @@ def test_refresh_backtest_ohlcv_data(
assert log_has_re(r"Downloading pair ETH/BTC, funding_rate, interval 8h\.", caplog)
assert log_has_re(r"Downloading pair ETH/BTC, mark, interval 4h\.", caplog)
# Test with only one pair - no parallel download should happen 1 pair/timeframe combination
# doesn't justify parallelization
parallel_mock.reset_mock()
dl_mock.reset_mock()
refresh_backtest_ohlcv_data(
exchange=ex,
pairs=[
"ETH/BTC",
],
timeframes=["5m"],
datadir=testdatadir,
timerange=timerange,
erase=False,
trading_mode=trademode,
)
assert parallel_mock.call_count == 0
def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir):
dl_mock = mocker.patch(
@@ -780,6 +797,7 @@ def test_download_all_pairs_history_parallel(mocker, default_conf_usdt):
exchange.refresh_latest_ohlcv.reset_mock()
# Test without timerange
# expected to call refresh_latest_ohlcv - as we can't know how much will be required.
result3 = _download_all_pairs_history_parallel(
exchange=exchange,
pairs=pairs,
@@ -787,8 +805,8 @@ def test_download_all_pairs_history_parallel(mocker, default_conf_usdt):
candle_type=candle_type,
timerange=None,
)
assert result3 == {}
assert exchange.refresh_latest_ohlcv.call_count == 0
assert result3 == expected
assert exchange.refresh_latest_ohlcv.call_count == 1
def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path, caplog) -> None:
+43 -2
View File
@@ -1,12 +1,13 @@
from copy import deepcopy
from datetime import timedelta
from unittest.mock import MagicMock, PropertyMock
import pytest
from freqtrade.enums import CandleType, MarginMode, TradingMode
from freqtrade.enums import CandleType, MarginMode, RunMode, TradingMode
from freqtrade.exceptions import OperationalException, RetryableOrderError
from freqtrade.exchange.common import API_RETRY_COUNT
from freqtrade.util import dt_now, dt_ts
from freqtrade.util import dt_now, dt_ts, dt_utc
from tests.conftest import EXMS, get_patched_exchange
from tests.exchange.test_exchange import ccxt_exceptionhandlers
@@ -193,3 +194,43 @@ def test__lev_prep_bitget(default_conf, mocker):
assert api_mock.set_margin_mode.call_count == 0
assert api_mock.set_leverage.call_count == 1
api_mock.set_leverage.assert_called_with(symbol="BTC/USDC:USDC", leverage=19.99)
def test_check_delisting_time_bitget(default_conf_usdt, mocker):
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bitget")
exchange._config["runmode"] = RunMode.BACKTEST
delist_fut_mock = MagicMock(return_value=None)
mocker.patch.object(exchange, "_check_delisting_futures", delist_fut_mock)
# Invalid run mode
resp = exchange.check_delisting_time("BTC/USDT")
assert resp is None
assert delist_fut_mock.call_count == 0
# Delist spot called
exchange._config["runmode"] = RunMode.DRY_RUN
resp1 = exchange.check_delisting_time("BTC/USDT")
assert resp1 is None
assert delist_fut_mock.call_count == 0
# Delist futures called
exchange.trading_mode = TradingMode.FUTURES
resp1 = exchange.check_delisting_time("BTC/USDT:USDT")
assert resp1 is None
assert delist_fut_mock.call_count == 1
def test__check_delisting_futures_bitget(default_conf_usdt, mocker, markets):
markets["BTC/USDT:USDT"] = deepcopy(markets["SOL/BUSD:BUSD"])
markets["BTC/USDT:USDT"]["info"]["limitOpenTime"] = "-1"
markets["SOL/BUSD:BUSD"]["info"]["limitOpenTime"] = "-1"
markets["ADA/USDT:USDT"]["info"]["limitOpenTime"] = "1760745600000" # 2025-10-18
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bitget")
mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets))
resp_sol = exchange._check_delisting_futures("SOL/BUSD:BUSD")
# No delisting date
assert resp_sol is None
# Has a delisting date
resp_ada = exchange._check_delisting_futures("ADA/USDT:USDT")
assert resp_ada == dt_utc(2025, 10, 18)
+44 -3
View File
@@ -1,10 +1,11 @@
from copy import deepcopy
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
from unittest.mock import MagicMock, PropertyMock
import pytest
from freqtrade.enums.marginmode import MarginMode
from freqtrade.enums.tradingmode import TradingMode
from freqtrade.enums import MarginMode, RunMode, TradingMode
from freqtrade.util import dt_utc
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has
from tests.exchange.test_exchange import ccxt_exceptionhandlers
@@ -214,3 +215,43 @@ def test_bybit__order_needs_price(
exchange.unified_account = uta
assert exchange._order_needs_price(side, order_type) == expected
def test_check_delisting_time_bybit(default_conf_usdt, mocker):
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bybit")
exchange._config["runmode"] = RunMode.BACKTEST
delist_fut_mock = MagicMock(return_value=None)
mocker.patch.object(exchange, "_check_delisting_futures", delist_fut_mock)
# Invalid run mode
resp = exchange.check_delisting_time("BTC/USDT:USDT")
assert resp is None
assert delist_fut_mock.call_count == 0
# Delist spot called
exchange._config["runmode"] = RunMode.DRY_RUN
resp1 = exchange.check_delisting_time("BTC/USDT")
assert resp1 is None
assert delist_fut_mock.call_count == 0
# Delist futures called
exchange.trading_mode = TradingMode.FUTURES
resp1 = exchange.check_delisting_time("BTC/USDT:USDT")
assert resp1 is None
assert delist_fut_mock.call_count == 1
def test__check_delisting_futures_bybit(default_conf_usdt, mocker, markets):
markets["BTC/USDT:USDT"] = deepcopy(markets["SOL/BUSD:BUSD"])
markets["BTC/USDT:USDT"]["info"]["deliveryTime"] = "0"
markets["SOL/BUSD:BUSD"]["info"]["deliveryTime"] = "0"
markets["ADA/USDT:USDT"]["info"]["deliveryTime"] = "1760745600000" # 2025-10-18
exchange = get_patched_exchange(mocker, default_conf_usdt, exchange="bybit")
mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=markets))
resp_sol = exchange._check_delisting_futures("SOL/BUSD:BUSD")
# SOL has no delisting date
assert resp_sol is None
# Actually has a delisting date
resp_ada = exchange._check_delisting_futures("ADA/USDT:USDT")
assert resp_ada == dt_utc(2025, 10, 18)
+120 -10
View File
@@ -742,10 +742,11 @@ def test_get_pair_base_currency(default_conf, mocker, pair, expected):
def test_validate_timeframes(default_conf, mocker, timeframe):
default_conf["timeframe"] = timeframe
api_mock = MagicMock()
id_mock = PropertyMock(return_value="test_exchange")
type(api_mock).id = id_mock
timeframes = PropertyMock(return_value={"1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"})
type(api_mock).timeframes = timeframes
id_mock = MagicMock(return_value="test_exchange")
api_mock.id = id_mock
api_mock.options = {}
timeframes = {"1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"}
api_mock.timeframes = timeframes
mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock))
mocker.patch(f"{EXMS}.reload_markets")
@@ -757,12 +758,11 @@ def test_validate_timeframes(default_conf, mocker, timeframe):
def test_validate_timeframes_failed(default_conf, mocker):
default_conf["timeframe"] = "3m"
api_mock = MagicMock()
id_mock = PropertyMock(return_value="test_exchange")
type(api_mock).id = id_mock
timeframes = PropertyMock(
return_value={"15s": "15s", "1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"}
)
type(api_mock).timeframes = timeframes
id_mock = MagicMock(return_value="test_exchange")
api_mock.id = id_mock
timeframes = {"15s": "15s", "1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"}
api_mock.timeframes = timeframes
api_mock.options = {}
mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock))
mocker.patch(f"{EXMS}.reload_markets")
@@ -1110,6 +1110,116 @@ def test_create_dry_run_order_fees(
assert order1["fee"]["rate"] == fee
@pytest.mark.parametrize(
"side,limit,offset,expected",
[
("buy", 46.0, 0.0, True),
("buy", 26.0, 0.0, True),
("buy", 25.55, 0.0, False),
("buy", 1, 0.0, False), # Very far away
("sell", 25.5, 0.0, True),
("sell", 50, 0.0, False), # Very far away
("sell", 25.58, 0.0, False),
("sell", 25.563, 0.01, False),
("sell", 5.563, 0.01, True),
],
)
def test__dry_is_price_crossed_with_orderbook(
default_conf, mocker, order_book_l2_usd, side, limit, offset, expected
):
# Best bid 25.563
# Best ask 25.566
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
exchange.fetch_l2_order_book = order_book_l2_usd
orderbook = order_book_l2_usd.return_value
result = exchange._dry_is_price_crossed(
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset
)
assert result is expected
assert order_book_l2_usd.call_count == 0
# Test without passing orderbook
order_book_l2_usd.reset_mock()
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset)
assert result is expected
def test__dry_is_price_crossed_empty_orderbook(default_conf, mocker):
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
empty_book = {"asks": [], "bids": []}
assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 100.0, orderbook=empty_book)
def test__dry_is_price_crossed_fetches_orderbook(default_conf, mocker, order_book_l2_usd):
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
exchange.fetch_l2_order_book = order_book_l2_usd
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 26.0)
assert order_book_l2_usd.call_count == 1
def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
exchange = get_patched_exchange(mocker, default_conf)
exchange.fetch_l2_order_book = MagicMock()
mocker.patch(f"{EXMS}.exchange_has", return_value=False)
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0)
assert exchange.fetch_l2_order_book.call_count == 0
@pytest.mark.parametrize(
"crossed,immediate,side,amount,expected_status,expected_fee_rate,expected_calls,taker_or_maker",
[
(True, True, "buy", 2.0, "closed", 0.005, 1, "taker"),
(True, False, "sell", 1.5, "closed", 0.005, 1, "maker"),
(False, False, "sell", 1.0, "open", None, 0, None),
],
)
def test_check_dry_limit_order_filled_parametrized(
default_conf,
mocker,
crossed,
immediate,
side,
amount,
expected_status,
expected_fee_rate,
expected_calls,
taker_or_maker,
):
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=crossed)
fee_mock = mocker.patch(f"{EXMS}.get_fee", return_value=0.005)
order = {
"symbol": "LTC/USDT",
"status": "open",
"type": "limit",
"side": side,
"price": 25.0,
"amount": amount,
"filled": 0.0,
"remaining": amount,
"cost": 25.0 * amount,
"fee": None,
}
result = exchange.check_dry_limit_order_filled(order, immediate=immediate)
assert result["status"] == expected_status
if crossed:
assert result["filled"] == amount
assert result["remaining"] == 0.0
assert result["fee"]["rate"] == expected_fee_rate
fee_mock.assert_called_once_with("LTC/USDT", taker_or_maker=taker_or_maker)
else:
assert result["filled"] == 0.0
assert result["remaining"] == amount
assert result["fee"] is None
assert fee_mock.call_count == expected_calls
@pytest.mark.parametrize(
"side,price,filled,converted",
[
+12 -4
View File
@@ -11,7 +11,7 @@ import pytest
from freqtrade.enums import CandleType
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
from freqtrade.exchange.exchange import timeframe_to_msecs
from freqtrade.exchange.exchange import Exchange, timeframe_to_msecs
from freqtrade.util import dt_floor_day, dt_now, dt_ts
from tests.exchange_online.conftest import EXCHANGE_FIXTURE_TYPE, EXCHANGES
@@ -422,15 +422,23 @@ class TestCCXTExchange:
trades_orig = nvspy.call_args_list[2][0][0]
assert len(trades_orig[-1].get("info")) > len(trades_orig[-2].get("info"))
def test_ccxt_get_fee(self, exchange: EXCHANGE_FIXTURE_TYPE):
exch, exchangename = exchange
pair = EXCHANGES[exchangename]["pair"]
def _ccxt_get_fee(self, exch: Exchange, pair: str):
threshold = 0.01
assert 0 < exch.get_fee(pair, "limit", "buy") < threshold
assert 0 < exch.get_fee(pair, "limit", "sell") < threshold
assert 0 < exch.get_fee(pair, "market", "buy") < threshold
assert 0 < exch.get_fee(pair, "market", "sell") < threshold
def test_ccxt_get_fee_spot(self, exchange: EXCHANGE_FIXTURE_TYPE):
exch, exchangename = exchange
pair = EXCHANGES[exchangename]["pair"]
self._ccxt_get_fee(exch, pair)
def test_ccxt_get_fee_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE):
exch, exchangename = exchange_futures
pair = EXCHANGES[exchangename].get("futures_pair", EXCHANGES[exchangename]["pair"])
self._ccxt_get_fee(exch, pair)
def test_ccxt_get_max_leverage_spot(self, exchange: EXCHANGE_FIXTURE_TYPE):
spot, spot_name = exchange
if spot:
+1
View File
@@ -4525,6 +4525,7 @@ def test_check_for_open_trades(mocker, default_conf_usdt, fee, is_short):
def test_startup_update_open_orders(mocker, default_conf_usdt, fee, caplog, is_short):
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
create_mock_trades(fee, is_short=is_short)
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
freqtrade.startup_update_open_orders()
assert not log_has_re(r"Error updating Order .*", caplog)
+1 -1
View File
@@ -2772,7 +2772,7 @@ def test_time_pair_generator_open_trades_first(mocker, default_conf, dynamic_pai
dummy_row = (end_date, 1.0, 1.1, 0.9, 1.0, 0, 0, 0, 0, None, None)
data = {pair: [dummy_row] for pair in pairs}
def mock_refresh(self):
def mock_refresh(self, **kwargs):
# Simulate shuffle
self._whitelist = pairs[::-1] # ['ETH/BTC', 'NEO/BTC', 'LTC/BTC', 'XRP/BTC']
+4
View File
@@ -1479,6 +1479,8 @@ def test_to_json(fee):
"contract_size": 1,
"orders": [],
"has_open_orders": False,
"nr_of_successful_entries": 0,
"nr_of_successful_exits": 0,
}
# Simulate dry_run entries
@@ -1570,6 +1572,8 @@ def test_to_json(fee):
"contract_size": 1,
"orders": [],
"has_open_orders": False,
"nr_of_successful_entries": 0,
"nr_of_successful_exits": 0,
}
+2 -1
View File
@@ -99,6 +99,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None:
"contract_size": 1,
"has_open_orders": False,
"nr_of_successful_entries": ANY,
"nr_of_successful_exits": ANY,
"orders": [
{
"amount": 91.07468123,
@@ -309,7 +310,7 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker, time_machine) -> No
)
assert "now" == result[0][2]
assert "ETH/BTC" in result[0][1]
assert "nan%" == result[0][3]
assert "N/A" == result[0][3]
assert isnan(fiat_profit_sum)
+4
View File
@@ -1605,6 +1605,8 @@ def test_api_status(
"precision_mode": None,
"orders": [ANY],
"has_open_orders": True,
"nr_of_successful_entries": ANY,
"nr_of_successful_exits": ANY,
}
mocker.patch(
@@ -1817,6 +1819,8 @@ def test_api_force_entry(botclient, mocker, fee, endpoint):
"price_precision": None,
"precision_mode": None,
"has_open_orders": False,
"nr_of_successful_entries": ANY,
"nr_of_successful_exits": ANY,
"orders": [],
}
+2 -1
View File
@@ -421,7 +421,8 @@ async def test_telegram_status_multi_entry(default_conf, update, mocker, fee) ->
assert msg_mock.call_count == 4
msg = msg_mock.call_args_list[3][0][0]
assert re.search(r"Number of Entries.*2", msg)
assert re.search(r"Number of Exits.*1", msg)
# Exit order is still open, hence not a successful exit
assert re.search(r"Number of Exits.*0", msg)
assert re.search(r"Close Date:", msg) is None
assert re.search(r"Close Profit:", msg) is None
+22 -1
View File
@@ -1,6 +1,13 @@
from datetime import timedelta
from freqtrade.util import decimals_per_coin, fmt_coin, fmt_coin2, format_duration, round_value
from freqtrade.util import (
decimals_per_coin,
fmt_coin,
fmt_coin2,
format_duration,
format_pct,
round_value,
)
def test_decimals_per_coin():
@@ -25,6 +32,7 @@ def test_fmt_coin():
assert fmt_coin(0.1274512123, "BTC", False) == "0.12745121"
assert fmt_coin(0.1274512123, "ETH", False) == "0.12745"
assert fmt_coin(222.2, "USDT", False, True) == "222.200"
assert fmt_coin(float("nan"), "USDT", False, True) == "N/A"
def test_fmt_coin2():
@@ -35,6 +43,7 @@ def test_fmt_coin2():
assert fmt_coin2(0.1274512123, "BTC") == "0.12745121 BTC"
assert fmt_coin2(0.1274512123, "ETH") == "0.12745121 ETH"
assert fmt_coin2(0.00001245, "PEPE") == "0.00001245 PEPE"
assert fmt_coin2(float("nan"), "PEPE") == "N/A PEPE"
def test_round_value():
@@ -46,6 +55,8 @@ def test_round_value():
assert round_value(0.1274512123, 5) == "0.12745"
assert round_value(222.2, 3, True) == "222.200"
assert round_value(222.2, 0, True) == "222"
assert round_value(float("nan"), 0, True) == "N/A"
assert round_value(float("nan"), 10, True) == "N/A"
def test_format_duration():
@@ -55,3 +66,13 @@ def test_format_duration():
assert format_duration(timedelta(minutes=1445)) == "1d 00:05"
assert format_duration(timedelta(minutes=11445)) == "7d 22:45"
assert format_duration(timedelta(minutes=101445)) == "70d 10:45"
def test_format_pct():
assert format_pct(0.1234) == "12.34%"
assert format_pct(0.1) == "10.00%"
assert format_pct(0.0) == "0.00%"
assert format_pct(-0.0567) == "-5.67%"
assert format_pct(-1.5567) == "-155.67%"
assert format_pct(None) == "N/A"
assert format_pct(float("nan")) == "N/A"