Merge branch 'develop' into feature_keyval_storage

This commit is contained in:
Matthias 2024-02-20 19:21:44 +01:00
commit f1af00dd39
36 changed files with 426 additions and 113 deletions

View File

@ -16,10 +16,10 @@ repos:
additional_dependencies:
- types-cachetools==5.3.0.7
- types-filelock==3.2.7
- types-requests==2.31.0.20240125
- types-requests==2.31.0.20240218
- types-tabulate==0.9.0.20240106
- types-python-dateutil==2.8.19.20240106
- SQLAlchemy==2.0.26
- SQLAlchemy==2.0.27
# stages: [push]
- repo: https://github.com/pycqa/isort
@ -31,7 +31,7 @@ repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version.
rev: 'v0.2.1'
rev: 'v0.2.2'
hooks:
- id: ruff

View File

@ -68,7 +68,7 @@ When used in the leading position of the chain of Pairlist Handlers, the `pair_w
The `refresh_period` setting allows to define the period (in seconds), at which the pairlist will be refreshed. Defaults to 1800s (30 minutes).
The pairlist cache (`refresh_period`) on `VolumePairList` is only applicable to generating pairlists.
Filtering instances (not the first position in the list) will not apply any cache and will always use up-to-date data.
Filtering instances (not the first position in the list) will not apply any cache (beyond caching candles for the duration of the candle in advanced mode) and will always use up-to-date data.
`VolumePairList` is per default based on the ticker data from exchange, as reported by the ccxt library:
@ -201,7 +201,7 @@ The RemotePairList is defined in the pairlists section of the configuration sett
The optional `mode` option specifies if the pairlist should be used as a `blacklist` or as a `whitelist`. The default value is "whitelist".
The optional `processing_mode` option in the RemotePairList configuration determines how the retrieved pairlist is processed. It can have two values: "filter" or "append".
The optional `processing_mode` option in the RemotePairList configuration determines how the retrieved pairlist is processed. It can have two values: "filter" or "append". The default value is "filter".
In "filter" mode, the retrieved pairlist is used as a filter. Only the pairs present in both the original pairlist and the retrieved pairlist are included in the final pairlist. Other pairs are filtered out.

View File

@ -109,7 +109,7 @@ Freqtrade does not depend or install any additional database driver. Please refe
The following systems have been tested and are known to work with freqtrade:
* sqlite (default)
* PostgreSQL)
* PostgreSQL
* MariaDB
!!! Warning

View File

@ -48,11 +48,13 @@ def import_kraken_trades_from_csv(config: Config, convert_to: str):
logger.info(f"Converting pairs: {', '.join(m[0] for m in markets)}.")
for pair, name in markets:
logger.debug(f"Converting pair {pair}, files */{name}.csv")
dfs = []
# Load and combine all csv files for this pair
for f in tradesdir.rglob(f"{name}.csv"):
df = pd.read_csv(f, names=KRAKEN_CSV_TRADE_COLUMNS)
dfs.append(df)
if not df.empty:
dfs.append(df)
# Load existing trades data
if not dfs:

View File

@ -143,8 +143,10 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date'
starting_balance=starting_balance
)
idxmin = max_drawdown_df['drawdown_relative'].idxmax() if relative \
else max_drawdown_df['drawdown'].idxmin()
idxmin = (
max_drawdown_df['drawdown_relative'].idxmax()
if relative else max_drawdown_df['drawdown'].idxmin()
)
if idxmin == 0:
raise ValueError("No losing trade, therefore no drawdown.")
high_date = profit_results.loc[max_drawdown_df.iloc[:idxmin]['high_value'].idxmax(), date_col]
@ -191,6 +193,9 @@ def calculate_cagr(days_passed: int, starting_balance: float, final_balance: flo
:param final_balance: Final balance to calculate CAGR against
:return: CAGR
"""
if final_balance < 0:
# With leveraged trades, final_balance can become negative.
return 0
return (final_balance / starting_balance) ** (1 / (days_passed / 365)) - 1

View File

@ -25,6 +25,7 @@ class Bybit(Exchange):
officially supported by the Freqtrade development team. So some features
may still not work as expected.
"""
unified_account = False
_ft_has: Dict = {
"ohlcv_candle_limit": 1000,
@ -82,9 +83,20 @@ class Bybit(Exchange):
Must be overridden in child methods if required.
"""
try:
if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']:
position_mode = self._api.set_position_mode(False)
self._log_exchange_response('set_position_mode', position_mode)
if not self._config['dry_run']:
if self.trading_mode == TradingMode.FUTURES:
position_mode = self._api.set_position_mode(False)
self._log_exchange_response('set_position_mode', position_mode)
is_unified = self._api.is_unified_enabled()
# Returns a tuple of bools, first for margin, second for Account
if is_unified and len(is_unified) > 1 and is_unified[1]:
self.unified_account = True
logger.info("Bybit: Unified account.")
raise OperationalException("Bybit: Unified account is not supported. "
"Please use a standard (sub)account.")
else:
self.unified_account = False
logger.info("Bybit: Standard account.")
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:

View File

@ -2,7 +2,7 @@ import asyncio
import logging
import time
from functools import wraps
from typing import Any, Callable, Optional, TypeVar, cast, overload
from typing import Any, Callable, Dict, List, Optional, TypeVar, cast, overload
from freqtrade.constants import ExchangeConfig
from freqtrade.exceptions import DDosProtection, RetryableOrderError, TemporaryError
@ -60,16 +60,17 @@ SUPPORTED_EXCHANGES = [
'okx',
]
EXCHANGE_HAS_REQUIRED = [
# either the main, or replacement methods (array) is required
EXCHANGE_HAS_REQUIRED: Dict[str, List[str]] = {
# Required / private
'fetchOrder',
'cancelOrder',
'createOrder',
'fetchBalance',
'fetchOrder': ['fetchOpenOrder', 'fetchClosedOrder'],
'cancelOrder': [],
'createOrder': [],
'fetchBalance': [],
# Public endpoints
'fetchOHLCV',
]
'fetchOHLCV': [],
}
EXCHANGE_HAS_OPTIONAL = [
# Private
@ -86,6 +87,7 @@ EXCHANGE_HAS_OPTIONAL = [
# 'fetchPositions', # Futures trading
# 'fetchLeverageTiers', # Futures initialization
# 'fetchMarketLeverageTiers', # Futures initialization
# 'fetchOpenOrder', 'fetchClosedOrder', # replacement for fetchOrder
# 'fetchOpenOrders', 'fetchClosedOrders', # 'fetchOrders', # Refinding balance...
]

View File

@ -43,6 +43,7 @@ from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.util import dt_from_ts, dt_now
from freqtrade.util.datetime_helpers import dt_humanize, dt_ts
from freqtrade.util.periodic_cache import PeriodicCache
logger = logging.getLogger(__name__)
@ -131,6 +132,7 @@ class Exchange:
# Holds candles
self._klines: Dict[PairWithTimeframe, DataFrame] = {}
self._expiring_candle_cache: Dict[str, PeriodicCache] = {}
# Holds all open sell orders for dry_run
self._dry_run_open_orders: Dict[str, Any] = {}
@ -1242,7 +1244,7 @@ class Exchange:
f'Insufficient funds to create {ordertype} {side} order on market {pair}. '
f'Tried to {side} amount {amount} at rate {limit_rate} with '
f'stop-price {stop_price_norm}. Message: {e}') from e
except (ccxt.InvalidOrder, ccxt.BadRequest) as e:
except (ccxt.InvalidOrder, ccxt.BadRequest, ccxt.OperationRejected) as e:
# Errors:
# `Order would trigger immediately.`
raise InvalidOrderException(
@ -1258,11 +1260,43 @@ class Exchange:
except ccxt.BaseError as e:
raise OperationalException(e) from e
def fetch_order_emulated(self, order_id: str, pair: str, params: Dict) -> Dict:
"""
Emulated fetch_order if the exchange doesn't support fetch_order, but requires separate
calls for open and closed orders.
"""
try:
order = self._api.fetch_open_order(order_id, pair, params=params)
self._log_exchange_response('fetch_open_order', order)
order = self._order_contracts_to_amount(order)
return order
except ccxt.OrderNotFound:
try:
order = self._api.fetch_closed_order(order_id, pair, params=params)
self._log_exchange_response('fetch_closed_order', order)
order = self._order_contracts_to_amount(order)
return order
except ccxt.OrderNotFound as e:
raise RetryableOrderError(
f'Order not found (pair: {pair} id: {order_id}). Message: {e}') from e
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Tried to get an invalid order (pair: {pair} id: {order_id}). Message: {e}') from e
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
raise TemporaryError(
f'Could not get order due to {e.__class__.__name__}. Message: {e}') from e
except ccxt.BaseError as e:
raise OperationalException(e) from e
@retrier(retries=API_FETCH_ORDER_RETRY_COUNT)
def fetch_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict:
if self._config['dry_run']:
return self.fetch_dry_run_order(order_id)
try:
if not self.exchange_has('fetchOrder'):
return self.fetch_order_emulated(order_id, pair, params)
order = self._api.fetch_order(order_id, pair, params=params)
self._log_exchange_response('fetch_order', order)
order = self._order_contracts_to_amount(order)
@ -2124,6 +2158,39 @@ class Exchange:
return results_df
def refresh_ohlcv_with_cache(
self,
pairs: List[PairWithTimeframe],
since_ms: int
) -> Dict[PairWithTimeframe, DataFrame]:
"""
Refresh ohlcv data for all pairs in needed_pairs if necessary.
Caches data with expiring per timeframe.
Should only be used for pairlists which need "on time" expirarion, and no longer cache.
"""
timeframes = [p[1] for p in pairs]
for timeframe in timeframes:
if timeframe not in self._expiring_candle_cache:
timeframe_in_sec = timeframe_to_seconds(timeframe)
# Initialise cache
self._expiring_candle_cache[timeframe] = PeriodicCache(ttl=timeframe_in_sec,
maxsize=1000)
# Get candles from cache
candles = {
c: self._expiring_candle_cache[c[1]].get(c, None) for c in pairs
if c in self._expiring_candle_cache[c[1]]
}
pairs_to_download = [p for p in pairs if p not in candles]
if pairs_to_download:
candles = self.refresh_latest_ohlcv(
pairs_to_download, since_ms=since_ms, cache=False
)
for c, val in candles.items():
self._expiring_candle_cache[c[1]][c] = val
return candles
def _now_is_time_to_refresh(self, pair: str, timeframe: str, candle_type: CandleType) -> bool:
# Timeframe in seconds
interval_in_sec = timeframe_to_seconds(timeframe)
@ -2685,7 +2752,7 @@ class Exchange:
self._log_exchange_response('set_leverage', res)
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except (ccxt.BadRequest, ccxt.InsufficientFunds) as e:
except (ccxt.BadRequest, ccxt.OperationRejected, ccxt.InsufficientFunds) as e:
if not accept_fail:
raise TemporaryError(
f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e
@ -2727,7 +2794,7 @@ class Exchange:
self._log_exchange_response('set_margin_mode', res)
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e
except ccxt.BadRequest as e:
except (ccxt.BadRequest, ccxt.OperationRejected) as e:
if not accept_fail:
raise TemporaryError(
f'Could not set margin mode due to {e.__class__.__name__}. Message: {e}') from e

View File

@ -40,21 +40,34 @@ def available_exchanges(ccxt_module: Optional[CcxtModuleType] = None) -> List[st
def validate_exchange(exchange: str) -> Tuple[bool, str]:
"""
returns: can_use, reason
with Reason including both missing and missing_opt
"""
ex_mod = getattr(ccxt, exchange.lower())()
result = True
reason = ''
if not ex_mod or not ex_mod.has:
return False, ''
missing = [k for k in EXCHANGE_HAS_REQUIRED if ex_mod.has.get(k) is not True]
missing = [
k for k, v in EXCHANGE_HAS_REQUIRED.items()
if ex_mod.has.get(k) is not True
and not (all(ex_mod.has.get(x) for x in v))
]
if missing:
return False, f"missing: {', '.join(missing)}"
result = False
reason += f"missing: {', '.join(missing)}"
missing_opt = [k for k in EXCHANGE_HAS_OPTIONAL if not ex_mod.has.get(k)]
if exchange.lower() in BAD_EXCHANGES:
return False, BAD_EXCHANGES.get(exchange.lower(), '')
if missing_opt:
return True, f"missing opt: {', '.join(missing_opt)}"
result = False
reason = BAD_EXCHANGES.get(exchange.lower(), '')
return True, ''
if missing_opt:
reason += f"{'. ' if reason else ''}missing opt: {', '.join(missing_opt)}. "
return result, reason
def _build_exchange_list_entry(

View File

@ -702,7 +702,7 @@ class FreqtradeBot(LoggingMixin):
delta = f"Delta: {bids_ask_delta}"
logger.info(
f"{bids}, {asks}, {delta}, Direction: {side.value}"
f"{bids}, {asks}, {delta}, Direction: {side.value} "
f"Bid Price: {order_book['bids'][0][0]}, Ask Price: {order_book['asks'][0][0]}, "
f"Immediate Bid Quantity: {order_book['bids'][0][1]}, "
f"Immediate Ask Quantity: {order_book['asks'][0][1]}."

View File

@ -24,7 +24,7 @@ from freqtrade.leverage import interest
from freqtrade.misc import safe_value_fallback
from freqtrade.persistence.base import ModelBase, SessionType
from freqtrade.persistence.custom_data import CustomDataWrapper, _CustomData
from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts
from freqtrade.util import FtPrecise, dt_from_ts, dt_now, dt_ts, dt_ts_none
logger = logging.getLogger(__name__)
@ -225,8 +225,7 @@ class Order(ModelBase):
'amount': self.safe_amount,
'safe_price': self.safe_price,
'ft_order_side': self.ft_order_side,
'order_filled_timestamp': int(self.order_filled_date.replace(
tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None,
'order_filled_timestamp': dt_ts_none(self.order_filled_utc),
'ft_is_entry': self.ft_order_side == entry_side,
'ft_order_tag': self.ft_order_tag,
}
@ -463,6 +462,17 @@ class LocalTrade:
return self.open_date_utc
return max([self.open_date_utc, dt_last_filled])
@property
def date_entry_fill_utc(self) -> Optional[datetime]:
""" Date of the first filled order"""
orders = self.select_filled_orders(self.entry_side)
if (
orders
and len(filled_date := [o.order_filled_utc for o in orders if o.order_filled_utc])
):
return min(filled_date)
return None
@property
def open_date_utc(self):
return self.open_date.replace(tzinfo=timezone.utc)
@ -627,15 +637,17 @@ class LocalTrade:
'fee_close_currency': self.fee_close_currency,
'open_date': self.open_date.strftime(DATETIME_PRINT_FORMAT),
'open_timestamp': int(self.open_date.replace(tzinfo=timezone.utc).timestamp() * 1000),
'open_timestamp': dt_ts_none(self.open_date_utc),
'open_fill_date': (self.date_entry_fill_utc.strftime(DATETIME_PRINT_FORMAT)
if self.date_entry_fill_utc else None),
'open_fill_timestamp': dt_ts_none(self.date_entry_fill_utc),
'open_rate': self.open_rate,
'open_rate_requested': self.open_rate_requested,
'open_trade_value': round(self.open_trade_value, 8),
'close_date': (self.close_date.strftime(DATETIME_PRINT_FORMAT)
if self.close_date else None),
'close_timestamp': int(self.close_date.replace(
tzinfo=timezone.utc).timestamp() * 1000) if self.close_date else None,
'close_timestamp': dt_ts_none(self.close_date_utc),
'realized_profit': self.realized_profit or 0.0,
# Close-profit corresponds to relative realized_profit ratio
'realized_profit_ratio': self.close_profit or None,
@ -661,8 +673,7 @@ class LocalTrade:
'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None,
'stoploss_last_update': (self.stoploss_last_update_utc.strftime(DATETIME_PRINT_FORMAT)
if self.stoploss_last_update_utc else None),
'stoploss_last_update_timestamp': int(self.stoploss_last_update_utc.timestamp() * 1000
) if self.stoploss_last_update_utc else None,
'stoploss_last_update_timestamp': dt_ts_none(self.stoploss_last_update_utc),
'initial_stop_loss_abs': self.initial_stop_loss,
'initial_stop_loss_ratio': (self.initial_stop_loss_pct
if self.initial_stop_loss_pct else None),

View File

@ -104,10 +104,7 @@ class VolatilityFilter(IPairList):
since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days))
# Get all candles
candles = {}
if needed_pairs:
candles = self._exchange.refresh_latest_ohlcv(needed_pairs, since_ms=since_ms,
cache=False)
candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms)
if self._enabled:
for p in deepcopy(pairlist):
@ -125,8 +122,7 @@ class VolatilityFilter(IPairList):
:return: True if the pair can stay, false if it should be removed
"""
# Check symbol in cache
cached_res = self._pair_cache.get(pair, None)
if cached_res is not None:
if (cached_res := self._pair_cache.get(pair, None)) is not None:
return cached_res
result = False

View File

@ -229,12 +229,8 @@ class VolumePairList(IPairList):
if p not in self._pair_cache
]
# Get all candles
candles = {}
if needed_pairs:
candles = self._exchange.refresh_latest_ohlcv(
needed_pairs, since_ms=since_ms, cache=False
)
candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms)
for i, p in enumerate(filtered_tickers):
contract_size = self._exchange.markets[p['symbol']].get('contractSize', 1.0) or 1.0
pair_candles = candles[

View File

@ -101,11 +101,7 @@ class RangeStabilityFilter(IPairList):
(p, '1d', self._def_candletype) for p in pairlist if p not in self._pair_cache]
since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days - 1))
# Get all candles
candles = {}
if needed_pairs:
candles = self._exchange.refresh_latest_ohlcv(needed_pairs, since_ms=since_ms,
cache=False)
candles = self._exchange.refresh_ohlcv_with_cache(needed_pairs, since_ms=since_ms)
if self._enabled:
for p in deepcopy(pairlist):
@ -123,8 +119,7 @@ class RangeStabilityFilter(IPairList):
:return: True if the pair can stay, false if it should be removed
"""
# Check symbol in cache
cached_res = self._pair_cache.get(pair, None)
if cached_res is not None:
if (cached_res := self._pair_cache.get(pair, None)) is not None:
return cached_res
result = True

View File

@ -288,6 +288,8 @@ class TradeSchema(BaseModel):
open_date: str
open_timestamp: int
open_fill_date: Optional[str]
open_fill_timestamp: Optional[int]
open_rate: float
open_rate_requested: Optional[float] = None
open_trade_value: float

View File

@ -1365,7 +1365,7 @@ class Telegram(RPCHandler):
@authorized_only
async def _enter_tag_performance(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /buys PAIR .
Handler for /entries PAIR .
Shows a performance statistic from finished trades
:param bot: telegram bot
:param update: message update
@ -1376,28 +1376,28 @@ class Telegram(RPCHandler):
pair = context.args[0]
trades = self._rpc._rpc_enter_tag_performance(pair)
output = "<b>Entry Tag Performance:</b>\n"
output = "*Entry Tag Performance:*\n"
for i, trade in enumerate(trades):
stat_line = (
f"{i + 1}.\t <code>{trade['enter_tag']}\t"
f"{i + 1}.\t `{trade['enter_tag']}\t"
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_ratio']:.2%}) "
f"({trade['count']})</code>\n")
f"({trade['count']})`\n")
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
await self._send_msg(output, parse_mode=ParseMode.HTML)
await self._send_msg(output, parse_mode=ParseMode.MARKDOWN)
output = stat_line
else:
output += stat_line
await self._send_msg(output, parse_mode=ParseMode.HTML,
await self._send_msg(output, parse_mode=ParseMode.MARKDOWN,
reload_able=True, callback_path="update_enter_tag_performance",
query=update.callback_query)
@authorized_only
async def _exit_reason_performance(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /sells.
Handler for /exits.
Shows a performance statistic from finished trades
:param bot: telegram bot
:param update: message update
@ -1408,21 +1408,21 @@ class Telegram(RPCHandler):
pair = context.args[0]
trades = self._rpc._rpc_exit_reason_performance(pair)
output = "<b>Exit Reason Performance:</b>\n"
output = "*Exit Reason Performance:*\n"
for i, trade in enumerate(trades):
stat_line = (
f"{i + 1}.\t <code>{trade['exit_reason']}\t"
f"{i + 1}.\t `{trade['exit_reason']}\t"
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_ratio']:.2%}) "
f"({trade['count']})</code>\n")
f"({trade['count']})`\n")
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
await self._send_msg(output, parse_mode=ParseMode.HTML)
await self._send_msg(output, parse_mode=ParseMode.MARKDOWN)
output = stat_line
else:
output += stat_line
await self._send_msg(output, parse_mode=ParseMode.HTML,
await self._send_msg(output, parse_mode=ParseMode.MARKDOWN,
reload_able=True, callback_path="update_exit_reason_performance",
query=update.callback_query)
@ -1440,21 +1440,21 @@ class Telegram(RPCHandler):
pair = context.args[0]
trades = self._rpc._rpc_mix_tag_performance(pair)
output = "<b>Mix Tag Performance:</b>\n"
output = "*Mix Tag Performance:*\n"
for i, trade in enumerate(trades):
stat_line = (
f"{i + 1}.\t <code>{trade['mix_tag']}\t"
f"{i + 1}.\t `{trade['mix_tag']}\t"
f"{fmt_coin(trade['profit_abs'], self._config['stake_currency'])} "
f"({trade['profit_ratio']:.2%}) "
f"({trade['count']})</code>\n")
f"({trade['count']})`\n")
if len(output + stat_line) >= MAX_MESSAGE_LENGTH:
await self._send_msg(output, parse_mode=ParseMode.HTML)
await self._send_msg(output, parse_mode=ParseMode.MARKDOWN)
output = stat_line
else:
output += stat_line
await self._send_msg(output, parse_mode=ParseMode.HTML,
await self._send_msg(output, parse_mode=ParseMode.MARKDOWN,
reload_able=True, callback_path="update_mix_tag_performance",
query=update.callback_query)
@ -1677,8 +1677,8 @@ class Telegram(RPCHandler):
" *table :* `will display trades in a table`\n"
" `pending buy orders are marked with an asterisk (*)`\n"
" `pending sell orders are marked with a double asterisk (**)`\n"
"*/buys <pair|none>:* `Shows the enter_tag performance`\n"
"*/sells <pair|none>:* `Shows the exit reason performance`\n"
"*/entries <pair|none>:* `Shows the enter_tag performance`\n"
"*/exits <pair|none>:* `Shows the exit reason performance`\n"
"*/mix_tags <pair|none>:* `Shows combined entry tag + exit reason performance`\n"
"*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n"
"*/profit [<n>]:* `Lists cumulative profit from all finished trades, "

View File

@ -1,6 +1,6 @@
from freqtrade.util.datetime_helpers import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts,
dt_ts_def, dt_utc, format_date, format_ms_time,
shorten_date)
dt_ts_def, dt_ts_none, dt_utc, format_date,
format_ms_time, shorten_date)
from freqtrade.util.formatters import decimals_per_coin, fmt_coin, round_value
from freqtrade.util.ft_precise import FtPrecise
from freqtrade.util.periodic_cache import PeriodicCache
@ -14,6 +14,7 @@ __all__ = [
'dt_now',
'dt_ts',
'dt_ts_def',
'dt_ts_none',
'dt_utc',
'format_date',
'format_ms_time',

View File

@ -31,13 +31,23 @@ def dt_ts(dt: Optional[datetime] = None) -> int:
def dt_ts_def(dt: Optional[datetime], default: int = 0) -> int:
"""
Return dt in ms as a timestamp in UTC.
If dt is None, return the current datetime in UTC.
If dt is None, return the given default.
"""
if dt:
return int(dt.timestamp() * 1000)
return default
def dt_ts_none(dt: Optional[datetime]) -> Optional[int]:
"""
Return dt in ms as a timestamp in UTC.
If dt is None, return the given default.
"""
if dt:
return int(dt.timestamp() * 1000)
return None
def dt_floor_day(dt: datetime) -> datetime:
"""Return the floor of the day for the given datetime."""
return dt.replace(hour=0, minute=0, second=0, microsecond=0)

View File

@ -7,10 +7,10 @@
-r docs/requirements-docs.txt
coveralls==3.3.1
ruff==0.2.1
ruff==0.2.2
mypy==1.8.0
pre-commit==3.6.1
pytest==8.0.0
pre-commit==3.6.2
pytest==8.0.1
pytest-asyncio==0.23.5
pytest-cov==4.1.0
pytest-mock==3.12.0
@ -26,6 +26,6 @@ nbconvert==7.16.0
# mypy types
types-cachetools==5.3.0.7
types-filelock==3.2.7
types-requests==2.31.0.20240125
types-requests==2.31.0.20240218
types-tabulate==0.9.0.20240106
types-python-dateutil==2.8.19.20240106

View File

@ -3,10 +3,10 @@
-r requirements-plot.txt
# Required for freqai
scikit-learn==1.4.0
scikit-learn==1.4.1.post1
joblib==1.3.2
catboost==1.2.2; 'arm' not in platform_machine and python_version < '3.12'
lightgbm==4.3.0
xgboost==2.0.3
tensorboard==2.15.2
tensorboard==2.16.2
datasieve==0.1.7

View File

@ -3,6 +3,6 @@
# Required for hyperopt
scipy==1.12.0
scikit-learn==1.4.0
scikit-learn==1.4.1.post1
ft-scikit-optimize==0.9.2
filelock==3.13.1

View File

@ -1,4 +1,4 @@
# Include all requirements to run the bot.
-r requirements.txt
plotly==5.18.0
plotly==5.19.0

View File

@ -2,17 +2,17 @@ numpy==1.26.4
pandas==2.1.4
pandas-ta==0.3.14b
ccxt==4.2.42
cryptography==42.0.2
ccxt==4.2.47
cryptography==42.0.3
aiohttp==3.9.3
SQLAlchemy==2.0.26
SQLAlchemy==2.0.27
python-telegram-bot==20.8
# can't be hard-pinned due to telegram-bot pinning httpx with ~
httpx>=0.24.1
arrow==1.3.0
cachetools==5.3.2
requests==2.31.0
urllib3==2.2.0
urllib3==2.2.1
jsonschema==4.21.1
TA-Lib==0.4.28
technical==1.4.3
@ -30,7 +30,7 @@ py_find_1st==1.1.6
# Load ticker files 30% faster
python-rapidjson==1.14
# Properly format api responses
orjson==3.9.13
orjson==3.9.14
# Notify systemd
sdnotify==0.3.2

View File

@ -70,7 +70,7 @@ setup(
],
install_requires=[
# from requirements.txt
'ccxt>=4.2.15',
'ccxt>=4.2.47',
'SQLAlchemy>=2.0.6',
'python-telegram-bot>=20.1',
'arrow>=1.0.0',

View File

@ -455,6 +455,13 @@ def test_calculate_max_drawdown2():
with pytest.raises(ValueError, match='No losing trade, therefore no drawdown.'):
calculate_max_drawdown(df, date_col='open_date', value_col='profit')
df1 = DataFrame(zip(values[:5], dates[:5]), columns=['profit', 'open_date'])
df1.loc[:, 'profit'] = df1['profit'] * -1
# No winning trade ...
drawdown, hdate, ldate, hval, lval, drawdown_rel = calculate_max_drawdown(
df1, date_col='open_date', value_col='profit')
assert drawdown == 0.043965
@pytest.mark.parametrize('profits,relative,highd,lowd,result,result_rel', [
([0.0, -500.0, 500.0, 10000.0, -1000.0], False, 3, 4, 1000.0, 0.090909),

View File

@ -1,20 +1,40 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from freqtrade.enums.marginmode import MarginMode
from freqtrade.enums.tradingmode import TradingMode
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange
from freqtrade.exceptions import OperationalException
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has
from tests.exchange.test_exchange import ccxt_exceptionhandlers
def test_additional_exchange_init_bybit(default_conf, mocker):
def test_additional_exchange_init_bybit(default_conf, mocker, caplog):
default_conf['dry_run'] = False
default_conf['trading_mode'] = TradingMode.FUTURES
default_conf['margin_mode'] = MarginMode.ISOLATED
api_mock = MagicMock()
api_mock.set_position_mode = MagicMock(return_value={"dualSidePosition": False})
get_patched_exchange(mocker, default_conf, id="bybit", api_mock=api_mock)
api_mock.is_unified_enabled = MagicMock(return_value=[False, False])
exchange = get_patched_exchange(mocker, default_conf, id="bybit", api_mock=api_mock)
assert api_mock.set_position_mode.call_count == 1
assert api_mock.is_unified_enabled.call_count == 1
assert exchange.unified_account is False
assert log_has("Bybit: Standard account.", caplog)
api_mock.set_position_mode.reset_mock()
api_mock.is_unified_enabled = MagicMock(return_value=[False, True])
with pytest.raises(OperationalException, match=r"Bybit: Unified account is not supported.*"):
get_patched_exchange(mocker, default_conf, id="bybit", api_mock=api_mock)
assert log_has("Bybit: Unified account.", caplog)
# exchange = get_patched_exchange(mocker, default_conf, id="bybit", api_mock=api_mock)
# assert api_mock.set_position_mode.call_count == 1
# assert api_mock.is_unified_enabled.call_count == 1
# assert exchange.unified_account is True
ccxt_exceptionhandlers(mocker, default_conf, api_mock, 'bybit',
"additional_exchange_init", "set_position_mode")
@ -111,6 +131,7 @@ def test_bybit_fetch_order_canceled_empty(default_conf_usdt, mocker):
'amount': 20.0,
})
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, id='bybit')
res = exchange.fetch_order('123', 'BTC/USDT')

View File

@ -2303,6 +2303,66 @@ def test_refresh_latest_ohlcv_cache(mocker, default_conf, candle_type, time_mach
assert res[pair2].at[0, 'open']
def test_refresh_ohlcv_with_cache(mocker, default_conf, time_machine) -> None:
start = datetime(2021, 8, 1, 0, 0, 0, 0, tzinfo=timezone.utc)
ohlcv = generate_test_data_raw('1h', 100, start.strftime('%Y-%m-%d'))
time_machine.move_to(start, tick=False)
pairs = [
('ETH/BTC', '1d', CandleType.SPOT),
('TKN/BTC', '1d', CandleType.SPOT),
('LTC/BTC', '1d', CandleType.SPOT),
('LTC/BTC', '5m', CandleType.SPOT),
('LTC/BTC', '1h', CandleType.SPOT),
]
ohlcv_data = {
p: ohlcv for p in pairs
}
ohlcv_mock = mocker.patch(f"{EXMS}.refresh_latest_ohlcv", return_value=ohlcv_data)
mocker.patch(f"{EXMS}.ohlcv_candle_limit", return_value=100)
exchange = get_patched_exchange(mocker, default_conf)
assert len(exchange._expiring_candle_cache) == 0
res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp())
assert ohlcv_mock.call_count == 1
assert ohlcv_mock.call_args_list[0][0][0] == pairs
assert len(ohlcv_mock.call_args_list[0][0][0]) == 5
assert len(res) == 5
# length of 3 - as we have 3 different timeframes
assert len(exchange._expiring_candle_cache) == 3
ohlcv_mock.reset_mock()
res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp())
assert ohlcv_mock.call_count == 0
# Expire 5m cache
time_machine.move_to(start + timedelta(minutes=6), tick=False)
ohlcv_mock.reset_mock()
res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp())
assert ohlcv_mock.call_count == 1
assert len(ohlcv_mock.call_args_list[0][0][0]) == 1
# Expire 5m and 1h cache
time_machine.move_to(start + timedelta(hours=2), tick=False)
ohlcv_mock.reset_mock()
res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp())
assert ohlcv_mock.call_count == 1
assert len(ohlcv_mock.call_args_list[0][0][0]) == 2
# Expire all caches
time_machine.move_to(start + timedelta(days=1, hours=2), tick=False)
ohlcv_mock.reset_mock()
res = exchange.refresh_ohlcv_with_cache(pairs, start.timestamp())
assert ohlcv_mock.call_count == 1
assert len(ohlcv_mock.call_args_list[0][0][0]) == 5
assert ohlcv_mock.call_args_list[0][0][0] == pairs
@pytest.mark.parametrize("exchange_name", EXCHANGES)
async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_name):
ohlcv = [
@ -3177,6 +3237,7 @@ def test_is_cancel_order_result_suitable(mocker, default_conf, exchange_name, or
def test_cancel_order_with_result(default_conf, mocker, exchange_name, corder,
call_corder, call_forder):
default_conf['dry_run'] = False
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
api_mock = MagicMock()
api_mock.cancel_order = MagicMock(return_value=corder)
api_mock.fetch_order = MagicMock(return_value={})
@ -3190,6 +3251,7 @@ def test_cancel_order_with_result(default_conf, mocker, exchange_name, corder,
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_cancel_order_with_result_error(default_conf, mocker, exchange_name, caplog):
default_conf['dry_run'] = False
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
api_mock = MagicMock()
api_mock.cancel_order = MagicMock(side_effect=ccxt.InvalidOrder("Did not find order"))
api_mock.fetch_order = MagicMock(side_effect=ccxt.InvalidOrder("Did not find order"))
@ -3287,6 +3349,7 @@ def test_fetch_order(default_conf, mocker, exchange_name, caplog):
order.myid = 123
order.symbol = 'TKN/BTC'
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
exchange = get_patched_exchange(mocker, default_conf, id=exchange_name)
exchange._dry_run_open_orders['X'] = order
assert exchange.fetch_order('X', 'TKN/BTC').myid == 123
@ -3331,10 +3394,80 @@ def test_fetch_order(default_conf, mocker, exchange_name, caplog):
order_id='_', pair='TKN/BTC')
@pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_order_emulated(default_conf, mocker, exchange_name, caplog):
default_conf['dry_run'] = True
default_conf['exchange']['log_responses'] = True
order = MagicMock()
order.myid = 123
order.symbol = 'TKN/BTC'
exchange = get_patched_exchange(mocker, default_conf, id=exchange_name)
mocker.patch(f'{EXMS}.exchange_has', return_value=False)
exchange._dry_run_open_orders['X'] = order
# Dry run - regular fetch_order behavior
assert exchange.fetch_order('X', 'TKN/BTC').myid == 123
with pytest.raises(InvalidOrderException, match=r'Tried to get an invalid dry-run-order.*'):
exchange.fetch_order('Y', 'TKN/BTC')
default_conf['dry_run'] = False
mocker.patch(f'{EXMS}.exchange_has', return_value=False)
api_mock = MagicMock()
api_mock.fetch_open_order = MagicMock(
return_value={'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'})
api_mock.fetch_closed_order = MagicMock(
return_value={'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'})
exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name)
assert exchange.fetch_order(
'X', 'TKN/BTC') == {'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'}
assert log_has(
("API fetch_open_order: {\'id\': \'123\', \'amount\': 2, \'symbol\': \'TKN/BTC\'}"
),
caplog
)
assert api_mock.fetch_open_order.call_count == 1
assert api_mock.fetch_closed_order.call_count == 0
caplog.clear()
# open_order doesn't find order
api_mock.fetch_open_order = MagicMock(side_effect=ccxt.OrderNotFound("Order not found"))
api_mock.fetch_closed_order = MagicMock(
return_value={'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'})
exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name)
assert exchange.fetch_order(
'X', 'TKN/BTC') == {'id': '123', 'amount': 2, 'symbol': 'TKN/BTC'}
assert log_has(
("API fetch_closed_order: {\'id\': \'123\', \'amount\': 2, \'symbol\': \'TKN/BTC\'}"
),
caplog
)
assert api_mock.fetch_open_order.call_count == 1
assert api_mock.fetch_closed_order.call_count == 1
caplog.clear()
with pytest.raises(InvalidOrderException):
api_mock.fetch_open_order = MagicMock(side_effect=ccxt.InvalidOrder("Order not found"))
api_mock.fetch_closed_order = MagicMock(side_effect=ccxt.InvalidOrder("Order not found"))
exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name)
exchange.fetch_order(order_id='_', pair='TKN/BTC')
assert api_mock.fetch_open_order.call_count == 1
api_mock.fetch_open_order = MagicMock(side_effect=ccxt.OrderNotFound("Order not found"))
exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name)
ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name,
'fetch_order_emulated', 'fetch_open_order',
retries=1,
order_id='_', pair='TKN/BTC', params={})
@pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_stoploss_order(default_conf, mocker, exchange_name):
default_conf['dry_run'] = True
mocker.patch(f"{EXMS}.exchange_has", return_value=True)
order = MagicMock()
order.myid = 123
exchange = get_patched_exchange(mocker, default_conf, id=exchange_name)

