diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 407894bd2..216a53bc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,10 @@ jobs: python-version: ["3.8", "3.9", "3.10"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} @@ -118,10 +118,10 @@ jobs: python-version: ["3.8", "3.9", "3.10"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} @@ -210,10 +210,10 @@ jobs: python-version: ["3.8", "3.9", "3.10"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} @@ -262,14 +262,14 @@ jobs: docs_check: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Documentation syntax run: | ./tests/test_docs.sh - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 @@ -325,10 +325,10 @@ jobs: if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: 3.8 @@ -405,7 +405,7 @@ jobs: if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Extract branch name shell: bash diff --git a/.github/workflows/docker_update_readme.yml b/.github/workflows/docker_update_readme.yml index 95a6e82e2..ebb773ad7 100644 --- a/.github/workflows/docker_update_readme.yml +++ b/.github/workflows/docker_update_readme.yml @@ -8,7 +8,7 @@ jobs: dockerHubDescription: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Docker Hub Description uses: peter-evans/dockerhub-description@v2.4.3 env: diff --git a/README.md b/README.md index 166e4833a..efa334a27 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Please read the [exchange specific notes](docs/exchanges.md) to learn about even - [X] [Binance](https://www.binance.com/) - [X] [Bittrex](https://bittrex.com/) -- [X] [FTX](https://ftx.com) +- [X] [FTX](https://ftx.com/#a=2258149) - [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Huobi](http://huobi.com/) - [X] [Kraken](https://kraken.com/) diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index d675fb1a9..7931476b4 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -56,7 +56,8 @@ "forcebuy": "market", "stoploss": "market", "stoploss_on_exchange": false, - "stoploss_on_exchange_interval": 60 + "stoploss_on_exchange_interval": 60, + "stoploss_on_exchange_limit_ratio": 0.99 }, "order_time_in_force": { "buy": "gtc", diff --git a/docs/index.md b/docs/index.md index 32b19bd94..2aa80c240 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,7 +44,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual, - [X] [Binance](https://www.binance.com/) - [X] [Bittrex](https://bittrex.com/) -- [X] [FTX](https://ftx.com) +- [X] [FTX](https://ftx.com/#a=2258149) - [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Huobi](http://huobi.com/) - [X] [Kraken](https://kraken.com/) diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 839485629..0ca0e4b63 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,4 +1,4 @@ mkdocs==1.2.3 -mkdocs-material==8.2.3 +mkdocs-material==8.2.5 mdx_truly_sane_lists==1.2 pymdown-extensions==9.2 diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 066a07c62..200917d43 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -140,7 +140,7 @@ CONF_SCHEMA = { 'minProperties': 1 }, 'amount_reserve_percent': {'type': 'number', 'minimum': 0.0, 'maximum': 0.5}, - 'stoploss': {'type': 'number', 'maximum': 0, 'exclusiveMaximum': True}, + 'stoploss': {'type': 'number', 'maximum': 0, 'exclusiveMaximum': True, 'minimum': -1}, 'trailing_stop': {'type': 'boolean'}, 'trailing_stop_positive': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'trailing_stop_positive_offset': {'type': 'number', 'minimum': 0, 'maximum': 1}, diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index da89a7c8a..cfd12622b 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -9,7 +9,7 @@ import logging from copy import deepcopy from datetime import datetime, timedelta, timezone from math import ceil -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Coroutine, Dict, List, Optional, Tuple import arrow import ccxt @@ -1371,6 +1371,22 @@ class Exchange: data = sorted(data, key=lambda x: x[0]) return pair, timeframe, data + def _build_coroutine(self, pair: str, timeframe: str, since_ms: Optional[int]) -> Coroutine: + if not since_ms and self.required_candle_call_count > 1: + # Multiple calls for one pair - to get more history + one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe) + move_to = one_call * self.required_candle_call_count + now = timeframe_to_next_date(timeframe) + since_ms = int((now - timedelta(seconds=move_to // 1000)).timestamp() * 1000) + + if since_ms: + return self._async_get_historic_ohlcv( + pair, timeframe, since_ms=since_ms, raise_=True) + else: + # One call ... "regular" refresh + return self._async_get_candle_history( + pair, timeframe, since_ms=since_ms) + def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *, since_ms: Optional[int] = None, cache: bool = True ) -> Dict[Tuple[str, str], DataFrame]: @@ -1389,22 +1405,15 @@ class Exchange: cached_pairs = [] # Gather coroutines to run for pair, timeframe in set(pair_list): + if timeframe not in self.timeframes: + logger.warning( + f"Cannot download ({pair}, {timeframe}) combination as this timeframe is " + f"not available on {self.name}. Available timeframes are " + f"{', '.join(self.timeframes)}.") + continue if ((pair, timeframe) not in self._klines or not cache or self._now_is_time_to_refresh(pair, timeframe)): - if not since_ms and self.required_candle_call_count > 1: - # Multiple calls for one pair - to get more history - one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe) - move_to = one_call * self.required_candle_call_count - now = timeframe_to_next_date(timeframe) - since_ms = int((now - timedelta(seconds=move_to // 1000)).timestamp() * 1000) - - if since_ms: - input_coroutines.append(self._async_get_historic_ohlcv( - pair, timeframe, since_ms=since_ms, raise_=True)) - else: - # One call ... "regular" refresh - input_coroutines.append(self._async_get_candle_history( - pair, timeframe, since_ms=since_ms)) + input_coroutines.append(self._build_coroutine(pair, timeframe, since_ms)) else: logger.debug( "Using cached candle (OHLCV) data for pair %s, timeframe %s ...", diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 207bfbc12..16864f814 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -873,11 +873,15 @@ class FreqtradeBot(LoggingMixin): stop_price = trade.open_rate * (1 + stoploss) if self.create_stoploss_order(trade=trade, stop_price=stop_price): + # The above will return False if the placement failed and the trade was force-sold. + # in which case the trade will be closed - which we must check below. trade.stoploss_last_update = datetime.utcnow() return False # If stoploss order is canceled for some reason we add it - if stoploss_order and stoploss_order['status'] in ('canceled', 'cancelled'): + if (trade.is_open + and stoploss_order + and stoploss_order['status'] in ('canceled', 'cancelled')): if self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss): return False else: @@ -887,7 +891,7 @@ class FreqtradeBot(LoggingMixin): # Finally we check if stoploss on exchange should be moved up because of trailing. # Triggered Orders are now real orders - so don't replace stoploss anymore if ( - stoploss_order + trade.is_open and stoploss_order and stoploss_order.get('status_stop') != 'triggered' and (self.config.get('trailing_stop', False) or self.config.get('use_custom_stoploss', False)) @@ -907,7 +911,9 @@ class FreqtradeBot(LoggingMixin): :param order: Current on exchange stoploss order :return: None """ - if self.exchange.stoploss_adjust(trade.stop_loss, order): + stoploss_norm = self.exchange.price_to_precision(trade.pair, trade.stop_loss) + + if self.exchange.stoploss_adjust(stoploss_norm, order): # we check if the update is necessary update_beat = self.strategy.order_types.get('stoploss_on_exchange_interval', 60) if (datetime.utcnow() - trade.stoploss_last_update).total_seconds() >= update_beat: diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index af093a1eb..92d1def30 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -304,7 +304,7 @@ class LocalTrade(): # absolute value of the initial stop loss initial_stop_loss: float = 0.0 # percentage value of the initial stop loss - initial_stop_loss_pct: float = 0.0 + initial_stop_loss_pct: Optional[float] = None # stoploss order id which is on exchange stoploss_order_id: Optional[str] = None # last update time of the stoploss order on exchange @@ -446,7 +446,8 @@ class LocalTrade(): new_loss = float(current_price * (1 - abs(stoploss))) # no stop loss assigned yet - if not self.stop_loss: + # if not self.stop_loss: + if self.initial_stop_loss_pct is None: logger.debug(f"{self.pair} - Assigning new stoploss...") self._set_new_stoploss(new_loss, stoploss) self.initial_stop_loss = new_loss @@ -786,6 +787,7 @@ class LocalTrade(): logger.info(f"Stoploss for {trade} needs adjustment...") # Force reset of stoploss trade.stop_loss = None + trade.initial_stop_loss_pct = None trade.adjust_stop_loss(trade.open_rate, desired_stoploss) logger.info(f"New stoploss: {trade.stop_loss}.") diff --git a/freqtrade/rpc/fiat_convert.py b/freqtrade/rpc/fiat_convert.py index 82a6a4778..70f3647b6 100644 --- a/freqtrade/rpc/fiat_convert.py +++ b/freqtrade/rpc/fiat_convert.py @@ -23,6 +23,7 @@ coingecko_mapping = { 'eth': 'ethereum', 'bnb': 'binancecoin', 'sol': 'solana', + 'usdt': 'tether', } diff --git a/requirements-dev.txt b/requirements-dev.txt index 9fc8a18ad..5c94ac7c8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,7 @@ flake8==4.0.1 flake8-tidy-imports==4.6.0 mypy==0.931 pytest==7.0.1 -pytest-asyncio==0.18.1 +pytest-asyncio==0.18.2 pytest-cov==3.0.0 pytest-mock==3.7.0 pytest-random-order==1.0.4 @@ -20,7 +20,7 @@ time-machine==2.6.0 nbconvert==6.4.2 # mypy types -types-cachetools==4.2.9 +types-cachetools==4.2.10 types-filelock==3.2.5 types-requests==2.27.11 types-tabulate==0.8.5 diff --git a/requirements.txt b/requirements.txt index bcdcb02a9..f8b6049f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,11 @@ numpy==1.22.2 pandas==1.4.1 pandas-ta==0.3.14b -ccxt==1.74.63 +ccxt==1.75.12 # Pin cryptography for now due to rust build errors with piwheels cryptography==36.0.1 aiohttp==3.8.1 -SQLAlchemy==1.4.31 +SQLAlchemy==1.4.32 python-telegram-bot==13.11 arrow==1.2.2 cachetools==4.2.2 @@ -31,7 +31,7 @@ python-rapidjson==1.6 sdnotify==0.3.2 # API Server -fastapi==0.74.1 +fastapi==0.75.0 uvicorn==0.17.5 pyjwt==2.3.0 aiofiles==0.8.0 diff --git a/tests/conftest.py b/tests/conftest.py index ae35b0326..57122c01c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,6 +107,8 @@ def patch_exchange(mocker, api_mock=None, id='binance', mock_markets=True) -> No mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock)) else: mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock()) + mocker.patch('freqtrade.exchange.Exchange.timeframes', PropertyMock( + return_value=['5m', '15m', '1h', '1d'])) def get_patched_exchange(mocker, config, api_mock=None, id='binance', diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 527e8050b..2bd84942d 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1692,6 +1692,13 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog) -> None: cache=False) assert len(res) == 3 assert exchange._api_async.fetch_ohlcv.call_count == 3 + exchange._api_async.fetch_ohlcv.reset_mock() + caplog.clear() + # Call with invalid timeframe + res = exchange.refresh_latest_ohlcv([('IOTA/ETH', '3m')],cache=False) + assert not res + assert len(res) == 0 + assert log_has_re(r'Cannot download \(IOTA\/ETH, 3m\).*', caplog) @pytest.mark.asyncio diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 7ed1a68f8..1aeb56cdd 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -926,12 +926,10 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, }), create_order=MagicMock(side_effect=[ {'id': limit_buy_order_usdt['id']}, - {'id': limit_sell_order_usdt['id']}, + limit_sell_order_usdt, + # {'id': limit_sell_order_usdt['id']}, ]), get_fee=fee, - ) - mocker.patch.multiple( - 'freqtrade.exchange.Binance', stoploss=stoploss ) freqtrade = FreqtradeBot(default_conf_usdt) @@ -956,7 +954,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, trade.stoploss_order_id = 100 hanging_stoploss_order = MagicMock(return_value={'status': 'open'}) - mocker.patch('freqtrade.exchange.Binance.fetch_stoploss_order', hanging_stoploss_order) + mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', hanging_stoploss_order) assert freqtrade.handle_stoploss_on_exchange(trade) is False assert trade.stoploss_order_id == 100 @@ -969,7 +967,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, trade.stoploss_order_id = 100 canceled_stoploss_order = MagicMock(return_value={'status': 'canceled'}) - mocker.patch('freqtrade.exchange.Binance.fetch_stoploss_order', canceled_stoploss_order) + mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', canceled_stoploss_order) stoploss.reset_mock() assert freqtrade.handle_stoploss_on_exchange(trade) is False @@ -1001,7 +999,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, 'average': 2, 'amount': limit_buy_order_usdt['amount'], }) - mocker.patch('freqtrade.exchange.Binance.fetch_stoploss_order', stoploss_order_hit) + mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', stoploss_order_hit) assert freqtrade.handle_stoploss_on_exchange(trade) is True assert log_has_re(r'STOP_LOSS_LIMIT is hit for Trade\(id=1, .*\)\.', caplog) assert trade.stoploss_order_id is None @@ -1009,7 +1007,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, caplog.clear() mocker.patch( - 'freqtrade.exchange.Binance.stoploss', + 'freqtrade.exchange.Exchange.stoploss', side_effect=ExchangeError() ) trade.is_open = True @@ -1021,9 +1019,9 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, # It should try to add stoploss order trade.stoploss_order_id = 100 stoploss.reset_mock() - mocker.patch('freqtrade.exchange.Binance.fetch_stoploss_order', + mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', side_effect=InvalidOrderException()) - mocker.patch('freqtrade.exchange.Binance.stoploss', stoploss) + mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss) freqtrade.handle_stoploss_on_exchange(trade) assert stoploss.call_count == 1 @@ -1033,10 +1031,37 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, trade.is_open = False stoploss.reset_mock() mocker.patch('freqtrade.exchange.Exchange.fetch_order') - mocker.patch('freqtrade.exchange.Binance.stoploss', stoploss) + mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss) assert freqtrade.handle_stoploss_on_exchange(trade) is False assert stoploss.call_count == 0 + # Seventh case: emergency exit triggered + # Trailing stop should not act anymore + stoploss_order_cancelled = MagicMock(side_effect=[{ + 'id': "100", + 'status': 'canceled', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'amount': limit_buy_order_usdt['amount'], + 'info': {'stopPrice': 22}, + }]) + trade.stoploss_order_id = 100 + trade.is_open = True + trade.stoploss_last_update = arrow.utcnow().shift(hours=-1).datetime + trade.stop_loss = 24 + freqtrade.config['trailing_stop'] = True + stoploss = MagicMock(side_effect=InvalidOrderException()) + + mocker.patch('freqtrade.exchange.Exchange.cancel_stoploss_order_with_result', + side_effect=InvalidOrderException()) + mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', stoploss_order_cancelled) + mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss) + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert trade.stoploss_order_id is None + assert trade.is_open is False + assert trade.sell_reason == str(SellType.EMERGENCY_SELL) + def test_handle_sle_cancel_cant_recreate(mocker, default_conf_usdt, fee, caplog, limit_buy_order_usdt, limit_sell_order_usdt) -> None: @@ -1360,6 +1385,32 @@ def test_handle_stoploss_on_exchange_trailing_error( assert log_has_re(r"Could not create trailing stoploss order for pair ETH/USDT\..*", caplog) +def test_stoploss_on_exchange_price_rounding( + mocker, default_conf_usdt, fee, open_trade_usdt) -> None: + patch_RPCManager(mocker) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_fee=fee, + ) + price_mock = MagicMock(side_effect=lambda p, s: int(s)) + stoploss_mock = MagicMock(return_value={'id': '13434334'}) + adjust_mock = MagicMock(return_value=False) + mocker.patch.multiple( + 'freqtrade.exchange.Binance', + stoploss=stoploss_mock, + stoploss_adjust=adjust_mock, + price_to_precision=price_mock, + ) + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + open_trade_usdt.stoploss_order_id = '13434334' + open_trade_usdt.stop_loss = 222.55 + + freqtrade.handle_trailing_stoploss_on_exchange(open_trade_usdt, {}) + assert price_mock.call_count == 1 + assert adjust_mock.call_count == 1 + assert adjust_mock.call_args_list[0][0][0] == 222 + + @pytest.mark.usefixtures("init_persistence") def test_handle_stoploss_on_exchange_custom_stop( mocker, default_conf_usdt, fee, limit_buy_order_usdt, limit_sell_order_usdt) -> None: