Merge branch 'develop' into feat/short

This commit is contained in:
Matthias 2021-12-01 07:11:11 +01:00
commit 67f3570bf3
34 changed files with 381 additions and 197 deletions

View File

@ -1,4 +1,4 @@
FROM python:3.9.7-slim-buster as base FROM python:3.9.9-slim-bullseye as base
# Setup env # Setup env
ENV LANG C.UTF-8 ENV LANG C.UTF-8

View File

@ -1,4 +1,4 @@
FROM python:3.7.10-slim-buster as base FROM python:3.9.9-slim-bullseye as base
# Setup env # Setup env
ENV LANG C.UTF-8 ENV LANG C.UTF-8

View File

@ -115,7 +115,7 @@ The result of backtesting will confirm if your bot has better odds of making a p
All profit calculations include fees, and freqtrade will use the exchange's default fees for the calculation. All profit calculations include fees, and freqtrade will use the exchange's default fees for the calculation.
!!! Warning "Using dynamic pairlists for backtesting" !!! Warning "Using dynamic pairlists for backtesting"
Using dynamic pairlists is possible, however it relies on the current market conditions - which will not reflect the historic status of the pairlist. Using dynamic pairlists is possible (not all of the handlers are allowed to be used in backtest mode), however it relies on the current market conditions - which will not reflect the historic status of the pairlist.
Also, when using pairlists other than StaticPairlist, reproducibility of backtesting-results cannot be guaranteed. Also, when using pairlists other than StaticPairlist, reproducibility of backtesting-results cannot be guaranteed.
Please read the [pairlists documentation](plugins.md#pairlists) for more information. Please read the [pairlists documentation](plugins.md#pairlists) for more information.

View File

@ -220,6 +220,9 @@ As this Filter uses past performance of the bot, it'll have some startup-period
Filters low-value coins which would not allow setting stoplosses. Filters low-value coins which would not allow setting stoplosses.
!!! Warning "Backtesting"
`PrecisionFilter` does not support backtesting mode using multiple strategies.
#### PriceFilter #### PriceFilter
The `PriceFilter` allows filtering of pairs by price. Currently the following price filters are supported: The `PriceFilter` allows filtering of pairs by price. Currently the following price filters are supported:
@ -257,7 +260,7 @@ Min price precision for SHITCOIN/BTC is 8 decimals. If its price is 0.00000011 -
Shuffles (randomizes) pairs in the pairlist. It can be used for preventing the bot from trading some of the pairs more frequently then others when you want all pairs be treated with the same priority. Shuffles (randomizes) pairs in the pairlist. It can be used for preventing the bot from trading some of the pairs more frequently then others when you want all pairs be treated with the same priority.
!!! Tip !!! Tip
You may set the `seed` value for this Pairlist to obtain reproducible results, which can be useful for repeated backtesting sessions. If `seed` is not set, the pairs are shuffled in the non-repeatable random order. You may set the `seed` value for this Pairlist to obtain reproducible results, which can be useful for repeated backtesting sessions. If `seed` is not set, the pairs are shuffled in the non-repeatable random order. ShuffleFilter will automatically detect runmodes and apply the `seed` only for backtesting modes - if a `seed` value is set.
#### SpreadFilter #### SpreadFilter

View File

@ -164,16 +164,17 @@ The resulting plot will have the following elements:
An advanced plot configuration can be specified in the strategy in the `plot_config` parameter. An advanced plot configuration can be specified in the strategy in the `plot_config` parameter.
Additional features when using plot_config include: Additional features when using `plot_config` include:
* Specify colors per indicator * Specify colors per indicator
* Specify additional subplots * Specify additional subplots
* Specify indicator pairs to fill area in between * Specify indicator pairs to fill area in between
The sample plot configuration below specifies fixed colors for the indicators. Otherwise, consecutive plots may produce different color schemes each time, making comparisons difficult. The sample plot configuration below specifies fixed colors for the indicators. Otherwise, consecutive plots may produce different color schemes each time, making comparisons difficult.
It also allows multiple subplots to display both MACD and RSI at the same time. It also allows multiple subplots to display both MACD and RSI at the same time.
Plot type can be configured using `type` key. Possible types are: Plot type can be configured using `type` key. Possible types are:
* `scatter` corresponding to `plotly.graph_objects.Scatter` class (default). * `scatter` corresponding to `plotly.graph_objects.Scatter` class (default).
* `bar` corresponding to `plotly.graph_objects.Bar` class. * `bar` corresponding to `plotly.graph_objects.Bar` class.
@ -182,40 +183,89 @@ Extra parameters to `plotly.graph_objects.*` constructor can be specified in `pl
Sample configuration with inline comments explaining the process: Sample configuration with inline comments explaining the process:
``` python ``` python
plot_config = { @property
'main_plot': { def plot_config(self):
# Configuration for main plot indicators. """
# Specifies `ema10` to be red, and `ema50` to be a shade of gray There are a lot of solutions how to build the return dictionary.
'ema10': {'color': 'red'}, The only important point is the return value.
'ema50': {'color': '#CCCCCC'}, Example:
# By omitting color, a random color is selected. plot_config = {'main_plot': {}, 'subplots': {}}
'sar': {},
# fill area between senkou_a and senkou_b """
'senkou_a': { plot_config = {}
'color': 'green', #optional plot_config['main_plot'] = {
'fill_to': 'senkou_b', # Configuration for main plot indicators.
'fill_label': 'Ichimoku Cloud', #optional # Assumes 2 parameters, emashort and emalong to be specified.
'fill_color': 'rgba(255,76,46,0.2)', #optional f'ema_{self.emashort.value}': {'color': 'red'},
}, f'ema_{self.emalong.value}': {'color': '#CCCCCC'},
# plot senkou_b, too. Not only the area to it. # By omitting color, a random color is selected.
'senkou_b': {} 'sar': {},
# fill area between senkou_a and senkou_b
'senkou_a': {
'color': 'green', #optional
'fill_to': 'senkou_b',
'fill_label': 'Ichimoku Cloud', #optional
'fill_color': 'rgba(255,76,46,0.2)', #optional
}, },
'subplots': { # plot senkou_b, too. Not only the area to it.
# Create subplot MACD 'senkou_b': {}
"MACD": { }
'macd': {'color': 'blue', 'fill_to': 'macdhist'}, plot_config['subplots'] = {
'macdsignal': {'color': 'orange'}, # Create subplot MACD
'macdhist': {'type': 'bar', 'plotly': {'opacity': 0.9}} "MACD": {
}, 'macd': {'color': 'blue', 'fill_to': 'macdhist'},
# Additional subplot RSI 'macdsignal': {'color': 'orange'},
"RSI": { 'macdhist': {'type': 'bar', 'plotly': {'opacity': 0.9}}
'rsi': {'color': 'red'} },
} # Additional subplot RSI
"RSI": {
'rsi': {'color': 'red'}
} }
} }
return plot_config
``` ```
??? Note "As attribute (former method)"
Assigning plot_config is also possible as Attribute (this used to be the default way).
This has the disadvantage that strategy parameters are not available, preventing certain configurations from working.
``` python
plot_config = {
'main_plot': {
# Configuration for main plot indicators.
# Specifies `ema10` to be red, and `ema50` to be a shade of gray
'ema10': {'color': 'red'},
'ema50': {'color': '#CCCCCC'},
# By omitting color, a random color is selected.
'sar': {},
# fill area between senkou_a and senkou_b
'senkou_a': {
'color': 'green', #optional
'fill_to': 'senkou_b',
'fill_label': 'Ichimoku Cloud', #optional
'fill_color': 'rgba(255,76,46,0.2)', #optional
},
# plot senkou_b, too. Not only the area to it.
'senkou_b': {}
},
'subplots': {
# Create subplot MACD
"MACD": {
'macd': {'color': 'blue', 'fill_to': 'macdhist'},
'macdsignal': {'color': 'orange'},
'macdhist': {'type': 'bar', 'plotly': {'opacity': 0.9}}
},
# Additional subplot RSI
"RSI": {
'rsi': {'color': 'red'}
}
}
}
```
!!! Note !!! Note
The above configuration assumes that `ema10`, `ema50`, `senkou_a`, `senkou_b`, The above configuration assumes that `ema10`, `ema50`, `senkou_a`, `senkou_b`,
`macd`, `macdsignal`, `macdhist` and `rsi` are columns in the DataFrame created by the strategy. `macd`, `macdsignal`, `macdhist` and `rsi` are columns in the DataFrame created by the strategy.

View File

@ -1,4 +1,4 @@
mkdocs==1.2.3 mkdocs==1.2.3
mkdocs-material==7.3.6 mkdocs-material==8.0.1
mdx_truly_sane_lists==1.2 mdx_truly_sane_lists==1.2
pymdown-extensions==9.1 pymdown-extensions==9.1

View File

@ -38,6 +38,11 @@ Sample configuration:
!!! Danger "Security warning" !!! Danger "Security warning"
By default, the configuration listens on localhost only (so it's not reachable from other systems). We strongly recommend to not expose this API to the internet and choose a strong, unique password, since others will potentially be able to control your bot. By default, the configuration listens on localhost only (so it's not reachable from other systems). We strongly recommend to not expose this API to the internet and choose a strong, unique password, since others will potentially be able to control your bot.
??? Note "API/UI Access on a remote servers"
If you're running on a VPS, you should consider using either a ssh tunnel, or setup a VPN (openVPN, wireguard) to connect to your bot.
This will ensure that freqUI is not directly exposed to the internet, which is not recommended for security reasons (freqUI does not support https out of the box).
Setup of these tools is not part of this tutorial, however many good tutorials can be found on the internet.
You can then access the API by going to `http://127.0.0.1:8080/api/v1/ping` in a browser to check if the API is running correctly. You can then access the API by going to `http://127.0.0.1:8080/api/v1/ping` in a browser to check if the API is running correctly.
This should return the response: This should return the response:

View File

@ -50,7 +50,9 @@ candles.head()
```python ```python
# Load strategy using values set above # Load strategy using values set above
from freqtrade.resolvers import StrategyResolver from freqtrade.resolvers import StrategyResolver
from freqtrade.data.dataprovider import DataProvider
strategy = StrategyResolver.load_strategy(config) strategy = StrategyResolver.load_strategy(config)
strategy.dp = DataProvider(config, None, None)
# Generate buy/sell signals using strategy # Generate buy/sell signals using strategy
df = strategy.analyze_ticker(candles, {'pair': pair}) df = strategy.analyze_ticker(candles, {'pair': pair})
@ -228,7 +230,7 @@ graph = generate_candlestick_graph(pair=pair,
# Show graph inline # Show graph inline
# graph.show() # graph.show()
# Render graph in a separate window # Render graph in a seperate window
graph.show(renderer="browser") graph.show(renderer="browser")
``` ```

View File

@ -50,7 +50,7 @@ Sample configuration (tested using IFTTT).
The url in `webhook.url` should point to the correct url for your webhook. If you're using [IFTTT](https://ifttt.com) (as shown in the sample above) please insert your event and key to the url. The url in `webhook.url` should point to the correct url for your webhook. If you're using [IFTTT](https://ifttt.com) (as shown in the sample above) please insert your event and key to the url.
You can set the POST body format to Form-Encoded (default) or JSON-Encoded. Use `"format": "form"` or `"format": "json"` respectively. Example configuration for Mattermost Cloud integration: You can set the POST body format to Form-Encoded (default), JSON-Encoded, or raw data. Use `"format": "form"`, `"format": "json"`, or `"format": "raw"` respectively. Example configuration for Mattermost Cloud integration:
```json ```json
"webhook": { "webhook": {
@ -63,7 +63,36 @@ You can set the POST body format to Form-Encoded (default) or JSON-Encoded. Use
}, },
``` ```
The result would be POST request with e.g. `{"text":"Status: running"}` body and `Content-Type: application/json` header which results `Status: running` message in the Mattermost channel. The result would be a POST request with e.g. `{"text":"Status: running"}` body and `Content-Type: application/json` header which results `Status: running` message in the Mattermost channel.
When using the Form-Encoded or JSON-Encoded configuration you can configure any number of payload values, and both the key and value will be ouput in the POST request. However, when using the raw data format you can only configure one value and it **must** be named `"data"`. In this instance the data key will not be output in the POST request, only the value. For example:
```json
"webhook": {
"enabled": true,
"url": "https://<YOURHOOKURL>",
"format": "raw",
"webhookstatus": {
"data": "Status: {status}"
}
},
```
The result would be a POST request with e.g. `Status: running` body and `Content-Type: text/plain` header.
Optional parameters are available to enable automatic retries for webhook messages. The `webhook.retries` parameter can be set for the maximum number of retries the webhook request should attempt if it is unsuccessful (i.e. HTTP response status is not 200). By default this is set to `0` which is disabled. An additional `webhook.retry_delay` parameter can be set to specify the time in seconds between retry attempts. By default this is set to `0.1` (i.e. 100ms). Note that increasing the number of retries or retry delay may slow down the trader if there are connectivity issues with the webhook. Example configuration for retries:
```json
"webhook": {
"enabled": true,
"url": "https://<YOURHOOKURL>",
"retries": 3,
"retry_delay": 0.2,
"webhookstatus": {
"status": "Status: {status}"
}
},
```
Different payloads can be configured for different events. Not all fields are necessary, but you should configure at least one of the dicts, otherwise the webhook will never be called. Different payloads can be configured for different events. Not all fields are necessary, but you should configure at least one of the dicts, otherwise the webhook will never be called.

View File

@ -52,6 +52,8 @@ USERPATH_STRATEGIES = 'strategies'
USERPATH_NOTEBOOKS = 'notebooks' USERPATH_NOTEBOOKS = 'notebooks'
TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent']
WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw']
ENV_VAR_PREFIX = 'FREQTRADE__' ENV_VAR_PREFIX = 'FREQTRADE__'
NON_OPEN_EXCHANGE_STATES = ('cancelled', 'canceled', 'closed', 'expired') NON_OPEN_EXCHANGE_STATES = ('cancelled', 'canceled', 'closed', 'expired')
@ -316,10 +318,16 @@ CONF_SCHEMA = {
'type': 'object', 'type': 'object',
'properties': { 'properties': {
'enabled': {'type': 'boolean'}, 'enabled': {'type': 'boolean'},
'url': {'type': 'string'},
'format': {'type': 'string', 'enum': WEBHOOK_FORMAT_OPTIONS, 'default': 'form'},
'retries': {'type': 'integer', 'minimum': 0},
'retry_delay': {'type': 'number', 'minimum': 0},
'webhookbuy': {'type': 'object'}, 'webhookbuy': {'type': 'object'},
'webhookbuycancel': {'type': 'object'}, 'webhookbuycancel': {'type': 'object'},
'webhookbuyfill': {'type': 'object'},
'webhooksell': {'type': 'object'}, 'webhooksell': {'type': 'object'},
'webhooksellcancel': {'type': 'object'}, 'webhooksellcancel': {'type': 'object'},
'webhooksellfill': {'type': 'object'},
'webhookstatus': {'type': 'object'}, 'webhookstatus': {'type': 'object'},
}, },
}, },

View File

@ -1365,7 +1365,7 @@ class Exchange:
results = await asyncio.gather(*input_coro, return_exceptions=True) results = await asyncio.gather(*input_coro, return_exceptions=True)
for res in results: for res in results:
if isinstance(res, Exception): if isinstance(res, Exception):
logger.warning("Async code raised an exception: %s", res.__class__.__name__) logger.warning(f"Async code raised an exception: {repr(res)}")
if raise_: if raise_:
raise raise
continue continue
@ -1396,7 +1396,7 @@ class Exchange:
cached_pairs = [] cached_pairs = []
# Gather coroutines to run # Gather coroutines to run
for pair, timeframe in set(pair_list): for pair, timeframe in set(pair_list):
if ((pair, timeframe) not in self._klines if ((pair, timeframe) not in self._klines or not cache
or self._now_is_time_to_refresh(pair, timeframe)): or self._now_is_time_to_refresh(pair, timeframe)):
if not since_ms and self.required_candle_call_count > 1: if not since_ms and self.required_candle_call_count > 1:
# Multiple calls for one pair - to get more history # Multiple calls for one pair - to get more history
@ -1419,27 +1419,30 @@ class Exchange:
) )
cached_pairs.append((pair, timeframe)) cached_pairs.append((pair, timeframe))
results = asyncio.get_event_loop().run_until_complete(
asyncio.gather(*input_coroutines, return_exceptions=True))
results_df = {} results_df = {}
# handle caching # Chunk requests into batches of 100 to avoid overwelming ccxt Throttling
for res in results: for input_coro in chunks(input_coroutines, 100):
if isinstance(res, Exception): results = asyncio.get_event_loop().run_until_complete(
logger.warning("Async code raised an exception: %s", res.__class__.__name__) asyncio.gather(*input_coro, return_exceptions=True))
continue
# Deconstruct tuple (has 3 elements) # handle caching
pair, timeframe, ticks = res for res in results:
# keeping last candle time as last refreshed time of the pair if isinstance(res, Exception):
if ticks: logger.warning(f"Async code raised an exception: {repr(res)}")
self._pairs_last_refresh_time[(pair, timeframe)] = ticks[-1][0] // 1000 continue
# keeping parsed dataframe in cache # Deconstruct tuple (has 3 elements)
ohlcv_df = ohlcv_to_dataframe( pair, timeframe, ticks = res
ticks, timeframe, pair=pair, fill_missing=True, # keeping last candle time as last refreshed time of the pair
drop_incomplete=self._ohlcv_partial_candle) if ticks:
results_df[(pair, timeframe)] = ohlcv_df self._pairs_last_refresh_time[(pair, timeframe)] = ticks[-1][0] // 1000
if cache: # keeping parsed dataframe in cache
self._klines[(pair, timeframe)] = ohlcv_df ohlcv_df = ohlcv_to_dataframe(
ticks, timeframe, pair=pair, fill_missing=True,
drop_incomplete=self._ohlcv_partial_candle)
results_df[(pair, timeframe)] = ohlcv_df
if cache:
self._klines[(pair, timeframe)] = ohlcv_df
# Return cached klines # Return cached klines
for pair, timeframe in cached_pairs: for pair, timeframe in cached_pairs:
results_df[(pair, timeframe)] = self.klines((pair, timeframe), copy=False) results_df[(pair, timeframe)] = self.klines((pair, timeframe), copy=False)

