diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 97e5d2f1e..2703c99bf 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -342,7 +342,7 @@ class Exchange: def get_pair_base_currency(self, pair: str) -> str: """ - Return a pair's quote currency + Return a pair's base currency """ return self.markets.get(pair, {}).get('base', '') @@ -1167,6 +1167,22 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e + @retrier + def fetch_positions(self) -> List[Dict]: + if self._config['dry_run'] or self.trading_mode != TradingMode.FUTURES: + return [] + try: + positions: List[Dict] = self._api.fetch_positions() + self._log_exchange_response('fetch_positions', positions) + return positions + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not get positions due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + @retrier def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict: """ diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index c1ec9ca06..abd38859b 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1335,11 +1335,13 @@ class FreqtradeBot(LoggingMixin): """ # Update wallets to ensure amounts tied up in a stoploss is now free! self.wallets.update() + if self.trading_mode == TradingMode.FUTURES: + return amount + trade_base_currency = self.exchange.get_pair_base_currency(pair) wallet_amount = self.wallets.get_free(trade_base_currency) logger.debug(f"{pair} - Wallet: {wallet_amount} - Trade-amount: {amount}") - # TODO-lev: Get wallet amount + value of positions - if wallet_amount >= amount or self.trading_mode == TradingMode.FUTURES: + if wallet_amount >= amount: # A safe exit amount isn't needed for futures, you can just exit/close the position return amount elif wallet_amount > amount * 0.98: diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index f7317a7d5..18491d687 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -385,6 +385,13 @@ class LocalTrade(): else: return "sell" + @property + def trade_direction(self) -> str: + if self.is_short: + return "short" + else: + return "long" + def __init__(self, **kwargs): for key in kwargs: setattr(self, key, kwargs[key]) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index a1910b4d3..e80bf3eb8 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -39,6 +39,11 @@ class Balance(BaseModel): used: float est_stake: float stake: str + # Starting with 2.x + side: str + leverage: float + is_position: bool + position: float class Balances(BaseModel): diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 5ece392f2..9b780d88d 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -30,6 +30,7 @@ from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.strategy.interface import SellCheckTuple +from freqtrade.wallets import PositionWallet, Wallet logger = logging.getLogger(__name__) @@ -566,7 +567,7 @@ class RPC: def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> Dict: """ Returns current account balance per crypto """ - output = [] + currencies = [] total = 0.0 try: tickers = self._freqtrade.exchange.get_tickers(cached=True) @@ -577,7 +578,8 @@ class RPC: starting_capital = self._freqtrade.wallets.get_starting_balance() starting_cap_fiat = self._fiat_converter.convert_amount( starting_capital, stake_currency, fiat_display_currency) if self._fiat_converter else 0 - + coin: str + balance: Wallet for coin, balance in self._freqtrade.wallets.get_all_balances().items(): if not balance.total: continue @@ -598,13 +600,34 @@ class RPC: logger.warning(f" Could not get rate for pair {coin}.") continue total = total + (est_stake or 0) - output.append({ + currencies.append({ 'currency': coin, + # TODO: The below can be simplified if we don't assign None to values. 'free': balance.free if balance.free is not None else 0, 'balance': balance.total if balance.total is not None else 0, 'used': balance.used if balance.used is not None else 0, 'est_stake': est_stake or 0, 'stake': stake_currency, + 'side': 'long', + 'leverage': 1, + 'position': 0, + 'is_position': False, + }) + symbol: str + position: PositionWallet + for symbol, position in self._freqtrade.wallets.get_all_positions().items(): + + currencies.append({ + 'currency': symbol, + 'free': 0, + 'balance': 0, + 'used': 0, + 'position': position.position, + 'est_stake': position.collateral, + 'stake': stake_currency, + 'leverage': position.leverage, + 'side': position.side, + 'is_position': True }) value = self._fiat_converter.convert_amount( @@ -616,7 +639,7 @@ class RPC: starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0 return { - 'currencies': output, + 'currencies': currencies, 'total': total, 'symbol': fiat_display_currency, 'value': value, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index bfc56d2c7..f11003d52 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -827,13 +827,21 @@ class Telegram(RPCHandler): for curr in result['currencies']: curr_output = '' if curr['est_stake'] > balance_dust_level: - curr_output = ( - f"*{curr['currency']}:*\n" - f"\t`Available: {curr['free']:.8f}`\n" - f"\t`Balance: {curr['balance']:.8f}`\n" - f"\t`Pending: {curr['used']:.8f}`\n" - f"\t`Est. {curr['stake']}: " - f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n") + if curr['is_position']: + curr_output = ( + f"*{curr['currency']}:*\n" + f"\t`{curr['side']}: {curr['position']:.8f}`\n" + f"\t`Leverage: {curr['leverage']:.1f}`\n" + f"\t`Est. {curr['stake']}: " + f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n") + else: + curr_output = ( + f"*{curr['currency']}:*\n" + f"\t`Available: {curr['free']:.8f}`\n" + f"\t`Balance: {curr['balance']:.8f}`\n" + f"\t`Pending: {curr['used']:.8f}`\n" + f"\t`Est. {curr['stake']}: " + f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n") elif curr['est_stake'] <= balance_dust_level: total_dust_balance += curr['est_stake'] total_dust_currencies += 1 diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 9659c63d9..153512897 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -3,12 +3,12 @@ import logging from copy import deepcopy -from typing import Any, Dict, NamedTuple, Optional +from typing import Dict, NamedTuple, Optional import arrow from freqtrade.constants import UNLIMITED_STAKE_AMOUNT -from freqtrade.enums import RunMode +from freqtrade.enums import RunMode, TradingMode from freqtrade.exceptions import DependencyException from freqtrade.exchange import Exchange from freqtrade.persistence import LocalTrade, Trade @@ -25,6 +25,14 @@ class Wallet(NamedTuple): total: float = 0 +class PositionWallet(NamedTuple): + symbol: str + position: float = 0 + leverage: float = 0 + collateral: float = 0 + side: str = 'long' + + class Wallets: def __init__(self, config: dict, exchange: Exchange, log: bool = True) -> None: @@ -32,6 +40,7 @@ class Wallets: self._log = log self._exchange = exchange self._wallets: Dict[str, Wallet] = {} + self._positions: Dict[str, PositionWallet] = {} self.start_cap = config['dry_run_wallet'] self._last_wallet_refresh = 0 self.update() @@ -66,6 +75,7 @@ class Wallets: """ # Recreate _wallets to reset closed trade balances _wallets = {} + _positions = {} open_trades = Trade.get_trades_proxy(is_open=True) # If not backtesting... # TODO: potentially remove the ._log workaround to determine backtest mode. @@ -74,24 +84,46 @@ class Wallets: else: tot_profit = LocalTrade.total_profit tot_in_trades = sum(trade.stake_amount for trade in open_trades) + used_stake = 0.0 + + if self._config.get('trading_mode', 'spot') != TradingMode.FUTURES: + current_stake = self.start_cap + tot_profit - tot_in_trades + total_stake = current_stake + for trade in open_trades: + curr = self._exchange.get_pair_base_currency(trade.pair) + _wallets[curr] = Wallet( + curr, + trade.amount, + 0, + trade.amount + ) + else: + tot_in_trades = 0 + for position in open_trades: + # size = self._exchange._contracts_to_amount(position.pair, position['contracts']) + size = position.amount + # TODO-lev: stake_amount in real trades does not include the leverage ... + collateral = position.stake_amount / position.leverage + leverage = position.leverage + tot_in_trades -= collateral + _positions[position.pair] = PositionWallet( + position.pair, position=size, + leverage=leverage, + collateral=collateral, + side=position.trade_direction + ) + current_stake = self.start_cap + tot_profit + used_stake = tot_in_trades + total_stake = current_stake - tot_in_trades - current_stake = self.start_cap + tot_profit - tot_in_trades _wallets[self._config['stake_currency']] = Wallet( - self._config['stake_currency'], - current_stake, - 0, - current_stake + currency=self._config['stake_currency'], + free=current_stake, + used=used_stake, + total=total_stake ) - - for trade in open_trades: - curr = self._exchange.get_pair_base_currency(trade.pair) - _wallets[curr] = Wallet( - curr, - trade.amount, - 0, - trade.amount - ) self._wallets = _wallets + self._positions = _positions def _update_live(self) -> None: balances = self._exchange.get_balances() @@ -109,6 +141,23 @@ class Wallets: if currency not in balances: del self._wallets[currency] + positions = self._exchange.fetch_positions() + self._positions = {} + for position in positions: + symbol = position['symbol'] + if position['side'] is None or position['collateral'] == 0.0: + # Position is not open ... + continue + size = self._exchange._contracts_to_amount(symbol, position['contracts']) + collateral = position['collateral'] + leverage = position['leverage'] + self._positions[symbol] = PositionWallet( + symbol, position=size, + leverage=leverage, + collateral=collateral, + side=position['side'] + ) + def update(self, require_update: bool = True) -> None: """ Updates wallets from the configured version. @@ -126,9 +175,12 @@ class Wallets: logger.info('Wallets synced.') self._last_wallet_refresh = arrow.utcnow().int_timestamp - def get_all_balances(self) -> Dict[str, Any]: + def get_all_balances(self) -> Dict[str, Wallet]: return self._wallets + def get_all_positions(self) -> Dict[str, PositionWallet]: + return self._positions + def get_starting_balance(self) -> float: """ Retrieves starting balance - based on either available capital, diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 9c54686e6..894f5b75b 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1547,6 +1547,27 @@ def test_get_balances_prod(default_conf, mocker, exchange_name): "get_balances", "fetch_balance") +@pytest.mark.parametrize("exchange_name", EXCHANGES) +def test_fetch_positions(default_conf, mocker, exchange_name): + mocker.patch('freqtrade.exchange.Exchange.validate_trading_mode_and_margin_mode') + api_mock = MagicMock() + api_mock.fetch_positions = MagicMock(return_value=[ + {'symbol': 'ETH/USDT:USDT', 'leverage': 5}, + {'symbol': 'XRP/USDT:USDT', 'leverage': 5}, + ]) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + assert exchange.fetch_positions() == [] + default_conf['dry_run'] = False + default_conf['trading_mode'] = 'futures' + + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + res = exchange.fetch_positions() + assert len(res) == 2 + + ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, + "fetch_positions", "fetch_positions") + + @pytest.mark.parametrize("exchange_name", EXCHANGES) def test_get_tickers(default_conf, mocker, exchange_name): api_mock = MagicMock() diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 0dd8b3b93..9bb809aaa 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -603,6 +603,30 @@ def test_rpc_balance_handle(default_conf, mocker, tickers): 'used': 5.0, } } + mock_pos = [ + { + "symbol": "ETH/USDT:USDT", + "timestamp": None, + "datetime": None, + "initialMargin": 0.0, + "initialMarginPercentage": None, + "maintenanceMargin": 0.0, + "maintenanceMarginPercentage": 0.005, + "entryPrice": 0.0, + "notional": 100.0, + "leverage": 5.0, + "unrealizedPnl": 0.0, + "contracts": 100.0, + "contractSize": 1, + "marginRatio": None, + "liquidationPrice": 0.0, + "markPrice": 2896.41, + "collateral": 20, + "marginType": "isolated", + "side": 'short', + "percentage": None + } + ] mocker.patch.multiple( 'freqtrade.rpc.fiat_convert.CoinGeckoAPI', @@ -612,12 +636,15 @@ def test_rpc_balance_handle(default_conf, mocker, tickers): mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( 'freqtrade.exchange.Exchange', + validate_trading_mode_and_margin_mode=MagicMock(), get_balances=MagicMock(return_value=mock_balance), + fetch_positions=MagicMock(return_value=mock_pos), get_tickers=tickers, get_valid_pair_combination=MagicMock( side_effect=lambda a, b: f"{b}/{a}" if a == "USDT" else f"{a}/{b}") ) default_conf['dry_run'] = False + default_conf['trading_mode'] = 'futures' freqtradebot = get_patched_freqtradebot(mocker, default_conf) patch_get_signal(freqtradebot) rpc = RPC(freqtradebot) @@ -630,28 +657,55 @@ def test_rpc_balance_handle(default_conf, mocker, tickers): assert tickers.call_args_list[0][1]['cached'] is True assert 'USD' == result['symbol'] assert result['currencies'] == [ - {'currency': 'BTC', - 'free': 10.0, - 'balance': 12.0, - 'used': 2.0, - 'est_stake': 12.0, - 'stake': 'BTC', - }, - {'free': 1.0, - 'balance': 5.0, - 'currency': 'ETH', - 'est_stake': 0.30794, - 'used': 4.0, - 'stake': 'BTC', + { + 'currency': 'BTC', + 'free': 10.0, + 'balance': 12.0, + 'used': 2.0, + 'est_stake': 12.0, + 'stake': 'BTC', + 'is_position': False, + 'leverage': 1.0, + 'position': 0.0, + 'side': 'long', + }, + { + 'free': 1.0, + 'balance': 5.0, + 'currency': 'ETH', + 'est_stake': 0.30794, + 'used': 4.0, + 'stake': 'BTC', + 'is_position': False, + 'leverage': 1.0, + 'position': 0.0, + 'side': 'long', - }, - {'free': 5.0, - 'balance': 10.0, - 'currency': 'USDT', - 'est_stake': 0.0011563153318162476, - 'used': 5.0, - 'stake': 'BTC', - } + }, + { + 'free': 5.0, + 'balance': 10.0, + 'currency': 'USDT', + 'est_stake': 0.0011563153318162476, + 'used': 5.0, + 'stake': 'BTC', + 'is_position': False, + 'leverage': 1.0, + 'position': 0.0, + 'side': 'long', + }, + { + 'free': 0.0, + 'balance': 0.0, + 'currency': 'ETH/USDT:USDT', + 'est_stake': 20, + 'used': 0, + 'stake': 'BTC', + 'is_position': True, + 'leverage': 5.0, + 'position': 1000.0, + 'side': 'short', + } ] assert result['total'] == 12.309096315331816 diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 99817f706..2455c9c9e 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -453,6 +453,10 @@ def test_api_balance(botclient, mocker, rpc_balance, tickers): 'used': 0.0, 'est_stake': 12.0, 'stake': 'BTC', + 'is_position': False, + 'leverage': 1.0, + 'position': 0.0, + 'side': 'long', } assert 'starting_capital' in response assert 'starting_capital_fiat' in response diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index be3983331..ed0c940fe 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -905,6 +905,10 @@ def test_balance_handle_too_large_response(default_conf, update, mocker) -> None 'balance': i, 'est_stake': 1, 'stake': 'BTC', + 'is_position': False, + 'leverage': 1.0, + 'position': 0.0, + 'side': 'long', }) mocker.patch('freqtrade.rpc.rpc.RPC._rpc_balance', return_value={ 'currencies': balances, diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 484c6e8a8..f7273950a 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -95,6 +95,7 @@ def test_enter_exit_side(fee, is_short): ) assert trade.enter_side == enter_side assert trade.exit_side == exit_side + assert trade.trade_direction == 'short' if is_short else 'long' @pytest.mark.usefixtures("init_persistence") diff --git a/tests/test_wallets.py b/tests/test_wallets.py index 4dd1c925f..e7b804a0b 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -6,7 +6,7 @@ import pytest from freqtrade.constants import UNLIMITED_STAKE_AMOUNT from freqtrade.exceptions import DependencyException -from tests.conftest import get_patched_freqtradebot, patch_wallet +from tests.conftest import create_mock_trades, get_patched_freqtradebot, patch_wallet def test_sync_wallet_at_boot(mocker, default_conf): @@ -234,3 +234,125 @@ def test_get_starting_balance(mocker, default_conf, available_capital, closed_pr freqtrade = get_patched_freqtradebot(mocker, default_conf) assert freqtrade.wallets.get_starting_balance() == expected + + +def test_sync_wallet_futures_live(mocker, default_conf): + default_conf['dry_run'] = False + default_conf['trading_mode'] = 'futures' + default_conf['margin_mode'] = 'isolated' + mock_result = [ + { + "symbol": "ETH/USDT:USDT", + "timestamp": None, + "datetime": None, + "initialMargin": 0.0, + "initialMarginPercentage": None, + "maintenanceMargin": 0.0, + "maintenanceMarginPercentage": 0.005, + "entryPrice": 0.0, + "notional": 100.0, + "leverage": 5.0, + "unrealizedPnl": 0.0, + "contracts": 100.0, + "contractSize": 1, + "marginRatio": None, + "liquidationPrice": 0.0, + "markPrice": 2896.41, + "collateral": 20, + "marginType": "isolated", + "side": 'short', + "percentage": None + }, + { + "symbol": "ADA/USDT:USDT", + "timestamp": None, + "datetime": None, + "initialMargin": 0.0, + "initialMarginPercentage": None, + "maintenanceMargin": 0.0, + "maintenanceMarginPercentage": 0.005, + "entryPrice": 0.0, + "notional": 100.0, + "leverage": 5.0, + "unrealizedPnl": 0.0, + "contracts": 100.0, + "contractSize": 1, + "marginRatio": None, + "liquidationPrice": 0.0, + "markPrice": 0.91, + "collateral": 20, + "marginType": "isolated", + "side": 'short', + "percentage": None + }, + { + # Closed position + "symbol": "SOL/BUSD:BUSD", + "timestamp": None, + "datetime": None, + "initialMargin": 0.0, + "initialMarginPercentage": None, + "maintenanceMargin": 0.0, + "maintenanceMarginPercentage": 0.005, + "entryPrice": 0.0, + "notional": 0.0, + "leverage": 5.0, + "unrealizedPnl": 0.0, + "contracts": 0.0, + "contractSize": 1, + "marginRatio": None, + "liquidationPrice": 0.0, + "markPrice": 15.41, + "collateral": 0.0, + "marginType": "isolated", + "side": 'short', + "percentage": None + } + ] + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value={ + "USDT": { + "free": 900, + "used": 100, + "total": 1000 + }, + }), + fetch_positions=MagicMock(return_value=mock_result) + ) + + freqtrade = get_patched_freqtradebot(mocker, default_conf) + + assert len(freqtrade.wallets._wallets) == 1 + assert len(freqtrade.wallets._positions) == 2 + + assert 'USDT' in freqtrade.wallets._wallets + assert 'ETH/USDT:USDT' in freqtrade.wallets._positions + assert freqtrade.wallets._last_wallet_refresh > 0 + + # Remove ETH/USDT:USDT position + del mock_result[0] + freqtrade.wallets.update() + assert len(freqtrade.wallets._positions) == 1 + assert 'ETH/USDT:USDT' not in freqtrade.wallets._positions + + +def test_sync_wallet_futures_dry(mocker, default_conf, fee): + default_conf['dry_run'] = True + default_conf['trading_mode'] = 'futures' + default_conf['margin_mode'] = 'isolated' + freqtrade = get_patched_freqtradebot(mocker, default_conf) + assert len(freqtrade.wallets._wallets) == 1 + assert len(freqtrade.wallets._positions) == 0 + + create_mock_trades(fee, is_short=None) + + freqtrade.wallets.update() + + assert len(freqtrade.wallets._wallets) == 1 + assert len(freqtrade.wallets._positions) == 4 + positions = freqtrade.wallets.get_all_positions() + positions['ETH/BTC'].side == 'short' + positions['ETC/BTC'].side == 'long' + positions['XRP/BTC'].side == 'long' + positions['LTC/BTC'].side == 'short'