Merge branch 'develop' into plugins/protections_backtest

This commit is contained in:
Matthias 2020-12-13 10:31:33 +01:00
commit 7eab33de08
24 changed files with 332 additions and 93 deletions

View File

@ -1,24 +1,41 @@
FROM python:3.8.6-slim-buster FROM python:3.8.6-slim-buster as base
RUN apt-get update \ # Setup env
&& apt-get -y install curl build-essential libssl-dev sqlite3 \ ENV LANG C.UTF-8
&& apt-get clean \ ENV LC_ALL C.UTF-8
&& pip install --upgrade pip ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONFAULTHANDLER 1
ENV PATH=/root/.local/bin:$PATH
# Prepare environment # Prepare environment
RUN mkdir /freqtrade RUN mkdir /freqtrade
WORKDIR /freqtrade WORKDIR /freqtrade
# Install dependencies
FROM base as python-deps
RUN apt-get update \
&& apt-get -y install curl build-essential libssl-dev git \
&& apt-get clean \
&& pip install --upgrade pip
# Install TA-lib # Install TA-lib
COPY build_helpers/* /tmp/ COPY build_helpers/* /tmp/
RUN cd /tmp && /tmp/install_ta-lib.sh && rm -r /tmp/*ta-lib* RUN cd /tmp && /tmp/install_ta-lib.sh && rm -r /tmp/*ta-lib*
ENV LD_LIBRARY_PATH /usr/local/lib ENV LD_LIBRARY_PATH /usr/local/lib
# Install dependencies # Install dependencies
COPY requirements.txt requirements-hyperopt.txt /freqtrade/ COPY requirements.txt requirements-hyperopt.txt /freqtrade/
RUN pip install numpy --no-cache-dir \ RUN pip install --user --no-cache-dir numpy \
&& pip install -r requirements-hyperopt.txt --no-cache-dir && 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 /root/.local /root/.local
# Install and execute # Install and execute
COPY . /freqtrade/ COPY . /freqtrade/

View File

@ -285,7 +285,7 @@ Since backtesting lacks some detailed information about what happens within a ca
- sells are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the sell will be at 2%) - sells are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the sell will be at 2%)
- sells are never "below the candle", so a ROI of 2% may result in a sell at 2.4% if low was at 2.4% profit - sells are never "below the candle", so a ROI of 2% may result in a sell at 2.4% if low was at 2.4% profit
- Forcesells caused by `<N>=-1` ROI entries use low as sell value, unless N falls on the candle open (e.g. `120: -1` for 1h candles) - Forcesells caused by `<N>=-1` ROI entries use low as sell value, unless N falls on the candle open (e.g. `120: -1` for 1h candles)
- Stoploss sells happen exactly at stoploss price, even if low was lower - Stoploss sells happen exactly at stoploss price, even if low was lower, but the loss will be `2 * fees` higher than the stoploss price
- 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 - 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 - Low happens before high for stoploss, protecting capital first
- Trailing stoploss - Trailing stoploss

View File

@ -8,7 +8,7 @@ If no additional parameter is specified, freqtrade will download data for `"1m"`
Exchange and pairs will come from `config.json` (if specified using `-c/--config`). Exchange and pairs will come from `config.json` (if specified using `-c/--config`).
Otherwise `--exchange` becomes mandatory. Otherwise `--exchange` becomes mandatory.
You can use a relative timerange (`--days 20`) or an absolute starting point (`--timerange 20200101`). For incremental downloads, the relative approach should be used. You can use a relative timerange (`--days 20`) or an absolute starting point (`--timerange 20200101-`). For incremental downloads, the relative approach should be used.
!!! Tip "Tip: Updating existing data" !!! Tip "Tip: Updating existing data"
If you already have backtesting data available in your data-directory and would like to refresh this data up to today, use `--days xx` with a number slightly higher than the missing number of days. Freqtrade will keep the available data and only download the missing data. If you already have backtesting data available in your data-directory and would like to refresh this data up to today, use `--days xx` with a number slightly higher than the missing number of days. Freqtrade will keep the available data and only download the missing data.

View File

@ -112,7 +112,7 @@ OS Specific steps are listed first, the [Common](#common) section below is neces
``` bash ``` bash
sudo apt-get install python3-venv libatlas-base-dev sudo apt-get install python3-venv libatlas-base-dev cmake
# Use pywheels.org to speed up installation # Use pywheels.org to speed up installation
sudo echo "[global]\nextra-index-url=https://www.piwheels.org/simple" > tee /etc/pip.conf sudo echo "[global]\nextra-index-url=https://www.piwheels.org/simple" > tee /etc/pip.conf

View File

@ -1,3 +1,3 @@
mkdocs-material==6.1.6 mkdocs-material==6.1.7
mdx_truly_sane_lists==1.2 mdx_truly_sane_lists==1.2
pymdown-extensions==8.0.1 pymdown-extensions==8.0.1

View File

@ -127,6 +127,7 @@ python3 scripts/rest_client.py --config rest_config.json <command> [optional par
| `performance` | Show performance of each finished trade grouped by pair. | `performance` | Show performance of each finished trade grouped by pair.
| `balance` | Show account balance per currency. | `balance` | Show account balance per currency.
| `daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7). | `daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7).
| `stats` | Display a summary of profit / loss reasons as well as average holding times.
| `whitelist` | Show the current whitelist. | `whitelist` | Show the current whitelist.
| `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist.
| `edge` | Show validated pairs by Edge if it is enabled. | `edge` | Show validated pairs by Edge if it is enabled.
@ -229,6 +230,9 @@ show_config
start start
Start the bot if it's in the stopped state. Start the bot if it's in the stopped state.
stats
Return the stats report (durations, sell-reasons).
status status
Get the status of open trades. Get the status of open trades.

View File

@ -113,6 +113,7 @@ official commands. You can ask at any moment for help with `/help`.
| `/performance` | Show performance of each finished trade grouped by pair | `/performance` | Show performance of each finished trade grouped by pair
| `/balance` | Show account balance per currency | `/balance` | Show account balance per currency
| `/daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7) | `/daily <n>` | Shows profit or loss per day, over the last n days (n defaults to 7)
| `/stats` | Shows Wins / losses by Sell reason as well as Avg. holding durations for buys and sells
| `/whitelist` | Show the current whitelist | `/whitelist` | Show the current whitelist
| `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist.
| `/edge` | Show validated pairs by Edge if it is enabled. | `/edge` | Show validated pairs by Edge if it is enabled.

View File

@ -532,7 +532,6 @@ class FreqtradeBot(LoggingMixin):
# reserve some percent defined in config (5% default) + stoploss # reserve some percent defined in config (5% default) + stoploss
amount_reserve_percent = 1.0 - self.config.get('amount_reserve_percent', amount_reserve_percent = 1.0 - self.config.get('amount_reserve_percent',
constants.DEFAULT_AMOUNT_RESERVE_PERCENT) constants.DEFAULT_AMOUNT_RESERVE_PERCENT)
if self.strategy.stoploss is not None:
amount_reserve_percent += self.strategy.stoploss amount_reserve_percent += self.strategy.stoploss
# it should not be more than 50% # it should not be more than 50%
amount_reserve_percent = max(amount_reserve_percent, 0.5) amount_reserve_percent = max(amount_reserve_percent, 0.5)
@ -1415,7 +1414,7 @@ class FreqtradeBot(LoggingMixin):
abs_tol=constants.MATH_CLOSE_PREC): abs_tol=constants.MATH_CLOSE_PREC):
order['amount'] = new_amount order['amount'] = new_amount
order.pop('filled', None) order.pop('filled', None)
trade.recalc_open_trade_price() trade.recalc_open_trade_value()
except DependencyException as exception: except DependencyException as exception:
logger.warning("Could not update trade amount: %s", exception) logger.warning("Could not update trade amount: %s", exception)
@ -1470,7 +1469,10 @@ class FreqtradeBot(LoggingMixin):
fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(order) fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(order)
logger.info(f"Fee for Trade {trade} [{order.get('side')}]: " logger.info(f"Fee for Trade {trade} [{order.get('side')}]: "
f"{fee_cost:.8g} {fee_currency} - rate: {fee_rate}") f"{fee_cost:.8g} {fee_currency} - rate: {fee_rate}")
if fee_rate is None or fee_rate < 0.02:
# Reject all fees that report as > 2%.
# These are most likely caused by a parsing bug in ccxt
# due to multiple trades (https://github.com/ccxt/ccxt/issues/8025)
trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', '')) trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', ''))
if trade_base_currency == fee_currency: if trade_base_currency == fee_currency:
# Apply fee to amount # Apply fee to amount

View File

@ -53,11 +53,11 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
else: else:
timeframe = get_column_def(cols, 'timeframe', 'null') timeframe = get_column_def(cols, 'timeframe', 'null')
open_trade_price = get_column_def(cols, 'open_trade_price', open_trade_value = get_column_def(cols, 'open_trade_value',
f'amount * open_rate * (1 + {fee_open})') f'amount * open_rate * (1 + {fee_open})')
close_profit_abs = get_column_def( close_profit_abs = get_column_def(
cols, 'close_profit_abs', cols, 'close_profit_abs',
f"(amount * close_rate * (1 - {fee_close})) - {open_trade_price}") f"(amount * close_rate * (1 - {fee_close})) - {open_trade_value}")
sell_order_status = get_column_def(cols, 'sell_order_status', 'null') sell_order_status = get_column_def(cols, 'sell_order_status', 'null')
amount_requested = get_column_def(cols, 'amount_requested', 'amount') amount_requested = get_column_def(cols, 'amount_requested', 'amount')
@ -79,7 +79,7 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct, stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct,
stoploss_order_id, stoploss_last_update, stoploss_order_id, stoploss_last_update,
max_rate, min_rate, sell_reason, sell_order_status, strategy, max_rate, min_rate, sell_reason, sell_order_status, strategy,
timeframe, open_trade_price, close_profit_abs timeframe, open_trade_value, close_profit_abs
) )
select id, lower(exchange), select id, lower(exchange),
case case
@ -102,7 +102,7 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
{max_rate} max_rate, {min_rate} min_rate, {sell_reason} sell_reason, {max_rate} max_rate, {min_rate} min_rate, {sell_reason} sell_reason,
{sell_order_status} sell_order_status, {sell_order_status} sell_order_status,
{strategy} strategy, {timeframe} timeframe, {strategy} strategy, {timeframe} timeframe,
{open_trade_price} open_trade_price, {close_profit_abs} close_profit_abs {open_trade_value} open_trade_value, {close_profit_abs} close_profit_abs
from {table_back_name} from {table_back_name}
""") """)
@ -134,7 +134,7 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
table_back_name = get_backup_name(tabs, 'trades_bak') table_back_name = get_backup_name(tabs, 'trades_bak')
# Check for latest column # Check for latest column
if not has_column(cols, 'amount_requested'): if not has_column(cols, 'open_trade_value'):
logger.info(f'Running database migration for trades - backup: {table_back_name}') logger.info(f'Running database migration for trades - backup: {table_back_name}')
migrate_trades_table(decl_base, inspector, engine, table_back_name, cols) migrate_trades_table(decl_base, inspector, engine, table_back_name, cols)
# Reread columns - the above recreated the table! # Reread columns - the above recreated the table!

View File

@ -221,8 +221,8 @@ class Trade(_DECL_BASE):
fee_close_currency = Column(String, nullable=True) fee_close_currency = Column(String, nullable=True)
open_rate = Column(Float) open_rate = Column(Float)
open_rate_requested = Column(Float) open_rate_requested = Column(Float)
# open_trade_price - calculated via _calc_open_trade_price # open_trade_value - calculated via _calc_open_trade_value
open_trade_price = Column(Float) open_trade_value = Column(Float)
close_rate = Column(Float) close_rate = Column(Float)
close_rate_requested = Column(Float) close_rate_requested = Column(Float)
close_profit = Column(Float) close_profit = Column(Float)
@ -256,7 +256,7 @@ class Trade(_DECL_BASE):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.recalc_open_trade_price() self.recalc_open_trade_value()
def __repr__(self): def __repr__(self):
open_since = self.open_date.strftime(DATETIME_PRINT_FORMAT) if self.is_open else 'closed' open_since = self.open_date.strftime(DATETIME_PRINT_FORMAT) if self.is_open else 'closed'
@ -288,7 +288,7 @@ class Trade(_DECL_BASE):
'open_timestamp': int(self.open_date.replace(tzinfo=timezone.utc).timestamp() * 1000), 'open_timestamp': int(self.open_date.replace(tzinfo=timezone.utc).timestamp() * 1000),
'open_rate': self.open_rate, 'open_rate': self.open_rate,
'open_rate_requested': self.open_rate_requested, 'open_rate_requested': self.open_rate_requested,
'open_trade_price': round(self.open_trade_price, 8), 'open_trade_value': round(self.open_trade_value, 8),
'close_date_hum': (arrow.get(self.close_date).humanize() 'close_date_hum': (arrow.get(self.close_date).humanize()
if self.close_date else None), if self.close_date else None),
@ -401,7 +401,7 @@ class Trade(_DECL_BASE):
# Update open rate and actual amount # Update open rate and actual amount
self.open_rate = Decimal(safe_value_fallback(order, 'average', 'price')) self.open_rate = Decimal(safe_value_fallback(order, 'average', 'price'))
self.amount = Decimal(safe_value_fallback(order, 'filled', 'amount')) self.amount = Decimal(safe_value_fallback(order, 'filled', 'amount'))
self.recalc_open_trade_price() self.recalc_open_trade_value()
if self.is_open: if self.is_open:
logger.info(f'{order_type.upper()}_BUY has been fulfilled for {self}.') logger.info(f'{order_type.upper()}_BUY has been fulfilled for {self}.')
self.open_order_id = None self.open_order_id = None
@ -477,7 +477,7 @@ class Trade(_DECL_BASE):
Trade.session.delete(self) Trade.session.delete(self)
Trade.session.flush() Trade.session.flush()
def _calc_open_trade_price(self) -> float: def _calc_open_trade_value(self) -> float:
""" """
Calculate the open_rate including open_fee. Calculate the open_rate including open_fee.
:return: Price in of the open trade incl. Fees :return: Price in of the open trade incl. Fees
@ -486,14 +486,14 @@ class Trade(_DECL_BASE):
fees = buy_trade * Decimal(self.fee_open) fees = buy_trade * Decimal(self.fee_open)
return float(buy_trade + fees) return float(buy_trade + fees)
def recalc_open_trade_price(self) -> None: def recalc_open_trade_value(self) -> None:
""" """
Recalculate open_trade_price. Recalculate open_trade_value.
Must be called whenever open_rate or fee_open is changed. Must be called whenever open_rate or fee_open is changed.
""" """
self.open_trade_price = self._calc_open_trade_price() self.open_trade_value = self._calc_open_trade_value()
def calc_close_trade_price(self, rate: Optional[float] = None, def calc_close_trade_value(self, rate: Optional[float] = None,
fee: Optional[float] = None) -> float: fee: Optional[float] = None) -> float:
""" """
Calculate the close_rate including fee Calculate the close_rate including fee
@ -520,11 +520,11 @@ class Trade(_DECL_BASE):
If rate is not set self.close_rate will be used If rate is not set self.close_rate will be used
:return: profit in stake currency as float :return: profit in stake currency as float
""" """
close_trade_price = self.calc_close_trade_price( close_trade_value = self.calc_close_trade_value(
rate=(rate or self.close_rate), rate=(rate or self.close_rate),
fee=(fee or self.fee_close) fee=(fee or self.fee_close)
) )
profit = close_trade_price - self.open_trade_price profit = close_trade_value - self.open_trade_value
return float(f"{profit:.8f}") return float(f"{profit:.8f}")
def calc_profit_ratio(self, rate: Optional[float] = None, def calc_profit_ratio(self, rate: Optional[float] = None,
@ -536,11 +536,11 @@ class Trade(_DECL_BASE):
:param fee: fee to use on the close rate (optional). :param fee: fee to use on the close rate (optional).
:return: profit ratio as float :return: profit ratio as float
""" """
close_trade_price = self.calc_close_trade_price( close_trade_value = self.calc_close_trade_value(
rate=(rate or self.close_rate), rate=(rate or self.close_rate),
fee=(fee or self.fee_close) fee=(fee or self.fee_close)
) )
profit_ratio = (close_trade_price / self.open_trade_price) - 1 profit_ratio = (close_trade_value / self.open_trade_value) - 1
return float(f"{profit_ratio:.8f}") return float(f"{profit_ratio:.8f}")
def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]: def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]:

View File

@ -88,9 +88,6 @@ class StrategyResolver(IResolver):
StrategyResolver._override_attribute_helper(strategy, config, StrategyResolver._override_attribute_helper(strategy, config,
attribute, default) attribute, default)
# Assign deprecated variable - to not break users code relying on this.
strategy.ticker_interval = strategy.timeframe
# Loop this list again to have output combined # Loop this list again to have output combined
for attribute, _, subkey in attributes: for attribute, _, subkey in attributes:
if subkey and attribute in config[subkey]: if subkey and attribute in config[subkey]:
@ -98,11 +95,7 @@ class StrategyResolver(IResolver):
elif attribute in config: elif attribute in config:
logger.info("Strategy using %s: %s", attribute, config[attribute]) logger.info("Strategy using %s: %s", attribute, config[attribute])
# Sort and apply type conversions StrategyResolver._normalize_attributes(strategy)
strategy.minimal_roi = OrderedDict(sorted(
{int(key): value for (key, value) in strategy.minimal_roi.items()}.items(),
key=lambda t: t[0]))
strategy.stoploss = float(strategy.stoploss)
StrategyResolver._strategy_sanity_validations(strategy) StrategyResolver._strategy_sanity_validations(strategy)
return strategy return strategy
@ -131,6 +124,24 @@ class StrategyResolver(IResolver):
setattr(strategy, attribute, default) setattr(strategy, attribute, default)
config[attribute] = default config[attribute] = default
@staticmethod
def _normalize_attributes(strategy: IStrategy) -> IStrategy:
"""
Normalize attributes to have the correct type.
"""
# Assign deprecated variable - to not break users code relying on this.
if hasattr(strategy, 'timeframe'):
strategy.ticker_interval = strategy.timeframe
# Sort and apply type conversions
if hasattr(strategy, 'minimal_roi'):
strategy.minimal_roi = OrderedDict(sorted(
{int(key): value for (key, value) in strategy.minimal_roi.items()}.items(),
key=lambda t: t[0]))
if hasattr(strategy, 'stoploss'):
strategy.stoploss = float(strategy.stoploss)
return strategy
@staticmethod @staticmethod
def _strategy_sanity_validations(strategy): def _strategy_sanity_validations(strategy):
if not all(k in strategy.order_types for k in REQUIRED_ORDERTYPES): if not all(k in strategy.order_types for k in REQUIRED_ORDERTYPES):

View File

@ -198,6 +198,8 @@ class ApiServer(RPC):
self.app.add_url_rule(f'{BASE_URI}/logs', 'log', view_func=self._get_logs, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/logs', 'log', view_func=self._get_logs, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/profit', 'profit', self.app.add_url_rule(f'{BASE_URI}/profit', 'profit',
view_func=self._profit, methods=['GET']) view_func=self._profit, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/stats', 'stats',
view_func=self._stats, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/performance', 'performance', self.app.add_url_rule(f'{BASE_URI}/performance', 'performance',
view_func=self._performance, methods=['GET']) view_func=self._performance, methods=['GET'])
self.app.add_url_rule(f'{BASE_URI}/status', 'status', self.app.add_url_rule(f'{BASE_URI}/status', 'status',
@ -417,6 +419,18 @@ class ApiServer(RPC):
return jsonify(stats) return jsonify(stats)
@require_login
@rpc_catch_errors
def _stats(self):
"""
Handler for /stats.
Returns a Object with "durations" and "sell_reasons" as keys.
"""
stats = self._rpc_stats()
return jsonify(stats)
@require_login @require_login
@rpc_catch_errors @rpc_catch_errors
def _performance(self): def _performance(self):

View File

@ -275,6 +275,39 @@ class RPC:
"trades_count": len(output) "trades_count": len(output)
} }
def _rpc_stats(self) -> Dict[str, Any]:
"""
Generate generic stats for trades in database
"""
def trade_win_loss(trade):
if trade.close_profit > 0:
return 'wins'
elif trade.close_profit < 0:
return 'losses'
else:
return 'draws'
trades = trades = Trade.get_trades([Trade.is_open.is_(False)])
# Sell reason
sell_reasons = {}
for trade in trades:
if trade.sell_reason not in sell_reasons:
sell_reasons[trade.sell_reason] = {'wins': 0, 'losses': 0, 'draws': 0}
sell_reasons[trade.sell_reason][trade_win_loss(trade)] += 1
# Duration
dur: Dict[str, List[int]] = {'wins': [], 'draws': [], 'losses': []}
for trade in trades:
if trade.close_date is not None and trade.open_date is not None:
trade_dur = (trade.close_date - trade.open_date).total_seconds()
dur[trade_win_loss(trade)].append(trade_dur)
wins_dur = sum(dur['wins']) / len(dur['wins']) if len(dur['wins']) > 0 else 'N/A'
draws_dur = sum(dur['draws']) / len(dur['draws']) if len(dur['draws']) > 0 else 'N/A'
losses_dur = sum(dur['losses']) / len(dur['losses']) if len(dur['losses']) > 0 else 'N/A'
durations = {'wins': wins_dur, 'draws': draws_dur, 'losses': losses_dur}
return {'sell_reasons': sell_reasons, 'durations': durations}
def _rpc_trade_statistics( def _rpc_trade_statistics(
self, stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: self, stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
""" Returns cumulative profit statistics """ """ Returns cumulative profit statistics """

View File

@ -5,6 +5,7 @@ This module manage Telegram communication
""" """
import json import json
import logging import logging
from datetime import timedelta
from typing import Any, Callable, Dict, List, Union from typing import Any, Callable, Dict, List, Union
import arrow import arrow
@ -98,6 +99,7 @@ class Telegram(RPC):
CommandHandler('trades', self._trades), CommandHandler('trades', self._trades),
CommandHandler('delete', self._delete_trade), CommandHandler('delete', self._delete_trade),
CommandHandler('performance', self._performance), CommandHandler('performance', self._performance),
CommandHandler('stats', self._stats),
CommandHandler('daily', self._daily), CommandHandler('daily', self._daily),
CommandHandler('count', self._count), CommandHandler('count', self._count),
CommandHandler('locks', self._locks), CommandHandler('locks', self._locks),
@ -388,6 +390,48 @@ class Telegram(RPC):
f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`") f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`")
self._send_msg(markdown_msg) self._send_msg(markdown_msg)
@authorized_only
def _stats(self, update: Update, context: CallbackContext) -> None:
"""
Handler for /stats
Show stats of recent trades
"""
stats = self._rpc_stats()
reason_map = {
'roi': 'ROI',
'stop_loss': 'Stoploss',
'trailing_stop_loss': 'Trail. Stop',
'stoploss_on_exchange': 'Stoploss',
'sell_signal': 'Sell Signal',
'force_sell': 'Forcesell',
'emergency_sell': 'Emergency Sell',
}
sell_reasons_tabulate = [
[
reason_map.get(reason, reason),
sum(count.values()),
count['wins'],
count['losses']
] for reason, count in stats['sell_reasons'].items()
]
sell_reasons_msg = tabulate(
sell_reasons_tabulate,
headers=['Sell Reason', 'Sells', 'Wins', 'Losses']
)
durations = stats['durations']
duration_msg = tabulate([
['Wins', str(timedelta(seconds=durations['wins']))
if durations['wins'] != 'N/A' else 'N/A'],
['Losses', str(timedelta(seconds=durations['losses']))
if durations['losses'] != 'N/A' else 'N/A']
],
headers=['', 'Avg. Duration']
)
msg = (f"""```\n{sell_reasons_msg}```\n```\n{duration_msg}```""")
self._send_msg(msg, ParseMode.MARKDOWN)
@authorized_only @authorized_only
def _balance(self, update: Update, context: CallbackContext) -> None: def _balance(self, update: Update, context: CallbackContext) -> None:
""" Handler for /balance """ """ Handler for /balance """
@ -743,6 +787,8 @@ class Telegram(RPC):
"*/delete <trade_id>:* `Instantly delete the given trade in the database`\n" "*/delete <trade_id>:* `Instantly delete the given trade in the database`\n"
"*/performance:* `Show performance of each finished trade grouped by pair`\n" "*/performance:* `Show performance of each finished trade grouped by pair`\n"
"*/daily <n>:* `Shows profit or loss per day, over the last n days`\n" "*/daily <n>:* `Shows profit or loss per day, over the last n days`\n"
"*/stats:* `Shows Wins / losses by Sell reason as well as "
"Avg. holding durationsfor buys and sells.`\n"
"*/count:* `Show number of active trades compared to allowed number of trades`\n" "*/count:* `Show number of active trades compared to allowed number of trades`\n"
"*/locks:* `Show currently locked pairs`\n" "*/locks:* `Show currently locked pairs`\n"
"*/balance:* `Show account balance per currency`\n" "*/balance:* `Show account balance per currency`\n"

View File

@ -551,8 +551,7 @@ class IStrategy(ABC):
# evaluate if the stoploss was hit if stoploss is not on exchange # 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 # in Dry-Run, this handles stoploss logic as well, as the logic will not be different to
# regular stoploss handling. # regular stoploss handling.
if ((self.stoploss is not None) and if ((trade.stop_loss >= current_rate) and
(trade.stop_loss >= current_rate) and
(not self.order_types.get('stoploss_on_exchange') or self.config['dry_run'])): (not self.order_types.get('stoploss_on_exchange') or self.config['dry_run'])):
sell_type = SellType.STOP_LOSS sell_type = SellType.STOP_LOSS

View File

@ -1,7 +1,7 @@
numpy==1.19.4 numpy==1.19.4
pandas==1.1.4 pandas==1.1.4
ccxt==1.38.55 ccxt==1.39.10
aiohttp==3.7.3 aiohttp==3.7.3
SQLAlchemy==1.3.20 SQLAlchemy==1.3.20
python-telegram-bot==13.1 python-telegram-bot==13.1

View File

@ -139,6 +139,13 @@ class FtRestClient():
""" """
return self._get("profit") return self._get("profit")
def stats(self):
"""Return the stats report (durations, sell-reasons).
:return: json object
"""
return self._get("stats")
def performance(self): def performance(self):
"""Return the performance of the different coins. """Return the performance of the different coins.