View File

@ -575,8 +575,9 @@ class FreqtradeBot(LoggingMixin):
pair: str, pair: str,
stake_amount: float, stake_amount: float,
price: Optional[float] = None, price: Optional[float] = None,
forcebuy: bool = False, *,
is_short: bool = False, is_short: bool = False,
ordertype: Optional[str] = None,
enter_tag: Optional[str] = None enter_tag: Optional[str] = None
) -> bool: ) -> bool:
""" """
@ -649,12 +650,7 @@ class FreqtradeBot(LoggingMixin):
) )
amount = (stake_amount / enter_limit_requested) * leverage amount = (stake_amount / enter_limit_requested) * leverage
order_type = self.strategy.order_types['buy'] order_type = ordertype or self.strategy.order_types['buy']
if forcebuy:
# Forcebuy can define a different ordertype
# TODO-lev: get a forceshort? What is this
order_type = self.strategy.order_types.get('forcebuy', order_type)
# TODO-lev: Will this work for shorting?
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested,
@ -1053,7 +1049,7 @@ class FreqtradeBot(LoggingMixin):
if should_exit.sell_flag: if should_exit.sell_flag:
logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.sell_type}' logger.info(f'Exit for {trade.pair} detected. Reason: {should_exit.sell_type}'
f'Tag: {exit_tag if exit_tag is not None else "None"}') f'Tag: {exit_tag if exit_tag is not None else "None"}')
self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag) self.execute_trade_exit(trade, exit_rate, should_exit, exit_tag=exit_tag)
return True return True
return False return False
@ -1271,12 +1267,14 @@ class FreqtradeBot(LoggingMixin):
f"Not enough amount to exit trade. Trade-amount: {amount}, Wallet: {wallet_amount}") f"Not enough amount to exit trade. Trade-amount: {amount}, Wallet: {wallet_amount}")
def execute_trade_exit( def execute_trade_exit(
self, self,
trade: Trade, trade: Trade,
limit: float, limit: float,
sell_reason: SellCheckTuple, # TODO-lev update to exit_reason sell_reason: SellCheckTuple,
exit_tag: Optional[str] = None *,
) -> bool: exit_tag: Optional[str] = None,
ordertype: Optional[str] = None,
) -> bool:
""" """
Executes a trade exit for the given trade and limit Executes a trade exit for the given trade and limit
:param trade: Trade instance :param trade: Trade instance
@ -1319,14 +1317,10 @@ class FreqtradeBot(LoggingMixin):
except InvalidOrderException: except InvalidOrderException:
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}") logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
order_type = self.strategy.order_types[exit_type] order_type = ordertype or self.strategy.order_types[exit_type]
if sell_reason.sell_type == SellType.EMERGENCY_SELL: if sell_reason.sell_type == SellType.EMERGENCY_SELL:
# Emergency sells (default to market!) # Emergency sells (default to market!)
order_type = self.strategy.order_types.get("emergencysell", "market") order_type = self.strategy.order_types.get("emergencysell", "market")
if sell_reason.sell_type == SellType.FORCE_SELL:
# Force sells (default to the sell_type defined in the strategy,
# but we allow this value to be changed)
order_type = self.strategy.order_types.get("forcesell", order_type)
amount = self._safe_exit_amount(trade.pair, trade.amount) amount = self._safe_exit_amount(trade.pair, trade.amount)
time_in_force = self.strategy.order_time_in_force['sell'] # TODO-lev update to exit time_in_force = self.strategy.order_time_in_force['sell'] # TODO-lev update to exit