View File

@ -324,7 +324,8 @@ def get_futures_exchange(exchange_name, exchange_conf, class_mocker):
@pytest.fixture(params=EXCHANGES, scope="class")
def exchange(request, exchange_conf):
def exchange(request, exchange_conf, class_mocker):
class_mocker.patch('freqtrade.exchange.bybit.Bybit.additional_exchange_init')
yield from get_exchange(request.param, exchange_conf)

View File

@ -1400,6 +1400,8 @@ def test_to_json(fee):
'is_open': None,
'open_date': trade.open_date.strftime(DATETIME_PRINT_FORMAT),
'open_timestamp': int(trade.open_date.timestamp() * 1000),
'open_fill_date': None,
'open_fill_timestamp': None,
'close_date': None,
'close_timestamp': None,
'open_rate': 0.123,
@ -1486,6 +1488,8 @@ def test_to_json(fee):
'quote_currency': 'BTC',
'open_date': trade.open_date.strftime(DATETIME_PRINT_FORMAT),
'open_timestamp': int(trade.open_date.timestamp() * 1000),
'open_fill_date': None,
'open_fill_timestamp': None,
'close_date': trade.close_date.strftime(DATETIME_PRINT_FORMAT),
'close_timestamp': int(trade.close_date.timestamp() * 1000),
'open_rate': 0.123,