View File

@ -1588,16 +1588,7 @@ def fetch_trades_result():
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def trades_for_order2(): def trades_for_order2():
return [{'info': {'id': 34567, return [{'info': {},
'orderId': 123456,
'price': '0.24544100',
'qty': '8.00000000',
'commission': '0.00800000',
'commissionAsset': 'LTC',
'time': 1521663363189,
'isBuyer': True,
'isMaker': False,
'isBestMatch': True},
'timestamp': 1521663363189, 'timestamp': 1521663363189,
'datetime': '2018-03-21T20:16:03.189Z', 'datetime': '2018-03-21T20:16:03.189Z',
'symbol': 'LTC/ETH', 'symbol': 'LTC/ETH',
@ -1609,16 +1600,7 @@ def trades_for_order2():
'cost': 1.963528, 'cost': 1.963528,
'amount': 4.0, 'amount': 4.0,
'fee': {'cost': 0.004, 'currency': 'LTC'}}, 'fee': {'cost': 0.004, 'currency': 'LTC'}},
{'info': {'id': 34567, {'info': {},
'orderId': 123456,
'price': '0.24544100',
'qty': '8.00000000',
'commission': '0.00800000',
'commissionAsset': 'LTC',
'time': 1521663363189,
'isBuyer': True,
'isMaker': False,
'isBestMatch': True},
'timestamp': 1521663363189, 'timestamp': 1521663363189,
'datetime': '2018-03-21T20:16:03.189Z', 'datetime': '2018-03-21T20:16:03.189Z',
'symbol': 'LTC/ETH', 'symbol': 'LTC/ETH',
@ -1632,6 +1614,14 @@ def trades_for_order2():
'fee': {'cost': 0.004, 'currency': 'LTC'}}] 'fee': {'cost': 0.004, 'currency': 'LTC'}}]
@pytest.fixture(scope="function")
def trades_for_order3(trades_for_order2):
# Different fee currencies for each trade
trades_for_order = deepcopy(trades_for_order2)
trades_for_order[0]['fee'] = {'cost': 0.02, 'currency': 'BNB'}
return trades_for_order
@pytest.fixture @pytest.fixture
def buy_order_fee(): def buy_order_fee():
return { return {

View File

@ -1,3 +1,5 @@
from datetime import datetime, timedelta, timezone
from freqtrade.persistence.models import Order, Trade from freqtrade.persistence.models import Order, Trade
@ -82,6 +84,9 @@ def mock_trade_2(fee):
is_open=False, is_open=False,
open_order_id='dry_run_sell_12345', open_order_id='dry_run_sell_12345',
strategy='DefaultStrategy', strategy='DefaultStrategy',
sell_reason='sell_signal',
open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20),
close_date=datetime.now(tz=timezone.utc),
) )
o = Order.parse_from_ccxt_object(mock_order_2(), 'ETC/BTC', 'buy') o = Order.parse_from_ccxt_object(mock_order_2(), 'ETC/BTC', 'buy')
trade.orders.append(o) trade.orders.append(o)
@ -134,6 +139,9 @@ def mock_trade_3(fee):
close_profit=0.01, close_profit=0.01,
exchange='bittrex', exchange='bittrex',
is_open=False, is_open=False,
sell_reason='roi',
open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20),
close_date=datetime.now(tz=timezone.utc),
) )
o = Order.parse_from_ccxt_object(mock_order_3(), 'XRP/BTC', 'buy') o = Order.parse_from_ccxt_object(mock_order_3(), 'XRP/BTC', 'buy')
trade.orders.append(o) trade.orders.append(o)