View File

@ -92,7 +92,8 @@ class Backtesting:
self.init_backtest_detail() self.init_backtest_detail()
self.pairlists = PairListManager(self.exchange, self.config) self.pairlists = PairListManager(self.exchange, self.config)
if 'VolumePairList' in self.pairlists.name_list: if 'VolumePairList' in self.pairlists.name_list:
raise OperationalException("VolumePairList not allowed for backtesting.") raise OperationalException("VolumePairList not allowed for backtesting. "
"Please use StaticPairlist instead.")
if 'PerformanceFilter' in self.pairlists.name_list: if 'PerformanceFilter' in self.pairlists.name_list:
raise OperationalException("PerformanceFilter not allowed for backtesting.") raise OperationalException("PerformanceFilter not allowed for backtesting.")

View File

@ -46,20 +46,11 @@ def _get_line_floatfmt(stake_currency: str) -> List[str]:
'.2f', 'd', 's', 's'] '.2f', 'd', 's', 's']
def _get_line_header(first_column: str, stake_currency: str) -> List[str]: def _get_line_header(first_column: str, stake_currency: str, direction: str = 'Buys') -> List[str]:
""" """
Generate header lines (goes in line with _generate_result_line()) Generate header lines (goes in line with _generate_result_line())
""" """
return [first_column, 'Buys', 'Avg Profit %', 'Cum Profit %', return [first_column, direction, 'Avg Profit %', 'Cum Profit %',
f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration',
'Win Draw Loss Win%']
def _get_line_header_sell(first_column: str, stake_currency: str) -> List[str]:
"""
Generate header lines (goes in line with _generate_result_line())
"""
return [first_column, 'Sells', 'Avg Profit %', 'Cum Profit %',
f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration',
'Win Draw Loss Win%'] 'Win Draw Loss Win%']
@ -156,7 +147,7 @@ def generate_tag_metrics(tag_type: str,
if skip_nan and result['profit_abs'].isnull().all(): if skip_nan and result['profit_abs'].isnull().all():
continue continue
tabular_data.append(_generate_tag_result_line(result, starting_balance, tag)) tabular_data.append(_generate_result_line(result, starting_balance, tag))
# Sort by total profit %: # Sort by total profit %:
tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True)
@ -168,39 +159,6 @@ def generate_tag_metrics(tag_type: str,
return [] return []
def _generate_tag_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict:
"""
Generate one result dict, with "first_column" as key.
"""
profit_sum = result['profit_ratio'].sum()
# (end-capital - starting capital) / starting capital
profit_total = result['profit_abs'].sum() / starting_balance
return {
'key': first_column,
'trades': len(result),
'profit_mean': result['profit_ratio'].mean() if len(result) > 0 else 0.0,
'profit_mean_pct': result['profit_ratio'].mean() * 100.0 if len(result) > 0 else 0.0,
'profit_sum': profit_sum,
'profit_sum_pct': round(profit_sum * 100.0, 2),
'profit_total_abs': result['profit_abs'].sum(),
'profit_total': profit_total,
'profit_total_pct': round(profit_total * 100.0, 2),
'duration_avg': str(timedelta(
minutes=round(result['trade_duration'].mean()))
) if not result.empty else '0:00',
# 'duration_max': str(timedelta(
# minutes=round(result['trade_duration'].max()))
# ) if not result.empty else '0:00',
# 'duration_min': str(timedelta(
# minutes=round(result['trade_duration'].min()))
# ) if not result.empty else '0:00',
'wins': len(result[result['profit_abs'] > 0]),
'draws': len(result[result['profit_abs'] == 0]),
'losses': len(result[result['profit_abs'] < 0]),
}
def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List[Dict]: def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List[Dict]:
""" """
Generate small table outlining Backtest results Generate small table outlining Backtest results
@ -637,7 +595,7 @@ def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_curr
if(tag_type == "enter_tag"): if(tag_type == "enter_tag"):
headers = _get_line_header("TAG", stake_currency) headers = _get_line_header("TAG", stake_currency)
else: else:
headers = _get_line_header_sell("TAG", stake_currency) headers = _get_line_header("TAG", stake_currency, 'Sells')
floatfmt = _get_line_floatfmt(stake_currency) floatfmt = _get_line_floatfmt(stake_currency)
output = [ output = [
[ [

View File

@ -5,6 +5,7 @@ import logging
import random import random
from typing import Any, Dict, List from typing import Any, Dict, List
from freqtrade.enums.runmode import RunMode
from freqtrade.plugins.pairlist.IPairList import IPairList from freqtrade.plugins.pairlist.IPairList import IPairList
@ -18,7 +19,15 @@ class ShuffleFilter(IPairList):
pairlist_pos: int) -> None: pairlist_pos: int) -> None:
super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos)
self._seed = pairlistconfig.get('seed') # Apply seed in backtesting mode to get comparable results,
# but not in live modes to get a non-repeating order of pairs during live modes.
if config.get('runmode') in (RunMode.LIVE, RunMode.DRY_RUN):
self._seed = None
logger.info("Live mode detected, not applying seed.")
else:
self._seed = pairlistconfig.get('seed')
logger.info(f"Backtesting mode detected, applying seed value: {self._seed}")
self._random = random.Random(self._seed) self._random = random.Random(self._seed)
@property @property

View File

@ -1,4 +1,5 @@
from datetime import date, datetime from datetime import date, datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel from pydantic import BaseModel
@ -125,19 +126,24 @@ class Daily(BaseModel):
class UnfilledTimeout(BaseModel): class UnfilledTimeout(BaseModel):
buy: int buy: Optional[int]
sell: int sell: Optional[int]
unit: str unit: Optional[str]
exit_timeout_count: Optional[int] exit_timeout_count: Optional[int]
class OrderTypeValues(str, Enum):
limit = 'limit'
market = 'market'
class OrderTypes(BaseModel): class OrderTypes(BaseModel):
buy: str buy: OrderTypeValues
sell: str sell: OrderTypeValues
emergencysell: Optional[str] emergencysell: Optional[OrderTypeValues]
forcesell: Optional[str] forcesell: Optional[OrderTypeValues]
forcebuy: Optional[str] forcebuy: Optional[OrderTypeValues]
stoploss: str stoploss: OrderTypeValues
stoploss_on_exchange: bool stoploss_on_exchange: bool
stoploss_on_exchange_interval: Optional[int] stoploss_on_exchange_interval: Optional[int]
@ -278,10 +284,12 @@ class Logs(BaseModel):
class ForceBuyPayload(BaseModel): class ForceBuyPayload(BaseModel):
pair: str pair: str
price: Optional[float] price: Optional[float]
ordertype: Optional[OrderTypeValues]
class ForceSellPayload(BaseModel): class ForceSellPayload(BaseModel):
tradeid: str tradeid: str
ordertype: Optional[OrderTypeValues]
class BlacklistPayload(BaseModel): class BlacklistPayload(BaseModel):

View File

@ -29,7 +29,8 @@ logger = logging.getLogger(__name__)
# API version # API version
# Pre-1.1, no version was provided # Pre-1.1, no version was provided
# Version increments should happen in "small" steps (1.1, 1.12, ...) unless big changes happen. # Version increments should happen in "small" steps (1.1, 1.12, ...) unless big changes happen.
API_VERSION = 1.1 # 1.11: forcebuy and forcesell accept ordertype
API_VERSION = 1.11
# Public API, requires no auth. # Public API, requires no auth.
router_public = APIRouter() router_public = APIRouter()
@ -129,7 +130,8 @@ def show_config(rpc: Optional[RPC] = Depends(get_rpc_optional), config=Depends(g
@router.post('/forcebuy', response_model=ForceBuyResponse, tags=['trading']) @router.post('/forcebuy', response_model=ForceBuyResponse, tags=['trading'])
def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)): def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)):
trade = rpc._rpc_forcebuy(payload.pair, payload.price) ordertype = payload.ordertype.value if payload.ordertype else None
trade = rpc._rpc_forcebuy(payload.pair, payload.price, ordertype)
if trade: if trade:
return ForceBuyResponse.parse_obj(trade.to_json()) return ForceBuyResponse.parse_obj(trade.to_json())
@ -139,7 +141,8 @@ def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)):
@router.post('/forcesell', response_model=ResultMsg, tags=['trading']) @router.post('/forcesell', response_model=ResultMsg, tags=['trading'])
def forcesell(payload: ForceSellPayload, rpc: RPC = Depends(get_rpc)): def forcesell(payload: ForceSellPayload, rpc: RPC = Depends(get_rpc)):
return rpc._rpc_forcesell(payload.tradeid) ordertype = payload.ordertype.value if payload.ordertype else None
return rpc._rpc_forcesell(payload.tradeid, ordertype)
@router.get('/blacklist', response_model=BlacklistResponse, tags=['info', 'pairlist']) @router.get('/blacklist', response_model=BlacklistResponse, tags=['info', 'pairlist'])

