Merge branch 'margin-db' into margin-commands

This commit is contained in:
Sam Germain 2021-06-20 01:32:59 -06:00
commit e99b163985
27 changed files with 302 additions and 223 deletions

View File

@ -178,7 +178,9 @@
"sell_fill": "on",
"buy_cancel": "on",
"sell_cancel": "on"
}
},
"reload": true,
"balance_dust_level": 0.01
},
"api_server": {
"enabled": false,

View File

@ -1,58 +0,0 @@
FROM --platform=linux/arm64/v8 python:3.9.4-slim-buster as base
# Setup env
ENV LANG C.UTF-8
ENV LC_ALL C.UTF-8
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONFAULTHANDLER 1
ENV PATH=/home/ftuser/.local/bin:$PATH
ENV FT_APP_ENV="docker"
# Prepare environment
RUN mkdir /freqtrade \
&& apt-get update \
&& apt-get -y install sudo libatlas3-base curl sqlite3 libhdf5-serial-dev \
&& apt-get clean \
&& useradd -u 1000 -G sudo -U -m ftuser \
&& chown ftuser:ftuser /freqtrade \
# Allow sudoers
&& echo "ftuser ALL=(ALL) NOPASSWD: /bin/chown" >> /etc/sudoers
WORKDIR /freqtrade
# Install dependencies
FROM base as python-deps
RUN apt-get update \
&& apt-get -y install build-essential libssl-dev git libffi-dev libgfortran5 pkg-config cmake gcc \
&& apt-get clean \
&& pip install --upgrade pip
# Install TA-lib
COPY build_helpers/* /tmp/
RUN cd /tmp && /tmp/install_ta-lib.sh && rm -r /tmp/*ta-lib*
ENV LD_LIBRARY_PATH /usr/local/lib
# Install dependencies
COPY --chown=ftuser:ftuser requirements.txt requirements-hyperopt.txt /freqtrade/
USER ftuser
RUN pip install --user --no-cache-dir numpy \
&& pip install --user --no-cache-dir -r requirements-hyperopt.txt
# Copy dependencies to runtime-image
FROM base as runtime-image
COPY --from=python-deps /usr/local/lib /usr/local/lib
ENV LD_LIBRARY_PATH /usr/local/lib
COPY --from=python-deps --chown=ftuser:ftuser /home/ftuser/.local /home/ftuser/.local
USER ftuser
# Install and execute
COPY --chown=ftuser:ftuser . /freqtrade/
RUN pip install -e . --user --no-cache-dir --no-build-isolation\
&& mkdir /freqtrade/user_data/ \
&& freqtrade install-ui
ENTRYPOINT ["freqtrade"]
# Default to trade mode
CMD [ "trade" ]

View File

@ -284,7 +284,7 @@ A backtesting result will look like that:
| Backtesting to | 2019-05-01 00:00:00 |
| Max open trades | 3 |
| | |
| Total trades | 429 |
| Total/Daily Avg Trades| 429 / 3.575 |
| Starting balance | 0.01000000 BTC |
| Final balance | 0.01762792 BTC |
| Absolute profit | 0.00762792 BTC |
@ -373,12 +373,11 @@ It contains some useful key metrics about performance of your strategy on backte
| Backtesting to | 2019-05-01 00:00:00 |
| Max open trades | 3 |
| | |
| Total trades | 429 |
| Total/Daily Avg Trades| 429 / 3.575 |
| Starting balance | 0.01000000 BTC |
| Final balance | 0.01762792 BTC |
| Absolute profit | 0.00762792 BTC |
| Total profit % | 76.2% |
| Trades per day | 3.575 |
| Avg. stake amount | 0.001 BTC |
| Total trade volume | 0.429 BTC |
| | |
@ -409,12 +408,11 @@ It contains some useful key metrics about performance of your strategy on backte
- `Backtesting from` / `Backtesting to`: Backtesting range (usually defined with the `--timerange` option).
- `Max open trades`: Setting of `max_open_trades` (or `--max-open-trades`) - or number of pairs in the pairlist (whatever is lower).
- `Total trades`: Identical to the total trades of the backtest output table.
- `Total/Daily Avg Trades`: Identical to the total trades of the backtest output table / Total trades divided by the backtesting duration in days (this will give you information about how many trades to expect from the strategy).
- `Starting balance`: Start balance - as given by dry-run-wallet (config or command line).
- `Final balance`: Final balance - starting balance + absolute profit.
- `Absolute profit`: Profit made in stake currency.
- `Total profit %`: Total profit. Aligned to the `TOTAL` row's `Tot Profit %` from the first table. Calculated as `(End capital Starting capital) / Starting capital`.
- `Trades per day`: Total trades divided by the backtesting duration in days (this will give you information about how many trades to expect from the strategy).
- `Avg. stake amount`: Average stake amount, either `stake_amount` or the average when using dynamic stake amount.
- `Total trade volume`: Volume generated on the exchange to reach the above profit.
- `Best Pair` / `Worst Pair`: Best and worst performing pair, and it's corresponding `Cum Profit %`.
@ -446,6 +444,7 @@ Since backtesting lacks some detailed information about what happens within a ca
- Stoploss is evaluated before ROI within one candle. So you can often see more trades with the `stoploss` sell reason comparing to the results obtained with the same strategy in the Dry Run/Live Trade modes
- Low happens before high for stoploss, protecting capital first
- Trailing stoploss
- Trailing Stoploss is only adjusted if it's below the candle's low (otherwise it would be triggered)
- High happens first - adjusting stoploss
- Low uses the adjusted stoploss (so sells with large high-low difference are backtested correctly)
- ROI applies before trailing-stop, ensuring profits are "top-capped" at ROI if both ROI and trailing stop applies

View File

@ -102,10 +102,11 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `exchange.markets_refresh_interval` | The interval in minutes in which markets are reloaded. <br>*Defaults to `60` minutes.* <br> **Datatype:** Positive Integer
| `exchange.skip_pair_validation` | Skip pairlist validation on startup.<br>*Defaults to `false`<br> **Datatype:** Boolean
| `exchange.skip_open_order_update` | Skips open order updates on startup should the exchange cause problems. Only relevant in live conditions.<br>*Defaults to `false`<br> **Datatype:** Boolean
| `exchange.log_responses` | Log relevant exchange responses. For debug mode only - use with care.<br>*Defaults to `false`<br> **Datatype:** Boolean
| `edge.*` | Please refer to [edge configuration document](edge.md) for detailed explanation.
| `experimental.block_bad_exchanges` | Block exchanges known to not work with freqtrade. Leave on default unless you want to test if that exchange works now. <br>*Defaults to `true`.* <br> **Datatype:** Boolean
| `pairlists` | Define one or more pairlists to be used. [More information](plugins.md#pairlists-and-pairlist-handlers). <br>*Defaults to `StaticPairList`.* <br> **Datatype:** List of Dicts
| `protections` | Define one or more protections to be used. [More information](plugins.md#protections). [Strategy Override](#parameters-in-the-strategy). <br> **Datatype:** List of Dicts
| `protections` | Define one or more protections to be used. [More information](plugins.md#protections). <br> **Datatype:** List of Dicts
| `telegram.enabled` | Enable the usage of Telegram. <br> **Datatype:** Boolean
| `telegram.token` | Your Telegram bot token. Only required if `telegram.enabled` is `true`. <br>**Keep it in secret, do not disclose publicly.** <br> **Datatype:** String
| `telegram.chat_id` | Your personal Telegram account id. Only required if `telegram.enabled` is `true`. <br>**Keep it in secret, do not disclose publicly.** <br> **Datatype:** String
@ -156,7 +157,6 @@ Values set in the configuration file always overwrite values set in the strategy
* `order_time_in_force`
* `unfilledtimeout`
* `disable_dataframe_checks`
* `protections`
* `use_sell_signal` (ask_strategy)
* `sell_profit_only` (ask_strategy)
* `sell_profit_offset` (ask_strategy)

View File

@ -98,7 +98,7 @@ Create a new directory and place the [docker-compose file](https://raw.githubuse
image: freqtradeorg/freqtrade:custom_arm64
build:
context: .
dockerfile: "./docker/Dockerfile.aarch64"
dockerfile: "Dockerfile"
```
The above snippet creates a new directory called `ft_userdata`, downloads the latest compose file and pulls the freqtrade image.

View File

@ -8,7 +8,6 @@ All protection end times are rounded up to the next candle to avoid sudden, unex
!!! Note
Not all Protections will work for all strategies, and parameters will need to be tuned for your strategy to improve performance.
To align your protection with your strategy, you can define protections in the strategy.
!!! Tip
Each Protection can be configured multiple times with different parameters, to allow different levels of protection (short-term / long-term).
@ -47,16 +46,16 @@ This applies across all pairs, unless `only_per_pair` is set to true, which will
The below example stops trading for all pairs for 4 candles after the last trade if the bot hit stoploss 4 times within the last 24 candles.
```json
"protections": [
``` python
protections = [
{
"method": "StoplossGuard",
"lookback_period_candles": 24,
"trade_limit": 4,
"stop_duration_candles": 4,
"only_per_pair": false
"only_per_pair": False
}
],
]
```
!!! Note
@ -69,8 +68,8 @@ The below example stops trading for all pairs for 4 candles after the last trade
The below sample stops trading for 12 candles if max-drawdown is > 20% considering all pairs - with a minimum of `trade_limit` trades - within the last 48 candles. If desired, `lookback_period` and/or `stop_duration` can be used.
```json
"protections": [
``` python
protections = [
{
"method": "MaxDrawdown",
"lookback_period_candles": 48,
@ -78,7 +77,7 @@ The below sample stops trading for 12 candles if max-drawdown is > 20% consideri
"stop_duration_candles": 12,
"max_allowed_drawdown": 0.2
},
],
]
```
#### Low Profit Pairs
@ -88,8 +87,8 @@ If that ratio is below `required_profit`, that pair will be locked for `stop_dur
The below example will stop trading a pair for 60 minutes if the pair does not have a required profit of 2% (and a minimum of 2 trades) within the last 6 candles.
```json
"protections": [
``` python
protections = [
{
"method": "LowProfitPairs",
"lookback_period_candles": 6,
@ -97,7 +96,7 @@ The below example will stop trading a pair for 60 minutes if the pair does not h
"stop_duration": 60,
"required_profit": 0.02
}
],
]
```
#### Cooldown Period
@ -106,13 +105,13 @@ The below example will stop trading a pair for 60 minutes if the pair does not h
The below example will stop trading a pair for 2 candles after closing a trade, allowing this pair to "cool down".
```json
"protections": [
``` python
protections = [
{
"method": "CooldownPeriod",
"stop_duration_candles": 2
}
],
]
```
!!! Note
@ -132,46 +131,6 @@ The below example assumes a timeframe of 1 hour:
* Locks all pairs that had 4 Trades within the last 6 hours (`6 * 1h candles`) with a combined profit ratio of below 0.02 (<2%) (`LowProfitPairs`).
* Locks all pairs for 2 candles that had a profit of below 0.01 (<1%) within the last 24h (`24 * 1h candles`), a minimum of 4 trades.
```json
"timeframe": "1h",
"protections": [
{
"method": "CooldownPeriod",
"stop_duration_candles": 5
},
{
"method": "MaxDrawdown",
"lookback_period_candles": 48,
"trade_limit": 20,
"stop_duration_candles": 4,
"max_allowed_drawdown": 0.2
},
{
"method": "StoplossGuard",
"lookback_period_candles": 24,
"trade_limit": 4,
"stop_duration_candles": 2,
"only_per_pair": false
},
{
"method": "LowProfitPairs",
"lookback_period_candles": 6,
"trade_limit": 2,
"stop_duration_candles": 60,
"required_profit": 0.02
},
{
"method": "LowProfitPairs",
"lookback_period_candles": 24,
"trade_limit": 4,
"stop_duration_candles": 2,
"required_profit": 0.01
}
],
```
You can use the same in your strategy, the syntax is only slightly different:
``` python
from freqtrade.strategy import IStrategy

View File

@ -95,6 +95,7 @@ Example configuration showing the different settings:
"buy_fill": "off",
"sell_fill": "off"
},
"reload": true,
"balance_dust_level": 0.01
},
```
@ -105,6 +106,7 @@ Example configuration showing the different settings:
`balance_dust_level` will define what the `/balance` command takes as "dust" - Currencies with a balance below this will be shown.
`reload` allows you to disable reload-buttons on selected messages.
## Create a custom keyboard (command shortcut buttons)

View File

@ -275,7 +275,8 @@ CONF_SCHEMA = {
'default': 'off'
},
}
}
},
'reload': {'type': 'boolean'},
},
'required': ['enabled', 'token', 'chat_id'],
},

View File

@ -68,6 +68,7 @@ class Binance(Exchange):
amount=amount, price=rate, params=params)
logger.info('stoploss limit order added for %s. '
'stop price: %s. limit: %s', pair, stop_price, rate)
self._log_exchange_response('create_stoploss_order', order)
return order
except ccxt.InsufficientFunds as e:
raise InsufficientFundsError(

View File

@ -104,6 +104,7 @@ class Exchange:
logger.info('Instance is running with dry_run enabled')
logger.info(f"Using CCXT {ccxt.__version__}")
exchange_config = config['exchange']
self.log_responses = exchange_config.get('log_responses', False)
# Deep merge ft_has with default ft_has options
self._ft_has = deep_merge_dicts(self._ft_has, deepcopy(self._ft_has_default))
@ -226,6 +227,11 @@ class Exchange:
"""exchange ccxt precisionMode"""
return self._api.precisionMode
def _log_exchange_response(self, endpoint, response) -> None:
""" Log exchange responses """
if self.log_responses:
logger.info(f"API {endpoint}: {response}")
def ohlcv_candle_limit(self, timeframe: str) -> int:
"""
Exchange ohlcv candle limit
@ -622,8 +628,10 @@ class Exchange:
or self._api.options.get("createMarketBuyOrderRequiresPrice", False))
rate_for_order = self.price_to_precision(pair, rate) if needs_price else None
return self._api.create_order(pair, ordertype, side,
amount, rate_for_order, params)
order = self._api.create_order(pair, ordertype, side,
amount, rate_for_order, params)
self._log_exchange_response('create_order', order)
return order
except ccxt.InsufficientFunds as e:
raise InsufficientFundsError(
@ -694,7 +702,9 @@ class Exchange:
if self._config['dry_run']:
return self.fetch_dry_run_order(order_id)
try:
return self._api.fetch_order(order_id, pair)
order = self._api.fetch_order(order_id, pair)
self._log_exchange_response('fetch_order', order)
return order
except ccxt.OrderNotFound as e:
raise RetryableOrderError(
f'Order not found (pair: {pair} id: {order_id}). Message: {e}') from e
@ -744,7 +754,9 @@ class Exchange:
return {}
try:
return self._api.cancel_order(order_id, pair)
order = self._api.cancel_order(order_id, pair)
self._log_exchange_response('cancel_order', order)
return order
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Could not cancel order. Message: {e}') from e
@ -1042,6 +1054,7 @@ class Exchange:
pair, int((since.replace(tzinfo=timezone.utc).timestamp() - 5) * 1000))
matched_trades = [trade for trade in my_trades if trade['order'] == order_id]
self._log_exchange_response('get_trades_for_order', matched_trades)
return matched_trades
except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e

View File

@ -69,6 +69,7 @@ class Ftx(Exchange):
order = self._api.create_order(symbol=pair, type=ordertype, side='sell',
amount=amount, params=params)
self._log_exchange_response('create_stoploss_order', order)
logger.info('stoploss order added for %s. '
'stop price: %s.', pair, stop_price)
return order
@ -99,12 +100,14 @@ class Ftx(Exchange):
orders = self._api.fetch_orders(pair, None, params={'type': 'stop'})
order = [order for order in orders if order['id'] == order_id]
self._log_exchange_response('fetch_stoploss_order', order)
if len(order) == 1:
if order[0].get('status') == 'closed':
# Trigger order was triggered ...
real_order_id = order[0].get('info', {}).get('orderId')
order1 = self._api.fetch_order(real_order_id, pair)
self._log_exchange_response('fetch_stoploss_order1', order1)
# Fake type to stop - as this was really a stop order.
order1['id_stop'] = order1['id']
order1['id'] = order_id
@ -131,7 +134,9 @@ class Ftx(Exchange):
if self._config['dry_run']:
return {}
try:
return self._api.cancel_order(order_id, pair, params={'type': 'stop'})
order = self._api.cancel_order(order_id, pair, params={'type': 'stop'})
self._log_exchange_response('cancel_stoploss_order', order)
return order
except ccxt.InvalidOrder as e:
raise InvalidOrderException(
f'Could not cancel order. Message: {e}') from e

View File

@ -103,6 +103,7 @@ class Kraken(Exchange):
order = self._api.create_order(symbol=pair, type=ordertype, side='sell',
amount=amount, price=stop_price, params=params)
self._log_exchange_response('create_stoploss_order', order)
logger.info('stoploss order added for %s. '
'stop price: %s.', pair, stop_price)
return order

View File

@ -70,7 +70,7 @@ class FreqtradeBot(LoggingMixin):
PairLocks.timeframe = self.config['timeframe']
self.protections = ProtectionManager(self.config)
self.protections = ProtectionManager(self.config, self.strategy.protections)
# RPC runs in separate threads, can start handling external commands just after
# initialization, even before Freqtradebot has a chance to start its throttling,

View File

@ -137,7 +137,7 @@ class Backtesting:
if hasattr(strategy, 'protections'):
conf = deepcopy(conf)
conf['protections'] = strategy.protections
self.protections = ProtectionManager(conf)
self.protections = ProtectionManager(self.config, strategy.protections)
def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
"""
@ -225,6 +225,22 @@ class Backtesting:
# sell at open price.
return sell_row[OPEN_IDX]
# Special case: trailing triggers within same candle as trade opened. Assume most
# pessimistic price movement, which is moving just enough to arm stoploss and
# immediately going down to stop price.
if (sell.sell_type == SellType.TRAILING_STOP_LOSS and trade_dur == 0
and self.strategy.trailing_stop_positive):
if self.strategy.trailing_only_offset_is_reached:
# Worst case: price reaches stop_positive_offset and dives down.
stop_rate = (sell_row[OPEN_IDX] *
(1 + abs(self.strategy.trailing_stop_positive_offset) -
abs(self.strategy.trailing_stop_positive)))
else:
# Worst case: price ticks tiny bit above open and dives down.
stop_rate = sell_row[OPEN_IDX] * (1 - abs(self.strategy.trailing_stop_positive))
assert stop_rate < sell_row[HIGH_IDX]
return stop_rate
# Set close_rate to stoploss
return trade.stop_loss
elif sell.sell_type == (SellType.ROI):

19
freqtrade/optimize/hyperopt_tools.py Normal file → Executable file
View File

@ -91,7 +91,7 @@ class HyperoptTools():
if print_json:
result_dict: Dict = {}
for s in ['buy', 'sell', 'roi', 'stoploss', 'trailing']:
HyperoptTools._params_update_for_json(result_dict, params, s)
HyperoptTools._params_update_for_json(result_dict, params, non_optimized, s)
print(rapidjson.dumps(result_dict, default=str, number_mode=rapidjson.NM_NATIVE))
else:
@ -104,17 +104,24 @@ class HyperoptTools():
HyperoptTools._params_pretty_print(params, 'trailing', "Trailing stop:")
@staticmethod
def _params_update_for_json(result_dict, params, space: str) -> None:
if space in params:
def _params_update_for_json(result_dict, params, non_optimized, space: str) -> None:
if (space in params) or (space in non_optimized):
space_params = HyperoptTools._space_params(params, space)
space_non_optimized = HyperoptTools._space_params(non_optimized, space)
all_space_params = space_params
# Merge non optimized params if there are any
if len(space_non_optimized) > 0:
all_space_params = {**space_params, **space_non_optimized}
if space in ['buy', 'sell']:
result_dict.setdefault('params', {}).update(space_params)
result_dict.setdefault('params', {}).update(all_space_params)
elif space == 'roi':
# Convert keys in min_roi dict to strings because
# rapidjson cannot dump dicts with integer keys...
result_dict['minimal_roi'] = {str(k): v for k, v in space_params.items()}
result_dict['minimal_roi'] = {str(k): v for k, v in all_space_params.items()}
else: # 'stoploss', 'trailing'
result_dict.update(space_params)
result_dict.update(all_space_params)
@staticmethod
def _params_pretty_print(params, space: str, header: str, non_optimized={}) -> None:

View File

@ -556,7 +556,8 @@ def text_table_add_metrics(strat_results: Dict) -> str:
('Backtesting to', strat_results['backtest_end']),
('Max open trades', strat_results['max_open_trades']),
('', ''), # Empty line to improve readability
('Total trades', strat_results['total_trades']),
('Total/Daily Avg Trades',
f"{strat_results['total_trades']} / {strat_results['trades_per_day']}"),
('Starting balance', round_coin_value(strat_results['starting_balance'],
strat_results['stake_currency'])),
('Final balance', round_coin_value(strat_results['final_balance'],
@ -564,7 +565,6 @@ def text_table_add_metrics(strat_results: Dict) -> str:
('Absolute profit ', round_coin_value(strat_results['profit_total_abs'],
strat_results['stake_currency'])),
('Total profit %', f"{round(strat_results['profit_total'] * 100, 2):}%"),
('Trades per day', strat_results['trades_per_day']),
('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'],
strat_results['stake_currency'])),
('Total trade volume', round_coin_value(strat_results['total_volume'],

View File

@ -83,7 +83,8 @@ class PairListManager():
pairlist = self._pairlist_handlers[0].gen_pairlist(tickers)
# Process all Pairlist Handlers in the chain
for pairlist_handler in self._pairlist_handlers:
# except for the first one, which is the generator.
for pairlist_handler in self._pairlist_handlers[1:]:
pairlist = pairlist_handler.filter_pairlist(pairlist, tickers)
# Validation against blacklist happens after the chain of Pairlist Handlers

View File

@ -15,11 +15,11 @@ logger = logging.getLogger(__name__)
class ProtectionManager():
def __init__(self, config: dict) -> None:
def __init__(self, config: Dict, protections: List) -> None:
self._config = config
self._protection_handlers: List[IProtection] = []
for protection_handler_config in self._config.get('protections', []):
for protection_handler_config in protections:
protection_handler = ProtectionResolver.load_protection(
protection_handler_config['method'],
config=config,

View File

@ -113,7 +113,9 @@ class StrategyResolver(IResolver):
- Strategy
- default (if not None)
"""
if attribute in config:
if (attribute in config
and not isinstance(getattr(type(strategy), 'my_property', None), property)):
# Ensure Properties are not overwritten
setattr(strategy, attribute, config[attribute])
logger.info("Override strategy '%s' with value in config file: %s.",
attribute, config[attribute])

View File

@ -10,13 +10,13 @@ from datetime import date, datetime, timedelta
from html import escape
from itertools import chain
from math import isnan
from typing import Any, Callable, Dict, List, Optional, Union, cast
from typing import Any, Callable, Dict, List, Optional, Union
import arrow
from tabulate import tabulate
from telegram import (InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ParseMode,
ReplyKeyboardMarkup, Update)
from telegram.error import NetworkError, TelegramError
from telegram import (CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton,
ParseMode, ReplyKeyboardMarkup, Update)
from telegram.error import BadRequest, NetworkError, TelegramError
from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater
from telegram.utils.helpers import escape_markdown
@ -47,9 +47,13 @@ def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]:
update = kwargs.get('update') or args[0]
# Reject unauthorized messages
chat_id = int(self._config['telegram']['chat_id'])
if update.callback_query:
cchat_id = int(update.callback_query.message.chat.id)
else:
cchat_id = int(update.message.chat_id)
if int(update.message.chat_id) != chat_id:
chat_id = int(self._config['telegram']['chat_id'])
if cchat_id != chat_id:
logger.info(
'Rejected unauthorized message from: %s',
update.message.chat_id
@ -91,7 +95,7 @@ class Telegram(RPCHandler):
Validates the keyboard configuration from telegram config
section.
"""
self._keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = [
self._keyboard: List[List[Union[str, KeyboardButton]]] = [
['/daily', '/profit', '/balance'],
['/status', '/status table', '/performance'],
['/count', '/start', '/stop', '/help']
@ -164,8 +168,21 @@ class Telegram(RPCHandler):
CommandHandler('help', self._help),
CommandHandler('version', self._version),
]
callbacks = [
CallbackQueryHandler(self._status_table, pattern='update_status_table'),
CallbackQueryHandler(self._daily, pattern='update_daily'),
CallbackQueryHandler(self._profit, pattern='update_profit'),
CallbackQueryHandler(self._balance, pattern='update_balance'),
CallbackQueryHandler(self._performance, pattern='update_performance'),
CallbackQueryHandler(self._count, pattern='update_count'),
CallbackQueryHandler(self._forcebuy_inline),
]
for handle in handles:
self._updater.dispatcher.add_handler(handle)
for callback in callbacks:
self._updater.dispatcher.add_handler(callback)
self._updater.start_polling(
bootstrap_retries=-1,
timeout=30,
@ -177,11 +194,6 @@ class Telegram(RPCHandler):
[h.command for h in handles]
)
self._current_callback_query_handler: Optional[CallbackQueryHandler] = None
self._callback_query_handlers = {
'forcebuy': CallbackQueryHandler(self._forcebuy_inline)
}
def cleanup(self) -> None:
"""
Stops all running telegram threads.
@ -409,7 +421,9 @@ class Telegram(RPCHandler):
# insert separators line between Total
lines = message.split("\n")
message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]])
self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML)
self._send_msg(f"<pre>{message}</pre>", parse_mode=ParseMode.HTML,
reload_able=True, callback_path="update_status_table",
query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@ -447,7 +461,8 @@ class Telegram(RPCHandler):
],
tablefmt='simple')
message = f'<b>Daily Profit over the last {timescale} days</b>:\n<pre>{stats_tab}</pre>'
self._send_msg(message, parse_mode=ParseMode.HTML)
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
callback_path="update_daily", query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@ -519,7 +534,8 @@ class Telegram(RPCHandler):
if stats['closed_trade_count'] > 0:
markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`")
self._send_msg(markdown_msg)
self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit",
query=update.callback_query)
@authorized_only
def _stats(self, update: Update, context: CallbackContext) -> None:
@ -606,7 +622,8 @@ class Telegram(RPCHandler):
f"\t`{result['stake']}: {result['total']: .8f}`\n"
f"\t`{result['symbol']}: "
f"{round_coin_value(result['value'], result['symbol'], False)}`\n")
self._send_msg(output)
self._send_msg(output, reload_able=True, callback_path="update_balance",
query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@ -713,10 +730,10 @@ class Telegram(RPCHandler):
self._forcebuy_action(pair, price)
else:
whitelist = self._rpc._rpc_whitelist()['whitelist']
pairs = [InlineKeyboardButton(pair, callback_data=pair) for pair in whitelist]
self._send_inline_msg("Which pair?",
keyboard=self._layout_inline_keyboard(pairs),
callback_query_handler='forcebuy')
pairs = [InlineKeyboardButton(text=pair, callback_data=pair) for pair in whitelist]
self._send_msg(msg="Which pair?",
keyboard=self._layout_inline_keyboard(pairs))
@authorized_only
def _trades(self, update: Update, context: CallbackContext) -> None:
@ -800,7 +817,9 @@ class Telegram(RPCHandler):
else:
output += stat_line
self._send_msg(output, parse_mode=ParseMode.HTML)
self._send_msg(output, parse_mode=ParseMode.HTML,
reload_able=True, callback_path="update_performance",
query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@ -820,7 +839,9 @@ class Telegram(RPCHandler):
tablefmt='simple')
message = "<pre>{}</pre>".format(message)
logger.debug(message)
self._send_msg(message, parse_mode=ParseMode.HTML)
self._send_msg(message, parse_mode=ParseMode.HTML,
reload_able=True, callback_path="update_count",
query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@ -1052,29 +1073,42 @@ class Telegram(RPCHandler):
f"*Current state:* `{val['state']}`"
)
def _send_inline_msg(self, msg: str, callback_query_handler,
parse_mode: str = ParseMode.MARKDOWN, disable_notification: bool = False,
keyboard: List[List[InlineKeyboardButton]] = None, ) -> None:
"""
Send given markdown message
:param msg: message
:param bot: alternative bot
:param parse_mode: telegram parse mode
:return: None
"""
if self._current_callback_query_handler:
self._updater.dispatcher.remove_handler(self._current_callback_query_handler)
self._current_callback_query_handler = self._callback_query_handlers[callback_query_handler]
self._updater.dispatcher.add_handler(self._current_callback_query_handler)
def _update_msg(self, query: CallbackQuery, msg: str, callback_path: str = "",
reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None:
if reload_able:
reply_markup = InlineKeyboardMarkup([
[InlineKeyboardButton("Refresh", callback_data=callback_path)],
])
else:
reply_markup = InlineKeyboardMarkup([[]])
msg += "\nUpdated: {}".format(datetime.now().ctime())
if not query.message:
return
chat_id = query.message.chat_id
message_id = query.message.message_id
self._send_msg(msg, parse_mode, disable_notification,
cast(List[List[Union[str, KeyboardButton, InlineKeyboardButton]]], keyboard),
reply_markup=InlineKeyboardMarkup)
try:
self._updater.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=msg,
parse_mode=parse_mode,
reply_markup=reply_markup
)
except BadRequest as e:
if 'not modified' in e.message.lower():
pass
else:
logger.warning('TelegramError: %s', e.message)
except TelegramError as telegram_err:
logger.warning('TelegramError: %s! Giving up on that message.', telegram_err.message)
def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN,
disable_notification: bool = False,
keyboard: List[List[Union[str, KeyboardButton, InlineKeyboardButton]]] = None,
reply_markup=ReplyKeyboardMarkup) -> None:
keyboard: List[List[InlineKeyboardButton]] = None,
callback_path: str = "",
reload_able: bool = False,
query: Optional[CallbackQuery] = None) -> None:
"""
Send given markdown message
:param msg: message
@ -1082,9 +1116,19 @@ class Telegram(RPCHandler):
:param parse_mode: telegram parse mode
:return: None
"""
if keyboard is None:
keyboard = self._keyboard
reply_markup = reply_markup(keyboard, resize_keyboard=True)
reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]
if query:
self._update_msg(query=query, msg=msg, parse_mode=parse_mode,
callback_path=callback_path, reload_able=reload_able)
return
if reload_able and self._config['telegram'].get('reload', True):
reply_markup = InlineKeyboardMarkup([
[InlineKeyboardButton("Refresh", callback_data=callback_path)]])
else:
if keyboard is not None:
reply_markup = InlineKeyboardMarkup(keyboard, resize_keyboard=True)
else:
reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True)
try:
try:
self._updater.bot.send_message(

View File

@ -107,7 +107,7 @@ class IStrategy(ABC, HyperStrategyMixin):
startup_candle_count: int = 0
# Protections
protections: List
protections: List = []
# Class level variables (intentional) containing
# the dataprovider (dp) (access to other candles, historic data, ...)
@ -453,18 +453,25 @@ class IStrategy(ABC, HyperStrategyMixin):
"""
Ensure dataframe (length, last candle) was not modified, and has all elements we need.
"""
message_template = "Dataframe returned from strategy has mismatching {}."
message = ""
if df_len != len(dataframe):
message = "length"
if dataframe is None:
message = "No dataframe returned (return statement missing?)."
elif 'buy' not in dataframe:
message = "Buy column not set."
elif 'sell' not in dataframe:
message = "Sell column not set."
elif df_len != len(dataframe):
message = message_template.format("length")
elif df_close != dataframe["close"].iloc[-1]:
message = "last close price"
message = message_template.format("last close price")
elif df_date != dataframe["date"].iloc[-1]:
message = "last date"
message = message_template.format("last date")
if message:
if self.disable_dataframe_checks:
logger.warning(f"Dataframe returned from strategy has mismatching {message}.")
logger.warning(message)
else:
raise StrategyError(f"Dataframe returned from strategy has mismatching {message}.")
raise StrategyError(message)
def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[bool, bool]:
"""
@ -524,15 +531,14 @@ class IStrategy(ABC, HyperStrategyMixin):
:param force_stoploss: Externally provided stoploss
:return: True if trade should be sold, False otherwise
"""
# Set current rate to low for backtesting sell
current_rate = low or rate
current_rate = rate
current_profit = trade.calc_profit_ratio(current_rate)
trade.adjust_min_max_rates(high or current_rate)
stoplossflag = self.stop_loss_reached(current_rate=current_rate, trade=trade,
current_time=date, current_profit=current_profit,
force_stoploss=force_stoploss, high=high)
force_stoploss=force_stoploss, low=low, high=high)
# Set current rate to high for backtesting sell
current_rate = high or rate
@ -599,18 +605,21 @@ class IStrategy(ABC, HyperStrategyMixin):
def stop_loss_reached(self, current_rate: float, trade: Trade,
current_time: datetime, current_profit: float,
force_stoploss: float, high: float = None) -> SellCheckTuple:
force_stoploss: float, low: float = None,
high: float = None) -> SellCheckTuple:
"""
Based on current profit of the trade and configured (trailing) stoploss,
decides to sell or not
:param current_profit: current profit as ratio
:param low: Low value of this candle, only set in backtesting
:param high: High value of this candle, only set in backtesting
"""
stop_loss_value = force_stoploss if force_stoploss else self.stoploss
# Initiate stoploss with open_rate. Does nothing if stoploss is already set.
trade.adjust_stop_loss(trade.open_rate, stop_loss_value, initial=True)
if self.use_custom_stoploss:
if self.use_custom_stoploss and trade.stop_loss < (low or current_rate):
stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None
)(pair=trade.pair, trade=trade,
current_time=current_time,
@ -623,7 +632,7 @@ class IStrategy(ABC, HyperStrategyMixin):
else:
logger.warning("CustomStoploss function did not return valid stoploss")
if self.trailing_stop:
if self.trailing_stop and trade.stop_loss < (low or current_rate):
# trailing stoploss handling
sl_offset = self.trailing_stop_positive_offset
@ -643,7 +652,7 @@ class IStrategy(ABC, HyperStrategyMixin):
# evaluate if the stoploss was hit if stoploss is not on exchange
# in Dry-Run, this handles stoploss logic as well, as the logic will not be different to
# regular stoploss handling.
if ((trade.stop_loss >= current_rate) and
if ((trade.stop_loss >= (low or current_rate)) and
(not self.order_types.get('stoploss_on_exchange') or self.config['dry_run'])):
sell_type = SellType.STOP_LOSS
@ -652,7 +661,7 @@ class IStrategy(ABC, HyperStrategyMixin):
if trade.initial_stop_loss != trade.stop_loss:
sell_type = SellType.TRAILING_STOP_LOSS
logger.debug(
f"{trade.pair} - HIT STOP: current price at {current_rate:.6f}, "
f"{trade.pair} - HIT STOP: current price at {(low or current_rate):.6f}, "
f"stoploss is {trade.stop_loss:.6f}, "
f"initial stoploss was at {trade.initial_stop_loss:.6f}, "
f"trade opened at {trade.open_rate:.6f}")

View File

@ -2271,8 +2271,9 @@ def test_cancel_stoploss_order_with_result(default_conf, mocker, exchange_name):
@pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_order(default_conf, mocker, exchange_name):
def test_fetch_order(default_conf, mocker, exchange_name, caplog):
default_conf['dry_run'] = True
default_conf['exchange']['log_responses'] = True
order = MagicMock()
order.myid = 123
exchange = get_patched_exchange(mocker, default_conf, id=exchange_name)
@ -2287,6 +2288,7 @@ def test_fetch_order(default_conf, mocker, exchange_name):
api_mock.fetch_order = MagicMock(return_value=456)
exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name)
assert exchange.fetch_order('X', 'TKN/BTC') == 456
assert log_has("API fetch_order: 456", caplog)
with pytest.raises(InvalidOrderException):
api_mock.fetch_order = MagicMock(side_effect=ccxt.InvalidOrder("Order not found"))

View File

@ -457,6 +457,50 @@ tc28 = BTContainer(data=[
trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=3)]
)
# Test 29: trailing_stop should be triggered by low of next candle, without adjusting stoploss using
# high of stoploss candle.
# stop-loss: 10%, ROI: 10% (should not apply)
tc29 = BTContainer(data=[
# D O H L C V B S
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5050, 5000, 4900, 6172, 0, 0], # enter trade (signal on last candle)
[2, 4900, 5250, 4500, 5100, 6172, 0, 0], # Triggers trailing-stoploss
[3, 5100, 5100, 4650, 4750, 6172, 0, 0],
[4, 4750, 4950, 4350, 4750, 6172, 0, 0]],
stop_loss=-0.10, roi={"0": 0.10}, profit_perc=-0.02, trailing_stop=True,
trailing_stop_positive=0.03,
trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=2)]
)
# Test 30: trailing_stop should be triggered immediately on trade open candle.
# stop-loss: 10%, ROI: 10% (should not apply)
tc30 = BTContainer(data=[
# D O H L C V B S
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5500, 5000, 4900, 6172, 0, 0], # enter trade (signal on last candle) and stop
[2, 4900, 5250, 4500, 5100, 6172, 0, 0],
[3, 5100, 5100, 4650, 4750, 6172, 0, 0],
[4, 4750, 4950, 4350, 4750, 6172, 0, 0]],
stop_loss=-0.10, roi={"0": 0.10}, profit_perc=-0.01, trailing_stop=True,
trailing_stop_positive=0.01,
trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=1)]
)
# Test 31: trailing_stop should be triggered immediately on trade open candle.
# stop-loss: 10%, ROI: 10% (should not apply)
tc31 = BTContainer(data=[
# D O H L C V B S
[0, 5000, 5050, 4950, 5000, 6172, 1, 0],
[1, 5000, 5500, 5000, 4900, 6172, 0, 0], # enter trade (signal on last candle) and stop
[2, 4900, 5250, 4500, 5100, 6172, 0, 0],
[3, 5100, 5100, 4650, 4750, 6172, 0, 0],
[4, 4750, 4950, 4350, 4750, 6172, 0, 0]],
stop_loss=-0.10, roi={"0": 0.10}, profit_perc=0.01, trailing_stop=True,
trailing_only_offset_is_reached=True, trailing_stop_positive_offset=0.02,
trailing_stop_positive=0.01,
trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=1)]
)
TESTS = [
tc0,
tc1,
@ -487,6 +531,9 @@ TESTS = [
tc26,
tc27,
tc28,
tc29,
tc30,
tc31,
]

View File

@ -75,7 +75,7 @@ def whitelist_conf_agefilter(default_conf):
"method": "VolumePairList",
"number_assets": 5,
"sort_key": "quoteVolume",
"refresh_period": 0,
"refresh_period": -1,
},
{
"method": "AgeFilter",
@ -687,7 +687,6 @@ def test_agefilter_caching(mocker, markets, whitelist_conf_agefilter, tickers, o
freqtrade.pairlists.refresh_pairlist()
assert len(freqtrade.pairlists.whitelist) == 3
assert freqtrade.exchange.refresh_latest_ohlcv.call_count > 0
# freqtrade.config['exchange']['pair_whitelist'].append('HOT/BTC')
previous_call_count = freqtrade.exchange.refresh_latest_ohlcv.call_count
freqtrade.pairlists.refresh_pairlist()

View File

@ -70,8 +70,7 @@ def test_protectionmanager(mocker, default_conf):
])
def test_protections_init(mocker, default_conf, timeframe, expected, protconf):
default_conf['timeframe'] = timeframe
default_conf['protections'] = protconf
man = ProtectionManager(default_conf)
man = ProtectionManager(default_conf, protconf)
assert len(man._protection_handlers) == len(protconf)
assert man._protection_handlers[0]._lookback_period == expected[0]
assert man._protection_handlers[0]._stop_duration == expected[1]

View File

@ -13,7 +13,7 @@ from unittest.mock import ANY, MagicMock
import arrow
import pytest
from telegram import Chat, Message, ReplyKeyboardMarkup, Update
from telegram.error import NetworkError
from telegram.error import BadRequest, NetworkError, TelegramError
from freqtrade import __version__
from freqtrade.constants import CANCEL_REASON
@ -25,8 +25,8 @@ from freqtrade.loggers import setup_logging
from freqtrade.persistence import PairLocks, Trade
from freqtrade.rpc import RPC
from freqtrade.rpc.telegram import Telegram, authorized_only
from tests.conftest import (create_mock_trades, get_patched_freqtradebot, log_has, patch_exchange,
patch_get_signal, patch_whitelist)
from tests.conftest import (create_mock_trades, get_patched_freqtradebot, log_has, log_has_re,
patch_exchange, patch_get_signal, patch_whitelist)
class DummyCls(Telegram):
@ -55,14 +55,6 @@ class DummyCls(Telegram):
raise Exception('test')
def get_telegram_testobject_with_inline(mocker, default_conf, mock=True, ftbot=None):
inline_msg_mock = MagicMock()
telegram, ftbot, msg_mock = get_telegram_testobject(mocker, default_conf)
mocker.patch('freqtrade.rpc.telegram.Telegram._send_inline_msg', inline_msg_mock)
return telegram, ftbot, msg_mock, inline_msg_mock
def get_telegram_testobject(mocker, default_conf, mock=True, ftbot=None):
msg_mock = MagicMock()
if mock:
@ -920,8 +912,8 @@ def test_forcebuy_no_pair(default_conf, update, mocker) -> None:
fbuy_mock = MagicMock(return_value=None)
mocker.patch('freqtrade.rpc.RPC._rpc_forcebuy', fbuy_mock)
telegram, freqtradebot, _, inline_msg_mock = get_telegram_testobject_with_inline(mocker,
default_conf)
telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf)
patch_get_signal(freqtradebot, (True, False))
context = MagicMock()
@ -929,10 +921,10 @@ def test_forcebuy_no_pair(default_conf, update, mocker) -> None:
telegram._forcebuy(update=update, context=context)
assert fbuy_mock.call_count == 0
assert inline_msg_mock.call_count == 1
assert inline_msg_mock.call_args_list[0][0][0] == 'Which pair?'
assert inline_msg_mock.call_args_list[0][1]['callback_query_handler'] == 'forcebuy'
keyboard = inline_msg_mock.call_args_list[0][1]['keyboard']
assert msg_mock.call_count == 1
assert msg_mock.call_args_list[0][1]['msg'] == 'Which pair?'
# assert msg_mock.call_args_list[0][1]['callback_query_handler'] == 'forcebuy'
keyboard = msg_mock.call_args_list[0][1]['keyboard']
assert reduce(lambda acc, x: acc + len(x), keyboard, 0) == 4
update = MagicMock()
update.callback_query = MagicMock()
@ -1569,7 +1561,7 @@ def test__sell_emoji(default_conf, mocker, msg, expected):
assert telegram._get_sell_emoji(msg) == expected
def test__send_msg(default_conf, mocker) -> None:
def test_telegram__send_msg(default_conf, mocker, caplog) -> None:
mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())
bot = MagicMock()
telegram, _, _ = get_telegram_testobject(mocker, default_conf, mock=False)
@ -1580,6 +1572,28 @@ def test__send_msg(default_conf, mocker) -> None:
telegram._send_msg('test')
assert len(bot.method_calls) == 1
# Test update
query = MagicMock()
telegram._send_msg('test', callback_path="DeadBeef", query=query, reload_able=True)
edit_message_text = telegram._updater.bot.edit_message_text
assert edit_message_text.call_count == 1
assert "Updated: " in edit_message_text.call_args_list[0][1]['text']
telegram._updater.bot.edit_message_text = MagicMock(side_effect=BadRequest("not modified"))
telegram._send_msg('test', callback_path="DeadBeef", query=query)
assert telegram._updater.bot.edit_message_text.call_count == 1
assert not log_has_re(r"TelegramError: .*", caplog)
telegram._updater.bot.edit_message_text = MagicMock(side_effect=BadRequest(""))
telegram._send_msg('test2', callback_path="DeadBeef", query=query)
assert telegram._updater.bot.edit_message_text.call_count == 1
assert log_has_re(r"TelegramError: .*", caplog)
telegram._updater.bot.edit_message_text = MagicMock(side_effect=TelegramError("DeadBEEF"))
telegram._send_msg('test3', callback_path="DeadBeef", query=query)
assert log_has_re(r"TelegramError: DeadBEEF! Giving up.*", caplog)
def test__send_msg_network_error(default_conf, mocker, caplog) -> None:
mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())

View File

@ -153,6 +153,8 @@ def test_assert_df_raise(mocker, caplog, ohlcv_history):
def test_assert_df(ohlcv_history, caplog):
df_len = len(ohlcv_history) - 1
ohlcv_history.loc[:, 'buy'] = 0
ohlcv_history.loc[:, 'sell'] = 0
# Ensure it's running when passed correctly
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
ohlcv_history.loc[df_len, 'close'], ohlcv_history.loc[df_len, 'date'])
@ -170,6 +172,18 @@ def test_assert_df(ohlcv_history, caplog):
match=r"Dataframe returned from strategy.*last date\."):
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
ohlcv_history.loc[df_len, 'close'], ohlcv_history.loc[0, 'date'])
with pytest.raises(StrategyError,
match=r"No dataframe returned \(return statement missing\?\)."):
_STRATEGY.assert_df(None, len(ohlcv_history),
ohlcv_history.loc[df_len, 'close'], ohlcv_history.loc[0, 'date'])
with pytest.raises(StrategyError,
match="Buy column not set"):
_STRATEGY.assert_df(ohlcv_history.drop('buy', axis=1), len(ohlcv_history),
ohlcv_history.loc[df_len, 'close'], ohlcv_history.loc[0, 'date'])
with pytest.raises(StrategyError,
match="Sell column not set"):
_STRATEGY.assert_df(ohlcv_history.drop('sell', axis=1), len(ohlcv_history),
ohlcv_history.loc[df_len, 'close'], ohlcv_history.loc[0, 'date'])
_STRATEGY.disable_dataframe_checks = True
caplog.clear()