View File

@ -62,7 +62,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None:
'fee_close_cost': ANY, 'fee_close_cost': ANY,
'fee_close_currency': ANY, 'fee_close_currency': ANY,
'open_rate_requested': ANY, 'open_rate_requested': ANY,
'open_trade_price': 0.0010025, 'open_trade_value': 0.0010025,
'close_rate_requested': ANY, 'close_rate_requested': ANY,
'sell_reason': ANY, 'sell_reason': ANY,
'sell_order_status': ANY, 'sell_order_status': ANY,
@ -127,7 +127,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None:
'fee_close_cost': ANY, 'fee_close_cost': ANY,
'fee_close_currency': ANY, 'fee_close_currency': ANY,
'open_rate_requested': ANY, 'open_rate_requested': ANY,
'open_trade_price': ANY, 'open_trade_value': ANY,
'close_rate_requested': ANY, 'close_rate_requested': ANY,
'sell_reason': ANY, 'sell_reason': ANY,
'sell_order_status': ANY, 'sell_order_status': ANY,

View File

@ -559,6 +559,35 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, li
} }
@pytest.mark.usefixtures("init_persistence")
def test_api_stats(botclient, mocker, ticker, fee, markets,):
ftbot, client = botclient
patch_get_signal(ftbot, (True, False))
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
get_balances=MagicMock(return_value=ticker),
fetch_ticker=ticker,
get_fee=fee,
markets=PropertyMock(return_value=markets)
)
rc = client_get(client, f"{BASE_URI}/stats")
assert_response(rc, 200)
assert 'durations' in rc.json
assert 'sell_reasons' in rc.json
create_mock_trades(fee)
rc = client_get(client, f"{BASE_URI}/stats")
assert_response(rc, 200)
assert 'durations' in rc.json
assert 'sell_reasons' in rc.json
assert 'wins' in rc.json['durations']
assert 'losses' in rc.json['durations']
assert 'draws' in rc.json['durations']
def test_api_performance(botclient, mocker, ticker, fee): def test_api_performance(botclient, mocker, ticker, fee):
ftbot, client = botclient ftbot, client = botclient
patch_get_signal(ftbot, (True, False)) patch_get_signal(ftbot, (True, False))
@ -678,7 +707,7 @@ def test_api_status(botclient, mocker, ticker, fee, markets):
'min_rate': 1.098e-05, 'min_rate': 1.098e-05,
'open_order_id': None, 'open_order_id': None,
'open_rate_requested': 1.098e-05, 'open_rate_requested': 1.098e-05,
'open_trade_price': 0.0010025, 'open_trade_value': 0.0010025,
'sell_reason': None, 'sell_reason': None,
'sell_order_status': None, 'sell_order_status': None,
'strategy': 'DefaultStrategy', 'strategy': 'DefaultStrategy',
@ -805,7 +834,7 @@ def test_api_forcebuy(botclient, mocker, fee):
'min_rate': None, 'min_rate': None,
'open_order_id': '123456', 'open_order_id': '123456',
'open_rate_requested': None, 'open_rate_requested': None,
'open_trade_price': 0.24605460, 'open_trade_value': 0.24605460,
'sell_reason': None, 'sell_reason': None,
'sell_order_status': None, 'sell_order_status': None,
'strategy': None, 'strategy': None,