View File

@ -646,7 +646,7 @@ class RPC:
return {'status': 'No more buy will occur from now. Run /reload_config to reset.'} return {'status': 'No more buy will occur from now. Run /reload_config to reset.'}
def _rpc_forcesell(self, trade_id: str) -> Dict[str, str]: def _rpc_forcesell(self, trade_id: str, ordertype: Optional[str] = None) -> Dict[str, str]:
""" """
Handler for forcesell <id>. Handler for forcesell <id>.
Sells the given trade at current price Sells the given trade at current price
@ -671,7 +671,11 @@ class RPC:
current_rate = self._freqtrade.exchange.get_rate( current_rate = self._freqtrade.exchange.get_rate(
trade.pair, refresh=False, side=closing_side) trade.pair, refresh=False, side=closing_side)
sell_reason = SellCheckTuple(sell_type=SellType.FORCE_SELL) sell_reason = SellCheckTuple(sell_type=SellType.FORCE_SELL)
self._freqtrade.execute_trade_exit(trade, current_rate, sell_reason) order_type = ordertype or self._freqtrade.strategy.order_types.get(
"forcesell", self._freqtrade.strategy.order_types["sell"])
self._freqtrade.execute_trade_exit(
trade, current_rate, sell_reason, ordertype=order_type)
# ---- EOF def _exec_forcesell ---- # ---- EOF def _exec_forcesell ----
if self._freqtrade.state != State.RUNNING: if self._freqtrade.state != State.RUNNING:
@ -699,7 +703,8 @@ class RPC:
self._freqtrade.wallets.update() self._freqtrade.wallets.update()
return {'result': f'Created sell order for trade {trade_id}.'} return {'result': f'Created sell order for trade {trade_id}.'}
def _rpc_forcebuy(self, pair: str, price: Optional[float]) -> Optional[Trade]: def _rpc_forcebuy(self, pair: str, price: Optional[float],
order_type: Optional[str] = None) -> Optional[Trade]:
""" """
Handler for forcebuy <asset> <price> Handler for forcebuy <asset> <price>
Buys a pair trade at the given or current price Buys a pair trade at the given or current price
@ -727,7 +732,10 @@ class RPC:
stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair) stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair)
# execute buy # execute buy
if self._freqtrade.execute_entry(pair, stakeamount, price, forcebuy=True): if not order_type:
order_type = self._freqtrade.strategy.order_types.get(
'forcebuy', self._freqtrade.strategy.order_types['buy'])
if self._freqtrade.execute_entry(pair, stakeamount, price, ordertype=order_type):
Trade.commit() Trade.commit()
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first() trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
return trade return trade

