diff --git a/config.json.example b/config.json.example index 5198b9d81..d6806c0e9 100644 --- a/config.json.example +++ b/config.json.example @@ -59,6 +59,7 @@ "chat_id": "your_telegram_chat_id" }, "initial_state": "running", + "forcebuy_enable": false, "internals": { "process_throttle_secs": 5 } diff --git a/config_full.json.example b/config_full.json.example index bdc6361d2..af6c7c045 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -71,6 +71,7 @@ }, "db_url": "sqlite:///tradesv3.sqlite", "initial_state": "running", + "forcebuy_enable": false, "internals": { "process_throttle_secs": 5 }, diff --git a/docs/configuration.md b/docs/configuration.md index 3512f891b..15ba4b48d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,13 +53,14 @@ The table below will list all configuration parameters. | `telegram.enabled` | true | Yes | Enable or not the usage of Telegram. | `telegram.token` | token | No | Your Telegram bot token. Only required if `telegram.enabled` is `true`. | `telegram.chat_id` | chat_id | No | Your personal Telegram account id. Only required if `telegram.enabled` is `true`. -| `webhook.enabled` | false | No | Enable useage of Webhook notifications +| `webhook.enabled` | false | No | Enable usage of Webhook notifications | `webhook.url` | false | No | URL for the webhook. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details. | `webhook.webhookbuy` | false | No | Payload to send on buy. Only required if `webhook.enabled` is `true`. See the [webhook documentationV](webhook-config.md) for more details. | `webhook.webhooksell` | false | No | Payload to send on sell. Only required if `webhook.enabled` is `true`. See the [webhook documentationV](webhook-config.md) for more details. | `webhook.webhookstatus` | false | No | Payload to send on status calls. Only required if `webhook.enabled` is `true`. See the [webhook documentationV](webhook-config.md) for more details. | `db_url` | `sqlite:///tradesv3.sqlite` | No | Declares database URL to use. NOTE: This defaults to `sqlite://` if `dry_run` is `True`. | `initial_state` | running | No | Defines the initial application state. More information below. +| `forcebuy_enable` | false | No | Enables the RPC Commands to force a buy. More information below. | `strategy` | DefaultStrategy | No | Defines Strategy class to use. | `strategy_path` | null | No | Adds an additional strategy lookup path (must be a folder). | `internals.process_throttle_secs` | 5 | Yes | Set the process throttle. Value in second. @@ -113,6 +114,15 @@ Go to the [trailing stoploss Documentation](stoploss.md) for details on trailing Possible values are `running` or `stopped`. (default=`running`) If the value is `stopped` the bot has to be started with `/start` first. +### Understand forcebuy_enable + +`forcebuy_enable` enables the usage of forcebuy commands via Telegram. +This is disabled for security reasons by default, and will show a warning message on startup if enabled. +You send `/forcebuy ETH/BTC` to the bot, who buys the pair and holds it until a regular sell-signal appears (ROI, stoploss, /forcesell). + +Can be dangerous with some strategies, so use with care +See [the telegram documentation](telegram-usage.md) for details on usage. + ### Understand process_throttle_secs `process_throttle_secs` is an optional field that defines in seconds how long the bot should wait diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 945e31f9c..28213fb5d 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -23,6 +23,7 @@ official commands. You can ask at any moment for help with `/help`. | `/profit` | | Display a summary of your profit/loss from close trades and some stats about your performance | `/forcesell ` | | Instantly sells the given trade (Ignoring `minimum_roi`). | `/forcesell all` | | Instantly sells all open trades (Ignoring `minimum_roi`). +| `/forcebuy [rate]` | | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) | `/performance` | | Show performance of each finished trade grouped by pair | `/balance` | | Show account balance per currency | `/daily ` | 7 | Shows profit or loss per day, over the last n days @@ -30,16 +31,20 @@ official commands. You can ask at any moment for help with `/help`. | `/version` | | Show version ## Telegram commands in action + Below, example of Telegram message you will receive for each command. ### /start + > **Status:** `running` ### /stop + > `Stopping trader ...` > **Status:** `stopped` ## /status + For each open trade, the bot will send you the following message. > **Trade ID:** `123` @@ -54,6 +59,7 @@ For each open trade, the bot will send you the following message. > **Open Order:** `None` ## /status table + Return the status of all open trades in a table format. ``` ID Pair Since Profit @@ -63,6 +69,7 @@ Return the status of all open trades in a table format. ``` ## /count + Return the number of trades used and available. ``` current max @@ -71,6 +78,7 @@ current max ``` ## /profit + Return a summary of your profit/loss and performance. > **ROI:** Close trades @@ -90,7 +98,14 @@ Return a summary of your profit/loss and performance. > **BITTREX:** Selling BTC/LTC with limit `0.01650000 (profit: ~-4.07%, -0.00008168)` +## /forcebuy + +> **BITTREX**: Buying ETH/BTC with limit `0.03400000` (`1.000000 ETH`, `225.290 USD`) + +Note that for this to work, `forcebuy_enable` needs to be set to true. + ## /performance + Return the performance of each crypto-currency the bot has sold. > Performance: > 1. `RCN/BTC 57.77%` @@ -101,6 +116,7 @@ Return the performance of each crypto-currency the bot has sold. > ... ## /balance + Return the balance of all crypto-currency your have on the exchange. > **Currency:** BTC @@ -114,6 +130,7 @@ Return the balance of all crypto-currency your have on the exchange. > **Pending:** 0.0 ## /daily + Per default `/daily` will return the 7 last days. The example below if for `/daily 3`: @@ -127,5 +144,6 @@ Day Profit BTC Profit USD ``` ## /version + > **Version:** `0.14.3` diff --git a/freqtrade/configuration.py b/freqtrade/configuration.py index d61ff4dd7..e043525a7 100644 --- a/freqtrade/configuration.py +++ b/freqtrade/configuration.py @@ -127,6 +127,9 @@ class Configuration(object): config['db_url'] = constants.DEFAULT_DB_PROD_URL logger.info('Dry run is disabled') + if config.get('forcebuy_enable', False): + logger.warning('`forcebuy` RPC message enabled.') + logger.info(f'Using DB: "{config["db_url"]}"') # Check if the exchange set by the user is supported diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 12f10d3b9..2b09aa6c9 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -130,6 +130,7 @@ CONF_SCHEMA = { }, 'db_url': {'type': 'string'}, 'initial_state': {'type': 'string', 'enum': ['running', 'stopped']}, + 'forcebuy_enable': {'type': 'boolean'}, 'internals': { 'type': 'object', 'properties': { diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 6cc46a07e..51160332d 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -427,7 +427,7 @@ class FreqtradeBot(object): return True return False - def execute_buy(self, pair: str, stake_amount: float) -> bool: + def execute_buy(self, pair: str, stake_amount: float, price: Optional[float] = None) -> bool: """ Executes a limit buy for the given pair :param pair: pair for which we want to create a LIMIT_BUY @@ -438,8 +438,11 @@ class FreqtradeBot(object): stake_currency = self.config['stake_currency'] fiat_currency = self.config.get('fiat_display_currency', None) - # Calculate amount - buy_limit = self.get_target_bid(pair, self.exchange.get_ticker(pair)) + if price: + buy_limit = price + else: + # Calculate amount + buy_limit = self.get_target_bid(pair, self.exchange.get_ticker(pair)) min_stake_amount = self._get_min_pair_stake_amount(pair_s, buy_limit) if min_stake_amount is not None and min_stake_amount > stake_amount: diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 900ad1998..c3cbce2e7 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -385,6 +385,40 @@ class RPC(object): _exec_forcesell(trade) Trade.session.flush() + def _rpc_forcebuy(self, pair: str, price: Optional[float]) -> Optional[Trade]: + """ + Handler for forcebuy + Buys a pair trade at the given or current price + """ + + if not self._freqtrade.config.get('forcebuy_enable', False): + raise RPCException('Forcebuy not enabled.') + + if self._freqtrade.state != State.RUNNING: + raise RPCException('trader is not running') + + # Check pair is in stake currency + stake_currency = self._freqtrade.config.get('stake_currency') + if not pair.endswith(stake_currency): + raise RPCException( + f'Wrong pair selected. Please pairs with stake {stake_currency} pairs only') + # check if valid pair + + # check if pair already has an open pair + trade = Trade.query.filter(Trade.is_open.is_(True)).filter(Trade.pair.is_(pair)).first() + if trade: + raise RPCException(f'position for {pair} already open - id: {trade.id}') + + # gen stake amount + stakeamount = self._freqtrade._get_trade_stake_amount() + + # execute buy + if self._freqtrade.execute_buy(pair, stakeamount, price): + trade = Trade.query.filter(Trade.is_open.is_(True)).filter(Trade.pair.is_(pair)).first() + return trade + else: + return None + def _rpc_performance(self) -> List[Dict]: """ Handler for performance. diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 040f053f1..eaabd35c6 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -86,6 +86,7 @@ class Telegram(RPC): CommandHandler('start', self._start), CommandHandler('stop', self._stop), CommandHandler('forcesell', self._forcesell), + CommandHandler('forcebuy', self._forcebuy), CommandHandler('performance', self._performance), CommandHandler('daily', self._daily), CommandHandler('count', self._count), @@ -375,6 +376,24 @@ class Telegram(RPC): except RPCException as e: self._send_msg(str(e), bot=bot) + @authorized_only + def _forcebuy(self, bot: Bot, update: Update) -> None: + """ + Handler for /forcebuy . + Buys a pair trade at the given or current price + :param bot: telegram bot + :param update: message update + :return: None + """ + + message = update.message.text.replace('/forcebuy', '').strip().split() + pair = message[0] + price = float(message[1]) if len(message) > 1 else None + try: + self._rpc_forcebuy(pair, price) + except RPCException as e: + self._send_msg(str(e), bot=bot) + @authorized_only def _performance(self, bot: Bot, update: Update) -> None: """ diff --git a/freqtrade/tests/rpc/test_rpc.py b/freqtrade/tests/rpc/test_rpc.py index b181231c8..19692db50 100644 --- a/freqtrade/tests/rpc/test_rpc.py +++ b/freqtrade/tests/rpc/test_rpc.py @@ -569,3 +569,79 @@ def test_rpc_count(mocker, default_conf, ticker, fee, markets) -> None: trades = rpc._rpc_count() nb_trades = len(trades) assert nb_trades == 1 + + +def test_rpcforcebuy(mocker, default_conf, ticker, fee, markets, limit_buy_order) -> None: + default_conf['forcebuy_enable'] = True + patch_coinmarketcap(mocker) + patch_exchange(mocker) + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + buy_mm = MagicMock(return_value={'id': limit_buy_order['id']}) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + get_ticker=ticker, + get_fee=fee, + get_markets=markets, + buy=buy_mm + ) + + freqtradebot = FreqtradeBot(default_conf) + patch_get_signal(freqtradebot, (True, False)) + rpc = RPC(freqtradebot) + pair = 'ETH/BTC' + trade = rpc._rpc_forcebuy(pair, None) + assert isinstance(trade, Trade) + assert trade.pair == pair + assert trade.open_rate == ticker()['ask'] + + # Test buy duplicate + with pytest.raises(RPCException, match=r'position for ETH/BTC already open - id: 1'): + rpc._rpc_forcebuy(pair, 0.0001) + pair = 'XRP/BTC' + trade = rpc._rpc_forcebuy(pair, 0.0001) + assert isinstance(trade, Trade) + assert trade.pair == pair + assert trade.open_rate == 0.0001 + + # Test buy pair not with stakes + with pytest.raises(RPCException, match=r'Wrong pair selected. Please pairs with stake.*'): + rpc._rpc_forcebuy('XRP/ETH', 0.0001) + pair = 'XRP/BTC' + + # Test not buying + default_conf['stake_amount'] = 0.0000001 + freqtradebot = FreqtradeBot(default_conf) + patch_get_signal(freqtradebot, (True, False)) + rpc = RPC(freqtradebot) + pair = 'TKN/BTC' + trade = rpc._rpc_forcebuy(pair, None) + assert trade is None + + +def test_rpcforcebuy_stopped(mocker, default_conf) -> None: + default_conf['forcebuy_enable'] = True + default_conf['initial_state'] = 'stopped' + patch_coinmarketcap(mocker) + patch_exchange(mocker) + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + + freqtradebot = FreqtradeBot(default_conf) + patch_get_signal(freqtradebot, (True, False)) + rpc = RPC(freqtradebot) + pair = 'ETH/BTC' + with pytest.raises(RPCException, match=r'trader is not running'): + rpc._rpc_forcebuy(pair, None) + + +def test_rpcforcebuy_disabled(mocker, default_conf) -> None: + patch_coinmarketcap(mocker) + patch_exchange(mocker) + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + + freqtradebot = FreqtradeBot(default_conf) + patch_get_signal(freqtradebot, (True, False)) + rpc = RPC(freqtradebot) + pair = 'ETH/BTC' + with pytest.raises(RPCException, match=r'Forcebuy not enabled.'): + rpc._rpc_forcebuy(pair, None) diff --git a/freqtrade/tests/rpc/test_rpc_telegram.py b/freqtrade/tests/rpc/test_rpc_telegram.py index 45e01aa57..097fc1ff2 100644 --- a/freqtrade/tests/rpc/test_rpc_telegram.py +++ b/freqtrade/tests/rpc/test_rpc_telegram.py @@ -71,8 +71,8 @@ def test_init(default_conf, mocker, caplog) -> None: assert start_polling.start_polling.call_count == 1 message_str = "rpc.telegram is listening for following commands: [['status'], ['profit'], " \ - "['balance'], ['start'], ['stop'], ['forcesell'], ['performance'], ['daily'], " \ - "['count'], ['reload_conf'], ['help'], ['version']]" + "['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], " \ + "['performance'], ['daily'], ['count'], ['reload_conf'], ['help'], ['version']]" assert log_has(message_str, caplog.record_tuples) @@ -868,6 +868,63 @@ def test_forcesell_handle_invalid(default_conf, update, mocker) -> None: assert 'invalid argument' in msg_mock.call_args_list[0][0][0] +def test_forcebuy_handle(default_conf, update, markets, mocker) -> None: + patch_coinmarketcap(mocker, value={'price_usd': 15000.0}) + mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) + mocker.patch('freqtrade.rpc.telegram.Telegram._send_msg', MagicMock()) + mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + _load_markets=MagicMock(return_value={}), + get_markets=markets + ) + fbuy_mock = MagicMock(return_value=None) + mocker.patch('freqtrade.rpc.RPC._rpc_forcebuy', fbuy_mock) + + freqtradebot = FreqtradeBot(default_conf) + patch_get_signal(freqtradebot, (True, False)) + telegram = Telegram(freqtradebot) + + update.message.text = '/forcebuy ETH/BTC' + telegram._forcebuy(bot=MagicMock(), update=update) + + assert fbuy_mock.call_count == 1 + assert fbuy_mock.call_args_list[0][0][0] == 'ETH/BTC' + assert fbuy_mock.call_args_list[0][0][1] is None + + # Reset and retry with specified price + fbuy_mock = MagicMock(return_value=None) + mocker.patch('freqtrade.rpc.RPC._rpc_forcebuy', fbuy_mock) + update.message.text = '/forcebuy ETH/BTC 0.055' + telegram._forcebuy(bot=MagicMock(), update=update) + + assert fbuy_mock.call_count == 1 + assert fbuy_mock.call_args_list[0][0][0] == 'ETH/BTC' + assert isinstance(fbuy_mock.call_args_list[0][0][1], float) + assert fbuy_mock.call_args_list[0][0][1] == 0.055 + + +def test_forcebuy_handle_exception(default_conf, update, markets, mocker) -> None: + patch_coinmarketcap(mocker, value={'price_usd': 15000.0}) + mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) + rpc_mock = mocker.patch('freqtrade.rpc.telegram.Telegram._send_msg', MagicMock()) + mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + _load_markets=MagicMock(return_value={}), + get_markets=markets + ) + freqtradebot = FreqtradeBot(default_conf) + patch_get_signal(freqtradebot, (True, False)) + telegram = Telegram(freqtradebot) + + update.message.text = '/forcebuy ETH/Nonepair' + telegram._forcebuy(bot=MagicMock(), update=update) + + assert rpc_mock.call_count == 1 + assert rpc_mock.call_args_list[0][0][0] == 'Forcebuy not enabled.' + + def test_performance_handle(default_conf, update, ticker, fee, limit_buy_order, limit_sell_order, markets, mocker) -> None: patch_coinmarketcap(mocker) diff --git a/freqtrade/tests/test_configuration.py b/freqtrade/tests/test_configuration.py index 547c38de9..daaaec090 100644 --- a/freqtrade/tests/test_configuration.py +++ b/freqtrade/tests/test_configuration.py @@ -455,5 +455,19 @@ def test_set_loggers() -> None: assert logging.getLogger('telegram').level is logging.INFO +def test_load_config_warn_forcebuy(default_conf, mocker, caplog) -> None: + default_conf['forcebuy_enable'] = True + mocker.patch('freqtrade.configuration.open', mocker.mock_open( + read_data=json.dumps(default_conf) + )) + + args = Arguments([], '').get_parsed_arg() + configuration = Configuration(args) + validated_conf = configuration.load_config() + + assert validated_conf.get('forcebuy_enable') + assert log_has('`forcebuy` RPC message enabled.', caplog.record_tuples) + + def test_validate_default_conf(default_conf) -> None: validate(default_conf, constants.CONF_SCHEMA) diff --git a/freqtrade/tests/test_freqtradebot.py b/freqtrade/tests/test_freqtradebot.py index 871e59240..55479cc6f 100644 --- a/freqtrade/tests/test_freqtradebot.py +++ b/freqtrade/tests/test_freqtradebot.py @@ -730,6 +730,54 @@ def test_balance_bigger_last_ask(mocker, default_conf) -> None: assert freqtrade.get_target_bid('ETH/BTC', {'ask': 5, 'last': 10}) == 5 +def test_execute_buy(mocker, default_conf, fee, markets, limit_buy_order) -> None: + patch_RPCManager(mocker) + patch_exchange(mocker) + freqtrade = FreqtradeBot(default_conf) + stake_amount = 2 + bid = 0.11 + get_bid = MagicMock(return_value=bid) + mocker.patch.multiple( + 'freqtrade.freqtradebot.FreqtradeBot', + get_target_bid=get_bid, + _get_min_pair_stake_amount=MagicMock(return_value=1) + ) + buy_mm = MagicMock(return_value={'id': limit_buy_order['id']}) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_ticker=MagicMock(return_value={ + 'bid': 0.00001172, + 'ask': 0.00001173, + 'last': 0.00001172 + }), + buy=buy_mm, + get_fee=fee, + get_markets=markets + ) + pair = 'ETH/BTC' + print(buy_mm.call_args_list) + + assert freqtrade.execute_buy(pair, stake_amount) + assert get_bid.call_count == 1 + assert buy_mm.call_count == 1 + call_args = buy_mm.call_args_list[0][0] + assert call_args[0] == pair + assert call_args[1] == bid + assert call_args[2] == stake_amount / bid + + # Test calling with price + fix_price = 0.06 + assert freqtrade.execute_buy(pair, stake_amount, fix_price) + # Make sure get_target_bid wasn't called again + assert get_bid.call_count == 1 + + assert buy_mm.call_count == 2 + call_args = buy_mm.call_args_list[1][0] + assert call_args[0] == pair + assert call_args[1] == fix_price + assert call_args[2] == stake_amount / fix_price + + def test_process_maybe_execute_buy(mocker, default_conf) -> None: freqtrade = get_patched_freqtradebot(mocker, default_conf)