View File

@ -74,9 +74,10 @@ def test_telegram_init(default_conf, mocker, caplog) -> None:
message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], "
"['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], ['trades'], " "['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], ['trades'], "
"['delete'], ['performance'], ['daily'], ['count'], ['locks'], " "['delete'], ['performance'], ['stats'], ['daily'], ['count'], ['locks'], "
"['reload_config', 'reload_conf'], ['show_config', 'show_conf'], ['stopbuy'], " "['reload_config', 'reload_conf'], ['show_config', 'show_conf'], ['stopbuy'], "
"['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']]") "['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']"
"]")
assert log_has(message_str, caplog) assert log_has(message_str, caplog)
@ -468,6 +469,41 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee,
assert '*Best Performing:* `ETH/BTC: 6.20%`' in msg_mock.call_args_list[-1][0][0] assert '*Best Performing:* `ETH/BTC: 6.20%`' in msg_mock.call_args_list[-1][0][0]
def test_telegram_stats(default_conf, update, ticker, ticker_sell_up, fee,
limit_buy_order, limit_sell_order, mocker) -> None:
mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0)
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
fetch_ticker=ticker,
get_fee=fee,
)
msg_mock = MagicMock()
mocker.patch.multiple(
'freqtrade.rpc.telegram.Telegram',
_init=MagicMock(),
_send_msg=msg_mock
)
freqtradebot = get_patched_freqtradebot(mocker, default_conf)
patch_get_signal(freqtradebot, (True, False))
telegram = Telegram(freqtradebot)
telegram._stats(update=update, context=MagicMock())
assert msg_mock.call_count == 1
# assert 'No trades yet.' in msg_mock.call_args_list[0][0][0]
msg_mock.reset_mock()
# Create some test data
create_mock_trades(fee)
telegram._stats(update=update, context=MagicMock())
assert msg_mock.call_count == 1
assert 'Sell Reason' in msg_mock.call_args_list[-1][0][0]
assert 'ROI' in msg_mock.call_args_list[-1][0][0]
assert 'Avg. Duration' in msg_mock.call_args_list[-1][0][0]
msg_mock.reset_mock()
def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance, tickers) -> None: def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance, tickers) -> None:
default_conf['dry_run'] = False default_conf['dry_run'] = False
mocker.patch('freqtrade.exchange.Exchange.get_balances', return_value=rpc_balance) mocker.patch('freqtrade.exchange.Exchange.get_balances', return_value=rpc_balance)

