diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index b06cf3ecb..4dba9ad4a 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -521,6 +521,39 @@ class AwesomeStrategy(IStrategy): ``` +### Stake size management + +It is possible to manage your risk by reducing or increasing stake amount when placing a new trade. + +```python +class AwesomeStrategy(IStrategy): + def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, + proposed_stake: float, min_stake: float, max_stake: float, + **kwargs) -> float: + + dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) + current_candle = dataframe.iloc[-1].squeeze() + + if current_candle['fastk_rsi_1h'] > current_candle['fastd_rsi_1h']: + if self.config['stake_amount'] == 'unlimited': + # Use entire available wallet during favorable conditions when in compounding mode. + return max_stake + else: + # Compound profits during favorable conditions instead of using a static stake. + return self.wallets.get_total_stake_amount() / self.config['max_open_trades'] + + # Use default stake amount. + return proposed_stake +``` + +Freqtrade will fall back to the `proposed_stake` value should your code raise an exception. The exception itself will be logged. + +!!! Tip + You do not _have_ to ensure that `min_stake <= returned_value <= max_stake`. Trades will succeed as the returned value will be clamped to supported range and this acton will be logged. + +!!! Tip + Returning `0` or `None` will prevent trades from being placed. + --- ## Derived strategies diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 7a7371357..5ef109387 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -424,16 +424,10 @@ class FreqtradeBot(LoggingMixin): if buy and not sell: stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge) - if not stake_amount: - logger.debug(f"Stake amount is 0, ignoring possible trade for {pair}.") - return False - - logger.info(f"Buy signal found: about create a new trade for {pair} with stake_amount: " - f"{stake_amount} ...") bid_check_dom = self.config.get('bid_strategy', {}).get('check_depth_of_market', {}) if ((bid_check_dom.get('enabled', False)) and - (bid_check_dom.get('bids_to_ask_delta', 0) > 0)): + (bid_check_dom.get('bids_to_ask_delta', 0) > 0)): if self._check_depth_of_market_buy(pair, bid_check_dom): return self.execute_buy(pair, stake_amount) else: @@ -488,13 +482,22 @@ class FreqtradeBot(LoggingMixin): min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, buy_limit_requested, self.strategy.stoploss) - if min_stake_amount is not None and min_stake_amount > stake_amount: - logger.warning( - f"Can't open a new trade for {pair}: stake amount " - f"is too small ({stake_amount} < {min_stake_amount})" - ) + + if not self.edge: + max_stake_amount = self.wallets.get_available_stake_amount() + stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, + default_retval=stake_amount)( + pair=pair, current_time=datetime.now(timezone.utc), + current_rate=buy_limit_requested, proposed_stake=stake_amount, + min_stake=min_stake_amount, max_stake=max_stake_amount) + stake_amount = self.wallets._validate_stake_amount(pair, stake_amount, min_stake_amount) + + if not stake_amount: return False + logger.info(f"Buy signal found: about create a new trade for {pair} with stake_amount: " + f"{stake_amount} ...") + amount = stake_amount / buy_limit_requested order_type = self.strategy.order_types['buy'] if forcebuy: diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 8f818047d..83cd8c2bb 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -129,6 +129,8 @@ class Backtesting: """ self.strategy: IStrategy = strategy strategy.dp = self.dataprovider + # Attach Wallets to Strategy baseclass + IStrategy.wallets = self.wallets # Set stoploss_on_exchange to false for backtesting, # since a "perfect" stoploss-sell is assumed anyway # And the regular "stoploss" function would not apply to that case @@ -312,7 +314,18 @@ class Backtesting: stake_amount = self.wallets.get_trade_stake_amount(pair, None) except DependencyException: return None - min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, row[OPEN_IDX], -0.05) + + min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, row[OPEN_IDX], -0.05) or 0 + max_stake_amount = self.wallets.get_available_stake_amount() + + stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, + default_retval=stake_amount)( + pair=pair, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX], + proposed_stake=stake_amount, min_stake=min_stake_amount, max_stake=max_stake_amount) + stake_amount = self.wallets._validate_stake_amount(pair, stake_amount, min_stake_amount) + + if not stake_amount: + return None order_type = self.strategy.order_types['buy'] time_in_force = self.strategy.order_time_in_force['sell'] diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 26bcb0369..f9772e1df 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -304,6 +304,23 @@ class IStrategy(ABC, HyperStrategyMixin): """ return None + def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, + proposed_stake: float, min_stake: float, max_stake: float, + **kwargs) -> float: + """ + Customize stake size for each new trade. This method is not called when edge module is + enabled. + + :param pair: Pair that's currently analyzed + :param current_time: datetime object, containing the current datetime + :param current_rate: Rate, calculated based on pricing settings in ask_strategy. + :param proposed_stake: A stake amount proposed by the bot. + :param min_stake: Minimal stake size allowed by exchange. + :param max_stake: Balance available for trading. + :return: A stake size, which is between min_stake and max_stake. + """ + return proposed_stake + def informative_pairs(self) -> ListPairsWithTimeframes: """ Define additional, informative pair/interval combinations to be cached from the exchange. diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 1b2ec4550..42144dad7 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -131,7 +131,22 @@ class Wallets: def get_all_balances(self) -> Dict[str, Any]: return self._wallets - def _get_available_stake_amount(self, val_tied_up: float) -> float: + def get_total_stake_amount(self): + """ + Return the total currently available balance in stake currency, including tied up stake and + respecting tradable_balance_ratio. + Calculated as + ( + free amount) * tradable_balance_ratio + """ + # Ensure % is used from the overall balance + # Otherwise we'd risk lowering stakes with each open trade. + # (tied up + current free) * ratio) - tied up + val_tied_up = Trade.total_open_trades_stakes() + available_amount = ((val_tied_up + self.get_free(self._config['stake_currency'])) * + self._config['tradable_balance_ratio']) + return available_amount + + def get_available_stake_amount(self) -> float: """ Return the total currently available balance in stake currency, respecting tradable_balance_ratio. @@ -142,9 +157,7 @@ class Wallets: # Ensure % is used from the overall balance # Otherwise we'd risk lowering stakes with each open trade. # (tied up + current free) * ratio) - tied up - available_amount = ((val_tied_up + self.get_free(self._config['stake_currency'])) * - self._config['tradable_balance_ratio']) - val_tied_up - return available_amount + return self.get_total_stake_amount() - Trade.total_open_trades_stakes() def _calculate_unlimited_stake_amount(self, available_amount: float, val_tied_up: float) -> float: @@ -193,7 +206,7 @@ class Wallets: # Ensure wallets are uptodate. self.update() val_tied_up = Trade.total_open_trades_stakes() - available_amount = self._get_available_stake_amount(val_tied_up) + available_amount = self.get_available_stake_amount() if edge: stake_amount = edge.stake_amount( @@ -209,3 +222,23 @@ class Wallets: available_amount, val_tied_up) return self._check_available_stake_amount(stake_amount, available_amount) + + def _validate_stake_amount(self, pair, stake_amount, min_stake_amount): + if not stake_amount: + logger.debug(f"Stake amount is {stake_amount}, ignoring possible trade for {pair}.") + return 0 + + max_stake_amount = self.get_available_stake_amount() + if min_stake_amount is not None and stake_amount < min_stake_amount: + stake_amount = min_stake_amount + logger.info( + f"Stake amount for pair {pair} is too small ({stake_amount} < {min_stake_amount}), " + f"adjusting to {min_stake_amount}." + ) + if stake_amount > max_stake_amount: + stake_amount = max_stake_amount + logger.info( + f"Stake amount for pair {pair} is too big ({stake_amount} > {max_stake_amount}), " + f"adjusting to {max_stake_amount}." + ) + return stake_amount diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 30d86f979..a1881c306 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -496,6 +496,17 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None: trade = backtesting._enter_trade(pair, row=row) assert trade is not None + backtesting.strategy.custom_stake_amount = lambda **kwargs: 123.5 + trade = backtesting._enter_trade(pair, row=row) + assert trade + assert trade.stake_amount == 123.5 + + # In case of error - use proposed stake + backtesting.strategy.custom_stake_amount = lambda **kwargs: 20 / 0 + trade = backtesting._enter_trade(pair, row=row) + assert trade + assert trade.stake_amount == 495 + # Stake-amount too high! mocker.patch("freqtrade.exchange.Exchange.get_min_pair_stake_amount", return_value=600.0) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index cc29dc157..0049e59bb 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -886,7 +886,7 @@ def test_rpcforcebuy(mocker, default_conf, ticker, fee, limit_buy_order_open) -> # Test not buying freqtradebot = get_patched_freqtradebot(mocker, default_conf) - freqtradebot.config['stake_amount'] = 0.0000001 + freqtradebot.config['stake_amount'] = 0 patch_get_signal(freqtradebot, (True, False)) rpc = RPC(freqtradebot) pair = 'TKN/BTC' diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 99e11e893..addf72bbb 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -397,7 +397,7 @@ def test_create_trade_minimal_amount(default_conf, ticker, limit_buy_order_open, def test_create_trade_too_small_stake_amount(default_conf, ticker, limit_buy_order_open, - fee, mocker) -> None: + fee, mocker, caplog) -> None: patch_RPCManager(mocker) patch_exchange(mocker) buy_mock = MagicMock(return_value=limit_buy_order_open) @@ -413,6 +413,27 @@ def test_create_trade_too_small_stake_amount(default_conf, ticker, limit_buy_ord patch_get_signal(freqtrade) + assert freqtrade.create_trade('ETH/BTC') + assert log_has_re(r"Stake amount for pair .* is too small.*", caplog) + + +def test_create_trade_zero_stake_amount(default_conf, ticker, limit_buy_order_open, + fee, mocker) -> None: + patch_RPCManager(mocker) + patch_exchange(mocker) + buy_mock = MagicMock(return_value=limit_buy_order_open) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker, + buy=buy_mock, + get_fee=fee, + ) + + freqtrade = FreqtradeBot(default_conf) + freqtrade.config['stake_amount'] = 0 + + patch_get_signal(freqtrade) + assert not freqtrade.create_trade('ETH/BTC') @@ -842,6 +863,24 @@ def test_execute_buy(mocker, default_conf, fee, limit_buy_order, limit_buy_order assert trade.open_rate == 0.5 assert trade.stake_amount == 40.495905365 + # Test with custom stake + limit_buy_order['status'] = 'open' + limit_buy_order['id'] = '556' + + freqtrade.strategy.custom_stake_amount = lambda **kwargs: 150.0 + assert freqtrade.execute_buy(pair, stake_amount) + trade = Trade.query.all()[4] + assert trade + assert trade.stake_amount == 150 + + # Exception case + limit_buy_order['id'] = '557' + freqtrade.strategy.custom_stake_amount = lambda **kwargs: 20 / 0 + assert freqtrade.execute_buy(pair, stake_amount) + trade = Trade.query.all()[5] + assert trade + assert trade.stake_amount == 2.0 + # In case of the order is rejected and not filled at all limit_buy_order['status'] = 'rejected' limit_buy_order['amount'] = 90.99181073