View File

@ -112,6 +112,7 @@ class Telegram(RPCHandler):
r'/stats$', r'/count$', r'/locks$', r'/balance$', r'/stats$', r'/count$', r'/locks$', r'/balance$',
r'/stopbuy$', r'/reload_config$', r'/show_config$', r'/stopbuy$', r'/reload_config$', r'/show_config$',
r'/logs$', r'/whitelist$', r'/blacklist$', r'/edge$', r'/logs$', r'/whitelist$', r'/blacklist$', r'/edge$',
r'/weekly$', r'/weekly \d+$', r'/monthly$', r'/monthly \d+$',
r'/forcebuy$', r'/help$', r'/version$'] r'/forcebuy$', r'/help$', r'/version$']
# Create keys for generation # Create keys for generation
valid_keys_print = [k.replace('$', '') for k in valid_keys] valid_keys_print = [k.replace('$', '') for k in valid_keys]

View File

@ -2,6 +2,7 @@
This module manages webhook communication This module manages webhook communication
""" """
import logging import logging
import time
from typing import Any, Dict from typing import Any, Dict
from requests import RequestException, post from requests import RequestException, post
@ -28,12 +29,9 @@ class Webhook(RPCHandler):
super().__init__(rpc, config) super().__init__(rpc, config)
self._url = self._config['webhook']['url'] self._url = self._config['webhook']['url']
self._format = self._config['webhook'].get('format', 'form') self._format = self._config['webhook'].get('format', 'form')
self._retries = self._config['webhook'].get('retries', 0)
if self._format != 'form' and self._format != 'json': self._retry_delay = self._config['webhook'].get('retry_delay', 0.1)
raise NotImplementedError('Unknown webhook format `{}`, possible values are '
'`form` (default) and `json`'.format(self._format))
def cleanup(self) -> None: def cleanup(self) -> None:
""" """
@ -77,13 +75,30 @@ class Webhook(RPCHandler):
def _send_msg(self, payload: dict) -> None: def _send_msg(self, payload: dict) -> None:
"""do the actual call to the webhook""" """do the actual call to the webhook"""
try: success = False
if self._format == 'form': attempts = 0
post(self._url, data=payload) while not success and attempts <= self._retries:
elif self._format == 'json': if attempts:
post(self._url, json=payload) if self._retry_delay:
else: time.sleep(self._retry_delay)
raise NotImplementedError('Unknown format: {}'.format(self._format)) logger.info("Retrying webhook...")
except RequestException as exc: attempts += 1
logger.warning("Could not call webhook url. Exception: %s", exc)
try:
if self._format == 'form':
response = post(self._url, data=payload)
elif self._format == 'json':
response = post(self._url, json=payload)
elif self._format == 'raw':
response = post(self._url, data=payload['data'],
headers={'Content-Type': 'text/plain'})
else:
raise NotImplementedError('Unknown format: {}'.format(self._format))
# Throw a RequestException if the post was not successful
response.raise_for_status()
success = True
except RequestException as exc:
logger.warning("Could not call webhook url. Exception: %s", exc)