View File

@ -3744,6 +3744,48 @@ def test_get_real_amount_multi(default_conf, trades_for_order2, buy_order_fee, c
'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.992).', 'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.992).',
caplog) caplog)
assert trade.fee_open == 0.001
assert trade.fee_close == 0.001
assert trade.fee_open_cost is not None
assert trade.fee_open_currency is not None
assert trade.fee_close_cost is None
assert trade.fee_close_currency is None
def test_get_real_amount_multi2(default_conf, trades_for_order3, buy_order_fee, caplog, fee,
mocker, markets):
# Different fee currency on both trades
mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order3)
amount = float(sum(x['amount'] for x in trades_for_order3))
default_conf['stake_currency'] = 'ETH'
trade = Trade(
pair='LTC/ETH',
amount=amount,
exchange='binance',
fee_open=fee.return_value,
fee_close=fee.return_value,
open_rate=0.245441,
open_order_id="123456"
)
# Fake markets entry to enable fee parsing
markets['BNB/ETH'] = markets['ETH/BTC']
freqtrade = get_patched_freqtradebot(mocker, default_conf)
mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets))
mocker.patch('freqtrade.exchange.Exchange.fetch_ticker',
return_value={'ask': 0.19, 'last': 0.2})
# Amount is reduced by "fee"
assert freqtrade.get_real_amount(trade, buy_order_fee) == amount - (amount * 0.0005)
assert log_has('Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, '
'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.996).',
caplog)
# Overall fee is average of both trade's fee
assert trade.fee_open == 0.001518575
assert trade.fee_open_cost is not None
assert trade.fee_open_currency is not None
assert trade.fee_close_cost is None
assert trade.fee_close_currency is None
def test_get_real_amount_fromorder(default_conf, trades_for_order, buy_order_fee, fee, def test_get_real_amount_fromorder(default_conf, trades_for_order, buy_order_fee, fee,
caplog, mocker): caplog, mocker):
@ -4290,7 +4332,7 @@ def test_update_closed_trades_without_assigned_fees(mocker, default_conf, fee):
freqtrade = get_patched_freqtradebot(mocker, default_conf) freqtrade = get_patched_freqtradebot(mocker, default_conf)
def patch_with_fee(order): def patch_with_fee(order):
order.update({'fee': {'cost': 0.1, 'rate': 0.2, order.update({'fee': {'cost': 0.1, 'rate': 0.01,
'currency': order['symbol'].split('/')[0]}}) 'currency': order['symbol'].split('/')[0]}})
return order return order