View File

@ -223,7 +223,7 @@ def test_trade_serialize_load_back(fee):
'realized_profit_ratio', 'close_profit_pct',
'trade_duration_s', 'trade_duration',
'profit_ratio', 'profit_pct', 'profit_abs', 'stop_loss_abs',
'initial_stop_loss_abs',
'initial_stop_loss_abs', 'open_fill_date', 'open_fill_timestamp',
'orders',
]
failed = []

View File

@ -621,13 +621,20 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t
([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume",
"lookback_timeframe": "1d", "lookback_period": 6, "refresh_period": 86400}],
"BTC", "binance", ['LTC/BTC', 'XRP/BTC', 'ETH/BTC', 'HOT/BTC', 'NEO/BTC']),
# VolumePairlist in range mode as filter.
# TKN/BTC is removed because it doesn't have enough candles
([{"method": "VolumePairList", "number_assets": 5},
{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume",
"lookback_timeframe": "1d", "lookback_period": 2, "refresh_period": 86400}],
"BTC", "binance", ['LTC/BTC', 'XRP/BTC', 'ETH/BTC', 'TKN/BTC', 'HOT/BTC']),
# ftx data is already in Quote currency, therefore won't require conversion
# ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume",
# "lookback_timeframe": "1d", "lookback_period": 1, "refresh_period": 86400}],
# "BTC", "ftx", ['HOT/BTC', 'LTC/BTC', 'ETH/BTC', 'TKN/BTC', 'XRP/BTC']),
])
def test_VolumePairList_range(mocker, whitelist_conf, shitcoinmarkets, tickers, ohlcv_history,
pairlists, base_currency, exchange, volumefilter_result) -> None:
def test_VolumePairList_range(
mocker, whitelist_conf, shitcoinmarkets, tickers, ohlcv_history,
pairlists, base_currency, exchange, volumefilter_result, time_machine) -> None:
whitelist_conf['pairlists'] = pairlists
whitelist_conf['stake_currency'] = base_currency
whitelist_conf['exchange']['name'] = exchange
@ -686,23 +693,36 @@ def test_VolumePairList_range(mocker, whitelist_conf, shitcoinmarkets, tickers,
get_tickers=tickers,
markets=PropertyMock(return_value=shitcoinmarkets)
)
start_dt = dt_now()
time_machine.move_to(start_dt)
# remove ohlcv when looback_timeframe != 1d
# to enforce fallback to ticker data
if 'lookback_timeframe' in pairlists[0]:
if pairlists[0]['lookback_timeframe'] != '1d':
ohlcv_data = []
ohlcv_data = {}
mocker.patch.multiple(
EXMS,
refresh_latest_ohlcv=MagicMock(return_value=ohlcv_data),
)
ohclv_mock = mocker.patch(f"{EXMS}.refresh_latest_ohlcv", return_value=ohlcv_data)
freqtrade.pairlists.refresh_pairlist()
whitelist = freqtrade.pairlists.whitelist
assert ohclv_mock.call_count == 1
assert isinstance(whitelist, list)
assert whitelist == volumefilter_result
# Test caching
ohclv_mock.reset_mock()
freqtrade.pairlists.refresh_pairlist()
# in "filter" mode, caching is disabled.
assert ohclv_mock.call_count == 0
whitelist = freqtrade.pairlists.whitelist
assert whitelist == volumefilter_result
time_machine.move_to(start_dt + timedelta(days=2))
ohclv_mock.reset_mock()
freqtrade.pairlists.refresh_pairlist()
assert ohclv_mock.call_count == 1
whitelist = freqtrade.pairlists.whitelist
assert whitelist == volumefilter_result
def test_PrecisionFilter_error(mocker, whitelist_conf) -> None:

View File

@ -25,6 +25,8 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None:
'quote_currency': 'BTC',
'open_date': ANY,
'open_timestamp': ANY,
'open_fill_date': ANY,
'open_fill_timestamp': ANY,
'is_open': ANY,
'fee_open': ANY,
'fee_open_cost': ANY,

View File

@ -180,7 +180,9 @@ def test_api_auth():
def test_api_ws_auth(botclient):
ftbot, client = botclient
def url(token): return f"/api/v1/message/ws?token={token}"
def url(token):
return f"/api/v1/message/ws?token={token}"
bad_token = "bad-ws_token"
with pytest.raises(WebSocketDisconnect):
@ -1165,6 +1167,8 @@ def test_api_status(botclient, mocker, ticker, fee, markets, is_short,
'current_rate': current_rate,
'open_date': ANY,
'open_timestamp': ANY,
'open_fill_date': ANY,
'open_fill_timestamp': ANY,
'open_rate': 0.123,
'pair': 'ETH/BTC',
'base_currency': 'ETH',
@ -1368,6 +1372,8 @@ def test_api_force_entry(botclient, mocker, fee, endpoint):
'close_rate': 0.265441,
'open_date': ANY,
'open_timestamp': ANY,
'open_fill_date': ANY,
'open_fill_timestamp': ANY,
'open_rate': 0.245441,
'pair': 'ETH/BTC',
'base_currency': 'ETH',

View File

@ -1507,7 +1507,7 @@ async def test_telegram_entry_tag_performance_handle(
await telegram._enter_tag_performance(update=update, context=context)
assert msg_mock.call_count == 1
assert 'Entry Tag Performance' in msg_mock.call_args_list[0][0][0]
assert '<code>TEST1\t3.987 USDT (5.00%) (1)</code>' in msg_mock.call_args_list[0][0][0]
assert '`TEST1\t3.987 USDT (5.00%) (1)`' in msg_mock.call_args_list[0][0][0]
context.args = ['XRP/USDT']
await telegram._enter_tag_performance(update=update, context=context)
@ -1538,7 +1538,7 @@ async def test_telegram_exit_reason_performance_handle(
await telegram._exit_reason_performance(update=update, context=context)
assert msg_mock.call_count == 1
assert 'Exit Reason Performance' in msg_mock.call_args_list[0][0][0]
assert '<code>roi\t2.842 USDT (10.00%) (1)</code>' in msg_mock.call_args_list[0][0][0]
assert '`roi\t2.842 USDT (10.00%) (1)`' in msg_mock.call_args_list[0][0][0]
context.args = ['XRP/USDT']
await telegram._exit_reason_performance(update=update, context=context)
@ -1570,7 +1570,7 @@ async def test_telegram_mix_tag_performance_handle(default_conf_usdt, update, ti
await telegram._mix_tag_performance(update=update, context=context)
assert msg_mock.call_count == 1
assert 'Mix Tag Performance' in msg_mock.call_args_list[0][0][0]
assert ('<code>TEST3 roi\t2.842 USDT (10.00%) (1)</code>'
assert ('`TEST3 roi\t2.842 USDT (10.00%) (1)`'
in msg_mock.call_args_list[0][0][0])
context.args = ['XRP/USDT']

View File

@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone
import pytest
import time_machine
from freqtrade.util import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, dt_ts_def, dt_utc,
format_date, format_ms_time, shorten_date)
from freqtrade.util import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, dt_ts_def,
dt_ts_none, dt_utc, format_date, format_ms_time, shorten_date)
def test_dt_now():
@ -29,6 +29,13 @@ def test_dt_ts_def():
assert dt_ts_def(datetime(2023, 5, 5, tzinfo=timezone.utc), 123) == 1683244800000
def test_dt_ts_none():
assert dt_ts_none(None) is None
assert dt_ts_none(None) is None
assert dt_ts_none(datetime(2023, 5, 5, tzinfo=timezone.utc)) == 1683244800000
assert dt_ts_none(datetime(2023, 5, 5, tzinfo=timezone.utc)) == 1683244800000
def test_dt_utc():
assert dt_utc(2023, 5, 5) == datetime(2023, 5, 5, tzinfo=timezone.utc)
assert dt_utc(2023, 5, 5, 0, 0, 0, 555500) == datetime(2023, 5, 5, 0, 0, 0, 555500,

View File

@ -5,7 +5,7 @@ from freqtrade.util import PeriodicCache
def test_ttl_cache():
with time_machine.travel("2021-09-01 05:00:00 +00:00") as t:
with time_machine.travel("2021-09-01 05:00:00 +00:00", tick=False) as t:
cache = PeriodicCache(5, ttl=60)
cache1h = PeriodicCache(5, ttl=3600)