View File

@ -87,6 +87,7 @@ class {{ strategy }}(IStrategy):
'sell': 'gtc' 'sell': 'gtc'
} }
{{ plot_config | indent(4) }} {{ plot_config | indent(4) }}
def informative_pairs(self): def informative_pairs(self):
""" """
Define additional, informative pair/interval combinations to be cached from the exchange. Define additional, informative pair/interval combinations to be cached from the exchange.

View File

@ -79,7 +79,9 @@
"source": [ "source": [
"# Load strategy using values set above\n", "# Load strategy using values set above\n",
"from freqtrade.resolvers import StrategyResolver\n", "from freqtrade.resolvers import StrategyResolver\n",
"from freqtrade.data.dataprovider import DataProvider\n",
"strategy = StrategyResolver.load_strategy(config)\n", "strategy = StrategyResolver.load_strategy(config)\n",
"strategy.dp = DataProvider(config, None, None)\n",
"\n", "\n",
"# Generate buy/sell signals using strategy\n", "# Generate buy/sell signals using strategy\n",
"df = strategy.analyze_ticker(candles, {'pair': pair})\n", "df = strategy.analyze_ticker(candles, {'pair': pair})\n",

View File

@ -1,18 +1,20 @@
plot_config = { @property
# Main plot indicators (Moving averages, ...) def plot_config(self):
'main_plot': { return {
'tema': {}, # Main plot indicators (Moving averages, ...)
'sar': {'color': 'white'}, 'main_plot': {
}, 'tema': {},
'subplots': { 'sar': {'color': 'white'},
# Subplots - each dict defines one additional plot
"MACD": {
'macd': {'color': 'blue'},
'macdsignal': {'color': 'orange'},
}, },
"RSI": { 'subplots': {
'rsi': {'color': 'red'}, # Subplots - each dict defines one additional plot
"MACD": {
'macd': {'color': 'blue'},
'macdsignal': {'color': 'orange'},
},
"RSI": {
'rsi': {'color': 'red'},
}
} }
} }
}

View File

@ -82,8 +82,9 @@ markdown_extensions:
- pymdownx.snippets: - pymdownx.snippets:
base_path: docs base_path: docs
check_paths: true check_paths: true
- pymdownx.tabbed
- pymdownx.superfences - pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- pymdownx.tasklist: - pymdownx.tasklist:
custom_checkbox: true custom_checkbox: true
- mdx_truly_sane_lists - mdx_truly_sane_lists

View File

@ -14,16 +14,16 @@ pytest-mock==3.6.1
pytest-random-order==1.0.4 pytest-random-order==1.0.4
isort==5.10.1 isort==5.10.1
# For datetime mocking # For datetime mocking
time-machine==2.4.0 time-machine==2.4.1
# Convert jupyter notebooks to markdown documents # Convert jupyter notebooks to markdown documents
nbconvert==6.3.0 nbconvert==6.3.0
# mypy types # mypy types
types-cachetools==4.2.5 types-cachetools==4.2.6
types-filelock==3.2.1 types-filelock==3.2.1
types-requests==2.26.0 types-requests==2.26.1
types-tabulate==0.8.3 types-tabulate==0.8.3
# Extensions to datetime library # Extensions to datetime library
types-python-dateutil==2.8.2 types-python-dateutil==2.8.3

View File

@ -2,7 +2,7 @@
-r requirements.txt -r requirements.txt
# Required for hyperopt # Required for hyperopt
scipy==1.7.2 scipy==1.7.3
scikit-learn==1.0.1 scikit-learn==1.0.1
scikit-optimize==0.9.0 scikit-optimize==0.9.0
filelock==3.4.0 filelock==3.4.0

View File

@ -2,7 +2,7 @@ numpy==1.21.4
pandas==1.3.4 pandas==1.3.4
pandas-ta==0.3.14b pandas-ta==0.3.14b
ccxt==1.61.92 ccxt==1.62.42
# Pin cryptography for now due to rust build errors with piwheels # Pin cryptography for now due to rust build errors with piwheels
cryptography==36.0.0 cryptography==36.0.0
aiohttp==3.8.1 aiohttp==3.8.1
@ -34,14 +34,14 @@ sdnotify==0.3.2
fastapi==0.70.0 fastapi==0.70.0
uvicorn==0.15.0 uvicorn==0.15.0
pyjwt==2.3.0 pyjwt==2.3.0
aiofiles==0.7.0 aiofiles==0.8.0
psutil==5.8.0 psutil==5.8.0
# Support for colorized terminal output # Support for colorized terminal output
colorama==0.4.4 colorama==0.4.4
# Building config files interactively # Building config files interactively
questionary==1.10.0 questionary==1.10.0
prompt-toolkit==3.0.22 prompt-toolkit==3.0.23
# Extensions to datetime library # Extensions to datetime library
python-dateutil==2.8.2 python-dateutil==2.8.2

View File

@ -1731,12 +1731,21 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog) -> None:
assert len(res) == len(pairs) assert len(res) == len(pairs)
assert exchange._api_async.fetch_ohlcv.call_count == 0 assert exchange._api_async.fetch_ohlcv.call_count == 0
exchange.required_candle_call_count = 1
assert log_has(f"Using cached candle (OHLCV) data for pair {pairs[0][0]}, " assert log_has(f"Using cached candle (OHLCV) data for pair {pairs[0][0]}, "
f"timeframe {pairs[0][1]} ...", f"timeframe {pairs[0][1]} ...",
caplog) caplog)
res = exchange.refresh_latest_ohlcv([('IOTA/ETH', '5m'), ('XRP/ETH', '5m'), ('XRP/ETH', '1d')], res = exchange.refresh_latest_ohlcv([('IOTA/ETH', '5m'), ('XRP/ETH', '5m'), ('XRP/ETH', '1d')],
cache=False) cache=False)
assert len(res) == 3 assert len(res) == 3
assert exchange._api_async.fetch_ohlcv.call_count == 3
# Test the same again, should NOT return from cache!
exchange._api_async.fetch_ohlcv.reset_mock()
res = exchange.refresh_latest_ohlcv([('IOTA/ETH', '5m'), ('XRP/ETH', '5m'), ('XRP/ETH', '1d')],
cache=False)
assert len(res) == 3
assert exchange._api_async.fetch_ohlcv.call_count == 3
@pytest.mark.asyncio @pytest.mark.asyncio
@ -1832,7 +1841,7 @@ def test_refresh_latest_ohlcv_inv_result(default_conf, mocker, caplog):
assert len(res) == 1 assert len(res) == 1
# Test that each is in list at least once as order is not guaranteed # Test that each is in list at least once as order is not guaranteed
assert log_has("Error loading ETH/BTC. Result was [[]].", caplog) assert log_has("Error loading ETH/BTC. Result was [[]].", caplog)
assert log_has("Async code raised an exception: TypeError", caplog) assert log_has("Async code raised an exception: TypeError()", caplog)
def test_get_next_limit_in_list(): def test_get_next_limit_in_list():

View File

@ -441,7 +441,8 @@ def test_backtesting_no_pair_left(default_conf, mocker, caplog, testdatadir) ->
Backtesting(default_conf) Backtesting(default_conf)
default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}] default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}]
with pytest.raises(OperationalException, match='VolumePairList not allowed for backtesting.'): with pytest.raises(OperationalException,
match=r'VolumePairList not allowed for backtesting\..*StaticPairlist.*'):
Backtesting(default_conf) Backtesting(default_conf)
default_conf.update({ default_conf.update({
@ -473,7 +474,8 @@ def test_backtesting_pairlist_list(default_conf, mocker, caplog, testdatadir, ti
default_conf['timerange'] = '20180101-20180102' default_conf['timerange'] = '20180101-20180102'
default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}] default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}]
with pytest.raises(OperationalException, match='VolumePairList not allowed for backtesting.'): with pytest.raises(OperationalException,
match=r'VolumePairList not allowed for backtesting\..*StaticPairlist.*'):
Backtesting(default_conf) Backtesting(default_conf)
default_conf['pairlists'] = [{"method": "StaticPairList"}, {"method": "PerformanceFilter"}] default_conf['pairlists'] = [{"method": "StaticPairList"}, {"method": "PerformanceFilter"}]

View File

@ -7,6 +7,7 @@ import pytest
import time_machine import time_machine
from freqtrade.constants import AVAILABLE_PAIRLISTS from freqtrade.constants import AVAILABLE_PAIRLISTS
from freqtrade.enums.runmode import RunMode
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
@ -657,6 +658,22 @@ def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None:
assert log_has("PerformanceFilter is not available in this mode.", caplog) assert log_has("PerformanceFilter is not available in this mode.", caplog)
def test_ShuffleFilter_init(mocker, whitelist_conf, caplog) -> None:
whitelist_conf['pairlists'] = [
{"method": "StaticPairList"},
{"method": "ShuffleFilter", "seed": 42}
]
exchange = get_patched_exchange(mocker, whitelist_conf)
PairListManager(exchange, whitelist_conf)
assert log_has("Backtesting mode detected, applying seed value: 42", caplog)
caplog.clear()
whitelist_conf['runmode'] = RunMode.DRY_RUN
PairListManager(exchange, whitelist_conf)
assert not log_has("Backtesting mode detected, applying seed value: 42", caplog)
assert log_has("Live mode detected, not applying seed.", caplog)
@pytest.mark.usefixtures("init_persistence") @pytest.mark.usefixtures("init_persistence")
def test_PerformanceFilter_lookback(mocker, whitelist_conf, fee, caplog) -> None: def test_PerformanceFilter_lookback(mocker, whitelist_conf, fee, caplog) -> None:
whitelist_conf['exchange']['pair_whitelist'].append('XRP/BTC') whitelist_conf['exchange']['pair_whitelist'].append('XRP/BTC')

View File

@ -1109,7 +1109,7 @@ def test_rpcforcebuy(mocker, default_conf, ticker, fee, limit_buy_order_open) ->
with pytest.raises(RPCException, match=r'position for ETH/BTC already open - id: 1'): with pytest.raises(RPCException, match=r'position for ETH/BTC already open - id: 1'):
rpc._rpc_forcebuy(pair, 0.0001) rpc._rpc_forcebuy(pair, 0.0001)
pair = 'XRP/BTC' pair = 'XRP/BTC'
trade = rpc._rpc_forcebuy(pair, 0.0001) trade = rpc._rpc_forcebuy(pair, 0.0001, order_type='limit')
assert isinstance(trade, Trade) assert isinstance(trade, Trade)
assert trade.pair == pair assert trade.pair == pair
assert trade.open_rate == 0.0001 assert trade.open_rate == 0.0001

View File

@ -24,6 +24,7 @@ from freqtrade.freqtradebot import FreqtradeBot
from freqtrade.loggers import setup_logging from freqtrade.loggers import setup_logging
from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence import PairLocks, Trade
from freqtrade.rpc import RPC from freqtrade.rpc import RPC
from freqtrade.rpc.rpc import RPCException
from freqtrade.rpc.telegram import Telegram, authorized_only from freqtrade.rpc.telegram import Telegram, authorized_only
from tests.conftest import (CURRENT_TEST_STRATEGY, create_mock_trades, get_patched_freqtradebot, from tests.conftest import (CURRENT_TEST_STRATEGY, create_mock_trades, get_patched_freqtradebot,
log_has, log_has_re, patch_exchange, patch_get_signal, patch_whitelist) log_has, log_has_re, patch_exchange, patch_get_signal, patch_whitelist)
@ -1191,8 +1192,8 @@ def test_forcebuy_no_pair(default_conf, update, mocker) -> None:
assert fbuy_mock.call_count == 1 assert fbuy_mock.call_count == 1
def test_performance_handle(default_conf, update, ticker, fee, def test_telegram_performance_handle(default_conf, update, ticker, fee,
limit_buy_order, limit_sell_order, mocker) -> None: limit_buy_order, limit_sell_order, mocker) -> None:
mocker.patch.multiple( mocker.patch.multiple(
'freqtrade.exchange.Exchange', 'freqtrade.exchange.Exchange',
@ -1221,8 +1222,8 @@ def test_performance_handle(default_conf, update, ticker, fee,
assert '<code>ETH/BTC\t0.00006217 BTC (6.20%) (1)</code>' in msg_mock.call_args_list[0][0][0] assert '<code>ETH/BTC\t0.00006217 BTC (6.20%) (1)</code>' in msg_mock.call_args_list[0][0][0]
def test_buy_tag_performance_handle(default_conf, update, ticker, fee, def test_telegram_buy_tag_performance_handle(default_conf, update, ticker, fee,
limit_buy_order, limit_sell_order, mocker) -> None: limit_buy_order, limit_sell_order, mocker) -> None:
mocker.patch.multiple( mocker.patch.multiple(
'freqtrade.exchange.Exchange', 'freqtrade.exchange.Exchange',
fetch_ticker=ticker, fetch_ticker=ticker,
@ -1245,15 +1246,27 @@ def test_buy_tag_performance_handle(default_conf, update, ticker, fee,
trade.close_date = datetime.utcnow() trade.close_date = datetime.utcnow()
trade.is_open = False trade.is_open = False
context = MagicMock()
telegram._enter_tag_performance(update=update, context=MagicMock()) telegram._enter_tag_performance(update=update, context=context)
assert msg_mock.call_count == 1 assert msg_mock.call_count == 1
assert 'Buy Tag Performance' in msg_mock.call_args_list[0][0][0] assert 'Buy Tag Performance' in msg_mock.call_args_list[0][0][0]
assert '<code>TESTBUY\t0.00006217 BTC (6.20%) (1)</code>' in msg_mock.call_args_list[0][0][0] assert '<code>TESTBUY\t0.00006217 BTC (6.20%) (1)</code>' in msg_mock.call_args_list[0][0][0]
context.args = [trade.pair]
telegram._enter_tag_performance(update=update, context=context)
assert msg_mock.call_count == 2
def test_sell_reason_performance_handle(default_conf, update, ticker, fee, msg_mock.reset_mock()
limit_buy_order, limit_sell_order, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.RPC._rpc_enter_tag_performance',
side_effect=RPCException('Error'))
telegram._enter_tag_performance(update=update, context=MagicMock())
assert msg_mock.call_count == 1
assert "Error" in msg_mock.call_args_list[0][0][0]
def test_telegram_sell_reason_performance_handle(default_conf, update, ticker, fee,
limit_buy_order, limit_sell_order, mocker) -> None:
mocker.patch.multiple( mocker.patch.multiple(
'freqtrade.exchange.Exchange', 'freqtrade.exchange.Exchange',
fetch_ticker=ticker, fetch_ticker=ticker,
@ -1276,15 +1289,27 @@ def test_sell_reason_performance_handle(default_conf, update, ticker, fee,
trade.close_date = datetime.utcnow() trade.close_date = datetime.utcnow()
trade.is_open = False trade.is_open = False
context = MagicMock()
telegram._sell_reason_performance(update=update, context=MagicMock()) telegram._sell_reason_performance(update=update, context=context)
assert msg_mock.call_count == 1 assert msg_mock.call_count == 1
assert 'Sell Reason Performance' in msg_mock.call_args_list[0][0][0] assert 'Sell Reason Performance' in msg_mock.call_args_list[0][0][0]
assert '<code>TESTSELL\t0.00006217 BTC (6.20%) (1)</code>' in msg_mock.call_args_list[0][0][0] assert '<code>TESTSELL\t0.00006217 BTC (6.20%) (1)</code>' in msg_mock.call_args_list[0][0][0]
context.args = [trade.pair]
telegram._sell_reason_performance(update=update, context=context)
assert msg_mock.call_count == 2
msg_mock.reset_mock()
mocker.patch('freqtrade.rpc.rpc.RPC._rpc_sell_reason_performance',
side_effect=RPCException('Error'))
telegram._sell_reason_performance(update=update, context=MagicMock())
assert msg_mock.call_count == 1
assert "Error" in msg_mock.call_args_list[0][0][0]
def test_mix_tag_performance_handle(default_conf, update, ticker, fee, def test_telegram_mix_tag_performance_handle(default_conf, update, ticker, fee,
limit_buy_order, limit_sell_order, mocker) -> None: limit_buy_order, limit_sell_order, mocker) -> None:
mocker.patch.multiple( mocker.patch.multiple(
'freqtrade.exchange.Exchange', 'freqtrade.exchange.Exchange',
fetch_ticker=ticker, fetch_ticker=ticker,
@ -1310,12 +1335,25 @@ def test_mix_tag_performance_handle(default_conf, update, ticker, fee,
trade.close_date = datetime.utcnow() trade.close_date = datetime.utcnow()
trade.is_open = False trade.is_open = False
telegram._mix_tag_performance(update=update, context=MagicMock()) context = MagicMock()
telegram._mix_tag_performance(update=update, context=context)
assert msg_mock.call_count == 1 assert msg_mock.call_count == 1
assert 'Mix Tag Performance' in msg_mock.call_args_list[0][0][0] assert 'Mix Tag Performance' in msg_mock.call_args_list[0][0][0]
assert ('<code>TESTBUY TESTSELL\t0.00006217 BTC (6.20%) (1)</code>' assert ('<code>TESTBUY TESTSELL\t0.00006217 BTC (6.20%) (1)</code>'
in msg_mock.call_args_list[0][0][0]) in msg_mock.call_args_list[0][0][0])
context.args = [trade.pair]
telegram._mix_tag_performance(update=update, context=context)
assert msg_mock.call_count == 2
msg_mock.reset_mock()
mocker.patch('freqtrade.rpc.rpc.RPC._rpc_mix_tag_performance',
side_effect=RPCException('Error'))
telegram._mix_tag_performance(update=update, context=MagicMock())
assert msg_mock.call_count == 1
assert "Error" in msg_mock.call_args_list[0][0][0]
def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: def test_count_handle(default_conf, update, ticker, fee, mocker) -> None:
mocker.patch.multiple( mocker.patch.multiple(

View File

@ -292,3 +292,15 @@ def test__send_msg_with_json_format(default_conf, mocker, caplog):
webhook._send_msg(msg) webhook._send_msg(msg)
assert post.call_args[1] == {'json': msg} assert post.call_args[1] == {'json': msg}
def test__send_msg_with_raw_format(default_conf, mocker, caplog):
default_conf["webhook"] = get_webhook_dict()
default_conf["webhook"]["format"] = "raw"
webhook = Webhook(RPC(get_patched_freqtradebot(mocker, default_conf)), default_conf)
msg = {'data': 'Hello'}
post = MagicMock()
mocker.patch("freqtrade.rpc.webhook.post", post)
webhook._send_msg(msg)
assert post.call_args[1] == {'data': msg['data'], 'headers': {'Content-Type': 'text/plain'}}

View File

@ -67,6 +67,9 @@ def test_file_load_json(mocker, testdatadir) -> None:
@pytest.mark.parametrize("pair,expected_result", [ @pytest.mark.parametrize("pair,expected_result", [
("ETH/BTC", 'ETH_BTC'), ("ETH/BTC", 'ETH_BTC'),
("ETH/USDT", 'ETH_USDT'),
("ETH/USDT:USDT", 'ETH_USDT_USDT'), # swap with USDT as settlement currency
("ETH/USDT:USDT-210625", 'ETH_USDT_USDT_210625'), # expiring futures
("Fabric Token/ETH", 'Fabric_Token_ETH'), ("Fabric Token/ETH", 'Fabric_Token_ETH'),
("ETHH20", 'ETHH20'), ("ETHH20", 'ETHH20'),
(".XBTBON2H", '_XBTBON2H'), (".XBTBON2H", '_XBTBON2H'),