View File

@ -177,10 +177,10 @@ def test_calc_open_close_trade_price(limit_buy_order, limit_sell_order, fee):
trade.open_order_id = 'something' trade.open_order_id = 'something'
trade.update(limit_buy_order) trade.update(limit_buy_order)
assert trade._calc_open_trade_price() == 0.0010024999999225068 assert trade._calc_open_trade_value() == 0.0010024999999225068
trade.update(limit_sell_order) trade.update(limit_sell_order)
assert trade.calc_close_trade_price() == 0.0010646656050132426 assert trade.calc_close_trade_value() == 0.0010646656050132426
# Profit in BTC # Profit in BTC
assert trade.calc_profit() == 0.00006217 assert trade.calc_profit() == 0.00006217
@ -233,7 +233,7 @@ def test_calc_close_trade_price_exception(limit_buy_order, fee):
trade.open_order_id = 'something' trade.open_order_id = 'something'
trade.update(limit_buy_order) trade.update(limit_buy_order)
assert trade.calc_close_trade_price() == 0.0 assert trade.calc_close_trade_value() == 0.0
@pytest.mark.usefixtures("init_persistence") @pytest.mark.usefixtures("init_persistence")
@ -277,7 +277,7 @@ def test_update_invalid_order(limit_buy_order):
@pytest.mark.usefixtures("init_persistence") @pytest.mark.usefixtures("init_persistence")
def test_calc_open_trade_price(limit_buy_order, fee): def test_calc_open_trade_value(limit_buy_order, fee):
trade = Trade( trade = Trade(
pair='ETH/BTC', pair='ETH/BTC',
stake_amount=0.001, stake_amount=0.001,
@ -291,10 +291,10 @@ def test_calc_open_trade_price(limit_buy_order, fee):
trade.update(limit_buy_order) # Buy @ 0.00001099 trade.update(limit_buy_order) # Buy @ 0.00001099
# Get the open rate price with the standard fee rate # Get the open rate price with the standard fee rate
assert trade._calc_open_trade_price() == 0.0010024999999225068 assert trade._calc_open_trade_value() == 0.0010024999999225068
trade.fee_open = 0.003 trade.fee_open = 0.003
# Get the open rate price with a custom fee rate # Get the open rate price with a custom fee rate
assert trade._calc_open_trade_price() == 0.001002999999922468 assert trade._calc_open_trade_value() == 0.001002999999922468
@pytest.mark.usefixtures("init_persistence") @pytest.mark.usefixtures("init_persistence")
@ -312,14 +312,14 @@ def test_calc_close_trade_price(limit_buy_order, limit_sell_order, fee):
trade.update(limit_buy_order) # Buy @ 0.00001099 trade.update(limit_buy_order) # Buy @ 0.00001099
# Get the close rate price with a custom close rate and a regular fee rate # Get the close rate price with a custom close rate and a regular fee rate
assert trade.calc_close_trade_price(rate=0.00001234) == 0.0011200318470471794 assert trade.calc_close_trade_value(rate=0.00001234) == 0.0011200318470471794
# Get the close rate price with a custom close rate and a custom fee rate # Get the close rate price with a custom close rate and a custom fee rate
assert trade.calc_close_trade_price(rate=0.00001234, fee=0.003) == 0.0011194704275749754 assert trade.calc_close_trade_value(rate=0.00001234, fee=0.003) == 0.0011194704275749754
# Test when we apply a Sell order, and ask price with a custom fee rate # Test when we apply a Sell order, and ask price with a custom fee rate
trade.update(limit_sell_order) trade.update(limit_sell_order)
assert trade.calc_close_trade_price(fee=0.005) == 0.0010619972701635854 assert trade.calc_close_trade_value(fee=0.005) == 0.0010619972701635854
@pytest.mark.usefixtures("init_persistence") @pytest.mark.usefixtures("init_persistence")
@ -499,7 +499,7 @@ def test_migrate_old(mocker, default_conf, fee):
assert trade.max_rate == 0.0 assert trade.max_rate == 0.0
assert trade.stop_loss == 0.0 assert trade.stop_loss == 0.0
assert trade.initial_stop_loss == 0.0 assert trade.initial_stop_loss == 0.0
assert trade.open_trade_price == trade._calc_open_trade_price() assert trade.open_trade_value == trade._calc_open_trade_value()
assert trade.close_profit_abs is None assert trade.close_profit_abs is None
assert trade.fee_open_cost is None assert trade.fee_open_cost is None
assert trade.fee_open_currency is None assert trade.fee_open_currency is None
@ -607,7 +607,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog):
assert log_has("trying trades_bak1", caplog) assert log_has("trying trades_bak1", caplog)
assert log_has("trying trades_bak2", caplog) assert log_has("trying trades_bak2", caplog)
assert log_has("Running database migration for trades - backup: trades_bak2", caplog) assert log_has("Running database migration for trades - backup: trades_bak2", caplog)
assert trade.open_trade_price == trade._calc_open_trade_price() assert trade.open_trade_value == trade._calc_open_trade_value()
assert trade.close_profit_abs is None assert trade.close_profit_abs is None
assert log_has("Moving open orders to Orders table.", caplog) assert log_has("Moving open orders to Orders table.", caplog)
@ -677,7 +677,7 @@ def test_migrate_mid_state(mocker, default_conf, fee, caplog):
assert trade.max_rate == 0.0 assert trade.max_rate == 0.0
assert trade.stop_loss == 0.0 assert trade.stop_loss == 0.0
assert trade.initial_stop_loss == 0.0 assert trade.initial_stop_loss == 0.0
assert trade.open_trade_price == trade._calc_open_trade_price() assert trade.open_trade_value == trade._calc_open_trade_value()
assert log_has("trying trades_bak0", caplog) assert log_has("trying trades_bak0", caplog)
assert log_has("Running database migration for trades - backup: trades_bak0", caplog) assert log_has("Running database migration for trades - backup: trades_bak0", caplog)
@ -803,7 +803,7 @@ def test_to_json(default_conf, fee):
'close_timestamp': None, 'close_timestamp': None,
'open_rate': 0.123, 'open_rate': 0.123,
'open_rate_requested': None, 'open_rate_requested': None,
'open_trade_price': 15.1668225, 'open_trade_value': 15.1668225,
'fee_close': 0.0025, 'fee_close': 0.0025,
'fee_close_cost': None, 'fee_close_cost': None,
'fee_close_currency': None, 'fee_close_currency': None,
@ -896,7 +896,7 @@ def test_to_json(default_conf, fee):
'min_rate': None, 'min_rate': None,
'open_order_id': None, 'open_order_id': None,
'open_rate_requested': None, 'open_rate_requested': None,
'open_trade_price': 12.33075, 'open_trade_value': 12.33075,
'sell_reason': None, 'sell_reason': None,
'sell_order_status': None, 'sell_order_status': None,
'strategy': None, 'strategy': None,