Merge branch 'freqtrade:develop' into main-stash

This commit is contained in:
Stefano
2025-12-03 09:10:15 +09:00
committed by GitHub
32 changed files with 289 additions and 108 deletions
+4 -4
View File
@@ -2,7 +2,7 @@ version: 2
updates: updates:
- package-ecosystem: docker - package-ecosystem: docker
cooldown: cooldown:
default-days: 4 default-days: 7
directories: directories:
- "/" - "/"
- "/docker" - "/docker"
@@ -16,7 +16,7 @@ updates:
- package-ecosystem: devcontainers - package-ecosystem: devcontainers
directory: "/" directory: "/"
cooldown: cooldown:
default-days: 4 default-days: 7
schedule: schedule:
interval: daily interval: daily
open-pull-requests-limit: 10 open-pull-requests-limit: 10
@@ -24,7 +24,7 @@ updates:
- package-ecosystem: pip - package-ecosystem: pip
directory: "/" directory: "/"
cooldown: cooldown:
default-days: 4 default-days: 7
exclude: exclude:
- ccxt - ccxt
schedule: schedule:
@@ -51,7 +51,7 @@ updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
cooldown: cooldown:
default-days: 4 default-days: 7
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
@@ -15,7 +15,7 @@ jobs:
environment: environment:
name: develop name: develop
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -34,7 +34,7 @@ jobs:
run: python build_helpers/binance_update_lev_tiers.py run: python build_helpers/binance_update_lev_tiers.py
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with: with:
token: ${{ secrets.REPO_SCOPED_TOKEN }} token: ${{ secrets.REPO_SCOPED_TOKEN }}
add-paths: freqtrade/exchange/binance_leverage_tiers.json add-paths: freqtrade/exchange/binance_leverage_tiers.json
+10 -10
View File
@@ -28,7 +28,7 @@ jobs:
python-version: ["3.11", "3.12", "3.13"] python-version: ["3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -38,7 +38,7 @@ jobs:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
with: with:
activate-environment: true activate-environment: true
enable-cache: true enable-cache: true
@@ -177,7 +177,7 @@ jobs:
name: "Mypy Version Check" name: "Mypy Version Check"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -195,7 +195,7 @@ jobs:
name: "Pre-commit checks" name: "Pre-commit checks"
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -208,7 +208,7 @@ jobs:
name: "Documentation build" name: "Documentation build"
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -240,7 +240,7 @@ jobs:
name: "Tests and Linting - Online tests" name: "Tests and Linting - Online tests"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -250,7 +250,7 @@ jobs:
python-version: "3.12" python-version: "3.12"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2 uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
with: with:
activate-environment: true activate-environment: true
enable-cache: true enable-cache: true
@@ -320,7 +320,7 @@ jobs:
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -367,7 +367,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -396,7 +396,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
name: Deploy Docs through mike name: Deploy Docs through mike
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: true persist-credentials: true
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
packages: write packages: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
if: github.repository == 'freqtrade/freqtrade' if: github.repository == 'freqtrade/freqtrade'
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -152,7 +152,7 @@ jobs:
if: github.repository == 'freqtrade/freqtrade' if: github.repository == 'freqtrade/freqtrade'
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
dockerHubDescription: dockerHubDescription:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
auto-update: auto-update:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -28,7 +28,7 @@ jobs:
- name: Run auto-update - name: Run auto-update
run: pre-commit autoupdate run: pre-commit autoupdate
- uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with: with:
token: ${{ secrets.REPO_SCOPED_TOKEN }} token: ${{ secrets.REPO_SCOPED_TOKEN }}
add-paths: .pre-commit-config.yaml add-paths: .pre-commit-config.yaml
+2 -2
View File
@@ -22,9 +22,9 @@ jobs:
# actions: read # only needed for private repos # actions: read # only needed for private repos
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v6.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor 🌈 - name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@e673c3917a1aef3c65c972347ed84ccd013ecda4 # v0.2.0 uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0
+4 -4
View File
@@ -21,7 +21,7 @@ repos:
# stages: [push] # stages: [push]
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.18.2" rev: "v1.19.0"
hooks: hooks:
- id: mypy - id: mypy
exclude: build_helpers exclude: build_helpers
@@ -31,7 +31,7 @@ repos:
- 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.20251115 - types-python-dateutil==2.9.0.20251115
- scipy-stubs==1.16.3.0 - scipy-stubs==1.16.3.1
- 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.6' rev: 'v0.14.7'
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format
@@ -83,6 +83,6 @@ repos:
# Ensure github actions remain safe # Ensure github actions remain safe
- repo: https://github.com/woodruffw/zizmor-pre-commit - repo: https://github.com/woodruffw/zizmor-pre-commit
rev: v1.16.3 rev: v1.18.0
hooks: hooks:
- id: zizmor - id: zizmor
+1 -1
View File
@@ -2,6 +2,6 @@ markdown==3.10
mkdocs==1.6.1 mkdocs==1.6.1
mkdocs-material==9.7.0 mkdocs-material==9.7.0
mdx_truly_sane_lists==1.3 mdx_truly_sane_lists==1.3
pymdown-extensions==10.17.1 pymdown-extensions==10.17.2
jinja2==3.1.6 jinja2==3.1.6
mike==2.1.3 mike==2.1.3
+1 -1
View File
@@ -634,7 +634,7 @@ class AwesomeStrategy(IStrategy):
## Custom order price rules ## Custom order price rules
By default, freqtrade use the orderbook to automatically set an order price([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy. By default, freqtrade use the orderbook to automatically set an order price ([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy.
You can use this feature by creating a `custom_entry_price()` function in your strategy file to customize entry prices and `custom_exit_price()` for exits. You can use this feature by creating a `custom_entry_price()` function in your strategy file to customize entry prices and `custom_exit_price()` for exits.
+1 -1
View File
@@ -1,6 +1,6 @@
"""Freqtrade bot""" """Freqtrade bot"""
__version__ = "2025.11-dev" __version__ = "2025.12-dev"
if "dev" in __version__: if "dev" in __version__:
from pathlib import Path from pathlib import Path
+6 -2
View File
@@ -308,11 +308,15 @@ def _download_pair_history(
candle_type=candle_type, candle_type=candle_type,
until_ms=until_ms if until_ms else None, until_ms=until_ms if until_ms else None,
) )
logger.info(f"Downloaded data for {pair} with length {len(new_dataframe)}.") logger.info(
f"Downloaded data for {pair}, {timeframe}, {candle_type} with length "
f"{len(new_dataframe)}."
)
else: else:
new_dataframe = pair_candles new_dataframe = pair_candles
logger.info( logger.info(
f"Downloaded data for {pair} with length {len(new_dataframe)}. Parallel Method." f"Downloaded data for {pair}, {timeframe}, {candle_type} with length "
f"{len(new_dataframe)}. Parallel Method."
) )
if data.empty: if data.empty:
+59 -14
View File
@@ -104,6 +104,7 @@ from freqtrade.misc import (
deep_merge_dicts, deep_merge_dicts,
file_dump_json, file_dump_json,
file_load_json, file_load_json,
safe_value_fallback,
safe_value_fallback2, safe_value_fallback2,
) )
from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now from freqtrade.util import FtTTLCache, PeriodicCache, dt_from_ts, dt_now
@@ -1119,6 +1120,7 @@ class Exchange:
leverage: float, leverage: float,
params: dict | None = None, params: dict | None = None,
stop_loss: bool = False, stop_loss: bool = False,
stop_price: float | None = None,
) -> CcxtOrder: ) -> CcxtOrder:
now = dt_now() now = dt_now()
order_id = f"dry_run_{side}_{pair}_{now.timestamp()}" order_id = f"dry_run_{side}_{pair}_{now.timestamp()}"
@@ -1145,7 +1147,7 @@ class Exchange:
} }
if stop_loss: if stop_loss:
dry_order["info"] = {"stopPrice": dry_order["price"]} dry_order["info"] = {"stopPrice": dry_order["price"]}
dry_order[self._ft_has["stop_price_prop"]] = dry_order["price"] dry_order[self._ft_has["stop_price_prop"]] = stop_price or dry_order["price"]
# Workaround to avoid filling stoploss orders immediately # Workaround to avoid filling stoploss orders immediately
dry_order["ft_order_type"] = "stoploss" dry_order["ft_order_type"] = "stoploss"
orderbook: OrderBook | None = None orderbook: OrderBook | None = None
@@ -1163,7 +1165,11 @@ class Exchange:
if dry_order["type"] == "market" and not dry_order.get("ft_order_type"): if dry_order["type"] == "market" and not dry_order.get("ft_order_type"):
# Update market order pricing # Update market order pricing
average = self.get_dry_market_fill_price(pair, side, amount, rate, orderbook) slippage = 0.05
worst_rate = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
average = self.get_dry_market_fill_price(
pair, side, amount, rate, worst_rate, orderbook
)
dry_order.update( dry_order.update(
{ {
"average": average, "average": average,
@@ -1203,7 +1209,13 @@ class Exchange:
return dry_order return dry_order
def get_dry_market_fill_price( def get_dry_market_fill_price(
self, pair: str, side: str, amount: float, rate: float, orderbook: OrderBook | None self,
pair: str,
side: str,
amount: float,
rate: float,
worst_rate: float,
orderbook: OrderBook | None,
) -> float: ) -> float:
""" """
Get the market order fill price based on orderbook interpolation Get the market order fill price based on orderbook interpolation
@@ -1212,8 +1224,6 @@ class Exchange:
if not orderbook: if not orderbook:
orderbook = self.fetch_l2_order_book(pair, 20) orderbook = self.fetch_l2_order_book(pair, 20)
ob_type: OBLiteral = "asks" if side == "buy" else "bids" ob_type: OBLiteral = "asks" if side == "buy" else "bids"
slippage = 0.05
max_slippage_val = rate * ((1 + slippage) if side == "buy" else (1 - slippage))
remaining_amount = amount remaining_amount = amount
filled_value = 0.0 filled_value = 0.0
@@ -1237,11 +1247,10 @@ class Exchange:
forecast_avg_filled_price = max(filled_value, 0) / amount forecast_avg_filled_price = max(filled_value, 0) / amount
# Limit max. slippage to specified value # Limit max. slippage to specified value
if side == "buy": if side == "buy":
forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val) forecast_avg_filled_price = min(forecast_avg_filled_price, worst_rate)
else: else:
forecast_avg_filled_price = max(forecast_avg_filled_price, max_slippage_val) forecast_avg_filled_price = max(forecast_avg_filled_price, worst_rate)
return self.price_to_precision(pair, forecast_avg_filled_price) return self.price_to_precision(pair, forecast_avg_filled_price)
return rate return rate
@@ -1253,13 +1262,15 @@ class Exchange:
limit: float, limit: float,
orderbook: OrderBook | None = None, orderbook: OrderBook | None = None,
offset: float = 0.0, offset: float = 0.0,
is_stop: bool = False,
) -> bool: ) -> bool:
if not self.exchange_has("fetchL2OrderBook"): if not self.exchange_has("fetchL2OrderBook"):
return True # True unless checking a stoploss order
return not is_stop
if not orderbook: if not orderbook:
orderbook = self.fetch_l2_order_book(pair, 1) orderbook = self.fetch_l2_order_book(pair, 1)
try: try:
if side == "buy": if (side == "buy" and not is_stop) or (side == "sell" and is_stop):
price = orderbook["asks"][0][0] price = orderbook["asks"][0][0]
if limit * (1 - offset) >= price: if limit * (1 - offset) >= price:
return True return True
@@ -1278,6 +1289,38 @@ class Exchange:
""" """
Check dry-run limit order fill and update fee (if it filled). Check dry-run limit order fill and update fee (if it filled).
""" """
if order["status"] != "closed" and order.get("ft_order_type") == "stoploss":
pair = order["symbol"]
if not orderbook and self.exchange_has("fetchL2OrderBook"):
orderbook = self.fetch_l2_order_book(pair, 20)
price = safe_value_fallback(order, self._ft_has["stop_price_prop"], "price")
crossed = self._dry_is_price_crossed(
pair, order["side"], price, orderbook, is_stop=True
)
if crossed:
average = self.get_dry_market_fill_price(
pair,
order["side"],
order["amount"],
price,
worst_rate=order["price"],
orderbook=orderbook,
)
order.update(
{
"status": "closed",
"filled": order["amount"],
"remaining": 0,
"average": average,
"cost": order["amount"] * average,
}
)
self.add_dry_order_fee(
pair,
order,
"taker" if immediate else "maker",
)
return order
if ( if (
order["status"] != "closed" order["status"] != "closed"
and order["type"] in ["limit"] and order["type"] in ["limit"]
@@ -1517,8 +1560,9 @@ class Exchange:
ordertype, ordertype,
side, side,
amount, amount,
stop_price_norm, limit_rate or stop_price_norm,
stop_loss=True, stop_loss=True,
stop_price=stop_price_norm,
leverage=leverage, leverage=leverage,
) )
return dry_order return dry_order
@@ -3740,10 +3784,11 @@ class Exchange:
:param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price) :param mark_rates: Dataframe containing Mark rates (Type mark_ohlcv_price)
:param futures_funding_rate: Fake funding rate to use if funding_rates are not available :param futures_funding_rate: Fake funding rate to use if funding_rates are not available
""" """
relevant_cols = ["date", "open_mark", "open_fund"]
if futures_funding_rate is None: if futures_funding_rate is None:
return mark_rates.merge( return mark_rates.merge(
funding_rates, on="date", how="inner", suffixes=["_mark", "_fund"] funding_rates, on="date", how="inner", suffixes=["_mark", "_fund"]
) )[relevant_cols]
else: else:
if len(funding_rates) == 0: if len(funding_rates) == 0:
# No funding rate candles - full fillup with fallback variable # No funding rate candles - full fillup with fallback variable
@@ -3756,7 +3801,7 @@ class Exchange:
"low": "low_mark", "low": "low_mark",
"volume": "volume_mark", "volume": "volume_mark",
} }
) )[relevant_cols]
else: else:
# Fill up missing funding_rate candles with fallback value # Fill up missing funding_rate candles with fallback value
@@ -3764,7 +3809,7 @@ class Exchange:
funding_rates, on="date", how="left", suffixes=["_mark", "_fund"] funding_rates, on="date", how="left", suffixes=["_mark", "_fund"]
) )
combined["open_fund"] = combined["open_fund"].fillna(futures_funding_rate) combined["open_fund"] = combined["open_fund"].fillna(futures_funding_rate)
return combined return combined[relevant_cols]
def calculate_funding_fees( def calculate_funding_fees(
self, self,
+13 -2
View File
@@ -1063,7 +1063,16 @@ class FreqtradeBot(LoggingMixin):
return True return True
def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade: def cancel_stoploss_on_exchange(self, trade: Trade, allow_nonblocking: bool = False) -> Trade:
"""
Cancels on exchange stoploss orders for the given trade.
:param trade: Trade for which to cancel stoploss order
:param allow_nonblocking: If True, will skip cancelling stoploss on exchange
if the exchange supports blocking stoploss orders.
"""
if allow_nonblocking and not self.exchange.get_option("stoploss_blocks_assets", True):
logger.info(f"Skipping cancelling stoploss on exchange for {trade}.")
return trade
# First cancelling stoploss on exchange ... # First cancelling stoploss on exchange ...
for oslo in trade.open_sl_orders: for oslo in trade.open_sl_orders:
try: try:
@@ -2088,7 +2097,7 @@ class FreqtradeBot(LoggingMixin):
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate) limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
# First cancelling stoploss on exchange ... # First cancelling stoploss on exchange ...
trade = self.cancel_stoploss_on_exchange(trade) trade = self.cancel_stoploss_on_exchange(trade, allow_nonblocking=True)
order_type = ordertype or self.strategy.order_types[exit_type] order_type = ordertype or self.strategy.order_types[exit_type]
if exit_check.exit_type == ExitType.EMERGENCY_EXIT: if exit_check.exit_type == ExitType.EMERGENCY_EXIT:
@@ -2378,6 +2387,8 @@ class FreqtradeBot(LoggingMixin):
self.strategy.ft_stoploss_adjust( self.strategy.ft_stoploss_adjust(
current_rate, trade, datetime.now(UTC), profit, 0, after_fill=True current_rate, trade, datetime.now(UTC), profit, 0, after_fill=True
) )
if not trade.is_open:
self.cancel_stoploss_on_exchange(trade)
# Updating wallets when order is closed # Updating wallets when order is closed
self.wallets.update() self.wallets.update()
return trade return trade
+5 -4
View File
@@ -48,7 +48,7 @@ from freqtrade.leverage import interest
from freqtrade.misc import safe_value_fallback from freqtrade.misc import safe_value_fallback
from freqtrade.persistence.base import ModelBase, SessionType from freqtrade.persistence.base import ModelBase, SessionType
from freqtrade.persistence.custom_data import CustomDataWrapper, _CustomData from freqtrade.persistence.custom_data import CustomDataWrapper, _CustomData
from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none, round_value
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -654,9 +654,10 @@ class LocalTrade:
) )
return ( return (
f"Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, " f"Trade(id={self.id}, pair={self.pair}, amount={round_value(self.amount, 8)}, "
f"is_short={self.is_short or False}, leverage={self.leverage or 1.0}, " f"is_short={self.is_short or False}, "
f"open_rate={self.open_rate:.8f}, open_since={open_since})" f"leverage={round_value(self.leverage or 1.0, 1)}, "
f"open_rate={round_value(self.open_rate, 8)}, open_since={open_since})"
) )
def to_json(self, minified: bool = False) -> dict[str, Any]: def to_json(self, minified: bool = False) -> dict[str, Any]:
+3 -2
View File
@@ -90,15 +90,16 @@ def dt_humanize_delta(dt: datetime):
return humanize.naturaltime(dt) return humanize.naturaltime(dt)
def format_date(date: datetime | None) -> str: def format_date(date: datetime | None, fallback: str = "") -> str:
""" """
Return a formatted date string. Return a formatted date string.
Returns an empty string if date is None. Returns an empty string if date is None.
:param date: datetime to format :param date: datetime to format
:param fallback: value to return if date is None
""" """
if date: if date:
return date.strftime(DATETIME_PRINT_FORMAT) return date.strftime(DATETIME_PRINT_FORMAT)
return "" return fallback
def format_ms_time(date: int | float) -> str: def format_ms_time(date: int | float) -> str:
+2 -2
View File
@@ -23,7 +23,7 @@ def strip_trailing_zeros(value: str) -> str:
return value.rstrip("0").rstrip(".") return value.rstrip("0").rstrip(".")
def round_value(value: float, decimals: int, keep_trailing_zeros=False) -> str: def round_value(value: float | None, decimals: int, keep_trailing_zeros=False) -> str:
""" """
Round value to given decimals Round value to given decimals
:param value: Value to be rounded :param value: Value to be rounded
@@ -31,7 +31,7 @@ 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): if value is None or isnan(value):
return "N/A" return "N/A"
val = f"{value:.{decimals}f}" val = f"{value:.{decimals}f}"
if not keep_trailing_zeros: if not keep_trailing_zeros:
+1 -1
View File
@@ -1,7 +1,7 @@
from freqtrade_client.ft_rest_client import FtRestClient from freqtrade_client.ft_rest_client import FtRestClient
__version__ = "2025.11-dev" __version__ = "2025.12-dev"
if "dev" in __version__: if "dev" in __version__:
from pathlib import Path from pathlib import Path
+4 -4
View File
@@ -6,9 +6,9 @@
-r requirements-freqai-rl.txt -r requirements-freqai-rl.txt
-r docs/requirements-docs.txt -r docs/requirements-docs.txt
ruff==0.14.5 ruff==0.14.6
mypy==1.18.2 mypy==1.18.2
pre-commit==4.4.0 pre-commit==4.5.0
pytest==9.0.1 pytest==9.0.1
pytest-asyncio==1.3.0 pytest-asyncio==1.3.0
pytest-cov==7.0.0 pytest-cov==7.0.0
@@ -18,13 +18,13 @@ 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==3.0.0 time-machine==3.1.0
# Convert jupyter notebooks to markdown documents # Convert jupyter notebooks to markdown documents
nbconvert==7.16.6 nbconvert==7.16.6
# mypy types # mypy types
scipy-stubs==1.16.3.0 # keep in sync with `scipy` in `requirements-hyperopt.txt` scipy-stubs==1.16.3.1 # keep in sync with `scipy` in `requirements-hyperopt.txt`
types-cachetools==6.2.0.20251022 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
+1 -1
View File
@@ -7,6 +7,6 @@ scikit-learn==1.7.2
joblib==1.5.2 joblib==1.5.2
catboost==1.2.8; 'arm' not in platform_machine catboost==1.2.8; 'arm' not in platform_machine
lightgbm==4.6.0 lightgbm==4.6.0
xgboost==3.1.1 xgboost==3.1.2
tensorboard==2.20.0 tensorboard==2.20.0
datasieve==0.1.9 datasieve==0.1.9
+3 -3
View File
@@ -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.20 ccxt==4.5.22
cryptography==46.0.3 cryptography==46.0.3
aiohttp==3.13.2 aiohttp==3.13.2
SQLAlchemy==2.0.44 SQLAlchemy==2.0.44
@@ -37,8 +37,8 @@ orjson==3.11.4
sdnotify==0.3.2 sdnotify==0.3.2
# API Server # API Server
fastapi==0.121.3 fastapi==0.122.0
pydantic==2.12.4 pydantic==2.12.5
uvicorn==0.38.0 uvicorn==0.38.0
pyjwt==2.10.1 pyjwt==2.10.1
aiofiles==25.1.0 aiofiles==25.1.0
+1 -1
View File
@@ -896,7 +896,7 @@ def test_download_pair_history_with_pair_candles(mocker, default_conf, tmp_path,
assert get_historic_ohlcv_mock.call_count == 0 assert get_historic_ohlcv_mock.call_count == 0
# Verify the log message indicating parallel method was used (line 315-316) # Verify the log message indicating parallel method was used (line 315-316)
assert log_has("Downloaded data for TEST/BTC with length 3. Parallel Method.", caplog) assert log_has("Downloaded data for TEST/BTC, 5m, spot with length 3. Parallel Method.", caplog)
# Verify data was stored # Verify data was stored
assert data_handler_mock.ohlcv_store.call_count == 1 assert data_handler_mock.ohlcv_store.call_count == 1
+2 -1
View File
@@ -157,7 +157,8 @@ def test_create_stoploss_order_dry_run_binance(default_conf, mocker):
assert "type" in order assert "type" in order
assert order["type"] == order_type assert order["type"] == order_type
assert order["price"] == 220 assert order["price"] == 217.8
assert order["stopPrice"] == 220
assert order["amount"] == 1 assert order["amount"] == 1
+117 -14
View File
@@ -1111,21 +1111,29 @@ def test_create_dry_run_order_fees(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"side,limit,offset,expected", "side,limit,offset,is_stop,expected",
[ [
("buy", 46.0, 0.0, True), ("buy", 46.0, 0.0, False, True),
("buy", 26.0, 0.0, True), ("buy", 46.0, 0.0, True, False),
("buy", 25.55, 0.0, False), ("buy", 26.0, 0.0, False, True),
("buy", 1, 0.0, False), # Very far away ("buy", 26.0, 0.0, True, False), # Stop - didn't trigger
("sell", 25.5, 0.0, True), ("buy", 25.55, 0.0, False, False),
("sell", 50, 0.0, False), # Very far away ("buy", 25.55, 0.0, True, True), # Stop - triggered
("sell", 25.58, 0.0, False), ("buy", 1, 0.0, False, False), # Very far away
("sell", 25.563, 0.01, False), ("buy", 1, 0.0, True, True), # Current price is above stop - triggered
("sell", 5.563, 0.01, True), ("sell", 25.5, 0.0, False, True),
("sell", 50, 0.0, False, False), # Very far away
("sell", 25.58, 0.0, False, False),
("sell", 25.563, 0.01, False, False),
("sell", 25.563, 0.0, True, False), # stop order - Not triggered, best bid
("sell", 25.566, 0.0, True, True), # stop order - triggered
("sell", 26, 0.01, True, True), # stop order - triggered
("sell", 5.563, 0.01, False, True),
("sell", 5.563, 0.0, True, False), # stop order - not triggered
], ],
) )
def test__dry_is_price_crossed_with_orderbook( def test__dry_is_price_crossed_with_orderbook(
default_conf, mocker, order_book_l2_usd, side, limit, offset, expected default_conf, mocker, order_book_l2_usd, side, limit, offset, is_stop, expected
): ):
# Best bid 25.563 # Best bid 25.563
# Best ask 25.566 # Best ask 25.566
@@ -1134,14 +1142,14 @@ def test__dry_is_price_crossed_with_orderbook(
exchange.fetch_l2_order_book = order_book_l2_usd exchange.fetch_l2_order_book = order_book_l2_usd
orderbook = order_book_l2_usd.return_value orderbook = order_book_l2_usd.return_value
result = exchange._dry_is_price_crossed( result = exchange._dry_is_price_crossed(
"LTC/USDT", side, limit, orderbook=orderbook, offset=offset "LTC/USDT", side, limit, orderbook=orderbook, offset=offset, is_stop=is_stop
) )
assert result is expected assert result is expected
assert order_book_l2_usd.call_count == 0 assert order_book_l2_usd.call_count == 0
# Test without passing orderbook # Test without passing orderbook
order_book_l2_usd.reset_mock() order_book_l2_usd.reset_mock()
result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset) result = exchange._dry_is_price_crossed("LTC/USDT", side, limit, offset=offset, is_stop=is_stop)
assert result is expected assert result is expected
@@ -1165,7 +1173,10 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
exchange.fetch_l2_order_book = MagicMock() exchange.fetch_l2_order_book = MagicMock()
mocker.patch(f"{EXMS}.exchange_has", return_value=False) mocker.patch(f"{EXMS}.exchange_has", return_value=False)
assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0) assert exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0)
assert exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0)
assert exchange.fetch_l2_order_book.call_count == 0 assert exchange.fetch_l2_order_book.call_count == 0
assert not exchange._dry_is_price_crossed("LTC/USDT", "buy", 1.0, is_stop=True)
assert not exchange._dry_is_price_crossed("LTC/USDT", "sell", 1.0, is_stop=True)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -1176,7 +1187,7 @@ def test__dry_is_price_crossed_without_orderbook_support(default_conf, mocker):
(False, False, "sell", 1.0, "open", None, 0, None), (False, False, "sell", 1.0, "open", None, 0, None),
], ],
) )
def test_check_dry_limit_order_filled_parametrized( def test_check_dry_limit_order_filled(
default_conf, default_conf,
mocker, mocker,
crossed, crossed,
@@ -1220,6 +1231,70 @@ def test_check_dry_limit_order_filled_parametrized(
assert fee_mock.call_count == expected_calls assert fee_mock.call_count == expected_calls
@pytest.mark.parametrize(
"immediate,crossed,expected_status,expected_fee_type",
[
(True, True, "closed", "taker"),
(False, True, "closed", "maker"),
(True, False, "open", None),
],
)
def test_check_dry_limit_order_filled_stoploss(
default_conf, mocker, immediate, crossed, expected_status, expected_fee_type, order_book_l2_usd
):
exchange = get_patched_exchange(mocker, default_conf)
mocker.patch.multiple(
EXMS,
exchange_has=MagicMock(return_value=True),
_dry_is_price_crossed=MagicMock(return_value=crossed),
fetch_l2_order_book=order_book_l2_usd,
)
average_mock = mocker.patch(f"{EXMS}.get_dry_market_fill_price", return_value=24.25)
fee_mock = mocker.patch(
f"{EXMS}.add_dry_order_fee",
autospec=True,
side_effect=lambda self, pair, dry_order, taker_or_maker: dry_order,
)
amount = 1.75
order = {
"symbol": "LTC/USDT",
"status": "open",
"type": "limit",
"side": "sell",
"amount": amount,
"filled": 0.0,
"remaining": amount,
"price": 25.0,
"average": 0.0,
"cost": 0.0,
"fee": None,
"ft_order_type": "stoploss",
"stopLossPrice": 24.5,
}
result = exchange.check_dry_limit_order_filled(order, immediate=immediate)
assert result["status"] == expected_status
assert order_book_l2_usd.call_count == 1
if crossed:
assert result["filled"] == amount
assert result["remaining"] == 0
assert result["average"] == 24.25
assert result["cost"] == pytest.approx(amount * 24.25)
assert average_mock.call_count == 1
assert fee_mock.call_count == 1
assert fee_mock.call_args[0][1] == "LTC/USDT"
assert fee_mock.call_args[0][3] == expected_fee_type
else:
assert result["filled"] == 0.0
assert result["remaining"] == amount
assert result["average"] == 0.0
assert average_mock.call_count == 0
assert fee_mock.call_count == 0
@pytest.mark.parametrize( @pytest.mark.parametrize(
"side,price,filled,converted", "side,price,filled,converted",
[ [
@@ -5229,6 +5304,7 @@ def test_combine_funding_and_mark(
{"date": trade_date, "open": mark_price}, {"date": trade_date, "open": mark_price},
] ]
) )
# Test fallback to futures funding rate for missing funding rates
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate) df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
if futures_funding_rate is not None: if futures_funding_rate is not None:
@@ -5256,6 +5332,33 @@ def test_combine_funding_and_mark(
assert len(df) == 0 assert len(df) == 0
# Test fallback to futures funding rate for middle missing funding rate
funding_rates = DataFrame(
[
{"date": prior2_date, "open": funding_rate},
# missing 1 hour
{"date": trade_date, "open": funding_rate},
],
)
mark_rates = DataFrame(
[
{"date": prior2_date, "open": mark_price},
{"date": prior_date, "open": mark_price},
{"date": trade_date, "open": mark_price},
]
)
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
if futures_funding_rate is not None:
assert len(df) == 3
assert df.iloc[0]["open_fund"] == funding_rate
assert df.iloc[1]["open_fund"] == futures_funding_rate
assert df.iloc[2]["open_fund"] == funding_rate
assert df["date"].to_list() == [prior2_date, prior_date, trade_date]
else:
assert len(df) == 2
assert df["date"].to_list() == [prior2_date, trade_date]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"exchange,rate_start,rate_end,d1,d2,amount,expected_fees", "exchange,rate_start,rate_end,d1,d2,amount,expected_fees",
+2 -1
View File
@@ -123,7 +123,8 @@ def test_create_stoploss_order_dry_run_htx(default_conf, mocker):
assert "type" in order assert "type" in order
assert order["type"] == order_type assert order["type"] == order_type
assert order["price"] == 220 assert order["price"] == 217.8
assert order["stopPrice"] == 220
assert order["amount"] == 1 assert order["amount"] == 1
+13 -13
View File
@@ -2548,9 +2548,9 @@ def test_manage_open_orders_exception(
caplog.clear() caplog.clear()
freqtrade.manage_open_orders() freqtrade.manage_open_orders()
assert log_has_re( assert log_has_re(
r"Cannot query order for Trade\(id=1, pair=ADA/USDT, amount=30.00000000, " r"Cannot query order for Trade\(id=1, pair=ADA/USDT, amount=30, "
r"is_short=False, leverage=1.0, " r"is_short=False, leverage=1, "
r"open_rate=2.00000000, open_since=" r"open_rate=2, open_since="
f"{open_trade_usdt.open_date.strftime('%Y-%m-%d %H:%M:%S')}" f"{open_trade_usdt.open_date.strftime('%Y-%m-%d %H:%M:%S')}"
r"\) due to Traceback \(most recent call last\):\n*", r"\) due to Traceback \(most recent call last\):\n*",
caplog, caplog,
@@ -3751,8 +3751,8 @@ def test_get_real_amount_quote(
# Amount is reduced by "fee" # Amount is reduced by "fee"
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == (amount * 0.001) assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == (amount * 0.001)
assert log_has( assert log_has(
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, is_short=False," "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, is_short=False,"
" leverage=1.0, open_rate=0.24544100, open_since=closed), fee=0.008.", " leverage=1, open_rate=0.245441, open_since=closed), fee=0.008.",
caplog, caplog,
) )
@@ -3805,8 +3805,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
# Amount is reduced by "fee" # Amount is reduced by "fee"
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) is None assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) is None
assert log_has( assert log_has(
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, " "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed) failed: " "is_short=False, leverage=1, open_rate=0.245441, open_since=closed) failed: "
"myTrade-dict empty found", "myTrade-dict empty found",
caplog, caplog,
) )
@@ -3825,8 +3825,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
0, 0,
True, True,
( (
"Fee for Trade Trade(id=None, pair=LTC/ETH, amount=8.00000000, is_short=False, " "Fee for Trade Trade(id=None, pair=LTC/ETH, amount=8, is_short=False, "
"leverage=1.0, open_rate=0.24544100, open_since=closed) [buy]: 0.00094518 BNB -" "leverage=1, open_rate=0.245441, open_since=closed) [buy]: 0.00094518 BNB -"
" rate: None" " rate: None"
), ),
), ),
@@ -3836,8 +3836,8 @@ def test_get_real_amount_no_trade(default_conf_usdt, buy_order_fee, caplog, mock
0.004, 0.004,
False, False,
( (
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, " "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed), fee=0.004." "is_short=False, leverage=1, open_rate=0.245441, open_since=closed), fee=0.004."
), ),
), ),
# invalid, no currency in from fee dict # invalid, no currency in from fee dict
@@ -3941,8 +3941,8 @@ def test_get_real_amount_multi(
assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == expected_amount assert freqtrade.get_real_amount(trade, buy_order_fee, order_obj) == expected_amount
assert log_has( assert log_has(
( (
"Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, " "Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8, "
"is_short=False, leverage=1.0, open_rate=0.24544100, open_since=closed), " "is_short=False, leverage=1, open_rate=0.245441, open_since=closed), "
f"fee={expected_amount}." f"fee={expected_amount}."
), ),
caplog, caplog,
+12 -1
View File
@@ -50,7 +50,14 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
stoploss_order_mock = MagicMock(side_effect=stop_orders) stoploss_order_mock = MagicMock(side_effect=stop_orders)
# Sell 3rd trade (not called for the first trade) # Sell 3rd trade (not called for the first trade)
should_sell_mock = MagicMock(side_effect=[[], [ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)]]) should_sell_mock = MagicMock(side_effect=[[], [ExitCheckTuple(exit_type=ExitType.EXIT_SIGNAL)]])
cancel_order_mock = MagicMock()
def patch_stoploss(order_id, *args, **kwargs):
slo = stoploss_order_open.copy()
slo["id"] = order_id
slo["status"] = "canceled"
return slo
cancel_order_mock = MagicMock(side_effect=patch_stoploss)
mocker.patch.multiple( mocker.patch.multiple(
EXMS, EXMS,
create_stoploss=stoploss, create_stoploss=stoploss,
@@ -793,9 +800,13 @@ def test_dca_handle_similar_open_order(
# Should Create a new exit order # Should Create a new exit order
freqtrade.exchange.amount_to_contract_precision = MagicMock(return_value=2) freqtrade.exchange.amount_to_contract_precision = MagicMock(return_value=2)
freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-2) freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-2)
msg = r"Skipping cancelling stoploss on exchange for.*"
mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False) mocker.patch(f"{EXMS}._dry_is_price_crossed", return_value=False)
assert not log_has_re(msg, caplog)
freqtrade.process() freqtrade.process()
assert log_has_re(msg, caplog)
trade = Trade.get_trades().first() trade = Trade.get_trades().first()
assert trade.orders[-2].status == "closed" assert trade.orders[-2].status == "closed"
+10 -10
View File
@@ -372,8 +372,8 @@ def test_borrowed(fee, is_short, lev, borrowed, trading_mode):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"is_short,open_rate,close_rate,lev,profit,trading_mode", "is_short,open_rate,close_rate,lev,profit,trading_mode",
[ [
(False, 2.0, 2.2, 1.0, 0.09451372, spot), (False, 2, 2.2, 1, 0.09451372, spot),
(True, 2.2, 2.0, 3.0, 0.25894253, margin), (True, 2.2, 2.0, 3, 0.25894253, margin),
], ],
) )
@pytest.mark.usefixtures("init_persistence") @pytest.mark.usefixtures("init_persistence")
@@ -493,8 +493,8 @@ def test_update_limit_order(
assert trade.close_date is None assert trade.close_date is None
assert log_has_re( assert log_has_re(
f"LIMIT_{entry_side.upper()} has been fulfilled for " f"LIMIT_{entry_side.upper()} has been fulfilled for "
r"Trade\(id=2, pair=ADA/USDT, amount=30.00000000, " r"Trade\(id=2, pair=ADA/USDT, amount=30, "
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}0000000, " f"is_short={is_short}, leverage={lev}, open_rate={open_rate}, "
r"open_since=.*\).", r"open_since=.*\).",
caplog, caplog,
) )
@@ -511,8 +511,8 @@ def test_update_limit_order(
assert trade.close_date is not None assert trade.close_date is not None
assert log_has_re( assert log_has_re(
f"LIMIT_{exit_side.upper()} has been fulfilled for " f"LIMIT_{exit_side.upper()} has been fulfilled for "
r"Trade\(id=2, pair=ADA/USDT, amount=30.00000000, " r"Trade\(id=2, pair=ADA/USDT, amount=30, "
f"is_short={is_short}, leverage={lev}, open_rate={open_rate}0000000, " f"is_short={is_short}, leverage={lev}, open_rate={open_rate}, "
r"open_since=.*\).", r"open_since=.*\).",
caplog, caplog,
) )
@@ -545,8 +545,8 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee,
assert trade.close_date is None assert trade.close_date is None
assert log_has_re( assert log_has_re(
r"MARKET_BUY has been fulfilled for Trade\(id=1, " r"MARKET_BUY has been fulfilled for Trade\(id=1, "
r"pair=ADA/USDT, amount=30.00000000, is_short=False, leverage=1.0, " r"pair=ADA/USDT, amount=30, is_short=False, leverage=1, "
r"open_rate=2.00000000, open_since=.*\).", r"open_rate=2, open_since=.*\).",
caplog, caplog,
) )
@@ -561,8 +561,8 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee,
assert trade.close_date is not None assert trade.close_date is not None
assert log_has_re( assert log_has_re(
r"MARKET_SELL has been fulfilled for Trade\(id=1, " r"MARKET_SELL has been fulfilled for Trade\(id=1, "
r"pair=ADA/USDT, amount=30.00000000, is_short=False, leverage=1.0, " r"pair=ADA/USDT, amount=30, is_short=False, leverage=1, "
r"open_rate=2.00000000, open_since=.*\).", r"open_rate=2, open_since=.*\).",
caplog, caplog,
) )
+1
View File
@@ -108,6 +108,7 @@ def test_format_date() -> None:
date = datetime(2021, 9, 30, 22, 59, 3, 455555, tzinfo=UTC) date = datetime(2021, 9, 30, 22, 59, 3, 455555, tzinfo=UTC)
assert format_date(date) == "2021-09-30 22:59:03" assert format_date(date) == "2021-09-30 22:59:03"
assert format_date(None) == "" assert format_date(None) == ""
assert format_date(None, "closed") == "closed"
def test_format_ms_time_detailed() -> None: def test_format_ms_time_detailed() -> None:
+2
View File
@@ -57,6 +57,8 @@ def test_round_value():
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"), 0, True) == "N/A"
assert round_value(float("nan"), 10, True) == "N/A" assert round_value(float("nan"), 10, True) == "N/A"
assert round_value(None, 10, True) == "N/A"
assert round_value(None, 1, True) == "N/A"
def test_format_duration(): def test_format_duration():