Merge branch 'develop' into pr/dev-starlight/12506
This commit is contained in:
+24
-16
@@ -74,15 +74,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pytest --random-order --cov=freqtrade --cov=freqtrade_client --cov-config=.coveragerc
|
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')
|
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: |
|
run: |
|
||||||
# Allow failure for coveralls
|
# See https://github.com/codecov/codecov-action/issues/1851
|
||||||
uv pip install coveralls
|
rm -rf codecov codecov.SHA256SUM codecov.SHA256SUM.sig
|
||||||
coveralls || true
|
|
||||||
|
|
||||||
- name: Run json schema extract
|
- name: Run json schema extract
|
||||||
# This should be kept before the repository check to ensure that the schema is up-to-date
|
# 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 only once - when CI completes (and after deploy) in case it's successful
|
||||||
notify-complete:
|
notify-complete:
|
||||||
needs: [
|
needs: [
|
||||||
tests,
|
build,
|
||||||
docs-check,
|
|
||||||
mypy-version-check,
|
|
||||||
pre-commit,
|
|
||||||
build-linux-online
|
build-linux-online
|
||||||
]
|
]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@@ -304,11 +303,23 @@ jobs:
|
|||||||
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
if: always()
|
||||||
name: "Build"
|
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
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
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
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -403,10 +414,7 @@ jobs:
|
|||||||
docker-build:
|
docker-build:
|
||||||
name: "Docker Build and Deploy"
|
name: "Docker Build and Deploy"
|
||||||
needs: [
|
needs: [
|
||||||
tests,
|
build,
|
||||||
docs-check,
|
|
||||||
mypy-version-check,
|
|
||||||
pre-commit
|
|
||||||
]
|
]
|
||||||
if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade'
|
if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade'
|
||||||
uses: ./.github/workflows/docker-build.yml
|
uses: ./.github/workflows/docker-build.yml
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ permissions: {}
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
zizmor:
|
zizmor:
|
||||||
|
name: Run zizmor 🌈
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ repos:
|
|||||||
- types-filelock==3.2.7
|
- types-filelock==3.2.7
|
||||||
- types-requests==2.32.4.20250913
|
- types-requests==2.32.4.20250913
|
||||||
- types-tabulate==0.9.0.20241207
|
- 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
|
- scipy-stubs==1.16.3.0
|
||||||
- SQLAlchemy==2.0.44
|
- SQLAlchemy==2.0.44
|
||||||
# stages: [push]
|
# stages: [push]
|
||||||
@@ -44,7 +44,7 @@ repos:
|
|||||||
|
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: 'v0.14.4'
|
rev: 'v0.14.6'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import subprocess # noqa: S404, RUF100
|
import subprocess # noqa: S404, RUF100
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -62,4 +63,9 @@ def extract_command_partials():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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()
|
extract_command_partials()
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
usage: freqtrade list-timeframes [-h] [-v] [--no-color] [--logfile FILE] [-V]
|
usage: freqtrade list-timeframes [-h] [-v] [--no-color] [--logfile FILE] [-V]
|
||||||
[-c PATH] [-d PATH] [--userdir PATH]
|
[-c PATH] [-d PATH] [--userdir PATH]
|
||||||
[--exchange EXCHANGE] [-1]
|
[--exchange EXCHANGE] [-1]
|
||||||
|
[--trading-mode {spot,margin,futures}]
|
||||||
|
|
||||||
options:
|
options:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
--exchange EXCHANGE Exchange name. Only valid if no config is provided.
|
--exchange EXCHANGE Exchange name. Only valid if no config is provided.
|
||||||
-1, --one-column Print output in one column.
|
-1, --one-column Print output in one column.
|
||||||
|
--trading-mode, --tradingmode {spot,margin,futures}
|
||||||
|
Select Trading mode
|
||||||
|
|
||||||
Common arguments:
|
Common arguments:
|
||||||
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
|
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
|
||||||
|
|||||||
@@ -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:
|
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"
|
!!! 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"
|
!!! Warning "Backtesting"
|
||||||
`DelistFilter` does not support backtesting mode.
|
`DelistFilter` does not support backtesting mode.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
markdown==3.10
|
markdown==3.10
|
||||||
mkdocs==1.6.1
|
mkdocs==1.6.1
|
||||||
mkdocs-material==9.6.23
|
mkdocs-material==9.7.0
|
||||||
mdx_truly_sane_lists==1.3
|
mdx_truly_sane_lists==1.3
|
||||||
pymdown-extensions==10.16.1
|
pymdown-extensions==10.17.1
|
||||||
jinja2==3.1.6
|
jinja2==3.1.6
|
||||||
mike==2.1.3
|
mike==2.1.3
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ ARGS_BACKTEST_SHOW = [
|
|||||||
|
|
||||||
ARGS_LIST_EXCHANGES = ["print_one_column", "list_exchanges_all", "trading_mode", "dex_exchanges"]
|
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 = [
|
ARGS_LIST_PAIRS = [
|
||||||
"exchange",
|
"exchange",
|
||||||
|
|||||||
@@ -388,8 +388,10 @@ def refresh_backtest_ohlcv_data(
|
|||||||
for timeframe in timeframes:
|
for timeframe in timeframes:
|
||||||
# Get fast candles via parallel method on first loop through per timeframe
|
# 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.
|
# 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 (
|
if (
|
||||||
not no_parallel_download
|
not no_parallel_download
|
||||||
|
and (len(pairs) + len(timeframes)) > 2
|
||||||
and exchange.get_option("download_data_parallel_quick", True)
|
and exchange.get_option("download_data_parallel_quick", True)
|
||||||
and (
|
and (
|
||||||
((pair, timeframe, candle_type) not in fast_candles)
|
((pair, timeframe, candle_type) not in fast_candles)
|
||||||
@@ -474,7 +476,7 @@ def _download_all_pairs_history_parallel(
|
|||||||
:return: Candle pairs with timeframes
|
:return: Candle pairs with timeframes
|
||||||
"""
|
"""
|
||||||
candles: dict[PairWithTimeframe, DataFrame] = {}
|
candles: dict[PairWithTimeframe, DataFrame] = {}
|
||||||
since = 0
|
since: int | None = None
|
||||||
if timerange:
|
if timerange:
|
||||||
if timerange.starttype == "date":
|
if timerange.starttype == "date":
|
||||||
since = timerange.startts * 1000
|
since = timerange.startts * 1000
|
||||||
@@ -482,10 +484,12 @@ def _download_all_pairs_history_parallel(
|
|||||||
candle_limit = exchange.ohlcv_candle_limit(timeframe, candle_type)
|
candle_limit = exchange.ohlcv_candle_limit(timeframe, candle_type)
|
||||||
one_call_min_time_dt = dt_ts(date_minus_candles(timeframe, candle_limit))
|
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
|
# 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(
|
logger.info(
|
||||||
f"Downloading parallel candles for {timeframe} for all pairs "
|
f"Downloading parallel candles for {timeframe} for all pairs"
|
||||||
f"since {format_ms_time(since)}"
|
f" since {format_ms_time(since)}"
|
||||||
|
if since
|
||||||
|
else "."
|
||||||
)
|
)
|
||||||
needed_pairs: ListPairsWithTimeframes = [
|
needed_pairs: ListPairsWithTimeframes = [
|
||||||
(p, timeframe, candle_type) for p in [p for p in pairs]
|
(p, timeframe, candle_type) for p in [p for p in pairs]
|
||||||
|
|||||||
@@ -143,6 +143,20 @@ def _calc_drawdown_series(
|
|||||||
max_drawdown_df["drawdown_relative"] = (
|
max_drawdown_df["drawdown_relative"] = (
|
||||||
max_drawdown_df["high_value"] - max_drawdown_df["cumulative"]
|
max_drawdown_df["high_value"] - max_drawdown_df["cumulative"]
|
||||||
) / max_drawdown_df["high_value"]
|
) / 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
|
return max_drawdown_df
|
||||||
|
|
||||||
|
|
||||||
@@ -215,6 +229,7 @@ def calculate_max_drawdown(
|
|||||||
max_drawdown_df = _calc_drawdown_series(
|
max_drawdown_df = _calc_drawdown_series(
|
||||||
profit_results, date_col=date_col, value_col=value_col, starting_balance=starting_balance
|
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
|
# Calculate maximum drawdown
|
||||||
idxmin = (
|
idxmin = (
|
||||||
@@ -223,15 +238,15 @@ def calculate_max_drawdown(
|
|||||||
else max_drawdown_df["drawdown"].idxmin()
|
else max_drawdown_df["drawdown"].idxmin()
|
||||||
)
|
)
|
||||||
high_idx = max_drawdown_df.iloc[: idxmin + 1]["high_value"].idxmax()
|
high_idx = max_drawdown_df.iloc[: idxmin + 1]["high_value"].idxmax()
|
||||||
high_date = profit_results.loc[high_idx, date_col]
|
high_date = profit_results.at[max(high_idx - 1, 0), date_col]
|
||||||
low_date = profit_results.loc[idxmin, date_col]
|
low_date = profit_results.at[max(idxmin - 1, 0), date_col]
|
||||||
high_val = max_drawdown_df.loc[high_idx, "cumulative"]
|
high_val = max_drawdown_df.at[high_idx, "cumulative"]
|
||||||
low_val = max_drawdown_df.loc[idxmin, "cumulative"]
|
low_val = max_drawdown_df.at[idxmin, "cumulative"]
|
||||||
max_drawdown_rel = max_drawdown_df.loc[idxmin, "drawdown_relative"]
|
max_drawdown_rel = max_drawdown_df.at[idxmin, "drawdown_relative"]
|
||||||
|
|
||||||
# Calculate current drawdown
|
# Calculate current drawdown
|
||||||
current_high_idx = max_drawdown_df["high_value"].iloc[:-1].idxmax()
|
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_high_value = max_drawdown_df.iloc[-1]["high_value"]
|
||||||
current_cumulative = max_drawdown_df.iloc[-1]["cumulative"]
|
current_cumulative = max_drawdown_df.iloc[-1]["cumulative"]
|
||||||
current_drawdown_abs = current_high_value - current_cumulative
|
current_drawdown_abs = current_high_value - current_cumulative
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import ccxt
|
import ccxt
|
||||||
from cachetools import TTLCache
|
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
|
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_types import FtHas, Tickers
|
||||||
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
|
from freqtrade.exchange.exchange_utils_timeframe import timeframe_to_msecs
|
||||||
from freqtrade.misc import deep_merge_dicts, json_load
|
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
|
from freqtrade.util.datetime_helpers import dt_from_ts, dt_ts
|
||||||
|
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ class Binance(Exchange):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
super().__init__(*args, **kwargs)
|
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:
|
def get_proxy_coin(self) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import ccxt
|
import ccxt
|
||||||
|
|
||||||
from freqtrade.constants import BuySell
|
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 (
|
from freqtrade.exceptions import (
|
||||||
DDosProtection,
|
DDosProtection,
|
||||||
OperationalException,
|
OperationalException,
|
||||||
@@ -14,7 +14,7 @@ from freqtrade.exceptions import (
|
|||||||
from freqtrade.exchange import Exchange
|
from freqtrade.exchange import Exchange
|
||||||
from freqtrade.exchange.common import API_RETRY_COUNT, retrier
|
from freqtrade.exchange.common import API_RETRY_COUNT, retrier
|
||||||
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -37,6 +37,7 @@ class Bitget(Exchange):
|
|||||||
_ft_has_futures: FtHas = {
|
_ft_has_futures: FtHas = {
|
||||||
"mark_ohlcv_timeframe": "4h",
|
"mark_ohlcv_timeframe": "4h",
|
||||||
"funding_fee_candle_limit": 100,
|
"funding_fee_candle_limit": 100,
|
||||||
|
"has_delisting": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
||||||
@@ -236,3 +237,35 @@ class Bitget(Exchange):
|
|||||||
raise OperationalException(
|
raise OperationalException(
|
||||||
"Freqtrade currently only supports isolated futures for bitget"
|
"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
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ from datetime import datetime, timedelta
|
|||||||
import ccxt
|
import ccxt
|
||||||
|
|
||||||
from freqtrade.constants import BuySell
|
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.exceptions import DDosProtection, ExchangeError, OperationalException, TemporaryError
|
||||||
from freqtrade.exchange import Exchange
|
from freqtrade.exchange import Exchange
|
||||||
from freqtrade.exchange.common import retrier
|
from freqtrade.exchange.common import retrier
|
||||||
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
|
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas
|
||||||
from freqtrade.misc import deep_merge_dicts
|
from freqtrade.misc import deep_merge_dicts
|
||||||
|
from freqtrade.util import dt_from_ts, dt_ts
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -54,6 +55,7 @@ class Bybit(Exchange):
|
|||||||
"exchange_has_overrides": {
|
"exchange_has_overrides": {
|
||||||
"fetchOrder": True,
|
"fetchOrder": True,
|
||||||
},
|
},
|
||||||
|
"has_delisting": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
_supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [
|
_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"])
|
self.cache_leverage_tiers(tiers, self._config["stake_currency"])
|
||||||
return tiers
|
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
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from typing import Any, Literal, TypeGuard, TypeVar
|
|||||||
|
|
||||||
import ccxt
|
import ccxt
|
||||||
import ccxt.pro as ccxt_pro
|
import ccxt.pro as ccxt_pro
|
||||||
from cachetools import TTLCache
|
|
||||||
from ccxt import TICK_SIZE
|
from ccxt import TICK_SIZE
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
from pandas import DataFrame, concat
|
from pandas import DataFrame, concat
|
||||||
@@ -107,9 +106,8 @@ from freqtrade.misc import (
|
|||||||
file_load_json,
|
file_load_json,
|
||||||
safe_value_fallback2,
|
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.datetime_helpers import dt_humanize_delta, dt_ts, format_ms_time
|
||||||
from freqtrade.util.periodic_cache import PeriodicCache
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -230,13 +228,13 @@ class Exchange:
|
|||||||
|
|
||||||
self._cache_lock = Lock()
|
self._cache_lock = Lock()
|
||||||
# Cache for 10 minutes ...
|
# 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
|
# 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
|
# Caching only applies to RPC methods, so prices for open trades are still
|
||||||
# refreshed once every iteration.
|
# refreshed once every iteration.
|
||||||
# Shouldn't be too high either, as it'll freeze UI updates in case of open orders.
|
# 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._exit_rate_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
|
||||||
self._entry_rate_cache: TTLCache = TTLCache(maxsize=100, ttl=300)
|
self._entry_rate_cache: FtTTLCache = FtTTLCache(maxsize=100, ttl=300)
|
||||||
|
|
||||||
# Holds candles
|
# Holds candles
|
||||||
self._klines: dict[PairWithTimeframe, DataFrame] = {}
|
self._klines: dict[PairWithTimeframe, DataFrame] = {}
|
||||||
@@ -430,7 +428,15 @@ class Exchange:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def timeframes(self) -> list[str]:
|
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
|
@property
|
||||||
def markets(self) -> dict[str, Any]:
|
def markets(self) -> dict[str, Any]:
|
||||||
@@ -1295,7 +1301,7 @@ class Exchange:
|
|||||||
|
|
||||||
return order
|
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
|
Return dry-run order
|
||||||
Only call if running in dry-run mode.
|
Only call if running in dry-run mode.
|
||||||
@@ -1307,11 +1313,12 @@ class Exchange:
|
|||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
from freqtrade.persistence import Order
|
from freqtrade.persistence import Order
|
||||||
|
|
||||||
order = Order.order_by_id(order_id)
|
order_obj = Order.order_by_id(order_id)
|
||||||
if order:
|
if order_obj:
|
||||||
ccxt_order = order.to_ccxt_object(self._ft_has["stop_price_prop"])
|
order = order_obj.to_ccxt_object(self._ft_has["stop_price_prop"])
|
||||||
self._dry_run_open_orders[order_id] = ccxt_order
|
order = self.check_dry_limit_order_filled(order)
|
||||||
return ccxt_order
|
self._dry_run_open_orders[order_id] = order
|
||||||
|
return order
|
||||||
# Gracefully handle errors with dry-run orders.
|
# Gracefully handle errors with dry-run orders.
|
||||||
raise InvalidOrderException(
|
raise InvalidOrderException(
|
||||||
f"Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}"
|
f"Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}"
|
||||||
@@ -2155,7 +2162,9 @@ class Exchange:
|
|||||||
name = side.capitalize()
|
name = side.capitalize()
|
||||||
strat_name = "entry_pricing" if side == "entry" else "exit_pricing"
|
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:
|
if not refresh:
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
rate = cache_rate.get(pair)
|
rate = cache_rate.get(pair)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ from freqtrade.rpc.rpc_types import (
|
|||||||
from freqtrade.strategy.interface import IStrategy
|
from freqtrade.strategy.interface import IStrategy
|
||||||
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||||
from freqtrade.util import FtPrecise, MeasureTime, PeriodicCache, dt_from_ts, dt_now
|
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
|
from freqtrade.wallets import Wallets
|
||||||
|
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
Called on startup and after reloading the bot - triggers notifications and
|
Called on startup and after reloading the bot - triggers notifications and
|
||||||
performs startup tasks
|
performs startup tasks
|
||||||
"""
|
"""
|
||||||
migrate_binance_futures_names(self.config)
|
migrate_live_content(self.config, self.exchange)
|
||||||
set_startup_time()
|
set_startup_time()
|
||||||
|
|
||||||
self.rpc.startup_messages(self.config, self.pairlists, self.protections)
|
self.rpc.startup_messages(self.config, self.pairlists, self.protections)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from cachetools import cached
|
||||||
|
|
||||||
|
from freqtrade.util import FtTTLCache
|
||||||
|
|
||||||
|
|
||||||
class LoggingMixin:
|
class LoggingMixin:
|
||||||
@@ -18,7 +20,7 @@ class LoggingMixin:
|
|||||||
"""
|
"""
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.refresh_period = refresh_period
|
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:
|
def log_once(self, message: str, logmethod: Callable, force_show: bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class RecursiveAnalysis(BaseAnalysis):
|
|||||||
self.dict_recursive: dict[str, Any] = dict()
|
self.dict_recursive: dict[str, Any] = dict()
|
||||||
|
|
||||||
self.pair_to_used: str | None = None
|
self.pair_to_used: str | None = None
|
||||||
|
self._strat_scc: int | None = None
|
||||||
|
|
||||||
# For recursive bias check
|
# For recursive bias check
|
||||||
# analyzes two data frames with processed indicators and shows differences between them.
|
# 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])
|
backtesting._set_strategy(backtesting.strategylist[0])
|
||||||
|
|
||||||
strat = backtesting.strategy
|
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:
|
if self._strat_scc < 1:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class Backtesting:
|
|||||||
|
|
||||||
self.config["dry_run"] = True
|
self.config["dry_run"] = True
|
||||||
self.price_pair_prec: dict[str, Series] = {}
|
self.price_pair_prec: dict[str, Series] = {}
|
||||||
|
self.available_pairs: list[str] = []
|
||||||
self.run_ids: dict[str, str] = {}
|
self.run_ids: dict[str, str] = {}
|
||||||
self.strategylist: list[IStrategy] = []
|
self.strategylist: list[IStrategy] = []
|
||||||
self.all_bt_content: dict[str, BacktestContentType] = {}
|
self.all_bt_content: dict[str, BacktestContentType] = {}
|
||||||
@@ -176,7 +177,8 @@ class Backtesting:
|
|||||||
self._validate_pairlists_for_backtesting()
|
self._validate_pairlists_for_backtesting()
|
||||||
|
|
||||||
self.dataprovider.add_pairlisthandler(self.pairlists)
|
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:
|
if len(self.pairlists.whitelist) == 0:
|
||||||
raise OperationalException("No pair in whitelist.")
|
raise OperationalException("No pair in whitelist.")
|
||||||
@@ -211,7 +213,6 @@ class Backtesting:
|
|||||||
self._can_short = self.trading_mode != TradingMode.SPOT
|
self._can_short = self.trading_mode != TradingMode.SPOT
|
||||||
self._position_stacking: bool = self.config.get("position_stacking", False)
|
self._position_stacking: bool = self.config.get("position_stacking", False)
|
||||||
self.enable_protections: bool = self.config.get("enable_protections", 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)
|
migrate_data(config, self.exchange)
|
||||||
|
|
||||||
self.init_backtest()
|
self.init_backtest()
|
||||||
@@ -335,10 +336,12 @@ class Backtesting:
|
|||||||
self.progress.set_new_value(1)
|
self.progress.set_new_value(1)
|
||||||
self._load_bt_data_detail()
|
self._load_bt_data_detail()
|
||||||
self.price_pair_prec = {}
|
self.price_pair_prec = {}
|
||||||
|
|
||||||
for pair in self.pairlists.whitelist:
|
for pair in self.pairlists.whitelist:
|
||||||
if pair in data:
|
if pair in data:
|
||||||
# Load price precision logic
|
# Load price precision logic
|
||||||
self.price_pair_prec[pair] = get_tick_size_over_time(data[pair])
|
self.price_pair_prec[pair] = get_tick_size_over_time(data[pair])
|
||||||
|
self.available_pairs.append(pair)
|
||||||
return data, self.timerange
|
return data, self.timerange
|
||||||
|
|
||||||
def _load_bt_data_detail(self) -> None:
|
def _load_bt_data_detail(self) -> None:
|
||||||
@@ -1587,7 +1590,7 @@ class Backtesting:
|
|||||||
self.check_abort()
|
self.check_abort()
|
||||||
|
|
||||||
if self.dynamic_pairlist and self.pairlists:
|
if self.dynamic_pairlist and self.pairlists:
|
||||||
self.pairlists.refresh_pairlist()
|
self.pairlists.refresh_pairlist(pairs=self.available_pairs)
|
||||||
pairs = self.pairlists.whitelist
|
pairs = self.pairlists.whitelist
|
||||||
|
|
||||||
# Reset open trade count for this candle
|
# Reset open trade count for this candle
|
||||||
|
|||||||
@@ -755,6 +755,8 @@ class LocalTrade:
|
|||||||
"precision_mode": self.precision_mode,
|
"precision_mode": self.precision_mode,
|
||||||
"precision_mode_price": self.precision_mode_price,
|
"precision_mode_price": self.precision_mode_price,
|
||||||
"contract_size": self.contract_size,
|
"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,
|
"has_open_orders": self.has_open_orders,
|
||||||
"orders": orders_json,
|
"orders": orders_json,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ Provides dynamic pair list based on Market Cap
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from cachetools import TTLCache
|
|
||||||
|
|
||||||
from freqtrade.exceptions import OperationalException
|
from freqtrade.exceptions import OperationalException
|
||||||
from freqtrade.exchange.exchange_types import Tickers
|
from freqtrade.exchange.exchange_types import Tickers
|
||||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||||
|
from freqtrade.util import FtTTLCache
|
||||||
from freqtrade.util.coin_gecko import FtCoinGeckoApi
|
from freqtrade.util.coin_gecko import FtCoinGeckoApi
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ class MarketCapPairList(IPairList):
|
|||||||
self._max_rank = self._pairlistconfig.get("max_rank", 30)
|
self._max_rank = self._pairlistconfig.get("max_rank", 30)
|
||||||
self._refresh_period = self._pairlistconfig.get("refresh_period", 86400)
|
self._refresh_period = self._pairlistconfig.get("refresh_period", 86400)
|
||||||
self._categories = self._pairlistconfig.get("categories", [])
|
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", {})
|
_coingecko_config = self._config.get("coingecko", {})
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import logging
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from cachetools import TTLCache
|
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe
|
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 import timeframe_to_minutes, timeframe_to_prev_date
|
||||||
from freqtrade.exchange.exchange_types import Ticker, Tickers
|
from freqtrade.exchange.exchange_types import Ticker, Tickers
|
||||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -47,7 +46,7 @@ class PercentChangePairList(IPairList):
|
|||||||
self._min_value = self._pairlistconfig.get("min_value", None)
|
self._min_value = self._pairlistconfig.get("min_value", None)
|
||||||
self._max_value = self._pairlistconfig.get("max_value", None)
|
self._max_value = self._pairlistconfig.get("max_value", None)
|
||||||
self._refresh_period = self._pairlistconfig.get("refresh_period", 1800)
|
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_days = self._pairlistconfig.get("lookback_days", 0)
|
||||||
self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d")
|
self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d")
|
||||||
self._lookback_period = self._pairlistconfig.get("lookback_period", 0)
|
self._lookback_period = self._pairlistconfig.get("lookback_period", 0)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Any
|
|||||||
|
|
||||||
import rapidjson
|
import rapidjson
|
||||||
import requests
|
import requests
|
||||||
from cachetools import TTLCache
|
|
||||||
|
|
||||||
from freqtrade import __version__
|
from freqtrade import __version__
|
||||||
from freqtrade.configuration.load_config import CONFIG_PARSE_MODE
|
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.exchange.exchange_types import Tickers
|
||||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
||||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||||
|
from freqtrade.util import FtTTLCache
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -48,7 +48,7 @@ class RemotePairList(IPairList):
|
|||||||
self._number_pairs = self._pairlistconfig["number_assets"]
|
self._number_pairs = self._pairlistconfig["number_assets"]
|
||||||
self._refresh_period: int = self._pairlistconfig.get("refresh_period", 1800)
|
self._refresh_period: int = self._pairlistconfig.get("refresh_period", 1800)
|
||||||
self._keep_pairlist_on_failure = self._pairlistconfig.get("keep_pairlist_on_failure", True)
|
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._pairlist_url = self._pairlistconfig.get("pairlist_url", "")
|
||||||
self._read_timeout = self._pairlistconfig.get("read_timeout", 60)
|
self._read_timeout = self._pairlistconfig.get("read_timeout", 60)
|
||||||
self._bearer_token = self._pairlistconfig.get("bearer_token", "")
|
self._bearer_token = self._pairlistconfig.get("bearer_token", "")
|
||||||
@@ -159,7 +159,7 @@ class RemotePairList(IPairList):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._refresh_period = remote_refresh_period
|
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
|
self._init_done = True
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import sys
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from cachetools import TTLCache
|
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from freqtrade.constants import ListPairsWithTimeframes
|
from freqtrade.constants import ListPairsWithTimeframes
|
||||||
@@ -15,7 +14,7 @@ from freqtrade.exceptions import OperationalException
|
|||||||
from freqtrade.exchange.exchange_types import Tickers
|
from freqtrade.exchange.exchange_types import Tickers
|
||||||
from freqtrade.misc import plural
|
from freqtrade.misc import plural
|
||||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,7 +37,7 @@ class VolatilityFilter(IPairList):
|
|||||||
self._def_candletype = self._config["candle_type_def"]
|
self._def_candletype = self._config["candle_type_def"]
|
||||||
self._sort_direction: str | None = self._pairlistconfig.get("sort_direction", None)
|
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)
|
candle_limit = self._exchange.ohlcv_candle_limit("1d", self._def_candletype)
|
||||||
if self._days < 1:
|
if self._days < 1:
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import logging
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from cachetools import TTLCache
|
|
||||||
|
|
||||||
from freqtrade.constants import ListPairsWithTimeframes
|
from freqtrade.constants import ListPairsWithTimeframes
|
||||||
from freqtrade.exceptions import OperationalException
|
from freqtrade.exceptions import OperationalException
|
||||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
|
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
|
||||||
from freqtrade.exchange.exchange_types import Tickers
|
from freqtrade.exchange.exchange_types import Tickers
|
||||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -43,7 +41,7 @@ class VolumePairList(IPairList):
|
|||||||
self._min_value = self._pairlistconfig.get("min_value", 0)
|
self._min_value = self._pairlistconfig.get("min_value", 0)
|
||||||
self._max_value = self._pairlistconfig.get("max_value", None)
|
self._max_value = self._pairlistconfig.get("max_value", None)
|
||||||
self._refresh_period = self._pairlistconfig.get("refresh_period", 1800)
|
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_days = self._pairlistconfig.get("lookback_days", 0)
|
||||||
self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d")
|
self._lookback_timeframe = self._pairlistconfig.get("lookback_timeframe", "1d")
|
||||||
self._lookback_period = self._pairlistconfig.get("lookback_period", 0)
|
self._lookback_period = self._pairlistconfig.get("lookback_period", 0)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Rate of change pairlist filter
|
|||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from cachetools import TTLCache
|
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from freqtrade.constants import ListPairsWithTimeframes
|
from freqtrade.constants import ListPairsWithTimeframes
|
||||||
@@ -13,7 +12,7 @@ from freqtrade.exceptions import OperationalException
|
|||||||
from freqtrade.exchange.exchange_types import Tickers
|
from freqtrade.exchange.exchange_types import Tickers
|
||||||
from freqtrade.misc import plural
|
from freqtrade.misc import plural
|
||||||
from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter, SupportsBacktesting
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -32,7 +31,7 @@ class RangeStabilityFilter(IPairList):
|
|||||||
self._def_candletype = self._config["candle_type_def"]
|
self._def_candletype = self._config["candle_type_def"]
|
||||||
self._sort_direction: str | None = self._pairlistconfig.get("sort_direction", None)
|
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)
|
candle_limit = self._exchange.ohlcv_candle_limit("1d", self._def_candletype)
|
||||||
if self._days < 1:
|
if self._days < 1:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ PairList manager class
|
|||||||
import logging
|
import logging
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from cachetools import LRUCache, TTLCache, cached
|
from cachetools import LRUCache, cached
|
||||||
|
|
||||||
from freqtrade.constants import Config, ListPairsWithTimeframes
|
from freqtrade.constants import Config, ListPairsWithTimeframes
|
||||||
from freqtrade.data.dataprovider import DataProvider
|
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.IPairList import IPairList, SupportsBacktesting
|
||||||
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
|
||||||
from freqtrade.resolvers import PairListResolver
|
from freqtrade.resolvers import PairListResolver
|
||||||
|
from freqtrade.util import FtTTLCache
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -129,12 +130,24 @@ class PairListManager(LoggingMixin):
|
|||||||
"""List of short_desc for each Pairlist Handler"""
|
"""List of short_desc for each Pairlist Handler"""
|
||||||
return [{p.name: p.short_desc()} for p in self._pairlist_handlers]
|
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:
|
def _get_cached_tickers(self) -> Tickers:
|
||||||
return self._exchange.get_tickers()
|
return self._exchange.get_tickers()
|
||||||
|
|
||||||
def refresh_pairlist(self) -> None:
|
def refresh_pairlist(self, only_first: bool = False, pairs: list[str] | None = None) -> None:
|
||||||
"""Run pairlist through all configured Pairlist Handlers."""
|
"""
|
||||||
|
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 should be cached to avoid calling the exchange on each call.
|
||||||
tickers: dict = {}
|
tickers: dict = {}
|
||||||
if self._tickers_needed:
|
if self._tickers_needed:
|
||||||
@@ -143,10 +156,15 @@ class PairListManager(LoggingMixin):
|
|||||||
# Generate the pairlist with first Pairlist Handler in the chain
|
# Generate the pairlist with first Pairlist Handler in the chain
|
||||||
pairlist = self._pairlist_handlers[0].gen_pairlist(tickers)
|
pairlist = self._pairlist_handlers[0].gen_pairlist(tickers)
|
||||||
|
|
||||||
# Process all Pairlist Handlers in the chain
|
# Optional intersection with an explicit list of pairs (used in backtesting)
|
||||||
# except for the first one, which is the generator.
|
if pairs is not None:
|
||||||
for pairlist_handler in self._pairlist_handlers[1:]:
|
pairlist = [p for p in pairlist if p in pairs]
|
||||||
pairlist = pairlist_handler.filter_pairlist(pairlist, tickers)
|
|
||||||
|
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
|
# Validation against blacklist happens after the chain of Pairlist Handlers
|
||||||
# to ensure blacklist is respected.
|
# to ensure blacklist is respected.
|
||||||
|
|||||||
@@ -340,6 +340,8 @@ class TradeSchema(BaseModel):
|
|||||||
|
|
||||||
min_rate: float | None = None
|
min_rate: float | None = None
|
||||||
max_rate: float | None = None
|
max_rate: float | None = None
|
||||||
|
nr_of_successful_entries: int
|
||||||
|
nr_of_successful_exits: int
|
||||||
has_open_orders: bool
|
has_open_orders: bool
|
||||||
orders: list[OrderSchema]
|
orders: list[OrderSchema]
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class ApiBG:
|
|||||||
|
|
||||||
# Generic background jobs
|
# Generic background jobs
|
||||||
|
|
||||||
# TODO: Change this to TTLCache
|
# TODO: Change this to FtTTLCache
|
||||||
jobs: dict[str, JobsContainer] = {}
|
jobs: dict[str, JobsContainer] = {}
|
||||||
# Pairlist evaluate things
|
# Pairlist evaluate things
|
||||||
pairlist_running: bool = False
|
pairlist_running: bool = False
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from cachetools import TTLCache
|
|
||||||
from requests.exceptions import RequestException
|
from requests.exceptions import RequestException
|
||||||
|
|
||||||
from freqtrade.constants import SUPPORTED_FIAT, Config
|
from freqtrade.constants import SUPPORTED_FIAT, Config
|
||||||
from freqtrade.mixins.logging_mixin import LoggingMixin
|
from freqtrade.mixins.logging_mixin import LoggingMixin
|
||||||
|
from freqtrade.util import FtTTLCache
|
||||||
from freqtrade.util.coin_gecko import FtCoinGeckoApi
|
from freqtrade.util.coin_gecko import FtCoinGeckoApi
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ class CryptoToFiatConverter(LoggingMixin):
|
|||||||
|
|
||||||
def __init__(self, config: Config) -> None:
|
def __init__(self, config: Config) -> None:
|
||||||
# Timeout: 6h
|
# 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", {})
|
_coingecko_config = config.get("coingecko", {})
|
||||||
self._coingecko = FtCoinGeckoApi(
|
self._coingecko = FtCoinGeckoApi(
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from freqtrade.util import (
|
|||||||
dt_ts,
|
dt_ts,
|
||||||
dt_ts_def,
|
dt_ts_def,
|
||||||
format_date,
|
format_date,
|
||||||
|
format_pct,
|
||||||
shorten_date,
|
shorten_date,
|
||||||
)
|
)
|
||||||
from freqtrade.wallets import PositionWallet, Wallet
|
from freqtrade.wallets import PositionWallet, Wallet
|
||||||
@@ -302,7 +303,7 @@ class RPC:
|
|||||||
fiat_total_profit_sum = nan
|
fiat_total_profit_sum = nan
|
||||||
for trade in self._rpc_trade_status():
|
for trade in self._rpc_trade_status():
|
||||||
# Format profit as a string with the right sign
|
# 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)
|
fiat_profit = trade.get("profit_fiat", None)
|
||||||
if fiat_profit is None or isnan(fiat_profit):
|
if fiat_profit is None or isnan(fiat_profit):
|
||||||
fiat_profit = trade.get("profit_abs", 0.0)
|
fiat_profit = trade.get("profit_abs", 0.0)
|
||||||
|
|||||||
+61
-60
@@ -48,6 +48,7 @@ from freqtrade.util import (
|
|||||||
fmt_coin,
|
fmt_coin,
|
||||||
fmt_coin2,
|
fmt_coin2,
|
||||||
format_date,
|
format_date,
|
||||||
|
format_pct,
|
||||||
round_value,
|
round_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -481,7 +482,7 @@ class Telegram(RPCHandler):
|
|||||||
if is_final_exit:
|
if is_final_exit:
|
||||||
profit_prefix = "Sub "
|
profit_prefix = "Sub "
|
||||||
cp_extra = (
|
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"
|
f"({msg['cumulative_profit']:.8f} {msg['quote_currency']}{cp_fiat})`\n"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -497,7 +498,7 @@ class Telegram(RPCHandler):
|
|||||||
f"{exit_wording} {msg['pair']} (#{msg['trade_id']})\n"
|
f"{exit_wording} {msg['pair']} (#{msg['trade_id']})\n"
|
||||||
f"{self._add_analyzed_candle(msg['pair'])}"
|
f"{self._add_analyzed_candle(msg['pair'])}"
|
||||||
f"*{f'{profit_prefix}Profit' if is_fill else f'Unrealized {profit_prefix}Profit'}:* "
|
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"{cp_extra}"
|
||||||
f"{enter_tag}"
|
f"{enter_tag}"
|
||||||
f"*Exit Reason:* `{msg['exit_reason']}`\n"
|
f"*Exit Reason:* `{msg['exit_reason']}`\n"
|
||||||
@@ -670,14 +671,14 @@ class Telegram(RPCHandler):
|
|||||||
# TODO: This calculation ignores fees.
|
# TODO: This calculation ignores fees.
|
||||||
price_to_1st_entry = (cur_entry_average - first_avg) / first_avg
|
price_to_1st_entry = (cur_entry_average - first_avg) / first_avg
|
||||||
if is_open:
|
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(
|
lines.append(
|
||||||
f"*Amount:* {round_value(cur_entry_amount, 8)} "
|
f"*Amount:* {round_value(cur_entry_amount, 8)} "
|
||||||
f"({fmt_coin(order['cost'], quote_currency)})"
|
f"({fmt_coin(order['cost'], quote_currency)})"
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
f"*Average {wording} Price:* {round_value(cur_entry_average, 8)} "
|
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']}")
|
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)
|
results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
|
||||||
for r in results:
|
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(
|
lines_detail = self._prepare_order_details(
|
||||||
r["orders"], r["quote_currency"], r["is_open"]
|
r["orders"], r["quote_currency"], r["is_open"]
|
||||||
@@ -720,10 +721,10 @@ class Telegram(RPCHandler):
|
|||||||
if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
|
if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
|
||||||
msg += line + "\n"
|
msg += line + "\n"
|
||||||
else:
|
else:
|
||||||
await self._send_msg(msg.format(**r))
|
await self._send_msg(msg)
|
||||||
msg = "*Order List for Trade #*`{trade_id}` - continued\n" + line + "\n"
|
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
|
@authorized_only
|
||||||
async def _status(self, update: Update, context: CallbackContext) -> None:
|
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)
|
max_entries = self._config.get("max_entry_position_adjustment", -1)
|
||||||
for r in results:
|
for r in results:
|
||||||
r["open_date_hum"] = dt_humanize_delta(r["open_date"])
|
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["stake_amount_r"] = fmt_coin(r["stake_amount"], r["quote_currency"])
|
||||||
r["max_stake_amount_r"] = fmt_coin(
|
r["max_stake_amount_r"] = fmt_coin(
|
||||||
r["max_stake_amount"] or r["stake_amount"], r["quote_currency"]
|
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["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"])
|
r["total_profit_abs_r"] = fmt_coin(r["total_profit_abs"], r["quote_currency"])
|
||||||
lines = [
|
lines = [
|
||||||
"*Trade ID:* `{trade_id}`" + (" `(since {open_date_hum})`" if r["is_open"] else ""),
|
f"*Trade ID:* `{r['trade_id']}`"
|
||||||
"*Current Pair:* {pair}",
|
+ (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`'}"
|
f"*Direction:* {'`Short`' if r.get('is_short') else '`Long`'}"
|
||||||
+ " ` ({leverage}x)`"
|
+ (f" ` ({r['leverage']}x)`" if r.get("leverage") else "")
|
||||||
if r.get("leverage")
|
|
||||||
else ""
|
|
||||||
),
|
),
|
||||||
"*Amount:* `{amount} ({stake_amount_r})`",
|
f"*Amount:* `{r['amount']} ({r['stake_amount_r']})`",
|
||||||
"*Total invested:* `{max_stake_amount_r}`" if position_adjust else "",
|
f"*Total invested:* `{r['max_stake_amount_r']}`" if position_adjust else "",
|
||||||
"*Enter Tag:* `{enter_tag}`" if r["enter_tag"] else "",
|
f"*Enter Tag:* `{r['enter_tag']}`" if r["enter_tag"] else "",
|
||||||
"*Exit Reason:* `{exit_reason}`" if r["exit_reason"] else "",
|
f"*Exit Reason:* `{r['exit_reason']}`" if r.get("exit_reason") else "",
|
||||||
]
|
]
|
||||||
|
|
||||||
if position_adjust:
|
if position_adjust:
|
||||||
max_buy_str = f"/{max_entries + 1}" if (max_entries > 0) else ""
|
max_buy_str = f"/{max_entries + 1}" if (max_entries > 0) else ""
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
"*Number of Entries:* `{num_entries}" + max_buy_str + "`",
|
f"*Number of Entries:* `{r['nr_of_successful_entries']}{max_buy_str}`",
|
||||||
"*Number of Exits:* `{num_exits}`",
|
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"*Open Rate:* `{round_value(r['open_rate'], 8)}`",
|
||||||
f"*Close Rate:* `{round_value(r['close_rate'], 8)}`" if r["close_rate"] else "",
|
f"*Close Rate:* `{round_value(r['close_rate'], 8)}`" if r["close_rate"] else "",
|
||||||
"*Open Date:* `{open_date}`",
|
f"*Open Date:* `{r['open_date']}`",
|
||||||
"*Close Date:* `{close_date}`" if r["close_date"] else "",
|
f"*Close Date:* `{r['close_date']}`" if r["close_date"] else "",
|
||||||
(
|
(
|
||||||
f" \n*Current Rate:* `{round_value(r['current_rate'], 8)}`"
|
f" \n*Current Rate:* `{round_value(r['current_rate'], 8)}`"
|
||||||
if r["is_open"]
|
if r["is_open"]
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
("*Unrealized Profit:* " if r["is_open"] else "*Close Profit: *")
|
("*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["is_open"]:
|
||||||
if r.get("realized_profit"):
|
if (
|
||||||
lines.extend(
|
r.get("realized_profit") is not None
|
||||||
[
|
and r.get("realized_profit_ratio") is not None
|
||||||
"*Realized Profit:* `{realized_profit_ratio:.2%} "
|
):
|
||||||
"({realized_profit_r})`",
|
lines.append(
|
||||||
"*Total Profit:* `{total_profit_ratio:.2%} ({total_profit_abs_r})`",
|
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
|
# Append empty line to improve readability
|
||||||
lines.append(" ")
|
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 (
|
if (
|
||||||
r["stop_loss_abs"] != r["initial_stop_loss_abs"]
|
r["stop_loss_abs"] != r["initial_stop_loss_abs"]
|
||||||
and r["initial_stop_loss_ratio"] is not None
|
and r["initial_stop_loss_ratio"] is not None
|
||||||
):
|
):
|
||||||
# Adding initial stoploss only if it is different from stoploss
|
# Adding initial stoploss only if it is different from stoploss
|
||||||
lines.append(
|
lines.append(
|
||||||
"*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
|
f"*Initial Stoploss:* `{r['initial_stop_loss_abs']:.8f}` "
|
||||||
"`({initial_stop_loss_ratio:.2%})`"
|
f"`({format_pct(r['initial_stop_loss_ratio'])})`"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Adding stoploss and stoploss percentage only if it is not None
|
# Adding stoploss and stoploss percentage only if it is not None
|
||||||
lines.append(
|
lines.append(
|
||||||
f"*Stoploss:* `{round_value(r['stop_loss_abs'], 8)}` "
|
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(
|
lines.append(
|
||||||
f"*Stoploss distance:* `{round_value(r['stoploss_current_dist'], 8)}` "
|
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(
|
lines.append(
|
||||||
"*Open Order:* `{open_orders}`"
|
f"*Open Order:* `{open_orders}`"
|
||||||
+ ("- `{exit_order_status}`" if r["exit_order_status"] else "")
|
+ (f"- `{r['exit_order_status']}`" if r["exit_order_status"] else "")
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.__send_status_msg(lines, r)
|
await self.__send_status_msg(lines, r)
|
||||||
@@ -863,10 +864,10 @@ class Telegram(RPCHandler):
|
|||||||
if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
|
if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH:
|
||||||
msg += line + "\n"
|
msg += line + "\n"
|
||||||
else:
|
else:
|
||||||
await self._send_msg(msg.format(**r))
|
await self._send_msg(msg)
|
||||||
msg = "*Trade ID:* `{trade_id}` - continued\n" + line + "\n"
|
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
|
@authorized_only
|
||||||
async def _status_table(self, update: Update, context: CallbackContext) -> None:
|
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"{period['date']:{val.dateformat}} ({period['trade_count']})",
|
||||||
f"{fmt_coin(period['abs_profit'], stats['stake_currency'])}",
|
f"{fmt_coin(period['abs_profit'], stats['stake_currency'])}",
|
||||||
f"{period['fiat_value']:.2f} {stats['fiat_display_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"]
|
for period in stats["data"]
|
||||||
],
|
],
|
||||||
@@ -1069,7 +1070,7 @@ class Telegram(RPCHandler):
|
|||||||
markdown_msg = (
|
markdown_msg = (
|
||||||
f"{closed_roi_label}\n"
|
f"{closed_roi_label}\n"
|
||||||
f"∙ `{fmt_coin(profit_closed_coin, stake_cur)} "
|
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"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
||||||
f"{fiat_closed_trades}"
|
f"{fiat_closed_trades}"
|
||||||
)
|
)
|
||||||
@@ -1082,7 +1083,7 @@ class Telegram(RPCHandler):
|
|||||||
markdown_msg += (
|
markdown_msg += (
|
||||||
f"{all_roi_label}\n"
|
f"{all_roi_label}\n"
|
||||||
f"∙ `{fmt_coin(profit_all_coin, stake_cur)} "
|
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"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n"
|
||||||
f"{fiat_all_trades}"
|
f"{fiat_all_trades}"
|
||||||
f"*Total Trade Count:* `{trade_count}`\n"
|
f"*Total Trade Count:* `{trade_count}`\n"
|
||||||
@@ -1091,7 +1092,7 @@ class Telegram(RPCHandler):
|
|||||||
f"`{first_trade_date}`\n"
|
f"`{first_trade_date}`\n"
|
||||||
f"*Latest Trade opened:* `{latest_trade_date}`\n"
|
f"*Latest Trade opened:* `{latest_trade_date}`\n"
|
||||||
f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`\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})`"
|
f"*Expectancy (Ratio):* `{expectancy:.2f} ({expectancy_ratio:.2f})`"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1099,16 +1100,16 @@ class Telegram(RPCHandler):
|
|||||||
markdown_msg += (
|
markdown_msg += (
|
||||||
f"\n*Avg. Duration:* `{avg_duration}`\n"
|
f"\n*Avg. Duration:* `{avg_duration}`\n"
|
||||||
f"*Best Performing:* `{best_pair}: {best_pair_profit_abs} "
|
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"*Trading volume:* `{fmt_coin(stats['trading_volume'], stake_cur)}`\n"
|
||||||
f"*Profit factor:* `{stats['profit_factor']:.2f}`\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"({fmt_coin(stats['max_drawdown_abs'], stake_cur)})`\n"
|
||||||
f" from `{stats['max_drawdown_start']} "
|
f" from `{stats['max_drawdown_start']} "
|
||||||
f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n"
|
f"({fmt_coin(stats['drawdown_high'], stake_cur)})`\n"
|
||||||
f" to `{stats['max_drawdown_end']} "
|
f" to `{stats['max_drawdown_end']} "
|
||||||
f"({fmt_coin(stats['drawdown_low'], stake_cur)})`\n"
|
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"({fmt_coin(stats['current_drawdown_abs'], stake_cur)})`\n"
|
||||||
f" from `{stats['current_drawdown_start']} "
|
f" from `{stats['current_drawdown_start']} "
|
||||||
f"({fmt_coin(stats['current_drawdown_high'], stake_cur)})`\n"
|
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"])),
|
dt_humanize_delta(dt_from_ts(trade["close_timestamp"])),
|
||||||
f"{trade['pair']} (#{trade['trade_id']}"
|
f"{trade['pair']} (#{trade['trade_id']}"
|
||||||
f"{(' ' + ('S' if trade['is_short'] else 'L')) if nonspot else ''})",
|
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"]
|
for trade in trades["trades"]
|
||||||
],
|
],
|
||||||
@@ -1625,7 +1626,7 @@ class Telegram(RPCHandler):
|
|||||||
stat_line = (
|
stat_line = (
|
||||||
f"{i + 1}.\t <code>{trade['pair']}\t"
|
f"{i + 1}.\t <code>{trade['pair']}\t"
|
||||||
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
|
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"
|
f"({trade['count']})</code>\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1662,7 +1663,7 @@ class Telegram(RPCHandler):
|
|||||||
stat_line = (
|
stat_line = (
|
||||||
f"{i + 1}.\t `{trade['enter_tag']}\t"
|
f"{i + 1}.\t `{trade['enter_tag']}\t"
|
||||||
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
|
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"
|
f"({trade['count']})`\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1699,7 +1700,7 @@ class Telegram(RPCHandler):
|
|||||||
stat_line = (
|
stat_line = (
|
||||||
f"{i + 1}.\t `{trade['exit_reason']}\t"
|
f"{i + 1}.\t `{trade['exit_reason']}\t"
|
||||||
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
|
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"
|
f"({trade['count']})`\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1736,7 +1737,7 @@ class Telegram(RPCHandler):
|
|||||||
stat_line = (
|
stat_line = (
|
||||||
f"{i + 1}.\t `{trade['mix_tag']}\t"
|
f"{i + 1}.\t `{trade['mix_tag']}\t"
|
||||||
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
|
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"
|
f"({trade['count']})`\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"bids_to_ask_delta": 1
|
"bids_to_ask_delta": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exit_pricing":{
|
"exit_pricing": {
|
||||||
"price_side": "same",
|
"price_side": "same",
|
||||||
"use_order_book": true,
|
"use_order_book": true,
|
||||||
"order_book_top": 1
|
"order_book_top": 1
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ from freqtrade.util.formatters import (
|
|||||||
fmt_coin,
|
fmt_coin,
|
||||||
fmt_coin2,
|
fmt_coin2,
|
||||||
format_duration,
|
format_duration,
|
||||||
|
format_pct,
|
||||||
round_value,
|
round_value,
|
||||||
)
|
)
|
||||||
from freqtrade.util.ft_precise import FtPrecise
|
from freqtrade.util.ft_precise import FtPrecise
|
||||||
|
from freqtrade.util.ft_ttlcache import FtTTLCache
|
||||||
from freqtrade.util.measure_time import MeasureTime
|
from freqtrade.util.measure_time import MeasureTime
|
||||||
from freqtrade.util.periodic_cache import PeriodicCache
|
from freqtrade.util.periodic_cache import PeriodicCache
|
||||||
from freqtrade.util.progress_tracker import ( # noqa F401
|
from freqtrade.util.progress_tracker import ( # noqa F401
|
||||||
@@ -44,6 +46,7 @@ __all__ = [
|
|||||||
"format_date",
|
"format_date",
|
||||||
"format_ms_time",
|
"format_ms_time",
|
||||||
"format_ms_time_det",
|
"format_ms_time_det",
|
||||||
|
"format_pct",
|
||||||
"get_dry_run_wallet",
|
"get_dry_run_wallet",
|
||||||
"FtPrecise",
|
"FtPrecise",
|
||||||
"PeriodicCache",
|
"PeriodicCache",
|
||||||
@@ -57,4 +60,5 @@ __all__ = [
|
|||||||
"print_rich_table",
|
"print_rich_table",
|
||||||
"print_df_rich_table",
|
"print_df_rich_table",
|
||||||
"CustomProgress",
|
"CustomProgress",
|
||||||
|
"FtTTLCache",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from numpy import isnan
|
||||||
|
|
||||||
from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN
|
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"
|
:param keep_trailing_zeros: Keep trailing zeros "222.200" vs. "222.2"
|
||||||
:return: Rounded value as string
|
:return: Rounded value as string
|
||||||
"""
|
"""
|
||||||
|
if isnan(value):
|
||||||
|
return "N/A"
|
||||||
val = f"{value:.{decimals}f}"
|
val = f"{value:.{decimals}f}"
|
||||||
if not keep_trailing_zeros:
|
if not keep_trailing_zeros:
|
||||||
val = strip_trailing_zeros(val)
|
val = strip_trailing_zeros(val)
|
||||||
@@ -80,3 +84,15 @@ def format_duration(td: timedelta) -> str:
|
|||||||
h, r = divmod(td.seconds, 3600)
|
h, r = divmod(td.seconds, 3600)
|
||||||
m, _ = divmod(r, 60)
|
m, _ = divmod(r, 60)
|
||||||
return f"{d}d {h:02d}:{m:02d}"
|
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%}"
|
||||||
|
|||||||
@@ -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,7 +2,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from cachetools import TTLCache
|
from freqtrade.util import FtTTLCache
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -27,7 +27,7 @@ class MeasureTime:
|
|||||||
"""
|
"""
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
self._time_limit = time_limit
|
self._time_limit = time_limit
|
||||||
self.__cache: TTLCache = TTLCache(maxsize=1, ttl=ttl)
|
self.__cache: FtTTLCache = FtTTLCache(maxsize=1, ttl=ttl)
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self._start = time.time()
|
self._start = time.time()
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
from freqtrade.exchange import Exchange
|
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
|
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 persisted data from old formats to new formats
|
||||||
"""
|
"""
|
||||||
migrate_binance_futures_data(config)
|
migrate_binance_futures_data(config)
|
||||||
|
|
||||||
migrate_funding_fee_timeframe(config, exchange)
|
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)
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def migrate_binance_futures_names(config: Config):
|
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 (
|
if not (
|
||||||
config.get("trading_mode", TradingMode.SPOT) == TradingMode.FUTURES
|
config.get("trading_mode", TradingMode.SPOT) == TradingMode.FUTURES
|
||||||
and config["exchange"]["name"] == "binance"
|
and config["exchange"]["name"] == "binance"
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*", "**/user_data/*"
|
|||||||
known_first_party = ["freqtrade_client"]
|
known_first_party = ["freqtrade_client"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[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_format = "%(asctime)s %(levelname)s %(message)s"
|
||||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
-r requirements-freqai-rl.txt
|
-r requirements-freqai-rl.txt
|
||||||
-r docs/requirements-docs.txt
|
-r docs/requirements-docs.txt
|
||||||
|
|
||||||
ruff==0.14.3
|
ruff==0.14.5
|
||||||
mypy==1.18.2
|
mypy==1.18.2
|
||||||
pre-commit==4.3.0
|
pre-commit==4.4.0
|
||||||
pytest==8.4.2
|
pytest==9.0.1
|
||||||
pytest-asyncio==1.2.0
|
pytest-asyncio==1.3.0
|
||||||
pytest-cov==7.0.0
|
pytest-cov==7.0.0
|
||||||
pytest-mock==3.15.1
|
pytest-mock==3.15.1
|
||||||
pytest-random-order==1.2.0
|
pytest-random-order==1.2.0
|
||||||
@@ -18,7 +18,7 @@ pytest-timeout==2.4.0
|
|||||||
pytest-xdist==3.8.0
|
pytest-xdist==3.8.0
|
||||||
isort==7.0.0
|
isort==7.0.0
|
||||||
# For datetime mocking
|
# For datetime mocking
|
||||||
time-machine==2.19.0
|
time-machine==3.0.0
|
||||||
|
|
||||||
# Convert jupyter notebooks to markdown documents
|
# Convert jupyter notebooks to markdown documents
|
||||||
nbconvert==7.16.6
|
nbconvert==7.16.6
|
||||||
@@ -29,4 +29,4 @@ types-cachetools==6.2.0.20251022
|
|||||||
types-filelock==3.2.7
|
types-filelock==3.2.7
|
||||||
types-requests==2.32.4.20250913
|
types-requests==2.32.4.20250913
|
||||||
types-tabulate==0.9.0.20241207
|
types-tabulate==0.9.0.20241207
|
||||||
types-python-dateutil==2.9.0.20251008
|
types-python-dateutil==2.9.0.20251115
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
-r requirements-freqai.txt
|
-r requirements-freqai.txt
|
||||||
|
|
||||||
# Required for freqai-rl
|
# 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
|
gymnasium==1.2.2
|
||||||
# SB3 >=2.5.0 depends on torch 2.3.0 - which implies it dropped support x86 macos
|
# 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'
|
stable_baselines3==2.7.0; sys_platform != 'darwin' or platform_machine != 'x86_64'
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
scipy==1.16.3
|
scipy==1.16.3
|
||||||
scikit-learn==1.7.2
|
scikit-learn==1.7.2
|
||||||
filelock==3.20.0
|
filelock==3.20.0
|
||||||
optuna==4.5.0
|
optuna==4.6.0
|
||||||
cmaes==0.12.0
|
cmaes==0.12.0
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Include all requirements to run the bot.
|
# Include all requirements to run the bot.
|
||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
|
|
||||||
plotly==6.4.0
|
plotly==6.5.0
|
||||||
|
|||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
numpy==2.3.4
|
numpy==2.3.5
|
||||||
pandas==2.3.3
|
pandas==2.3.3
|
||||||
bottleneck==1.6.0
|
bottleneck==1.6.0
|
||||||
numexpr==2.14.1
|
numexpr==2.14.1
|
||||||
@@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16
|
|||||||
ta-lib==0.6.8
|
ta-lib==0.6.8
|
||||||
technical==1.5.3
|
technical==1.5.3
|
||||||
|
|
||||||
ccxt==4.5.17
|
ccxt==4.5.20
|
||||||
cryptography==46.0.3
|
cryptography==46.0.3
|
||||||
aiohttp==3.13.2
|
aiohttp==3.13.2
|
||||||
SQLAlchemy==2.0.44
|
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 ~
|
# can't be hard-pinned due to telegram-bot pinning httpx with ~
|
||||||
httpx>=0.24.1
|
httpx>=0.24.1
|
||||||
humanize==4.14.0
|
humanize==4.14.0
|
||||||
cachetools==6.2.1
|
cachetools==6.2.2
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
urllib3==2.5.0
|
urllib3==2.5.0
|
||||||
certifi==2025.10.5
|
certifi==2025.11.12
|
||||||
jsonschema==4.25.1
|
jsonschema==4.25.1
|
||||||
tabulate==0.9.0
|
tabulate==0.9.0
|
||||||
pycoingecko==3.2.0
|
pycoingecko==3.2.0
|
||||||
@@ -37,7 +37,7 @@ orjson==3.11.4
|
|||||||
sdnotify==0.3.2
|
sdnotify==0.3.2
|
||||||
|
|
||||||
# API Server
|
# API Server
|
||||||
fastapi==0.121.0
|
fastapi==0.121.3
|
||||||
pydantic==2.12.4
|
pydantic==2.12.4
|
||||||
uvicorn==0.38.0
|
uvicorn==0.38.0
|
||||||
pyjwt==2.10.1
|
pyjwt==2.10.1
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ def test_list_timeframes(mocker, capsys):
|
|||||||
"1h": "hour",
|
"1h": "hour",
|
||||||
"1d": "day",
|
"1d": "day",
|
||||||
}
|
}
|
||||||
|
api_mock.options = {}
|
||||||
|
|
||||||
patch_exchange(mocker, api_mock=api_mock, exchange="bybit")
|
patch_exchange(mocker, api_mock=api_mock, exchange="bybit")
|
||||||
args = [
|
args = [
|
||||||
"list-timeframes",
|
"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"^1h$", captured.out, re.MULTILINE)
|
||||||
assert re.search(r"^1d$", 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):
|
def test_list_markets(mocker, markets_static, capsys):
|
||||||
api_mock = MagicMock()
|
api_mock = MagicMock()
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ def mock_order_usdt_6(is_short: bool):
|
|||||||
"side": entry_side(is_short),
|
"side": entry_side(is_short),
|
||||||
"type": "limit",
|
"type": "limit",
|
||||||
"price": 10.0,
|
"price": 10.0,
|
||||||
|
"cost": 20.0,
|
||||||
"amount": 2.0,
|
"amount": 2.0,
|
||||||
"filled": 2.0,
|
"filled": 2.0,
|
||||||
"remaining": 0.0,
|
"remaining": 0.0,
|
||||||
@@ -317,6 +318,7 @@ def mock_order_usdt_6_exit(is_short: bool):
|
|||||||
"side": exit_side(is_short),
|
"side": exit_side(is_short),
|
||||||
"type": "limit",
|
"type": "limit",
|
||||||
"price": 12.0,
|
"price": 12.0,
|
||||||
|
"cost": 24.0,
|
||||||
"amount": 2.0,
|
"amount": 2.0,
|
||||||
"filled": 0.0,
|
"filled": 0.0,
|
||||||
"remaining": 2.0,
|
"remaining": 2.0,
|
||||||
|
|||||||
@@ -575,12 +575,18 @@ def test_calculate_max_drawdown2():
|
|||||||
# No losing trade ...
|
# No losing trade ...
|
||||||
drawdown = calculate_max_drawdown(df, date_col="open_date", value_col="profit")
|
drawdown = calculate_max_drawdown(df, date_col="open_date", value_col="profit")
|
||||||
assert drawdown.drawdown_abs == 0.0
|
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 = DataFrame(zip(values[:5], dates[:5], strict=False), columns=["profit", "open_date"])
|
||||||
df1.loc[:, "profit"] = df1["profit"] * -1
|
df1.loc[:, "profit"] = df1["profit"] * -1
|
||||||
# No winning trade ...
|
# No winning trade ...
|
||||||
drawdown = calculate_max_drawdown(df1, date_col="open_date", value_col="profit")
|
drawdown = calculate_max_drawdown(df1, date_col="open_date", value_col="profit")
|
||||||
assert drawdown.drawdown_abs == 0.055545
|
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(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -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, funding_rate, interval 8h\.", caplog)
|
||||||
assert log_has_re(r"Downloading pair ETH/BTC, mark, interval 4h\.", 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):
|
def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir):
|
||||||
dl_mock = mocker.patch(
|
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()
|
exchange.refresh_latest_ohlcv.reset_mock()
|
||||||
|
|
||||||
# Test without timerange
|
# 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(
|
result3 = _download_all_pairs_history_parallel(
|
||||||
exchange=exchange,
|
exchange=exchange,
|
||||||
pairs=pairs,
|
pairs=pairs,
|
||||||
@@ -787,8 +805,8 @@ def test_download_all_pairs_history_parallel(mocker, default_conf_usdt):
|
|||||||
candle_type=candle_type,
|
candle_type=candle_type,
|
||||||
timerange=None,
|
timerange=None,
|
||||||
)
|
)
|
||||||
assert result3 == {}
|
assert result3 == expected
|
||||||
assert exchange.refresh_latest_ohlcv.call_count == 0
|
assert exchange.refresh_latest_ohlcv.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path, caplog) -> None:
|
def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path, caplog) -> None:
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
from copy import deepcopy
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import MagicMock, PropertyMock
|
from unittest.mock import MagicMock, PropertyMock
|
||||||
|
|
||||||
import pytest
|
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.exceptions import OperationalException, RetryableOrderError
|
||||||
from freqtrade.exchange.common import API_RETRY_COUNT
|
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.conftest import EXMS, get_patched_exchange
|
||||||
from tests.exchange.test_exchange import ccxt_exceptionhandlers
|
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_margin_mode.call_count == 0
|
||||||
assert api_mock.set_leverage.call_count == 1
|
assert api_mock.set_leverage.call_count == 1
|
||||||
api_mock.set_leverage.assert_called_with(symbol="BTC/USDC:USDC", leverage=19.99)
|
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)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, PropertyMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from freqtrade.enums.marginmode import MarginMode
|
from freqtrade.enums import MarginMode, RunMode, TradingMode
|
||||||
from freqtrade.enums.tradingmode import TradingMode
|
from freqtrade.util import dt_utc
|
||||||
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has
|
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has
|
||||||
from tests.exchange.test_exchange import ccxt_exceptionhandlers
|
from tests.exchange.test_exchange import ccxt_exceptionhandlers
|
||||||
|
|
||||||
@@ -214,3 +215,43 @@ def test_bybit__order_needs_price(
|
|||||||
exchange.unified_account = uta
|
exchange.unified_account = uta
|
||||||
|
|
||||||
assert exchange._order_needs_price(side, order_type) == expected
|
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
@@ -742,10 +742,11 @@ def test_get_pair_base_currency(default_conf, mocker, pair, expected):
|
|||||||
def test_validate_timeframes(default_conf, mocker, timeframe):
|
def test_validate_timeframes(default_conf, mocker, timeframe):
|
||||||
default_conf["timeframe"] = timeframe
|
default_conf["timeframe"] = timeframe
|
||||||
api_mock = MagicMock()
|
api_mock = MagicMock()
|
||||||
id_mock = PropertyMock(return_value="test_exchange")
|
id_mock = MagicMock(return_value="test_exchange")
|
||||||
type(api_mock).id = id_mock
|
api_mock.id = id_mock
|
||||||
timeframes = PropertyMock(return_value={"1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"})
|
api_mock.options = {}
|
||||||
type(api_mock).timeframes = timeframes
|
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}._init_ccxt", MagicMock(return_value=api_mock))
|
||||||
mocker.patch(f"{EXMS}.reload_markets")
|
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):
|
def test_validate_timeframes_failed(default_conf, mocker):
|
||||||
default_conf["timeframe"] = "3m"
|
default_conf["timeframe"] = "3m"
|
||||||
api_mock = MagicMock()
|
api_mock = MagicMock()
|
||||||
id_mock = PropertyMock(return_value="test_exchange")
|
id_mock = MagicMock(return_value="test_exchange")
|
||||||
type(api_mock).id = id_mock
|
api_mock.id = id_mock
|
||||||
timeframes = PropertyMock(
|
timeframes = {"15s": "15s", "1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"}
|
||||||
return_value={"15s": "15s", "1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h"}
|
api_mock.timeframes = timeframes
|
||||||
)
|
api_mock.options = {}
|
||||||
type(api_mock).timeframes = timeframes
|
|
||||||
|
|
||||||
mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock))
|
mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock))
|
||||||
mocker.patch(f"{EXMS}.reload_markets")
|
mocker.patch(f"{EXMS}.reload_markets")
|
||||||
@@ -1110,6 +1110,116 @@ def test_create_dry_run_order_fees(
|
|||||||
assert order1["fee"]["rate"] == fee
|
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(
|
@pytest.mark.parametrize(
|
||||||
"side,price,filled,converted",
|
"side,price,filled,converted",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import pytest
|
|||||||
|
|
||||||
from freqtrade.enums import CandleType
|
from freqtrade.enums import CandleType
|
||||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date
|
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 freqtrade.util import dt_floor_day, dt_now, dt_ts
|
||||||
from tests.exchange_online.conftest import EXCHANGE_FIXTURE_TYPE, EXCHANGES
|
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]
|
trades_orig = nvspy.call_args_list[2][0][0]
|
||||||
assert len(trades_orig[-1].get("info")) > len(trades_orig[-2].get("info"))
|
assert len(trades_orig[-1].get("info")) > len(trades_orig[-2].get("info"))
|
||||||
|
|
||||||
def test_ccxt_get_fee(self, exchange: EXCHANGE_FIXTURE_TYPE):
|
def _ccxt_get_fee(self, exch: Exchange, pair: str):
|
||||||
exch, exchangename = exchange
|
|
||||||
pair = EXCHANGES[exchangename]["pair"]
|
|
||||||
threshold = 0.01
|
threshold = 0.01
|
||||||
assert 0 < exch.get_fee(pair, "limit", "buy") < threshold
|
assert 0 < exch.get_fee(pair, "limit", "buy") < threshold
|
||||||
assert 0 < exch.get_fee(pair, "limit", "sell") < 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", "buy") < threshold
|
||||||
assert 0 < exch.get_fee(pair, "market", "sell") < 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):
|
def test_ccxt_get_max_leverage_spot(self, exchange: EXCHANGE_FIXTURE_TYPE):
|
||||||
spot, spot_name = exchange
|
spot, spot_name = exchange
|
||||||
if spot:
|
if spot:
|
||||||
|
|||||||
@@ -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):
|
def test_startup_update_open_orders(mocker, default_conf_usdt, fee, caplog, is_short):
|
||||||
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
|
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
|
||||||
create_mock_trades(fee, is_short=is_short)
|
create_mock_trades(fee, is_short=is_short)
|
||||||
|
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
|
||||||
|
|
||||||
freqtrade.startup_update_open_orders()
|
freqtrade.startup_update_open_orders()
|
||||||
assert not log_has_re(r"Error updating Order .*", caplog)
|
assert not log_has_re(r"Error updating Order .*", caplog)
|
||||||
|
|||||||
@@ -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)
|
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}
|
data = {pair: [dummy_row] for pair in pairs}
|
||||||
|
|
||||||
def mock_refresh(self):
|
def mock_refresh(self, **kwargs):
|
||||||
# Simulate shuffle
|
# Simulate shuffle
|
||||||
self._whitelist = pairs[::-1] # ['ETH/BTC', 'NEO/BTC', 'LTC/BTC', 'XRP/BTC']
|
self._whitelist = pairs[::-1] # ['ETH/BTC', 'NEO/BTC', 'LTC/BTC', 'XRP/BTC']
|
||||||
|
|
||||||
|
|||||||
@@ -1479,6 +1479,8 @@ def test_to_json(fee):
|
|||||||
"contract_size": 1,
|
"contract_size": 1,
|
||||||
"orders": [],
|
"orders": [],
|
||||||
"has_open_orders": False,
|
"has_open_orders": False,
|
||||||
|
"nr_of_successful_entries": 0,
|
||||||
|
"nr_of_successful_exits": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Simulate dry_run entries
|
# Simulate dry_run entries
|
||||||
@@ -1570,6 +1572,8 @@ def test_to_json(fee):
|
|||||||
"contract_size": 1,
|
"contract_size": 1,
|
||||||
"orders": [],
|
"orders": [],
|
||||||
"has_open_orders": False,
|
"has_open_orders": False,
|
||||||
|
"nr_of_successful_entries": 0,
|
||||||
|
"nr_of_successful_exits": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None:
|
|||||||
"contract_size": 1,
|
"contract_size": 1,
|
||||||
"has_open_orders": False,
|
"has_open_orders": False,
|
||||||
"nr_of_successful_entries": ANY,
|
"nr_of_successful_entries": ANY,
|
||||||
|
"nr_of_successful_exits": ANY,
|
||||||
"orders": [
|
"orders": [
|
||||||
{
|
{
|
||||||
"amount": 91.07468123,
|
"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 "now" == result[0][2]
|
||||||
assert "ETH/BTC" in result[0][1]
|
assert "ETH/BTC" in result[0][1]
|
||||||
assert "nan%" == result[0][3]
|
assert "N/A" == result[0][3]
|
||||||
assert isnan(fiat_profit_sum)
|
assert isnan(fiat_profit_sum)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1605,6 +1605,8 @@ def test_api_status(
|
|||||||
"precision_mode": None,
|
"precision_mode": None,
|
||||||
"orders": [ANY],
|
"orders": [ANY],
|
||||||
"has_open_orders": True,
|
"has_open_orders": True,
|
||||||
|
"nr_of_successful_entries": ANY,
|
||||||
|
"nr_of_successful_exits": ANY,
|
||||||
}
|
}
|
||||||
|
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
@@ -1817,6 +1819,8 @@ def test_api_force_entry(botclient, mocker, fee, endpoint):
|
|||||||
"price_precision": None,
|
"price_precision": None,
|
||||||
"precision_mode": None,
|
"precision_mode": None,
|
||||||
"has_open_orders": False,
|
"has_open_orders": False,
|
||||||
|
"nr_of_successful_entries": ANY,
|
||||||
|
"nr_of_successful_exits": ANY,
|
||||||
"orders": [],
|
"orders": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -421,7 +421,8 @@ async def test_telegram_status_multi_entry(default_conf, update, mocker, fee) ->
|
|||||||
assert msg_mock.call_count == 4
|
assert msg_mock.call_count == 4
|
||||||
msg = msg_mock.call_args_list[3][0][0]
|
msg = msg_mock.call_args_list[3][0][0]
|
||||||
assert re.search(r"Number of Entries.*2", msg)
|
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 Date:", msg) is None
|
||||||
assert re.search(r"Close Profit:", msg) is None
|
assert re.search(r"Close Profit:", msg) is None
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
from datetime import timedelta
|
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():
|
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, "BTC", False) == "0.12745121"
|
||||||
assert fmt_coin(0.1274512123, "ETH", False) == "0.12745"
|
assert fmt_coin(0.1274512123, "ETH", False) == "0.12745"
|
||||||
assert fmt_coin(222.2, "USDT", False, True) == "222.200"
|
assert fmt_coin(222.2, "USDT", False, True) == "222.200"
|
||||||
|
assert fmt_coin(float("nan"), "USDT", False, True) == "N/A"
|
||||||
|
|
||||||
|
|
||||||
def test_fmt_coin2():
|
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, "BTC") == "0.12745121 BTC"
|
||||||
assert fmt_coin2(0.1274512123, "ETH") == "0.12745121 ETH"
|
assert fmt_coin2(0.1274512123, "ETH") == "0.12745121 ETH"
|
||||||
assert fmt_coin2(0.00001245, "PEPE") == "0.00001245 PEPE"
|
assert fmt_coin2(0.00001245, "PEPE") == "0.00001245 PEPE"
|
||||||
|
assert fmt_coin2(float("nan"), "PEPE") == "N/A PEPE"
|
||||||
|
|
||||||
|
|
||||||
def test_round_value():
|
def test_round_value():
|
||||||
@@ -46,6 +55,8 @@ def test_round_value():
|
|||||||
assert round_value(0.1274512123, 5) == "0.12745"
|
assert round_value(0.1274512123, 5) == "0.12745"
|
||||||
assert round_value(222.2, 3, True) == "222.200"
|
assert round_value(222.2, 3, True) == "222.200"
|
||||||
assert round_value(222.2, 0, True) == "222"
|
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():
|
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=1445)) == "1d 00:05"
|
||||||
assert format_duration(timedelta(minutes=11445)) == "7d 22:45"
|
assert format_duration(timedelta(minutes=11445)) == "7d 22:45"
|
||||||
assert format_duration(timedelta(minutes=101445)) == "70d 10: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"
|
||||||
|
|||||||
Reference in New Issue
Block a user