diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 1727da92e..ec2958c46 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -5,6 +5,8 @@ bot constants """ from typing import Any, Dict, List, Literal, Tuple +from ccxt import ROUND, ROUND_DOWN, ROUND_UP, TRUNCATE + from freqtrade.enums import CandleType, PriceType, RPCMessageType @@ -50,6 +52,8 @@ DEFAULT_DATAFRAME_COLUMNS = ['date', 'open', 'high', 'low', 'close', 'volume'] # it has wide consequences for stored trades files DEFAULT_TRADES_COLUMNS = ['timestamp', 'id', 'type', 'side', 'price', 'amount', 'cost'] TRADING_MODES = ['spot', 'margin', 'futures'] +PRICE_ROUND_MODES = [TRUNCATE, ROUND, ROUND_UP, ROUND_DOWN] +DEFAULT_PRICE_ROUND_MODE = ROUND_UP MARGIN_MODES = ['cross', 'isolated', ''] LAST_BT_RESULT_FN = '.last_result.json' @@ -476,7 +480,9 @@ CONF_SCHEMA = { 'outdated_offset': {'type': 'integer', 'minimum': 1}, 'markets_refresh_interval': {'type': 'integer'}, 'ccxt_config': {'type': 'object'}, - 'ccxt_async_config': {'type': 'object'} + 'ccxt_async_config': {'type': 'object'}, + 'price_rounding_mode': {'type': 'integer', 'enum': PRICE_ROUND_MODES, + 'default': DEFAULT_PRICE_ROUND_MODE} }, 'required': ['name'] }, diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index cdbda1506..cf7837a9a 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -19,9 +19,9 @@ from ccxt import TICK_SIZE from dateutil import parser from pandas import DataFrame, concat -from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, BidAsk, - BuySell, Config, EntryExit, ListPairsWithTimeframes, MakerTaker, - OBLiteral, PairWithTimeframe) +from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, DEFAULT_PRICE_ROUND_MODE, + NON_OPEN_EXCHANGE_STATES, BidAsk, BuySell, Config, EntryExit, + ListPairsWithTimeframes, MakerTaker, OBLiteral, PairWithTimeframe) from freqtrade.data.converter import clean_ohlcv_dataframe, ohlcv_to_dataframe, trades_dict_to_list from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode from freqtrade.enums.pricetype import PriceType @@ -143,7 +143,9 @@ class Exchange: if config.get('margin_mode') else MarginMode.NONE ) - self.liquidation_buffer = config.get('liquidation_buffer', 0.05) + self.liquidation_buffer = config.get("liquidation_buffer", 0.05) + self._price_rounding_mode = exchange_config.get("price_rounding_mode", + DEFAULT_PRICE_ROUND_MODE) # Deep merge ft_has with default ft_has options self._ft_has = deep_merge_dicts(self._ft_has, deepcopy(self._ft_has_default)) @@ -732,10 +734,11 @@ class Exchange: def price_to_precision(self, pair: str, price: float) -> float: """ - Returns the price rounded up to the precision the Exchange accepts. - Rounds up + Returns the price rounded to the precision the Exchange accepts. + The default price_rounding_mode in conf is ROUND_UP """ - return price_to_precision(price, self.get_precision_price(pair), self.precisionMode) + return price_to_precision(price, self.get_precision_price(pair), + self.precisionMode, self._price_rounding_mode) def price_get_one_pip(self, pair: str, price: float) -> float: """ diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index 6d3371a59..cf2b7986d 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -2,11 +2,12 @@ Exchange support utils """ from datetime import datetime, timedelta, timezone -from math import ceil +from math import ceil, floor from typing import Any, Dict, List, Optional, Tuple import ccxt -from ccxt import ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision +from ccxt import (DECIMAL_PLACES, ROUND, ROUND_DOWN, ROUND_UP, SIGNIFICANT_DIGITS, TICK_SIZE, + TRUNCATE, decimal_to_precision) from freqtrade.exchange.common import BAD_EXCHANGES, EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED from freqtrade.util import FtPrecise @@ -219,35 +220,48 @@ def amount_to_contract_precision( return amount -def price_to_precision(price: float, price_precision: Optional[float], - precisionMode: Optional[int]) -> float: +def price_to_precision( + price: float, + price_precision: Optional[float], + precisionMode: Optional[int], + rounding_mode: int = ROUND_UP, +) -> float: """ Returns the price rounded up to the precision the Exchange accepts. Partial Re-implementation of ccxt internal method decimal_to_precision(), which does not support rounding up TODO: If ccxt supports ROUND_UP for decimal_to_precision(), we could remove this and align with amount_to_precision(). - !!! Rounds up :param price: price to convert :param price_precision: price precision to use. Used from markets[pair]['precision']['price'] :param precisionMode: precision mode to use. Should be used from precisionMode one of ccxt's DECIMAL_PLACES, SIGNIFICANT_DIGITS, or TICK_SIZE + :param rounding_mode: rounding mode to use. Defaults to ROUND_UP :return: price rounded up to the precision the Exchange accepts - """ if price_precision is not None and precisionMode is not None: - # price = float(decimal_to_precision(price, rounding_mode=ROUND, - # precision=price_precision, - # counting_mode=self.precisionMode, - # )) if precisionMode == TICK_SIZE: + if rounding_mode == ROUND: + ticks = price / price_precision + rounded_ticks = round(ticks) + return rounded_ticks * price_precision precision = FtPrecise(price_precision) price_str = FtPrecise(price) missing = price_str % precision if not missing == FtPrecise("0"): - price = round(float(str(price_str - missing + precision)), 14) - else: - symbol_prec = price_precision - big_price = price * pow(10, symbol_prec) - price = ceil(big_price) / pow(10, symbol_prec) + return round(float(str(price_str - missing + precision)), 14) + return price + elif precisionMode in (SIGNIFICANT_DIGITS, DECIMAL_PLACES): + ndigits = round(price_precision) + if rounding_mode == ROUND: + return round(price, ndigits) + ticks = price * (10**ndigits) + if rounding_mode == ROUND_UP: + return ceil(ticks) / (10**ndigits) + if rounding_mode == TRUNCATE: + return int(ticks) / (10**ndigits) + if rounding_mode == ROUND_DOWN: + return floor(ticks) / (10**ndigits) + raise ValueError(f"Unknown rounding_mode {rounding_mode}") + raise ValueError(f"Unknown precisionMode {precisionMode}") return price diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 940319a45..545d36a32 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch import arrow import ccxt import pytest +from ccxt import DECIMAL_PLACES, ROUND, ROUND_UP, TICK_SIZE, TRUNCATE from pandas import DataFrame from freqtrade.enums import CandleType, MarginMode, TradingMode @@ -312,35 +313,47 @@ def test_amount_to_precision(amount, precision_mode, precision, expected,): assert amount_to_precision(amount, precision, precision_mode) == expected -@pytest.mark.parametrize("price,precision_mode,precision,expected", [ - (2.34559, 2, 4, 2.3456), - (2.34559, 2, 5, 2.34559), - (2.34559, 2, 3, 2.346), - (2.9999, 2, 3, 3.000), - (2.9909, 2, 3, 2.991), - # Tests for Tick_size - (2.34559, 4, 0.0001, 2.3456), - (2.34559, 4, 0.00001, 2.34559), - (2.34559, 4, 0.001, 2.346), - (2.9999, 4, 0.001, 3.000), - (2.9909, 4, 0.001, 2.991), - (2.9909, 4, 0.005, 2.995), - (2.9973, 4, 0.005, 3.0), - (2.9977, 4, 0.005, 3.0), - (234.43, 4, 0.5, 234.5), - (234.53, 4, 0.5, 235.0), - (0.891534, 4, 0.0001, 0.8916), - (64968.89, 4, 0.01, 64968.89), - (0.000000003483, 4, 1e-12, 0.000000003483), - +@pytest.mark.parametrize("price,precision_mode,precision,expected,rounding_mode", [ + # Tests for DECIMAL_PLACES, ROUND_UP + (2.34559, 2, 4, 2.3456, ROUND_UP), + (2.34559, 2, 5, 2.34559, ROUND_UP), + (2.34559, 2, 3, 2.346, ROUND_UP), + (2.9999, 2, 3, 3.000, ROUND_UP), + (2.9909, 2, 3, 2.991, ROUND_UP), + # Tests for DECIMAL_PLACES, ROUND + (2.345600000000001, DECIMAL_PLACES, 4, 2.3456, ROUND), + (2.345551, DECIMAL_PLACES, 4, 2.3456, ROUND), + (2.49, DECIMAL_PLACES, 0, 2., ROUND), + (2.51, DECIMAL_PLACES, 0, 3., ROUND), + (5.1, DECIMAL_PLACES, -1, 10., ROUND), + (4.9, DECIMAL_PLACES, -1, 0., ROUND), + # Tests for TICK_SIZE, ROUND_UP + (2.34559, TICK_SIZE, 0.0001, 2.3456, ROUND_UP), + (2.34559, TICK_SIZE, 0.00001, 2.34559, ROUND_UP), + (2.34559, TICK_SIZE, 0.001, 2.346, ROUND_UP), + (2.9999, TICK_SIZE, 0.001, 3.000, ROUND_UP), + (2.9909, TICK_SIZE, 0.001, 2.991, ROUND_UP), + (2.9909, TICK_SIZE, 0.005, 2.995, ROUND_UP), + (2.9973, TICK_SIZE, 0.005, 3.0, ROUND_UP), + (2.9977, TICK_SIZE, 0.005, 3.0, ROUND_UP), + (234.43, TICK_SIZE, 0.5, 234.5, ROUND_UP), + (234.53, TICK_SIZE, 0.5, 235.0, ROUND_UP), + (0.891534, TICK_SIZE, 0.0001, 0.8916, ROUND_UP), + (64968.89, TICK_SIZE, 0.01, 64968.89, ROUND_UP), + (0.000000003483, TICK_SIZE, 1e-12, 0.000000003483, ROUND_UP), + # Tests for TICK_SIZE, ROUND + (2.49, TICK_SIZE, 1., 2., ROUND), + (2.51, TICK_SIZE, 1., 3., ROUND), + (2.000000051, TICK_SIZE, 0.0000001, 2.0000001, ROUND), + (2.000000049, TICK_SIZE, 0.0000001, 2., ROUND), + (2.9909, TICK_SIZE, 0.005, 2.990, ROUND), + (2.9973, TICK_SIZE, 0.005, 2.995, ROUND), + (2.9977, TICK_SIZE, 0.005, 3.0, ROUND), + (234.24, TICK_SIZE, 0.5, 234., ROUND), + (234.26, TICK_SIZE, 0.5, 234.5, ROUND), ]) -def test_price_to_precision(price, precision_mode, precision, expected): - # digits counting mode - # DECIMAL_PLACES = 2 - # SIGNIFICANT_DIGITS = 3 - # TICK_SIZE = 4 - - assert price_to_precision(price, precision, precision_mode) == expected +def test_price_to_precision(price, precision_mode, precision, expected, rounding_mode): + assert price_to_precision(price, precision, precision_mode, rounding_mode) == expected @pytest.mark.parametrize("price,precision_mode,precision,expected", [ @@ -5307,3 +5320,29 @@ def test_stoploss_contract_size(mocker, default_conf, contract_size, order_amoun assert order['cost'] == 100 assert order['filled'] == 100 assert order['remaining'] == 100 + + +def test_price_to_precision_with_default_conf(default_conf, mocker): + conf = copy.deepcopy(default_conf) + patched_ex = get_patched_exchange(mocker, conf) + prec_price = patched_ex.price_to_precision("XRP/USDT", 1.0000000101) + assert prec_price == 1.00000002 + + +@pytest.mark.parametrize( + "rounding_mode, price, expected_price", + [ + (TRUNCATE, 1.0000000199, 1.00000001), + (ROUND, 1.0000000149, 1.00000001), + (ROUND, 1.0000000151, 1.00000002), + (ROUND_UP, 1.0000000101, 1.00000002), + ], +) +def test_price_to_precision_rounding_mode_from_conf( + default_conf, mocker, rounding_mode, price, expected_price +): + conf = copy.deepcopy(default_conf) + conf["exchange"]["price_rounding_mode"] = rounding_mode + patched_ex = get_patched_exchange(mocker, conf) + prec_price = patched_ex.price_to_precision("XRP/USDT", price) + assert prec_price == expected_price