Merge branch 'develop' into feat/short

This commit is contained in:
Matthias 2022-01-22 17:25:21 +01:00
commit f090dcc597
77 changed files with 1886 additions and 252 deletions

View File

@ -5,6 +5,7 @@ on:
branches: branches:
- stable - stable
- develop - develop
- ci/*
tags: tags:
release: release:
types: [published] types: [published]

View File

@ -1,4 +1,4 @@
FROM python:3.10.0-slim-bullseye 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -6,16 +6,16 @@ python -m pip install --upgrade pip wheel
$pyv = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" $pyv = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
if ($pyv -eq '3.7') { if ($pyv -eq '3.7') {
pip install build_helpers\TA_Lib-0.4.23-cp37-cp37m-win_amd64.whl pip install build_helpers\TA_Lib-0.4.24-cp37-cp37m-win_amd64.whl
} }
if ($pyv -eq '3.8') { if ($pyv -eq '3.8') {
pip install build_helpers\TA_Lib-0.4.23-cp38-cp38-win_amd64.whl pip install build_helpers\TA_Lib-0.4.24-cp38-cp38-win_amd64.whl
} }
if ($pyv -eq '3.9') { if ($pyv -eq '3.9') {
pip install build_helpers\TA_Lib-0.4.23-cp39-cp39-win_amd64.whl pip install build_helpers\TA_Lib-0.4.24-cp39-cp39-win_amd64.whl
} }
if ($pyv -eq '3.10') { if ($pyv -eq '3.10') {
pip install build_helpers\TA_Lib-0.4.23-cp310-cp310-win_amd64.whl pip install build_helpers\TA_Lib-0.4.24-cp310-cp310-win_amd64.whl
} }
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
pip install -e . pip install -e .

View File

@ -9,7 +9,9 @@
"cancel_open_orders_on_exit": false, "cancel_open_orders_on_exit": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,
"sell": 30 "sell": 10,
"exit_timeout_count": 0,
"unit": "minutes"
}, },
"bid_strategy": { "bid_strategy": {
"ask_last_balance": 0.0, "ask_last_balance": 0.0,

View File

@ -9,7 +9,9 @@
"cancel_open_orders_on_exit": false, "cancel_open_orders_on_exit": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,
"sell": 30 "sell": 10,
"exit_timeout_count": 0,
"unit": "minutes"
}, },
"bid_strategy": { "bid_strategy": {
"use_order_book": true, "use_order_book": true,

View File

@ -9,7 +9,9 @@
"cancel_open_orders_on_exit": false, "cancel_open_orders_on_exit": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,
"sell": 30 "sell": 10,
"exit_timeout_count": 0,
"unit": "minutes"
}, },
"bid_strategy": { "bid_strategy": {
"ask_last_balance": 0.0, "ask_last_balance": 0.0,

View File

@ -28,7 +28,7 @@
"stoploss": -0.10, "stoploss": -0.10,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,
"sell": 30, "sell": 10,
"exit_timeout_count": 0, "exit_timeout_count": 0,
"unit": "minutes" "unit": "minutes"
}, },

View File

@ -9,7 +9,9 @@
"cancel_open_orders_on_exit": false, "cancel_open_orders_on_exit": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,
"sell": 30 "sell": 10,
"exit_timeout_count": 0,
"unit": "minutes"
}, },
"bid_strategy": { "bid_strategy": {
"use_order_book": true, "use_order_book": true,

View File

@ -22,6 +22,7 @@ usage: freqtrade backtesting [-h] [-v] [--logfile FILE] [-V] [-c PATH]
[--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]] [--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]]
[--export {none,trades}] [--export-filename PATH] [--export {none,trades}] [--export-filename PATH]
[--breakdown {day,week,month} [{day,week,month} ...]] [--breakdown {day,week,month} [{day,week,month} ...]]
[--cache {none,day,week,month}]
optional arguments: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
@ -76,6 +77,9 @@ optional arguments:
_today.json` _today.json`
--breakdown {day,week,month} [{day,week,month} ...] --breakdown {day,week,month} [{day,week,month} ...]
Show backtesting breakdown per [day, week, month]. Show backtesting breakdown per [day, week, month].
--cache {none,day,week,month}
Load a cached backtest result no older than specified
age (default: day).
Common arguments: Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages). -v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
@ -466,6 +470,14 @@ freqtrade backtesting --strategy MyAwesomeStrategy --breakdown day month
The output will show a table containing the realized absolute Profit (in stake currency) for the given timeperiod, as well as wins, draws and losses that materialized (closed) on this day. The output will show a table containing the realized absolute Profit (in stake currency) for the given timeperiod, as well as wins, draws and losses that materialized (closed) on this day.
### Backtest result caching
To save time, by default backtest will reuse a cached result from within the last day when the backtested strategy and config match that of a previous backtest. To force a new backtest despite existing result for an identical run specify `--cache none` parameter.
!!! Warning
Caching is automatically disabled for open-ended timeranges (`--timerange 20210101-`), as freqtrade cannot ensure reliably that the underlying data didn't change. It can also use cached results where it shouldn't if the original backtest had missing data at the end, which was fixed by downloading more data.
In this instance, please use `--cache none` once to force a fresh backtest.
### Further backtest-result analysis ### Further backtest-result analysis
To further analyze your backtest results, you can [export the trades](#exporting-trades-to-file). To further analyze your backtest results, you can [export the trades](#exporting-trades-to-file).

View File

@ -38,6 +38,7 @@ By default, loop runs every few seconds (`internals.process_throttle_secs`) and
* Considers stoploss, ROI and sell-signal, `custom_sell()` and `custom_stoploss()`. * Considers stoploss, ROI and sell-signal, `custom_sell()` and `custom_stoploss()`.
* Determine sell-price based on `ask_strategy` configuration setting or by using the `custom_exit_price()` callback. * Determine sell-price based on `ask_strategy` configuration setting or by using the `custom_exit_price()` callback.
* Before a sell order is placed, `confirm_trade_exit()` strategy callback is called. * Before a sell order is placed, `confirm_trade_exit()` strategy callback is called.
* Check position adjustments for open trades if enabled by calling `adjust_trade_position()` and place additional order if required.
* Check if trade-slots are still available (if `max_open_trades` is reached). * Check if trade-slots are still available (if `max_open_trades` is reached).
* Verifies buy signal trying to enter new positions. * Verifies buy signal trying to enter new positions.
* Determine buy-price based on `bid_strategy` configuration setting, or by using the `custom_entry_price()` callback. * Determine buy-price based on `bid_strategy` configuration setting, or by using the `custom_entry_price()` callback.
@ -59,9 +60,9 @@ This loop will be repeated again and again until the bot is stopped.
* Confirm trade buy / sell (calls `confirm_trade_entry()` and `confirm_trade_exit()` if implemented in the strategy). * Confirm trade buy / sell (calls `confirm_trade_entry()` and `confirm_trade_exit()` if implemented in the strategy).
* Call `custom_entry_price()` (if implemented in the strategy) to determine entry price (Prices are moved to be within the opening candle). * Call `custom_entry_price()` (if implemented in the strategy) to determine entry price (Prices are moved to be within the opening candle).
* Determine stake size by calling the `custom_stake_amount()` callback. * Determine stake size by calling the `custom_stake_amount()` callback.
* Check position adjustments for open trades if enabled and call `adjust_trade_position()` to determine if an additional order is requested.
* Call `custom_stoploss()` and `custom_sell()` to find custom exit points. * Call `custom_stoploss()` and `custom_sell()` to find custom exit points.
* For sells based on sell-signal and custom-sell: Call `custom_exit_price()` to determine exit price (Prices are moved to be within the closing candle). * For sells based on sell-signal and custom-sell: Call `custom_exit_price()` to determine exit price (Prices are moved to be within the closing candle).
* Generate backtest report output * Generate backtest report output
!!! Note !!! Note

View File

@ -174,6 +174,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `user_data_dir` | Directory containing user data. <br> *Defaults to `./user_data/`*. <br> **Datatype:** String | `user_data_dir` | Directory containing user data. <br> *Defaults to `./user_data/`*. <br> **Datatype:** String
| `dataformat_ohlcv` | Data format to use to store historical candle (OHLCV) data. <br> *Defaults to `json`*. <br> **Datatype:** String | `dataformat_ohlcv` | Data format to use to store historical candle (OHLCV) data. <br> *Defaults to `json`*. <br> **Datatype:** String
| `dataformat_trades` | Data format to use to store historical trades data. <br> *Defaults to `jsongz`*. <br> **Datatype:** String | `dataformat_trades` | Data format to use to store historical trades data. <br> *Defaults to `jsongz`*. <br> **Datatype:** String
| `position_adjustment_enable` | Enables the strategy to use position adjustments (additional buys or sells). [More information here](strategy-callbacks.md#adjust-trade-position). <br> [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `false`.*<br> **Datatype:** Boolean
### Parameters in the strategy ### Parameters in the strategy
@ -198,6 +199,7 @@ Values set in the configuration file always overwrite values set in the strategy
* `sell_profit_offset` * `sell_profit_offset`
* `ignore_roi_if_buy_signal` * `ignore_roi_if_buy_signal`
* `ignore_buying_expired_candle_after` * `ignore_buying_expired_candle_after`
* `position_adjustment_enable`
### Configuring amount per trade ### Configuring amount per trade
@ -304,6 +306,15 @@ To allow the bot to trade all the available `stake_currency` in your account (mi
When using `"stake_amount" : "unlimited",` in combination with Dry-Run, Backtesting or Hyperopt, the balance will be simulated starting with a stake of `dry_run_wallet` which will evolve. When using `"stake_amount" : "unlimited",` in combination with Dry-Run, Backtesting or Hyperopt, the balance will be simulated starting with a stake of `dry_run_wallet` which will evolve.
It is therefore important to set `dry_run_wallet` to a sensible value (like 0.05 or 0.01 for BTC and 1000 or 100 for USDT, for example), otherwise, it may simulate trades with 100 BTC (or more) or 0.05 USDT (or less) at once - which may not correspond to your real available balance or is less than the exchange minimal limit for the order amount for the stake currency. It is therefore important to set `dry_run_wallet` to a sensible value (like 0.05 or 0.01 for BTC and 1000 or 100 for USDT, for example), otherwise, it may simulate trades with 100 BTC (or more) or 0.05 USDT (or less) at once - which may not correspond to your real available balance or is less than the exchange minimal limit for the order amount for the stake currency.
#### Dynamic stake amount with position adjustment
When you want to use position adjustment with unlimited stakes, you must also implement `custom_stake_amount` to a return a value depending on your strategy.
Typical value would be in the range of 25% - 50% of the proposed stakes, but depends highly on your strategy and how much you wish to leave into the wallet as position adjustment buffer.
For example if your position adjustment assumes it can do 2 additional buys with the same stake amounts then your buffer should be 66.6667% of the initially proposed unlimited stake amount.
Or another example if your position adjustment assumes it can do 1 additional buy with 3x the original stake amount then `custom_stake_amount` should return 25% of proposed stake amount and leave 75% for possible later position adjustments.
--8<-- "includes/pricing.md" --8<-- "includes/pricing.md"
### Understand minimal_roi ### Understand minimal_roi

View File

@ -188,12 +188,12 @@ There is however nothing preventing you from using GPU-enabled indicators within
Per default Hyperopt called without the `-e`/`--epochs` command line option will only Per default Hyperopt called without the `-e`/`--epochs` command line option will only
run 100 epochs, means 100 evaluations of your triggers, guards, ... Too few run 100 epochs, means 100 evaluations of your triggers, guards, ... Too few
to find a great result (unless if you are very lucky), so you probably to find a great result (unless if you are very lucky), so you probably
have to run it for 10.000 or more. But it will take an eternity to have to run it for 10000 or more. But it will take an eternity to
compute. compute.
Since hyperopt uses Bayesian search, running for too many epochs may not produce greater results. Since hyperopt uses Bayesian search, running for too many epochs may not produce greater results.
It's therefore recommended to run between 500-1000 epochs over and over until you hit at least 10.000 epochs in total (or are satisfied with the result). You can best judge by looking at the results - if the bot keeps discovering better strategies, it's best to keep on going. It's therefore recommended to run between 500-1000 epochs over and over until you hit at least 10000 epochs in total (or are satisfied with the result). You can best judge by looking at the results - if the bot keeps discovering better strategies, it's best to keep on going.
```bash ```bash
freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --strategy SampleStrategy -e 1000 freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --strategy SampleStrategy -e 1000
@ -217,9 +217,9 @@ already 8\*10^9\*10 evaluations. A roughly total of 80 billion evaluations.
Did you run 100 000 evaluations? Congrats, you've done roughly 1 / 100 000 th Did you run 100 000 evaluations? Congrats, you've done roughly 1 / 100 000 th
of the search space, assuming that the bot never tests the same parameters more than once. of the search space, assuming that the bot never tests the same parameters more than once.
* The time it takes to run 1000 hyperopt epochs depends on things like: The available cpu, hard-disk, ram, timeframe, timerange, indicator settings, indicator count, amount of coins that hyperopt test strategies on and the resulting trade count - which can be 650 trades in a year or 10.0000 trades depending if the strategy aims for big profits by trading rarely or for many low profit trades. * The time it takes to run 1000 hyperopt epochs depends on things like: The available cpu, hard-disk, ram, timeframe, timerange, indicator settings, indicator count, amount of coins that hyperopt test strategies on and the resulting trade count - which can be 650 trades in a year or 100000 trades depending if the strategy aims for big profits by trading rarely or for many low profit trades.
Example: 4% profit 650 times vs 0,3% profit a trade 10.000 times in a year. If we assume you set the --timerange to 365 days. Example: 4% profit 650 times vs 0,3% profit a trade 10000 times in a year. If we assume you set the --timerange to 365 days.
Example: Example:
`freqtrade --config config.json --strategy SampleStrategy --hyperopt SampleHyperopt -e 1000 --timerange 20190601-20200601` `freqtrade --config config.json --strategy SampleStrategy --hyperopt SampleHyperopt -e 1000 --timerange 20190601-20200601`

View File

@ -273,6 +273,9 @@ def plot_config(self):
!!! Warning !!! Warning
`plotly` arguments are only supported with plotly library and will not work with freq-ui. `plotly` arguments are only supported with plotly library and will not work with freq-ui.
!!! Note "Trade position adjustments"
If `position_adjustment_enable` / `adjust_trade_position()` is used, the trade initial buy price is averaged over multiple orders and the trade start price will most likely appear outside the candle range.
## Plot profit ## Plot profit
![plot-profit](assets/plot-profit.png) ![plot-profit](assets/plot-profit.png)

View File

@ -1,4 +1,4 @@
mkdocs==1.2.3 mkdocs==1.2.3
mkdocs-material==8.1.4 mkdocs-material==8.1.7
mdx_truly_sane_lists==1.2 mdx_truly_sane_lists==1.2
pymdown-extensions==9.1 pymdown-extensions==9.1

View File

@ -15,6 +15,7 @@ Currently available callbacks:
* [`check_buy_timeout()` and `check_sell_timeout()](#custom-order-timeout-rules) * [`check_buy_timeout()` and `check_sell_timeout()](#custom-order-timeout-rules)
* [`confirm_trade_entry()`](#trade-entry-buy-order-confirmation) * [`confirm_trade_entry()`](#trade-entry-buy-order-confirmation)
* [`confirm_trade_exit()`](#trade-exit-sell-order-confirmation) * [`confirm_trade_exit()`](#trade-exit-sell-order-confirmation)
* [`adjust_trade_position()`](#adjust-trade-position)
!!! Tip "Callback calling sequence" !!! Tip "Callback calling sequence"
You can find the callback calling sequence in [bot-basics](bot-basics.md#bot-execution-logic) You can find the callback calling sequence in [bot-basics](bot-basics.md#bot-execution-logic)
@ -572,6 +573,113 @@ class AwesomeStrategy(IStrategy):
``` ```
## Adjust trade position
The `position_adjustment_enable` strategy property enables the usage of `adjust_trade_position()` callback in the strategy.
For performance reasons, it's disabled by default and freqtrade will show a warning message on startup if enabled.
`adjust_trade_position()` can be used to perform additional orders, for example to manage risk with DCA (Dollar Cost Averaging).
The strategy is expected to return a stake_amount (in stake currency) between `min_stake` and `max_stake` if and when an additional buy order should be made (position is increased).
If there are not enough funds in the wallet (the return value is above `max_stake`) then the signal will be ignored.
Additional orders also result in additional fees and those orders don't count towards `max_open_trades`.
This callback is **not** called when there is an open order (either buy or sell) waiting for execution.
`adjust_trade_position()` is called very frequently for the duration of a trade, so you must keep your implementation as performant as possible.
!!! Note "About stake size"
Using fixed stake size means it will be the amount used for the first order, just like without position adjustment.
If you wish to buy additional orders with DCA, then make sure to leave enough funds in the wallet for that.
Using 'unlimited' stake amount with DCA orders requires you to also implement the `custom_stake_amount()` callback to avoid allocating all funds to the initial order.
!!! Warning
Stoploss is still calculated from the initial opening price, not averaged price.
!!! Warning "/stopbuy"
While `/stopbuy` command stops the bot from entering new trades, the position adjustment feature will continue buying new orders on existing trades.
!!! Warning "Backtesting"
During backtesting this callback is called for each candle in `timeframe` or `timeframe_detail`, so performance will be affected.
``` python
from freqtrade.persistence import Trade
class DigDeeperStrategy(IStrategy):
position_adjustment_enable = True
# Attempts to handle large drops with DCA. High stoploss is required.
stoploss = -0.30
# ... populate_* methods
# Example specific variables
max_dca_orders = 3
# This number is explained a bit further down
max_dca_multiplier = 5.5
# This is called when placing the initial order (opening trade)
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake: float, max_stake: float,
**kwargs) -> float:
# We need to leave most of the funds for possible further DCA orders
# This also applies to fixed stakes
return proposed_stake / self.max_dca_multiplier
def adjust_trade_position(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, min_stake: float,
max_stake: float, **kwargs):
"""
Custom trade adjustment logic, returning the stake amount that a trade should be increased.
This means extra buy orders with additional fees.
:param trade: trade object.
:param current_time: datetime object, containing the current datetime
:param current_rate: Current buy rate.
:param current_profit: Current profit (as ratio), calculated based on current_rate.
:param min_stake: Minimal stake size allowed by exchange.
:param max_stake: Balance available for trading.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: Stake amount to adjust your trade
"""
if current_profit > -0.05:
return None
# Obtain pair dataframe (just to show how to access it)
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
# Only buy when not actively falling price.
last_candle = dataframe.iloc[-1].squeeze()
previous_candle = dataframe.iloc[-2].squeeze()
if last_candle['close'] < previous_candle['close']:
return None
filled_buys = trade.select_filled_orders('buy')
count_of_buys = len(filled_buys)
# Allow up to 3 additional increasingly larger buys (4 in total)
# Initial buy is 1x
# If that falls to -5% profit, we buy 1.25x more, average profit should increase to roughly -2.2%
# If that falls down to -5% again, we buy 1.5x more
# If that falls once again down to -5%, we buy 1.75x more
# Total stake for this trade would be 1 + 1.25 + 1.5 + 1.75 = 5.5x of the initial allowed stake.
# That is why max_dca_multiplier is 5.5
# Hope you have a deep wallet!
if 0 < count_of_buys <= self.max_dca_orders:
try:
# This returns first order stake size
stake_amount = filled_buys[0].cost
# This then calculates current safety order size
stake_amount = stake_amount * (1 + (count_of_buys * 0.25))
return stake_amount
except Exception as exception:
return None
return None
```
## Leverage Callback ## Leverage Callback
When trading in markets that allow leverage, this method must return the desired Leverage (Defaults to 1 -> No leverage). When trading in markets that allow leverage, this method must return the desired Leverage (Defaults to 1 -> No leverage).
@ -598,4 +706,3 @@ class AwesomeStrategy(IStrategy):
:return: A leverage amount, which is between 1.0 and max_leverage. :return: A leverage amount, which is between 1.0 and max_leverage.
""" """
return 1.0 return 1.0
```

View File

@ -838,7 +838,7 @@ In some situations it may be confusing to deal with stops relative to current ra
from datetime import datetime from datetime import datetime
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.strategy import IStrategy, stoploss_from_open from freqtrade.strategy import IStrategy, stoploss_from_absolute
class AwesomeStrategy(IStrategy): class AwesomeStrategy(IStrategy):

View File

@ -23,7 +23,7 @@ git clone https://github.com/freqtrade/freqtrade.git
Install ta-lib according to the [ta-lib documentation](https://github.com/mrjbq7/ta-lib#windows). Install ta-lib according to the [ta-lib documentation](https://github.com/mrjbq7/ta-lib#windows).
As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), there is also a repository of unofficial pre-compiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which need to be downloaded and installed using `pip install TA_Lib-0.4.23-cp38-cp38-win_amd64.whl` (make sure to use the version matching your python version). As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), there is also a repository of unofficial pre-compiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which need to be downloaded and installed using `pip install TA_Lib-0.4.24-cp38-cp38-win_amd64.whl` (make sure to use the version matching your python version).
Freqtrade provides these dependencies for the latest 3 Python versions (3.7, 3.8, 3.9 and 3.10) and for 64bit Windows. Freqtrade provides these dependencies for the latest 3 Python versions (3.7, 3.8, 3.9 and 3.10) and for 64bit Windows.
Other versions must be downloaded from the above link. Other versions must be downloaded from the above link.

View File

@ -24,7 +24,7 @@ ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions", ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
"enable_protections", "dry_run_wallet", "timeframe_detail", "enable_protections", "dry_run_wallet", "timeframe_detail",
"strategy_list", "export", "exportfilename", "strategy_list", "export", "exportfilename",
"backtest_breakdown"] "backtest_breakdown", "backtest_cache"]
ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path", ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
"position_stacking", "use_max_market_positions", "position_stacking", "use_max_market_positions",

View File

@ -86,7 +86,7 @@ def ask_user_config() -> Dict[str, Any]:
{ {
"type": "select", "type": "select",
"name": "timeframe_in_config", "name": "timeframe_in_config",
"message": "Tim", "message": "Time",
"choices": ["Have the strategy define timeframe.", "Override in configuration."] "choices": ["Have the strategy define timeframe.", "Override in configuration."]
}, },
{ {

View File

@ -205,6 +205,12 @@ AVAILABLE_CLI_OPTIONS = {
nargs='+', nargs='+',
choices=constants.BACKTEST_BREAKDOWNS choices=constants.BACKTEST_BREAKDOWNS
), ),
"backtest_cache": Arg(
'--cache',
help='Load a cached backtest result no older than specified age (default: %(default)s).',
default=constants.BACKTEST_CACHE_DEFAULT,
choices=constants.BACKTEST_CACHE_AGE,
),
# Edge # Edge
"stoploss_range": Arg( "stoploss_range": Arg(
'--stoplosses', '--stoplosses',

View File

@ -276,6 +276,9 @@ class Configuration:
self._args_to_config(config, argname='backtest_breakdown', self._args_to_config(config, argname='backtest_breakdown',
logstring='Parameter --breakdown detected ...') logstring='Parameter --breakdown detected ...')
self._args_to_config(config, argname='backtest_cache',
logstring='Parameter --cache={} detected ...')
self._args_to_config(config, argname='disableparamexport', self._args_to_config(config, argname='disableparamexport',
logstring='Parameter --disableparamexport detected: {} ...') logstring='Parameter --disableparamexport detected: {} ...')

View File

@ -36,6 +36,8 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard']
AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5']
BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] BACKTEST_BREAKDOWNS = ['day', 'week', 'month']
BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month']
BACKTEST_CACHE_DEFAULT = 'day'
DRY_RUN_WALLET = 1000 DRY_RUN_WALLET = 1000
DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S' DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S'
MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons

View File

@ -2,6 +2,8 @@
Helpers when analyzing backtest data Helpers when analyzing backtest data
""" """
import logging import logging
from copy import copy
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
@ -10,7 +12,7 @@ import pandas as pd
from freqtrade.constants import LAST_BT_RESULT_FN from freqtrade.constants import LAST_BT_RESULT_FN
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.misc import json_load from freqtrade.misc import get_backtest_metadata_filename, json_load
from freqtrade.persistence import LocalTrade, Trade, init_db from freqtrade.persistence import LocalTrade, Trade, init_db
@ -100,10 +102,30 @@ def get_latest_hyperopt_file(directory: Union[Path, str], predef_filename: str =
if isinstance(directory, str): if isinstance(directory, str):
directory = Path(directory) directory = Path(directory)
if predef_filename: if predef_filename:
if Path(predef_filename).is_absolute():
raise OperationalException(
"--hyperopt-filename expects only the filename, not an absolute path.")
return directory / predef_filename return directory / predef_filename
return directory / get_latest_hyperopt_filename(directory) return directory / get_latest_hyperopt_filename(directory)
def load_backtest_metadata(filename: Union[Path, str]) -> Dict[str, Any]:
"""
Read metadata dictionary from backtest results file without reading and deserializing entire
file.
:param filename: path to backtest results file.
:return: metadata dict or None if metadata is not present.
"""
filename = get_backtest_metadata_filename(filename)
try:
with filename.open() as fp:
return json_load(fp)
except FileNotFoundError:
return {}
except Exception as e:
raise OperationalException('Unexpected error while loading backtest metadata.') from e
def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]: def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]:
""" """
Load backtest statistics file. Load backtest statistics file.
@ -120,9 +142,80 @@ def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]:
with filename.open() as file: with filename.open() as file:
data = json_load(file) data = json_load(file)
# Legacy list format does not contain metadata.
if isinstance(data, dict):
data['metadata'] = load_backtest_metadata(filename)
return data return data
def _load_and_merge_backtest_result(strategy_name: str, filename: Path, results: Dict[str, Any]):
bt_data = load_backtest_stats(filename)
for k in ('metadata', 'strategy'):
results[k][strategy_name] = bt_data[k][strategy_name]
comparison = bt_data['strategy_comparison']
for i in range(len(comparison)):
if comparison[i]['key'] == strategy_name:
results['strategy_comparison'].append(comparison[i])
break
def find_existing_backtest_stats(dirname: Union[Path, str], run_ids: Dict[str, str],
min_backtest_date: datetime = None) -> Dict[str, Any]:
"""
Find existing backtest stats that match specified run IDs and load them.
:param dirname: pathlib.Path object, or string pointing to the file.
:param run_ids: {strategy_name: id_string} dictionary.
:param min_backtest_date: do not load a backtest older than specified date.
:return: results dict.
"""
# Copy so we can modify this dict without affecting parent scope.
run_ids = copy(run_ids)
dirname = Path(dirname)
results: Dict[str, Any] = {
'metadata': {},
'strategy': {},
'strategy_comparison': [],
}
# Weird glob expression here avoids including .meta.json files.
for filename in reversed(sorted(dirname.glob('backtest-result-*-[0-9][0-9].json'))):
metadata = load_backtest_metadata(filename)
if not metadata:
# Files are sorted from newest to oldest. When file without metadata is encountered it
# is safe to assume older files will also not have any metadata.
break
for strategy_name, run_id in list(run_ids.items()):
strategy_metadata = metadata.get(strategy_name, None)
if not strategy_metadata:
# This strategy is not present in analyzed backtest.
continue
if min_backtest_date is not None:
try:
backtest_date = strategy_metadata['backtest_start_time']
except KeyError:
# TODO: this can be removed starting from feb 2022
# The metadata-file without start_time was only available in develop
# and was never included in an official release.
# Older metadata format without backtest time, too old to consider.
return results
backtest_date = datetime.fromtimestamp(backtest_date, tz=timezone.utc)
if backtest_date < min_backtest_date:
# Do not use a cached result for this strategy as first result is too old.
del run_ids[strategy_name]
continue
if strategy_metadata['run_id'] == run_id:
del run_ids[strategy_name]
_load_and_merge_backtest_result(strategy_name, filename, results)
if len(run_ids) == 0:
break
return results
def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = None) -> pd.DataFrame: def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = None) -> pd.DataFrame:
""" """
Load backtest data file. Load backtest data file.

View File

@ -752,8 +752,9 @@ class Exchange:
'cost': _amount * rate, 'cost': _amount * rate,
'type': ordertype, 'type': ordertype,
'side': side, 'side': side,
'filled': 0,
'remaining': _amount, 'remaining': _amount,
'datetime': arrow.utcnow().isoformat(), 'datetime': arrow.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
'timestamp': arrow.utcnow().int_timestamp * 1000, 'timestamp': arrow.utcnow().int_timestamp * 1000,
'status': "closed" if ordertype == "market" else "open", 'status': "closed" if ordertype == "market" else "open",
'fee': None, 'fee': None,
@ -768,6 +769,7 @@ class Exchange:
average = self.get_dry_market_fill_price(pair, side, amount, rate) average = self.get_dry_market_fill_price(pair, side, amount, rate)
dry_order.update({ dry_order.update({
'average': average, 'average': average,
'filled': _amount,
'cost': (dry_order['amount'] * average) / leverage 'cost': (dry_order['amount'] * average) / leverage
}) })
dry_order = self.add_dry_order_fee(pair, dry_order) dry_order = self.add_dry_order_fee(pair, dry_order)

View File

@ -17,7 +17,7 @@ from freqtrade.configuration import validate_config_consistency
from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.converter import order_book_to_dataframe
from freqtrade.data.dataprovider import DataProvider from freqtrade.data.dataprovider import DataProvider
from freqtrade.edge import Edge from freqtrade.edge import Edge
from freqtrade.enums import (Collateral, RPCMessageType, SellType, SignalDirection, State, from freqtrade.enums import (Collateral, RPCMessageType, RunMode, SellType, SignalDirection, State,
TradingMode) TradingMode)
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError, from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
InvalidOrderException, PricingError) InvalidOrderException, PricingError)
@ -202,6 +202,11 @@ class FreqtradeBot(LoggingMixin):
# First process current opened trades (positions) # First process current opened trades (positions)
self.exit_positions(trades) self.exit_positions(trades)
# Check if we need to adjust our current positions before attempting to buy new trades.
if self.strategy.position_adjustment_enable:
with self._exit_lock:
self.process_open_trade_positions()
# Then looking for buy opportunities # Then looking for buy opportunities
if self.get_free_open_trades(): if self.get_free_open_trades():
self.enter_positions() self.enter_positions()
@ -328,7 +333,8 @@ class FreqtradeBot(LoggingMixin):
for trade in trades: for trade in trades:
if trade.is_open and not trade.fee_updated(trade.enter_side): if trade.is_open and not trade.fee_updated(trade.enter_side):
order = trade.select_order(trade.enter_side, False) order = trade.select_order(trade.enter_side, False)
if order: open_order = trade.select_order(trade.enter_side, True)
if order and open_order is None:
logger.info( logger.info(
f"Updating {trade.enter_side}-fee on trade {trade}" f"Updating {trade.enter_side}-fee on trade {trade}"
f"for order {order.order_id}." f"for order {order.order_id}."
@ -500,6 +506,53 @@ class FreqtradeBot(LoggingMixin):
else: else:
return False return False
#
# BUY / increase positions / DCA logic and methods
#
def process_open_trade_positions(self):
"""
Tries to execute additional buy or sell orders for open trades (positions)
"""
# Walk through each pair and check if it needs changes
for trade in Trade.get_open_trades():
# If there is any open orders, wait for them to finish.
if trade.open_order_id is None:
try:
self.check_and_call_adjust_trade_position(trade)
except DependencyException as exception:
logger.warning('Unable to adjust position of trade for %s: %s',
trade.pair, exception)
def check_and_call_adjust_trade_position(self, trade: Trade):
"""
Check the implemented trading strategy for adjustment command.
If the strategy triggers the adjustment, a new order gets issued.
Once that completes, the existing trade is modified to match new data.
"""
# TODO-lev: Check what changes are necessary for DCA in relation to shorts.
current_rate = self.exchange.get_rate(trade.pair, refresh=True, side="buy")
current_profit = trade.calc_profit_ratio(current_rate)
min_stake_amount = self.exchange.get_min_pair_stake_amount(trade.pair,
current_rate,
self.strategy.stoploss)
max_stake_amount = self.wallets.get_available_stake_amount()
logger.debug(f"Calling adjust_trade_position for pair {trade.pair}")
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
default_retval=None)(
trade=trade, current_time=datetime.now(timezone.utc), current_rate=current_rate,
current_profit=current_profit, min_stake=min_stake_amount, max_stake=max_stake_amount)
if stake_amount is not None and stake_amount > 0.0:
# We should increase our position
self.execute_entry(trade.pair, stake_amount, trade=trade)
if stake_amount is not None and stake_amount < 0.0:
# We should decrease our position
# TODO: Selling part of the trade not implemented yet.
logger.error(f"Unable to decrease trade position / sell partially"
f" for pair {trade.pair}, feature not implemented.")
def _check_depth_of_market( def _check_depth_of_market(
self, self,
pair: str, pair: str,
@ -578,7 +631,8 @@ class FreqtradeBot(LoggingMixin):
*, *,
is_short: bool = False, is_short: bool = False,
ordertype: Optional[str] = None, ordertype: Optional[str] = None,
enter_tag: Optional[str] = None enter_tag: Optional[str] = None,
trade: Optional[Trade] = None,
) -> bool: ) -> bool:
""" """
Executes a limit buy for the given pair Executes a limit buy for the given pair
@ -591,43 +645,10 @@ class FreqtradeBot(LoggingMixin):
[side, name] = ['sell', 'Short'] if is_short else ['buy', 'Long'] [side, name] = ['sell', 'Short'] if is_short else ['buy', 'Long']
trade_side = 'short' if is_short else 'long' trade_side = 'short' if is_short else 'long'
pos_adjust = trade is not None
if price: enter_limit_requested, stake_amount = self.get_valid_enter_price_and_stake(
enter_limit_requested = price pair, price, stake_amount, side, trade_side, trade)
else:
# Calculate price
proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side=side)
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=proposed_enter_rate)(
pair=pair, current_time=datetime.now(timezone.utc),
proposed_rate=proposed_enter_rate)
enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate)
if not enter_limit_requested:
raise PricingError(f'Could not determine {side} price.')
# Min-stake-amount should actually include Leverage - this way our "minimal"
# stake- amount might be higher than necessary.
# We do however also need min-stake to determine leverage, therefore this is ignored as
# edge-case for now.
min_stake_amount = self.exchange.get_min_pair_stake_amount(
pair,
enter_limit_requested,
self.strategy.stoploss,
)
if not self.edge:
max_stake_amount = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
pair=pair, current_time=datetime.now(timezone.utc),
current_rate=enter_limit_requested, proposed_stake=stake_amount,
min_stake=min_stake_amount, max_stake=max_stake_amount,
side=trade_side
)
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
if not stake_amount: if not stake_amount:
return False return False
@ -643,16 +664,19 @@ class FreqtradeBot(LoggingMixin):
) if self.trading_mode != TradingMode.SPOT else 1.0 ) if self.trading_mode != TradingMode.SPOT else 1.0
# Cap leverage between 1.0 and max_leverage. # Cap leverage between 1.0 and max_leverage.
leverage = min(max(leverage, 1.0), max_leverage) leverage = min(max(leverage, 1.0), max_leverage)
if pos_adjust:
logger.info( logger.info(f"Position adjust: about to create a new order for {pair} with stake: "
f"{name} signal found: about create a new trade for {pair} with stake_amount: " f"{stake_amount} for {trade}")
f"{stake_amount} ..." else:
) logger.info(
f"{name} signal found: about create a new trade for {pair} with stake_amount: "
f"{stake_amount} ...")
amount = (stake_amount / enter_limit_requested) * leverage amount = (stake_amount / enter_limit_requested) * leverage
order_type = ordertype or self.strategy.order_types['buy'] order_type = ordertype or self.strategy.order_types['buy']
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( if not pos_adjust and 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,
time_in_force=time_in_force, current_time=datetime.now(timezone.utc), time_in_force=time_in_force, current_time=datetime.now(timezone.utc),
side=trade_side side=trade_side
@ -672,6 +696,7 @@ class FreqtradeBot(LoggingMixin):
order_obj = Order.parse_from_ccxt_object(order, pair, side) order_obj = Order.parse_from_ccxt_object(order, pair, side)
order_id = order['id'] order_id = order['id']
order_status = order.get('status', None) order_status = order.get('status', None)
logger.info(f"Order #{order_id} was created for {pair} and status is {order_status}.")
# we assume the order is executed at the price requested # we assume the order is executed at the price requested
enter_limit_filled_price = enter_limit_requested enter_limit_filled_price = enter_limit_requested
@ -717,39 +742,54 @@ class FreqtradeBot(LoggingMixin):
fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker') fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker')
open_date = datetime.now(timezone.utc) open_date = datetime.now(timezone.utc)
funding_fees = self.exchange.get_funding_fees(pair, amount, open_date) funding_fees = self.exchange.get_funding_fees(pair, amount, open_date)
# This is a new trade
if trade is None:
trade = Trade(
pair=pair,
stake_amount=stake_amount,
amount=amount,
is_open=True,
amount_requested=amount_requested,
fee_open=fee,
fee_close=fee,
open_rate=enter_limit_filled_price,
open_rate_requested=enter_limit_requested,
open_date=open_date,
exchange=self.exchange.id,
open_order_id=order_id,
strategy=self.strategy.get_strategy_name(),
enter_tag=enter_tag,
timeframe=timeframe_to_minutes(self.config['timeframe']),
leverage=leverage,
is_short=is_short,
interest_rate=interest_rate,
isolated_liq=isolated_liq,
trading_mode=self.trading_mode,
funding_fees=funding_fees
)
else:
# This is additional buy, we reset fee_open_currency so timeout checking can work
trade.is_open = True
trade.fee_open_currency = None
trade.open_rate_requested = enter_limit_requested
trade.open_order_id = order_id
trade = Trade(
pair=pair,
stake_amount=stake_amount,
amount=amount,
is_open=True,
amount_requested=amount_requested,
fee_open=fee,
fee_close=fee,
open_rate=enter_limit_filled_price,
open_rate_requested=enter_limit_requested,
open_date=open_date,
exchange=self.exchange.id,
open_order_id=order_id,
strategy=self.strategy.get_strategy_name(),
enter_tag=enter_tag,
timeframe=timeframe_to_minutes(self.config['timeframe']),
leverage=leverage,
is_short=is_short,
interest_rate=interest_rate,
isolated_liq=isolated_liq,
trading_mode=self.trading_mode,
funding_fees=funding_fees
)
trade.orders.append(order_obj) trade.orders.append(order_obj)
trade.recalc_trade_from_orders()
Trade.query.session.add(trade) Trade.query.session.add(trade)
Trade.commit() Trade.commit()
# Updating wallets # Updating wallets
self.wallets.update() self.wallets.update()
self._notify_enter(trade, order_type) self._notify_enter(trade, order, order_type)
if pos_adjust:
if order_status == 'closed':
logger.info(f"DCA order closed, trade should be up to date: {trade}")
trade = self.cancel_stoploss_on_exchange(trade)
else:
logger.info(f"DCA order {order_status}, will wait for resolution: {trade}")
# Update fees if order is closed # Update fees if order is closed
if order_status == 'closed': if order_status == 'closed':
@ -757,7 +797,59 @@ class FreqtradeBot(LoggingMixin):
return True return True
def _notify_enter(self, trade: Trade, order_type: Optional[str] = None, def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade:
# First cancelling stoploss on exchange ...
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
try:
logger.info(f"Canceling stoploss on exchange for {trade}")
co = self.exchange.cancel_stoploss_order_with_result(
trade.stoploss_order_id, trade.pair, trade.amount)
trade.update_order(co)
except InvalidOrderException:
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
return trade
def get_valid_enter_price_and_stake(
self, pair: str, price: Optional[float], stake_amount: float,
side: str, trade_side: str,
trade: Optional[Trade]) -> Tuple[float, float]:
if price:
enter_limit_requested = price
else:
# Calculate price
proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side=side)
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=proposed_enter_rate)(
pair=pair, current_time=datetime.now(timezone.utc),
proposed_rate=proposed_enter_rate)
enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate)
if not enter_limit_requested:
raise PricingError(f'Could not determine {side} price.')
# Min-stake-amount should actually include Leverage - this way our "minimal"
# stake- amount might be higher than necessary.
# We do however also need min-stake to determine leverage, therefore this is ignored as
# edge-case for now.
min_stake_amount = self.exchange.get_min_pair_stake_amount(
pair, enter_limit_requested, self.strategy.stoploss,)
if not self.edge and trade is None:
max_stake_amount = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
pair=pair, current_time=datetime.now(timezone.utc),
current_rate=enter_limit_requested, proposed_stake=stake_amount,
min_stake=min_stake_amount, max_stake=max_stake_amount,
side=trade_side
)
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
return enter_limit_requested, stake_amount
def _notify_enter(self, trade: Trade, order: Dict, order_type: Optional[str] = None,
fill: bool = False) -> None: fill: bool = False) -> None:
""" """
Sends rpc notification when a entry order occurred. Sends rpc notification when a entry order occurred.
@ -766,6 +858,13 @@ class FreqtradeBot(LoggingMixin):
msg_type = RPCMessageType.SHORT_FILL if trade.is_short else RPCMessageType.BUY_FILL msg_type = RPCMessageType.SHORT_FILL if trade.is_short else RPCMessageType.BUY_FILL
else: else:
msg_type = RPCMessageType.SHORT if trade.is_short else RPCMessageType.BUY msg_type = RPCMessageType.SHORT if trade.is_short else RPCMessageType.BUY
open_rate = safe_value_fallback(order, 'average', 'price')
if open_rate is None:
open_rate = trade.open_rate
current_rate = trade.open_rate_requested
if self.dataprovider.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
current_rate = self.exchange.get_rate(trade.pair, refresh=False, side=trade.enter_side)
msg = { msg = {
'trade_id': trade.id, 'trade_id': trade.id,
@ -776,15 +875,15 @@ class FreqtradeBot(LoggingMixin):
'pair': trade.pair, 'pair': trade.pair,
'leverage': trade.leverage if trade.leverage else None, 'leverage': trade.leverage if trade.leverage else None,
'direction': 'Short' if trade.is_short else 'Long', 'direction': 'Short' if trade.is_short else 'Long',
'limit': trade.open_rate, # Deprecated (?) 'limit': open_rate, # Deprecated (?)
'open_rate': trade.open_rate, 'open_rate': open_rate,
'order_type': order_type, 'order_type': order_type,
'stake_amount': trade.stake_amount, 'stake_amount': trade.stake_amount,
'stake_currency': self.config['stake_currency'], 'stake_currency': self.config['stake_currency'],
'fiat_currency': self.config.get('fiat_display_currency', None), 'fiat_currency': self.config.get('fiat_display_currency', None),
'amount': trade.amount, 'amount': safe_value_fallback(order, 'filled', 'amount') or trade.amount,
'open_date': trade.open_date or datetime.utcnow(), 'open_date': trade.open_date or datetime.utcnow(),
'current_rate': trade.open_rate_requested, 'current_rate': current_rate,
} }
# Send the message # Send the message
@ -1163,14 +1262,17 @@ class FreqtradeBot(LoggingMixin):
# Using filled to determine the filled amount # Using filled to determine the filled amount
filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled') filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled')
if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC): if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC):
logger.info( logger.info(f'{side} order fully cancelled. Removing {trade} from database.')
'%s order fully cancelled. Removing %s from database.', # if trade is not partially completed and it's the only order, just delete the trade
side, trade if len(trade.orders) <= 1:
) trade.delete()
# if trade is not partially completed, just delete the trade was_trade_fully_canceled = True
trade.delete() reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
was_trade_fully_canceled = True else:
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}" # FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below.
self.update_trade_state(trade, trade.open_order_id, corder)
trade.open_order_id = None
logger.info(f'Partial {side} order timeout for {trade}.')
else: else:
# if trade is partially complete, edit the stake details for the trade # if trade is partially complete, edit the stake details for the trade
# and close the order # and close the order
@ -1303,13 +1405,7 @@ class FreqtradeBot(LoggingMixin):
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate) limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
# First cancelling stoploss on exchange ... # First cancelling stoploss on exchange ...
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id: trade = self.cancel_stoploss_on_exchange(trade)
try:
co = self.exchange.cancel_stoploss_order_with_result(trade.stoploss_order_id,
trade.pair, trade.amount)
trade.update_order(co)
except InvalidOrderException:
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
order_type = ordertype or 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:
@ -1476,7 +1572,7 @@ class FreqtradeBot(LoggingMixin):
return False return False
# Update trade with order values # Update trade with order values
logger.info('Found open order for %s', trade) logger.info(f'Found open order for {trade}')
try: try:
order = action_order or self.exchange.fetch_order_or_stoploss_order(order_id, order = action_order or self.exchange.fetch_order_or_stoploss_order(order_id,
trade.pair, trade.pair,
@ -1492,29 +1588,26 @@ class FreqtradeBot(LoggingMixin):
# Handling of this will happen in check_handle_timedout. # Handling of this will happen in check_handle_timedout.
return True return True
# Try update amount (binance-fix) order = self.handle_order_fee(trade, order)
try:
new_amount = self.get_real_amount(trade, order)
if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount,
abs_tol=constants.MATH_CLOSE_PREC):
order['amount'] = new_amount
order.pop('filled', None)
trade.recalc_open_trade_value()
except DependencyException as exception:
logger.warning("Could not update trade amount: %s", exception)
trade.update(order) trade.update(order)
trade.recalc_trade_from_orders()
Trade.commit() Trade.commit()
# Updating wallets when order is closed if order['status'] in constants.NON_OPEN_EXCHANGE_STATES:
# If a buy order was closed, force update on stoploss on exchange
if order.get('side', None) == 'buy':
trade = self.cancel_stoploss_on_exchange(trade)
# Updating wallets when order is closed
self.wallets.update()
if not trade.is_open: if not trade.is_open:
if send_msg and not stoploss_order and not trade.open_order_id: if send_msg and not stoploss_order and not trade.open_order_id:
self._notify_exit(trade, '', True) self._notify_exit(trade, '', True)
self.handle_protections(trade.pair) self.handle_protections(trade.pair)
self.wallets.update()
elif send_msg and not trade.open_order_id: elif send_msg and not trade.open_order_id:
# Buy fill # Buy fill
self._notify_enter(trade, fill=True) self._notify_enter(trade, order, fill=True)
return False return False
@ -1551,6 +1644,18 @@ class FreqtradeBot(LoggingMixin):
return real_amount return real_amount
return amount return amount
def handle_order_fee(self, trade: Trade, order: Dict[str, Any]) -> Dict[str, Any]:
# Try update amount (binance-fix)
try:
new_amount = self.get_real_amount(trade, order)
if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount,
abs_tol=constants.MATH_CLOSE_PREC):
order['amount'] = new_amount
order.pop('filled', None)
except DependencyException as exception:
logger.warning("Could not update trade amount: %s", exception)
return order
def get_real_amount(self, trade: Trade, order: Dict) -> float: def get_real_amount(self, trade: Trade, order: Dict) -> float:
""" """
Detect and update trade fee. Detect and update trade fee.

View File

@ -7,11 +7,25 @@ from typing import Any, Dict
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
class FTBufferingHandler(BufferingHandler):
def flush(self):
"""
Override Flush behaviour - we keep half of the configured capacity
otherwise, we have moments with "empty" logs.
"""
self.acquire()
try:
# Keep half of the records in buffer.
self.buffer = self.buffer[-int(self.capacity / 2):]
finally:
self.release()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
LOGFORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' LOGFORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
# Initialize bufferhandler - will be used for /log endpoints # Initialize bufferhandler - will be used for /log endpoints
bufferHandler = BufferingHandler(1000) bufferHandler = FTBufferingHandler(1000)
bufferHandler.setFormatter(Formatter(LOGFORMAT)) bufferHandler.setFormatter(Formatter(LOGFORMAT))

View File

@ -2,11 +2,13 @@
Various tool function for Freqtrade and scripts Various tool function for Freqtrade and scripts
""" """
import gzip import gzip
import hashlib
import logging import logging
import re import re
from copy import deepcopy
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Iterator, List from typing import Any, Iterator, List, Union
from typing.io import IO from typing.io import IO
from urllib.parse import urlparse from urllib.parse import urlparse
@ -228,3 +230,34 @@ def parse_db_uri_for_logging(uri: str):
return uri return uri
pwd = parsed_db_uri.netloc.split(':')[1].split('@')[0] pwd = parsed_db_uri.netloc.split(':')[1].split('@')[0]
return parsed_db_uri.geturl().replace(f':{pwd}@', ':*****@') return parsed_db_uri.geturl().replace(f':{pwd}@', ':*****@')
def get_strategy_run_id(strategy) -> str:
"""
Generate unique identification hash for a backtest run. Identical config and strategy file will
always return an identical hash.
:param strategy: strategy object.
:return: hex string id.
"""
digest = hashlib.sha1()
config = deepcopy(strategy.config)
# Options that have no impact on results of individual backtest.
not_important_keys = ('strategy_list', 'original_config', 'telegram', 'api_server')
for k in not_important_keys:
if k in config:
del config[k]
# Explicitly allow NaN values (e.g. max_open_trades).
# as it does not matter for getting the hash.
digest.update(rapidjson.dumps(config, default=str,
number_mode=rapidjson.NM_NAN).encode('utf-8'))
with open(strategy.__file__, 'rb') as fp:
digest.update(fp.read())
return digest.hexdigest().lower()
def get_backtest_metadata_filename(filename: Union[Path, str]) -> Path:
"""Return metadata filename for specified backtest results file."""
filename = Path(filename)
return filename.parent / Path(f'{filename.stem}.meta{filename.suffix}')

View File

@ -12,20 +12,22 @@ from typing import Any, Dict, List, Optional, Tuple
from numpy import nan from numpy import nan
from pandas import DataFrame from pandas import DataFrame
from freqtrade import constants
from freqtrade.configuration import TimeRange, validate_config_consistency from freqtrade.configuration import TimeRange, validate_config_consistency
from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.constants import DATETIME_PRINT_FORMAT
from freqtrade.data import history from freqtrade.data import history
from freqtrade.data.btanalysis import trade_list_to_dataframe from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe
from freqtrade.data.converter import trim_dataframe, trim_dataframes from freqtrade.data.converter import trim_dataframe, trim_dataframes
from freqtrade.data.dataprovider import DataProvider from freqtrade.data.dataprovider import DataProvider
from freqtrade.enums import BacktestState, CandleType, SellType, TradingMode from freqtrade.enums import BacktestState, CandleType, SellType, TradingMode
from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
from freqtrade.misc import get_strategy_run_id
from freqtrade.mixins import LoggingMixin from freqtrade.mixins import LoggingMixin
from freqtrade.optimize.bt_progress import BTProgress from freqtrade.optimize.bt_progress import BTProgress
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results, from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
store_backtest_stats) store_backtest_stats)
from freqtrade.persistence import LocalTrade, PairLocks, Trade from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade
from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.plugins.pairlistmanager import PairListManager
from freqtrade.plugins.protectionmanager import ProtectionManager from freqtrade.plugins.protectionmanager import ProtectionManager
from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.resolvers import ExchangeResolver, StrategyResolver
@ -63,9 +65,10 @@ class Backtesting:
LoggingMixin.show_output = False LoggingMixin.show_output = False
self.config = config self.config = config
self.results: Optional[Dict[str, Any]] = None self.results: Dict[str, Any] = {}
config['dry_run'] = True config['dry_run'] = True
self.run_ids: Dict[str, str] = {}
self.strategylist: List[IStrategy] = [] self.strategylist: List[IStrategy] = []
self.all_results: Dict[str, Dict] = {} self.all_results: Dict[str, Dict] = {}
self._exchange_name = self.config['exchange']['name'] self._exchange_name = self.config['exchange']['name']
@ -373,12 +376,37 @@ class Backtesting:
else: else:
return sell_row[OPEN_IDX] return sell_row[OPEN_IDX]
def _get_adjust_trade_entry_for_candle(self, trade: LocalTrade, row: Tuple
) -> LocalTrade:
current_profit = trade.calc_profit_ratio(row[OPEN_IDX])
min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, row[OPEN_IDX], -0.1)
max_stake = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
default_retval=None)(
trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX],
current_profit=current_profit, min_stake=min_stake, max_stake=max_stake)
# Check if we should increase our position
if stake_amount is not None and stake_amount > 0.0:
pos_trade = self._enter_trade(
trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade)
if pos_trade is not None:
return pos_trade
return trade
def _get_sell_trade_entry_for_candle(self, trade: LocalTrade, def _get_sell_trade_entry_for_candle(self, trade: LocalTrade,
sell_row: Tuple) -> Optional[LocalTrade]: sell_row: Tuple) -> Optional[LocalTrade]:
# TODO-lev: add interest / funding fees to trade object -> # TODO-lev: add interest / funding fees to trade object ->
# Must be done either here, or one level higher -> # Must be done either here, or one level higher ->
# (if we don't want to do it at "detail" level) # (if we don't want to do it at "detail" level)
# Check if we need to adjust our current positions
if self.strategy.position_adjustment_enable:
trade = self._get_adjust_trade_entry_for_candle(trade, sell_row)
sell_candle_time = sell_row[DATE_IDX].to_pydatetime() sell_candle_time = sell_row[DATE_IDX].to_pydatetime()
enter = sell_row[SHORT_IDX] if trade.is_short else sell_row[LONG_IDX] enter = sell_row[SHORT_IDX] if trade.is_short else sell_row[LONG_IDX]
exit_ = sell_row[ESHORT_IDX] if trade.is_short else sell_row[ELONG_IDX] exit_ = sell_row[ESHORT_IDX] if trade.is_short else sell_row[ELONG_IDX]
@ -462,17 +490,14 @@ class Backtesting:
else: else:
return self._get_sell_trade_entry_for_candle(trade, sell_row) return self._get_sell_trade_entry_for_candle(trade, sell_row)
def _enter_trade(self, pair: str, row: List, direction: str) -> Optional[LocalTrade]: def _enter_trade(self, pair: str, row: Tuple, direction: str,
try: stake_amount: Optional[float] = None,
stake_amount = self.wallets.get_trade_stake_amount(pair, None) trade: Optional[LocalTrade] = None) -> Optional[LocalTrade]:
except DependencyException:
return None
current_time = row[DATE_IDX].to_pydatetime() current_time = row[DATE_IDX].to_pydatetime()
# let's call the custom entry price, using the open price as default price # let's call the custom entry price, using the open price as default price
propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
default_retval=row[OPEN_IDX])( default_retval=row[OPEN_IDX])(
pair=pair, current_time=row[DATE_IDX].to_pydatetime(), pair=pair, current_time=current_time,
proposed_rate=row[OPEN_IDX]) # default value is the open rate proposed_rate=row[OPEN_IDX]) # default value is the open rate
# Move rate to within the candle's low/high rate # Move rate to within the candle's low/high rate
@ -481,15 +506,25 @@ class Backtesting:
min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0 min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0
max_stake_amount = self.wallets.get_available_stake_amount() max_stake_amount = self.wallets.get_available_stake_amount()
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, pos_adjust = trade is not None
default_retval=stake_amount)( if not pos_adjust:
pair=pair, current_time=current_time, current_rate=propose_rate, try:
proposed_stake=stake_amount, min_stake=min_stake_amount, max_stake=max_stake_amount, stake_amount = self.wallets.get_trade_stake_amount(pair, None)
side=direction) except DependencyException:
return trade
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
default_retval=stake_amount)(
pair=pair, current_time=current_time, current_rate=propose_rate,
proposed_stake=stake_amount, min_stake=min_stake_amount, max_stake=max_stake_amount,
side=direction)
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount) stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
if not stake_amount: if not stake_amount:
return None # In case of pos adjust, still return the original trade
# If not pos adjust, trade is None
return trade
max_leverage = self.exchange.get_max_leverage(pair, stake_amount) max_leverage = self.exchange.get_max_leverage(pair, stake_amount)
leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)( leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
@ -506,31 +541,54 @@ class Backtesting:
order_type = self.strategy.order_types['buy'] order_type = self.strategy.order_types['buy']
time_in_force = self.strategy.order_time_in_force['sell'] time_in_force = self.strategy.order_time_in_force['sell']
# Confirm trade entry: # Confirm trade entry:
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( if not pos_adjust:
pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate, if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
time_in_force=time_in_force, current_time=current_time, pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate,
side=direction): time_in_force=time_in_force, current_time=current_time,
return None side=direction):
return None
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount): if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
# Enter trade amount = round(stake_amount / propose_rate, 8)
has_enter_tag = len(row) >= ENTER_TAG_IDX + 1 if trade is None:
trade = LocalTrade( # Enter trade
pair=pair, has_buy_tag = len(row) >= ENTER_TAG_IDX + 1
open_rate=row[OPEN_IDX], trade = LocalTrade(
open_date=current_time, pair=pair,
stake_amount=stake_amount, open_rate=propose_rate,
amount=round((stake_amount / propose_rate) * leverage, 8), open_date=current_time,
fee_open=self.fee, stake_amount=stake_amount,
fee_close=self.fee, amount=round((stake_amount / propose_rate) * leverage, 8),
is_open=True, fee_open=self.fee,
enter_tag=row[ENTER_TAG_IDX] if has_enter_tag else None, fee_close=self.fee,
exchange=self._exchange_name, is_open=True,
is_short=(direction == 'short'), enter_tag=row[ENTER_TAG_IDX] if has_buy_tag else None,
leverage=leverage, exchange=self._exchange_name,
is_short=(direction == 'short'),
leverage=leverage,
orders=[]
)
trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
order = Order(
ft_is_open=False,
ft_pair=trade.pair,
symbol=trade.pair,
ft_order_side="buy",
side="buy",
order_type="market",
status="closed",
price=propose_rate,
average=propose_rate,
amount=amount,
filled=amount,
cost=stake_amount + trade.fee_open
) )
return trade trade.orders.append(order)
return None if pos_adjust:
trade.recalc_trade_from_orders()
return trade
def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]], def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
data: Dict[str, List[Tuple]]) -> List[LocalTrade]: data: Dict[str, List[Tuple]]) -> List[LocalTrade]:
@ -734,6 +792,7 @@ class Backtesting:
) )
backtest_end_time = datetime.now(timezone.utc) backtest_end_time = datetime.now(timezone.utc)
results.update({ results.update({
'run_id': self.run_ids.get(strat.get_strategy_name(), ''),
'backtest_start_time': int(backtest_start_time.timestamp()), 'backtest_start_time': int(backtest_start_time.timestamp()),
'backtest_end_time': int(backtest_end_time.timestamp()), 'backtest_end_time': int(backtest_end_time.timestamp()),
}) })
@ -741,6 +800,33 @@ class Backtesting:
return min_date, max_date return min_date, max_date
def _get_min_cached_backtest_date(self):
min_backtest_date = None
backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT)
if self.timerange.stopts == 0 or datetime.fromtimestamp(
self.timerange.stopts, tz=timezone.utc) > datetime.now(tz=timezone.utc):
logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
elif backtest_cache_age == 'day':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
elif backtest_cache_age == 'week':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
elif backtest_cache_age == 'month':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
return min_backtest_date
def load_prior_backtest(self):
self.run_ids = {
strategy.get_strategy_name(): get_strategy_run_id(strategy)
for strategy in self.strategylist
}
# Load previous result that will be updated incrementally.
# This can be circumvented in certain instances in combination with downloading more data
min_backtest_date = self._get_min_cached_backtest_date()
if min_backtest_date is not None:
self.results = find_existing_backtest_stats(
self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date)
def start(self) -> None: def start(self) -> None:
""" """
Run backtesting end-to-end Run backtesting end-to-end
@ -752,15 +838,38 @@ class Backtesting:
self.load_bt_data_detail() self.load_bt_data_detail()
logger.info("Dataload complete. Calculating indicators") logger.info("Dataload complete. Calculating indicators")
for strat in self.strategylist: self.load_prior_backtest()
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
if len(self.strategylist) > 0:
self.results = generate_backtest_stats(data, self.all_results, for strat in self.strategylist:
min_date=min_date, max_date=max_date) if self.results and strat.get_strategy_name() in self.results['strategy']:
# When previous result hash matches - reuse that result and skip backtesting.
logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
continue
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
# Update old results with new ones.
if len(self.all_results) > 0:
results = generate_backtest_stats(
data, self.all_results, min_date=min_date, max_date=max_date)
if self.results:
self.results['metadata'].update(results['metadata'])
self.results['strategy'].update(results['strategy'])
self.results['strategy_comparison'].extend(results['strategy_comparison'])
else:
self.results = results
if self.config.get('export', 'none') == 'trades': if self.config.get('export', 'none') == 'trades':
store_backtest_stats(self.config['exportfilename'], self.results) store_backtest_stats(self.config['exportfilename'], self.results)
# Results may be mixed up now. Sort them so they follow --strategy-list order.
if 'strategy_list' in self.config and len(self.results) > 0:
self.results['strategy_comparison'] = sorted(
self.results['strategy_comparison'],
key=lambda c: self.config['strategy_list'].index(c['key']))
self.results['strategy'] = dict(
sorted(self.results['strategy'].items(),
key=lambda kv: self.config['strategy_list'].index(kv[0])))
if len(self.strategylist) > 0:
# Show backtest results # Show backtest results
show_backtest_results(self.config, self.results) show_backtest_results(self.config, self.results)

View File

@ -34,7 +34,7 @@ class EdgeCli:
self.config['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT self.config['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
self.strategy = StrategyResolver.load_strategy(self.config) self.strategy = StrategyResolver.load_strategy(self.config)
self.strategy.dp = DataProvider(config, None) self.strategy.dp = DataProvider(config, self.exchange)
validate_config_consistency(self.config) validate_config_consistency(self.config)

View File

@ -137,6 +137,7 @@ class HyperoptTools():
} }
if not HyperoptTools._test_hyperopt_results_exist(results_file): if not HyperoptTools._test_hyperopt_results_exist(results_file):
# No file found. # No file found.
logger.warning(f"Hyperopt file {results_file} not found.")
return [], 0 return [], 0
epochs = [] epochs = []

View File

@ -11,7 +11,8 @@ from tabulate import tabulate
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT
from freqtrade.data.btanalysis import (calculate_csum, calculate_market_change, from freqtrade.data.btanalysis import (calculate_csum, calculate_market_change,
calculate_max_drawdown) calculate_max_drawdown)
from freqtrade.misc import decimals_per_coin, file_dump_json, round_coin_value from freqtrade.misc import (decimals_per_coin, file_dump_json, get_backtest_metadata_filename,
round_coin_value)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -33,6 +34,11 @@ def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> N
recordfilename.parent, recordfilename.parent,
f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}' f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
).with_suffix(recordfilename.suffix) ).with_suffix(recordfilename.suffix)
# Store metadata separately.
file_dump_json(get_backtest_metadata_filename(filename), stats['metadata'])
del stats['metadata']
file_dump_json(filename, stats) file_dump_json(filename, stats)
latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN) latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN)
@ -515,16 +521,26 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame],
:param max_date: Backtest end date :param max_date: Backtest end date
:return: Dictionary containing results per strategy and a strategy summary. :return: Dictionary containing results per strategy and a strategy summary.
""" """
result: Dict[str, Any] = {'strategy': {}} result: Dict[str, Any] = {
'metadata': {},
'strategy': {},
'strategy_comparison': [],
}
market_change = calculate_market_change(btdata, 'close') market_change = calculate_market_change(btdata, 'close')
metadata = {}
pairlist = list(btdata.keys()) pairlist = list(btdata.keys())
for strategy, content in all_results.items(): for strategy, content in all_results.items():
strat_stats = generate_strategy_stats(pairlist, strategy, content, strat_stats = generate_strategy_stats(pairlist, strategy, content,
min_date, max_date, market_change=market_change) min_date, max_date, market_change=market_change)
metadata[strategy] = {
'run_id': content['run_id'],
'backtest_start_time': content['backtest_start_time'],
}
result['strategy'][strategy] = strat_stats result['strategy'][strategy] = strat_stats
strategy_results = generate_strategy_comparison(bt_stats=result['strategy']) strategy_results = generate_strategy_comparison(bt_stats=result['strategy'])
result['metadata'] = metadata
result['strategy_comparison'] = strategy_results result['strategy_comparison'] = strategy_results
return result return result

View File

@ -559,11 +559,11 @@ class LocalTrade():
self.amount = float(safe_value_fallback(order, 'filled', 'amount')) self.amount = float(safe_value_fallback(order, 'filled', 'amount'))
if 'leverage' in order: if 'leverage' in order:
self.leverage = order['leverage'] self.leverage = order['leverage']
self.recalc_open_trade_value()
if self.is_open: if self.is_open:
payment = "SELL" if self.is_short else "BUY" payment = "SELL" if self.is_short else "BUY"
logger.info(f'{order_type.upper()}_{payment} has been fulfilled for {self}.') logger.info(f'{order_type.upper()}_{payment} has been fulfilled for {self}.')
self.open_order_id = None self.open_order_id = None
self.recalc_trade_from_orders()
elif order_type in ('market', 'limit') and self.exit_side == order['side']: elif order_type in ('market', 'limit') and self.exit_side == order['side']:
if self.is_open: if self.is_open:
payment = "BUY" if self.is_short else "SELL" payment = "BUY" if self.is_short else "SELL"
@ -795,6 +795,37 @@ class LocalTrade():
return float(f"{profit_ratio:.8f}") return float(f"{profit_ratio:.8f}")
def recalc_trade_from_orders(self):
# We need at least 2 orders for averaging amounts and rates.
if len(self.orders) < 2:
# Just in case, still recalc open trade value
self.recalc_open_trade_value()
return
total_amount = 0.0
total_stake = 0.0
for temp_order in self.orders:
if (temp_order.ft_is_open or
(temp_order.ft_order_side != self.enter_side) or
(temp_order.status not in NON_OPEN_EXCHANGE_STATES)):
continue
tmp_amount = temp_order.amount
if temp_order.filled is not None:
tmp_amount = temp_order.filled
if tmp_amount > 0.0 and temp_order.average is not None:
total_amount += tmp_amount
total_stake += temp_order.average * tmp_amount
if total_amount > 0:
self.open_rate = total_stake / total_amount
self.stake_amount = total_stake
self.amount = total_amount
self.fee_open_cost = self.fee_open * self.stake_amount
self.recalc_open_trade_value()
if self.stop_loss_pct is not None and self.open_rate is not None:
self.adjust_stop_loss(self.open_rate, self.stop_loss_pct)
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]:
""" """
Finds latest order for this orderside and status Finds latest order for this orderside and status
@ -810,6 +841,34 @@ class LocalTrade():
else: else:
return None return None
def select_filled_orders(self, order_side: str) -> List['Order']:
"""
Finds filled orders for this orderside.
:param order_side: Side of the order (either 'buy' or 'sell')
:return: array of Order objects
"""
return [o for o in self.orders if o.ft_order_side == order_side and
o.ft_is_open is False and
(o.filled or 0) > 0 and
o.status in NON_OPEN_EXCHANGE_STATES]
@property
def nr_of_successful_buys(self) -> int:
"""
Helper function to count the number of buy orders that have been filled.
:return: int count of buy orders that have been filled for this trade.
"""
return len(self.select_filled_orders('buy'))
@property
def nr_of_successful_sells(self) -> int:
"""
Helper function to count the number of sell orders that have been filled.
:return: int count of sell orders that have been filled for this trade.
"""
return len(self.select_filled_orders('sell'))
@staticmethod @staticmethod
def get_trades_proxy(*, pair: str = None, is_open: bool = None, def get_trades_proxy(*, pair: str = None, is_open: bool = None,
open_date: datetime = None, close_date: datetime = None, open_date: datetime = None, close_date: datetime = None,
@ -897,7 +956,7 @@ class Trade(_DECL_BASE, LocalTrade):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan") orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan", lazy="joined")
exchange = Column(String(25), nullable=False) exchange = Column(String(25), nullable=False)
pair = Column(String(25), nullable=False, index=True) pair = Column(String(25), nullable=False, index=True)

View File

@ -235,10 +235,12 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
# Trades can be empty # Trades can be empty
if trades is not None and len(trades) > 0: if trades is not None and len(trades) > 0:
# Create description for sell summarizing the trade # Create description for sell summarizing the trade
trades['desc'] = trades.apply(lambda row: f"{row['profit_ratio']:.2%}, " trades['desc'] = trades.apply(
f"{row['sell_reason']}, " lambda row: f"{row['profit_ratio']:.2%}, " +
f"{row['trade_duration']} min", (f"{row['enter_tag']}, " if row['enter_tag'] is not None else "") +
axis=1) f"{row['sell_reason']}, " +
f"{row['trade_duration']} min",
axis=1)
trade_buys = go.Scatter( trade_buys = go.Scatter(
x=trades["open_date"], x=trades["open_date"],
y=trades["open_rate"], y=trades["open_rate"],

View File

@ -47,7 +47,7 @@ class SpreadFilter(IPairList):
spread = 1 - ticker['bid'] / ticker['ask'] spread = 1 - ticker['bid'] / ticker['ask']
if spread > self._max_spread_ratio: if spread > self._max_spread_ratio:
self.log_once(f"Removed {pair} from whitelist, because spread " self.log_once(f"Removed {pair} from whitelist, because spread "
f"{spread * 100:.3%} > {self._max_spread_ratio:.3%}", f"{spread:.3%} > {self._max_spread_ratio:.3%}",
logger.info) logger.info)
return False return False
else: else:

View File

@ -96,7 +96,8 @@ class StrategyResolver(IResolver):
("ignore_roi_if_buy_signal", False), ("ignore_roi_if_buy_signal", False),
("sell_profit_offset", 0.0), ("sell_profit_offset", 0.0),
("disable_dataframe_checks", False), ("disable_dataframe_checks", False),
("ignore_buying_expired_candle_after", 0) ("ignore_buying_expired_candle_after", 0),
("position_adjustment_enable", False)
] ]
for attribute, default in attributes: for attribute, default in attributes:
StrategyResolver._override_attribute_helper(strategy, config, StrategyResolver._override_attribute_helper(strategy, config,

View File

@ -39,7 +39,8 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
# Start backtesting # Start backtesting
# Initialize backtesting object # Initialize backtesting object
def run_backtest(): def run_backtest():
from freqtrade.optimize.optimize_reports import generate_backtest_stats from freqtrade.optimize.optimize_reports import (generate_backtest_stats,
store_backtest_stats)
from freqtrade.resolvers import StrategyResolver from freqtrade.resolvers import StrategyResolver
asyncio.set_event_loop(asyncio.new_event_loop()) asyncio.set_event_loop(asyncio.new_event_loop())
try: try:
@ -76,13 +77,25 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
lastconfig['enable_protections'] = btconfig.get('enable_protections') lastconfig['enable_protections'] = btconfig.get('enable_protections')
lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet') lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet')
ApiServer._bt.abort = False ApiServer._bt.results = {}
min_date, max_date = ApiServer._bt.backtest_one_strategy( ApiServer._bt.load_prior_backtest()
strat, ApiServer._bt_data, ApiServer._bt_timerange)
ApiServer._bt.abort = False
if (ApiServer._bt.results and
strat.get_strategy_name() in ApiServer._bt.results['strategy']):
# When previous result hash matches - reuse that result and skip backtesting.
logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
else:
min_date, max_date = ApiServer._bt.backtest_one_strategy(
strat, ApiServer._bt_data, ApiServer._bt_timerange)
ApiServer._bt.results = generate_backtest_stats(
ApiServer._bt_data, ApiServer._bt.all_results,
min_date=min_date, max_date=max_date)
if btconfig.get('export', 'none') == 'trades':
store_backtest_stats(btconfig['exportfilename'], ApiServer._bt.results)
ApiServer._bt.results = generate_backtest_stats(
ApiServer._bt_data, ApiServer._bt.all_results,
min_date=min_date, max_date=max_date)
logger.info("Backtest finished.") logger.info("Backtest finished.")
except DependencyException as e: except DependencyException as e:

View File

@ -281,6 +281,7 @@ class ForceBuyPayload(BaseModel):
pair: str pair: str
price: Optional[float] price: Optional[float]
ordertype: Optional[OrderTypeValues] ordertype: Optional[OrderTypeValues]
stakeamount: Optional[float]
class ForceSellPayload(BaseModel): class ForceSellPayload(BaseModel):

View File

@ -21,7 +21,7 @@ from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, Blac
Stats, StatusMsg, StrategyListResponse, Stats, StatusMsg, StrategyListResponse,
StrategyResponse, SysInfo, Version, StrategyResponse, SysInfo, Version,
WhitelistResponse) WhitelistResponse)
from freqtrade.rpc.api_server.deps import get_config, get_rpc, get_rpc_optional from freqtrade.rpc.api_server.deps import get_config, get_exchange, get_rpc, get_rpc_optional
from freqtrade.rpc.rpc import RPCException from freqtrade.rpc.rpc import RPCException
@ -32,7 +32,8 @@ logger = logging.getLogger(__name__)
# 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.
# 1.11: forcebuy and forcesell accept ordertype # 1.11: forcebuy and forcesell accept ordertype
# 1.12: add blacklist delete endpoint # 1.12: add blacklist delete endpoint
API_VERSION = 1.12 # 1.13: forcebuy supports stake_amount
API_VERSION = 1.13
# Public API, requires no auth. # Public API, requires no auth.
router_public = APIRouter() router_public = APIRouter()
@ -135,7 +136,9 @@ 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)):
ordertype = payload.ordertype.value if payload.ordertype else None ordertype = payload.ordertype.value if payload.ordertype else None
trade = rpc._rpc_forcebuy(payload.pair, payload.price, ordertype) stake_amount = payload.stakeamount if payload.stakeamount else None
trade = rpc._rpc_forcebuy(payload.pair, payload.price, ordertype, stake_amount)
if trade: if trade:
return ForceBuyResponse.parse_obj(trade.to_json()) return ForceBuyResponse.parse_obj(trade.to_json())
@ -218,12 +221,14 @@ def pair_candles(pair: str, timeframe: str, limit: Optional[int], rpc: RPC = Dep
@router.get('/pair_history', response_model=PairHistory, tags=['candle data']) @router.get('/pair_history', response_model=PairHistory, tags=['candle data'])
def pair_history(pair: str, timeframe: str, timerange: str, strategy: str, def pair_history(pair: str, timeframe: str, timerange: str, strategy: str,
config=Depends(get_config)): config=Depends(get_config), exchange=Depends(get_exchange)):
# The initial call to this endpoint can be slow, as it may need to initialize
# the exchange class.
config = deepcopy(config) config = deepcopy(config)
config.update({ config.update({
'strategy': strategy, 'strategy': strategy,
}) })
return RPC._rpc_analysed_history_full(config, pair, timeframe, timerange) return RPC._rpc_analysed_history_full(config, pair, timeframe, timerange, exchange)
@router.get('/plot_config', response_model=PlotConfig, tags=['candle data']) @router.get('/plot_config', response_model=PlotConfig, tags=['candle data'])

View File

@ -1,5 +1,7 @@
from typing import Any, Dict, Iterator, Optional from typing import Any, Dict, Iterator, Optional
from fastapi import Depends
from freqtrade.persistence import Trade from freqtrade.persistence import Trade
from freqtrade.rpc.rpc import RPC, RPCException from freqtrade.rpc.rpc import RPC, RPCException
@ -28,3 +30,11 @@ def get_config() -> Dict[str, Any]:
def get_api_config() -> Dict[str, Any]: def get_api_config() -> Dict[str, Any]:
return ApiServer._config['api_server'] return ApiServer._config['api_server']
def get_exchange(config=Depends(get_config)):
if not ApiServer._exchange:
from freqtrade.resolvers import ExchangeResolver
ApiServer._exchange = ExchangeResolver.load_exchange(
config['exchange']['name'], config)
return ApiServer._exchange

View File

@ -41,6 +41,8 @@ class ApiServer(RPCHandler):
_has_rpc: bool = False _has_rpc: bool = False
_bgtask_running: bool = False _bgtask_running: bool = False
_config: Dict[str, Any] = {} _config: Dict[str, Any] = {}
# Exchange - only available in webserver mode.
_exchange = None
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
""" """

View File

@ -243,19 +243,25 @@ class RPC:
profit_str += f" ({fiat_profit:.2f})" profit_str += f" ({fiat_profit:.2f})"
fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \ fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \
else fiat_profit_sum + fiat_profit else fiat_profit_sum + fiat_profit
trades_list.append([ detail_trade = [
f'{trade.id} {direction_str}', f'{trade.id} {direction_str}',
trade.pair + ('*' if (trade.open_order_id is not None trade.pair + ('*' if (trade.open_order_id is not None
and trade.close_rate_requested is None) else '') and trade.close_rate_requested is None) else '')
+ ('**' if (trade.close_rate_requested is not None) else ''), + ('**' if (trade.close_rate_requested is not None) else ''),
shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)), shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)),
profit_str profit_str
]) ]
if self._config.get('position_adjustment_enable', False):
filled_buys = trade.select_filled_orders('buy')
detail_trade.append(str(len(filled_buys)))
trades_list.append(detail_trade)
profitcol = "Profit" profitcol = "Profit"
if self._fiat_converter: if self._fiat_converter:
profitcol += " (" + fiat_display_currency + ")" profitcol += " (" + fiat_display_currency + ")"
columns = ['ID L/S', 'Pair', 'Since', profitcol] columns = ['ID L/S', 'Pair', 'Since', profitcol]
if self._config.get('position_adjustment_enable', False):
columns.append('# Buys')
return trades_list, columns, fiat_profit_sum return trades_list, columns, fiat_profit_sum
def _rpc_daily_profit( def _rpc_daily_profit(
@ -598,6 +604,7 @@ class RPC:
value = self._fiat_converter.convert_amount( value = self._fiat_converter.convert_amount(
total, stake_currency, fiat_display_currency) if self._fiat_converter else 0 total, stake_currency, fiat_display_currency) if self._fiat_converter else 0
trade_count = len(Trade.get_trades_proxy())
starting_capital_ratio = 0.0 starting_capital_ratio = 0.0
starting_capital_ratio = (total / starting_capital) - 1 if starting_capital else 0.0 starting_capital_ratio = (total / starting_capital) - 1 if starting_capital else 0.0
starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0 starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0
@ -614,6 +621,7 @@ class RPC:
'starting_capital_fiat': starting_cap_fiat, 'starting_capital_fiat': starting_cap_fiat,
'starting_capital_fiat_ratio': starting_cap_fiat_ratio, 'starting_capital_fiat_ratio': starting_cap_fiat_ratio,
'starting_capital_fiat_pct': round(starting_cap_fiat_ratio * 100, 2), 'starting_capital_fiat_pct': round(starting_cap_fiat_ratio * 100, 2),
'trade_count': trade_count,
'note': 'Simulated balances' if self._freqtrade.config['dry_run'] else '' 'note': 'Simulated balances' if self._freqtrade.config['dry_run'] else ''
} }
@ -705,8 +713,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], def _rpc_forcebuy(self, pair: str, price: Optional[float], order_type: Optional[str] = None,
order_type: Optional[str] = None) -> Optional[Trade]: stake_amount: Optional[float] = 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
@ -728,16 +736,19 @@ class RPC:
# check if pair already has an open pair # check if pair already has an open pair
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()
if trade: if trade:
raise RPCException(f'position for {pair} already open - id: {trade.id}') if not self._freqtrade.strategy.position_adjustment_enable:
raise RPCException(f'position for {pair} already open - id: {trade.id}')
# gen stake amount if not stake_amount:
stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair) # gen stake amount
stake_amount = self._freqtrade.wallets.get_trade_stake_amount(pair)
# execute buy # execute buy
if not order_type: if not order_type:
order_type = self._freqtrade.strategy.order_types.get( order_type = self._freqtrade.strategy.order_types.get(
'forcebuy', self._freqtrade.strategy.order_types['buy']) 'forcebuy', self._freqtrade.strategy.order_types['buy'])
if self._freqtrade.execute_entry(pair, stakeamount, price, ordertype=order_type): if self._freqtrade.execute_entry(pair, stake_amount, price,
ordertype=order_type, trade=trade):
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
@ -992,7 +1003,7 @@ class RPC:
@staticmethod @staticmethod
def _rpc_analysed_history_full(config, pair: str, timeframe: str, def _rpc_analysed_history_full(config, pair: str, timeframe: str,
timerange: str) -> Dict[str, Any]: timerange: str, exchange) -> Dict[str, Any]:
timerange_parsed = TimeRange.parse_timerange(timerange) timerange_parsed = TimeRange.parse_timerange(timerange)
_data = load_data( _data = load_data(
@ -1007,7 +1018,7 @@ class RPC:
from freqtrade.data.dataprovider import DataProvider from freqtrade.data.dataprovider import DataProvider
from freqtrade.resolvers.strategy_resolver import StrategyResolver from freqtrade.resolvers.strategy_resolver import StrategyResolver
strategy = StrategyResolver.load_strategy(config) strategy = StrategyResolver.load_strategy(config)
strategy.dp = DataProvider(config, exchange=None, pairlists=None) strategy.dp = DataProvider(config, exchange=exchange, pairlists=None)
df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair}) df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair})

View File

@ -85,12 +85,14 @@ class RPCManager:
timeframe = config['timeframe'] timeframe = config['timeframe']
exchange_name = config['exchange']['name'] exchange_name = config['exchange']['name']
strategy_name = config.get('strategy', '') strategy_name = config.get('strategy', '')
pos_adjust_enabled = 'On' if config['position_adjustment_enable'] else 'Off'
self.send_msg({ self.send_msg({
'type': RPCMessageType.STARTUP, 'type': RPCMessageType.STARTUP,
'status': f'*Exchange:* `{exchange_name}`\n' 'status': f'*Exchange:* `{exchange_name}`\n'
f'*Stake per trade:* `{stake_amount} {stake_currency}`\n' f'*Stake per trade:* `{stake_amount} {stake_currency}`\n'
f'*Minimum ROI:* `{minimal_roi}`\n' f'*Minimum ROI:* `{minimal_roi}`\n'
f'*{"Trailing " if trailing_stop else ""}Stoploss:* `{stoploss}`\n' f'*{"Trailing " if trailing_stop else ""}Stoploss:* `{stoploss}`\n'
f'*Position adjustment:* `{pos_adjust_enabled}`\n'
f'*Timeframe:* `{timeframe}`\n' f'*Timeframe:* `{timeframe}`\n'
f'*Strategy:* `{strategy_name}`' f'*Strategy:* `{strategy_name}`'
}) })

View File

@ -781,14 +781,17 @@ class Telegram(RPCHandler):
f"(< {balance_dust_level} {result['stake']}):*\n" f"(< {balance_dust_level} {result['stake']}):*\n"
f"\t`Est. {result['stake']}: " f"\t`Est. {result['stake']}: "
f"{round_coin_value(total_dust_balance, result['stake'], False)}`\n") f"{round_coin_value(total_dust_balance, result['stake'], False)}`\n")
tc = result['trade_count'] > 0
stake_improve = f" `({result['starting_capital_ratio']:.2%})`" if tc else ''
fiat_val = f" `({result['starting_capital_fiat_ratio']:.2%})`" if tc else ''
output += ("\n*Estimated Value*:\n" output += ("\n*Estimated Value*:\n"
f"\t`{result['stake']}: " f"\t`{result['stake']}: "
f"{round_coin_value(result['total'], result['stake'], False)}`" f"{round_coin_value(result['total'], result['stake'], False)}`"
f" `({result['starting_capital_ratio']:.2%})`\n" f"{stake_improve}\n"
f"\t`{result['symbol']}: " f"\t`{result['symbol']}: "
f"{round_coin_value(result['value'], result['symbol'], False)}`" f"{round_coin_value(result['value'], result['symbol'], False)}`"
f" `({result['starting_capital_fiat_ratio']:.2%})`\n") f"{fiat_val}\n")
self._send_msg(output, reload_able=True, callback_path="update_balance", self._send_msg(output, reload_able=True, callback_path="update_balance",
query=update.callback_query) query=update.callback_query)
except RPCException as e: except RPCException as e:

View File

@ -106,6 +106,9 @@ class IStrategy(ABC, HyperStrategyMixin):
sell_profit_offset: float sell_profit_offset: float
ignore_roi_if_buy_signal: bool ignore_roi_if_buy_signal: bool
# Position adjustment is disabled by default
position_adjustment_enable: bool = False
# Number of seconds after which the candle will no longer result in a buy on expired candles # Number of seconds after which the candle will no longer result in a buy on expired candles
ignore_buying_expired_candle_after: int = 0 ignore_buying_expired_candle_after: int = 0
@ -383,6 +386,28 @@ class IStrategy(ABC, HyperStrategyMixin):
""" """
return proposed_stake return proposed_stake
def adjust_trade_position(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, min_stake: float,
max_stake: float, **kwargs) -> Optional[float]:
"""
Custom trade adjustment logic, returning the stake amount that a trade should be increased.
This means extra buy orders with additional fees.
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
When not implemented by a strategy, returns None
:param trade: trade object.
:param current_time: datetime object, containing the current datetime
:param current_rate: Current buy rate.
:param current_profit: Current profit (as ratio), calculated based on current_rate.
:param min_stake: Minimal stake size allowed by exchange.
:param max_stake: Balance available for trading.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: Stake amount to adjust your trade
"""
return None
def leverage(self, pair: str, current_time: datetime, current_rate: float, def leverage(self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, side: str, proposed_leverage: float, max_leverage: float, side: str,
**kwargs) -> float: **kwargs) -> float:
@ -687,6 +712,8 @@ class IStrategy(ABC, HyperStrategyMixin):
enter = latest[SignalType.ENTER_LONG.value] == 1 enter = latest[SignalType.ENTER_LONG.value] == 1
exit_ = latest.get(SignalType.EXIT_LONG.value, 0) == 1 exit_ = latest.get(SignalType.EXIT_LONG.value, 0) == 1
exit_tag = latest.get(SignalTagType.EXIT_TAG.value, None) exit_tag = latest.get(SignalTagType.EXIT_TAG.value, None)
# Tags can be None, which does not resolve to False.
exit_tag = exit_tag if isinstance(exit_tag, str) else None
logger.debug(f"exit-trigger: {latest['date']} (pair={pair}) " logger.debug(f"exit-trigger: {latest['date']} (pair={pair}) "
f"enter={enter} exit={exit_}") f"enter={enter} exit={exit_}")
@ -726,6 +753,8 @@ class IStrategy(ABC, HyperStrategyMixin):
enter_signal = SignalDirection.SHORT enter_signal = SignalDirection.SHORT
enter_tag_value = latest.get(SignalTagType.ENTER_TAG.value, None) enter_tag_value = latest.get(SignalTagType.ENTER_TAG.value, None)
enter_tag_value = enter_tag_value if isinstance(enter_tag_value, str) else None
timeframe_seconds = timeframe_to_seconds(timeframe) timeframe_seconds = timeframe_to_seconds(timeframe)
if self.ignore_expired_candle( if self.ignore_expired_candle(

View File

@ -15,7 +15,8 @@
"cancel_open_orders_on_exit": false, "cancel_open_orders_on_exit": false,
"unfilledtimeout": { "unfilledtimeout": {
"buy": 10, "buy": 10,
"sell": 30, "sell": 10,
"exit_timeout_count": 0,
"unit": "minutes" "unit": "minutes"
}, },
"bid_strategy": { "bid_strategy": {

View File

@ -25,6 +25,9 @@ multi_line_output=0
lines_after_imports=2 lines_after_imports=2
skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*"] skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[build-system] [build-system]
requires = ["setuptools >= 46.4.0", "wheel"] requires = ["setuptools >= 46.4.0", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"

View File

@ -5,25 +5,25 @@
coveralls==3.3.1 coveralls==3.3.1
flake8==4.0.1 flake8==4.0.1
flake8-tidy-imports==4.5.0 flake8-tidy-imports==4.6.0
mypy==0.930 mypy==0.931
pytest==6.2.5 pytest==6.2.5
pytest-asyncio==0.16.0 pytest-asyncio==0.17.1
pytest-cov==3.0.0 pytest-cov==3.0.0
pytest-mock==3.6.1 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.5.0 time-machine==2.6.0
# Convert jupyter notebooks to markdown documents # Convert jupyter notebooks to markdown documents
nbconvert==6.3.0 nbconvert==6.4.0
# mypy types # mypy types
types-cachetools==4.2.7 types-cachetools==4.2.9
types-filelock==3.2.1 types-filelock==3.2.4
types-requests==2.26.3 types-requests==2.27.7
types-tabulate==0.8.4 types-tabulate==0.8.5
# Extensions to datetime library # Extensions to datetime library
types-python-dateutil==2.8.4 types-python-dateutil==2.8.7

View File

@ -7,4 +7,4 @@ scikit-learn==1.0.2
scikit-optimize==0.9.0 scikit-optimize==0.9.0
filelock==3.4.2 filelock==3.4.2
joblib==1.1.0 joblib==1.1.0
progressbar2==3.55.0 progressbar2==4.0.0

View File

@ -1,20 +1,20 @@
numpy==1.21.5; python_version <= '3.7' numpy==1.21.5; python_version <= '3.7'
numpy==1.22.0; python_version > '3.7' numpy==1.22.1; python_version > '3.7'
pandas==1.3.5 pandas==1.3.5
pandas-ta==0.3.14b pandas-ta==0.3.14b
ccxt==1.66.32 ccxt==1.68.20
# 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.1 cryptography==36.0.1
aiohttp==3.8.1 aiohttp==3.8.1
SQLAlchemy==1.4.29 SQLAlchemy==1.4.29
python-telegram-bot==13.9 python-telegram-bot==13.10
arrow==1.2.1 arrow==1.2.1
cachetools==4.2.2 cachetools==4.2.2
requests==2.26.0 requests==2.27.1
urllib3==1.26.7 urllib3==1.26.8
jsonschema==4.3.3 jsonschema==4.4.0
TA-Lib==0.4.23 TA-Lib==0.4.24
technical==1.3.0 technical==1.3.0
tabulate==0.8.9 tabulate==0.8.9
pycoingecko==2.2.0 pycoingecko==2.2.0
@ -32,8 +32,8 @@ python-rapidjson==1.5
sdnotify==0.3.2 sdnotify==0.3.2
# API Server # API Server
fastapi==0.70.1 fastapi==0.72.0
uvicorn==0.16.0 uvicorn==0.17.0
pyjwt==2.3.0 pyjwt==2.3.0
aiofiles==0.8.0 aiofiles==0.8.0
psutil==5.9.0 psutil==5.9.0

View File

@ -10,8 +10,7 @@ hyperopt = [
'filelock', 'filelock',
'joblib', 'joblib',
'progressbar2', 'progressbar2',
'psutil', ]
]
develop = [ develop = [
'coveralls', 'coveralls',
@ -69,6 +68,7 @@ setup(
'blosc', 'blosc',
'fastapi', 'fastapi',
'uvicorn', 'uvicorn',
'psutil',
'pyjwt', 'pyjwt',
'aiofiles', 'aiofiles',
'schedule' 'schedule'

View File

@ -13,7 +13,8 @@ from freqtrade.data.btanalysis import (BT_DATA_COLUMNS, analyze_trade_parallelis
calculate_underwater, combine_dataframes_with_mean, calculate_underwater, combine_dataframes_with_mean,
create_cum_profit, extract_trades_of_period, create_cum_profit, extract_trades_of_period,
get_latest_backtest_filename, get_latest_hyperopt_file, get_latest_backtest_filename, get_latest_hyperopt_file,
load_backtest_data, load_trades, load_trades_from_db) load_backtest_data, load_backtest_metadata, load_trades,
load_trades_from_db)
from freqtrade.data.history import load_data, load_pair_history from freqtrade.data.history import load_data, load_pair_history
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from tests.conftest import CURRENT_TEST_STRATEGY, create_mock_trades from tests.conftest import CURRENT_TEST_STRATEGY, create_mock_trades
@ -40,7 +41,7 @@ def test_get_latest_backtest_filename(testdatadir, mocker):
get_latest_backtest_filename(testdatadir) get_latest_backtest_filename(testdatadir)
def test_get_latest_hyperopt_file(testdatadir, mocker): def test_get_latest_hyperopt_file(testdatadir):
res = get_latest_hyperopt_file(testdatadir / 'does_not_exist', 'testfile.pickle') res = get_latest_hyperopt_file(testdatadir / 'does_not_exist', 'testfile.pickle')
assert res == testdatadir / 'does_not_exist/testfile.pickle' assert res == testdatadir / 'does_not_exist/testfile.pickle'
@ -50,6 +51,23 @@ def test_get_latest_hyperopt_file(testdatadir, mocker):
res = get_latest_hyperopt_file(str(testdatadir.parent)) res = get_latest_hyperopt_file(str(testdatadir.parent))
assert res == testdatadir.parent / "hyperopt_results.pickle" assert res == testdatadir.parent / "hyperopt_results.pickle"
# Test with absolute path
with pytest.raises(
OperationalException,
match="--hyperopt-filename expects only the filename, not an absolute path."):
get_latest_hyperopt_file(str(testdatadir.parent), str(testdatadir.parent))
def test_load_backtest_metadata(mocker, testdatadir):
res = load_backtest_metadata(testdatadir / 'nonexistant.file.json')
assert res == {}
mocker.patch('freqtrade.data.btanalysis.get_backtest_metadata_filename')
mocker.patch('freqtrade.data.btanalysis.json_load', side_effect=Exception())
with pytest.raises(OperationalException,
match=r"Unexpected error.*loading backtest metadata\."):
load_backtest_metadata(testdatadir / 'nonexistant.file.json')
def test_load_backtest_data_old_format(testdatadir, mocker): def test_load_backtest_data_old_format(testdatadir, mocker):

View File

@ -40,7 +40,7 @@ EXCHANGES = {
}, },
'ftx': { 'ftx': {
'pair': 'BTC/USD', 'pair': 'BTC/USD',
'stake_currency': 'USDT', 'stake_currency': 'USD',
'hasQuoteVolume': True, 'hasQuoteVolume': True,
'timeframe': '5m', 'timeframe': '5m',
'futures_pair': 'BTC/USD:USD', 'futures_pair': 'BTC/USD:USD',

View File

@ -1098,6 +1098,7 @@ def test_create_dry_run_order_limit_fill(default_conf, mocker, side, startprice,
assert order_book_l2_usd.call_count == 1 assert order_book_l2_usd.call_count == 1
assert order_closed['status'] == 'open' assert order_closed['status'] == 'open'
assert not order['fee'] assert not order['fee']
assert order_closed['filled'] == 0
order_book_l2_usd.reset_mock() order_book_l2_usd.reset_mock()
order_closed['price'] = endprice order_closed['price'] = endprice
@ -1105,6 +1106,8 @@ def test_create_dry_run_order_limit_fill(default_conf, mocker, side, startprice,
order_closed = exchange.fetch_dry_run_order(order['id']) order_closed = exchange.fetch_dry_run_order(order['id'])
assert order_closed['status'] == 'closed' assert order_closed['status'] == 'closed'
assert order['fee'] assert order['fee']
assert order_closed['filled'] == 1
assert order_closed['filled'] == order_closed['amount']
# Empty orderbook test # Empty orderbook test
mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book',
@ -1150,6 +1153,7 @@ def test_create_dry_run_order_market_fill(default_conf, mocker, side, rate, amou
assert order["type"] == "market" assert order["type"] == "market"
assert order["symbol"] == "LTC/USDT" assert order["symbol"] == "LTC/USDT"
assert order['status'] == 'closed' assert order['status'] == 'closed'
assert order['filled'] == amount
assert round(order["average"], 4) == round(endprice, 4) assert round(order["average"], 4) == round(endprice, 4)

View File

@ -11,6 +11,7 @@ import pandas as pd
import pytest import pytest
from arrow import Arrow from arrow import Arrow
from freqtrade import constants
from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_backtesting from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_backtesting
from freqtrade.configuration import TimeRange from freqtrade.configuration import TimeRange
from freqtrade.data import history from freqtrade.data import history
@ -20,6 +21,7 @@ from freqtrade.data.dataprovider import DataProvider
from freqtrade.data.history import get_timerange from freqtrade.data.history import get_timerange
from freqtrade.enums import RunMode, SellType from freqtrade.enums import RunMode, SellType
from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.misc import get_strategy_run_id
from freqtrade.optimize.backtesting import Backtesting from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence import LocalTrade from freqtrade.persistence import LocalTrade
from freqtrade.resolvers import StrategyResolver from freqtrade.resolvers import StrategyResolver
@ -1266,3 +1268,130 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker,
assert 'BACKTESTING REPORT' in captured.out assert 'BACKTESTING REPORT' in captured.out
assert 'SELL REASON STATS' in captured.out assert 'SELL REASON STATS' in captured.out
assert 'LEFT OPEN TRADES REPORT' in captured.out assert 'LEFT OPEN TRADES REPORT' in captured.out
@pytest.mark.filterwarnings("ignore:deprecated")
@pytest.mark.parametrize('run_id', ['2', 'changed'])
@pytest.mark.parametrize('start_delta', [{'days': 0}, {'days': 1}, {'weeks': 1}, {'weeks': 4}])
@pytest.mark.parametrize('cache', constants.BACKTEST_CACHE_AGE)
def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testdatadir, run_id,
start_delta, cache):
default_conf.update({
"use_sell_signal": True,
"sell_profit_only": False,
"sell_profit_offset": 0.0,
"ignore_roi_if_buy_signal": False,
})
patch_exchange(mocker)
backtestmock = MagicMock(return_value={
'results': pd.DataFrame(columns=BT_DATA_COLUMNS),
'config': default_conf,
'locks': [],
'rejected_signals': 20,
'final_balance': 1000,
})
mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist',
PropertyMock(return_value=['UNITTEST/BTC']))
mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock)
mocker.patch('freqtrade.optimize.backtesting.show_backtest_results', MagicMock())
now = min_backtest_date = datetime.now(tz=timezone.utc)
start_time = now - timedelta(**start_delta) + timedelta(hours=1)
if cache == 'none':
min_backtest_date = now + timedelta(days=1)
elif cache == 'day':
min_backtest_date = now - timedelta(days=1)
elif cache == 'week':
min_backtest_date = now - timedelta(weeks=1)
elif cache == 'month':
min_backtest_date = now - timedelta(weeks=4)
load_backtest_metadata = MagicMock(return_value={
'StrategyTestV2': {'run_id': '1', 'backtest_start_time': now.timestamp()},
'TestStrategyLegacyV1': {'run_id': run_id, 'backtest_start_time': start_time.timestamp()}
})
load_backtest_stats = MagicMock(side_effect=[
{
'metadata': {'StrategyTestV2': {'run_id': '1'}},
'strategy': {'StrategyTestV2': {}},
'strategy_comparison': [{'key': 'StrategyTestV2'}]
},
{
'metadata': {'TestStrategyLegacyV1': {'run_id': '2'}},
'strategy': {'TestStrategyLegacyV1': {}},
'strategy_comparison': [{'key': 'TestStrategyLegacyV1'}]
}
])
mocker.patch('pathlib.Path.glob', return_value=[
Path(datetime.strftime(datetime.now(), 'backtest-result-%Y-%m-%d_%H-%M-%S.json'))])
mocker.patch.multiple('freqtrade.data.btanalysis',
load_backtest_metadata=load_backtest_metadata,
load_backtest_stats=load_backtest_stats)
mocker.patch('freqtrade.optimize.backtesting.get_strategy_run_id', side_effect=['1', '2', '2'])
patched_configuration_load_config_file(mocker, default_conf)
args = [
'backtesting',
'--config', 'config.json',
'--datadir', str(testdatadir),
'--strategy-path', str(Path(__file__).parents[1] / 'strategy/strats'),
'--timeframe', '1m',
'--timerange', '1510694220-1510700340',
'--enable-position-stacking',
'--disable-max-market-positions',
'--cache', cache,
'--strategy-list',
'StrategyTestV2',
'TestStrategyLegacyV1',
]
args = get_args(args)
start_backtesting(args)
# check the logs, that will contain the backtest result
exists = [
'Parameter -i/--timeframe detected ... Using timeframe: 1m ...',
'Parameter --timerange detected: 1510694220-1510700340 ...',
f'Using data directory: {testdatadir} ...',
'Loading data from 2017-11-14 20:57:00 '
'up to 2017-11-14 22:58:00 (0 days).',
'Parameter --enable-position-stacking detected ...',
]
for line in exists:
assert log_has(line, caplog)
if cache == 'none':
assert backtestmock.call_count == 2
exists = [
'Running backtesting for Strategy StrategyTestV2',
'Running backtesting for Strategy TestStrategyLegacyV1',
'Ignoring max_open_trades (--disable-max-market-positions was used) ...',
'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:58:00 (0 days).',
]
elif run_id == '2' and min_backtest_date < start_time:
assert backtestmock.call_count == 0
exists = [
'Reusing result of previous backtest for StrategyTestV2',
'Reusing result of previous backtest for TestStrategyLegacyV1',
]
else:
exists = [
'Reusing result of previous backtest for StrategyTestV2',
'Running backtesting for Strategy TestStrategyLegacyV1',
'Ignoring max_open_trades (--disable-max-market-positions was used) ...',
'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:58:00 (0 days).',
]
assert backtestmock.call_count == 1
for line in exists:
assert log_has(line, caplog)
def test_get_strategy_run_id(default_conf_usdt):
default_conf_usdt.update({
'strategy': 'StrategyTestV2',
'max_open_trades': float('inf')
})
strategy = StrategyResolver.load_strategy(default_conf_usdt)
x = get_strategy_run_id(strategy)
assert isinstance(x, str)

View File

@ -0,0 +1,83 @@
# pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, unused-argument
from copy import deepcopy
import pandas as pd
from arrow import Arrow
from freqtrade.configuration import TimeRange
from freqtrade.data import history
from freqtrade.data.history import get_timerange
from freqtrade.enums import SellType
from freqtrade.optimize.backtesting import Backtesting
from tests.conftest import patch_exchange
def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) -> None:
default_conf['use_sell_signal'] = False
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
mocker.patch("freqtrade.exchange.Exchange.get_min_pair_stake_amount", return_value=0.00001)
patch_exchange(mocker)
default_conf.update({
"stake_amount": 100.0,
"dry_run_wallet": 1000.0,
"strategy": "StrategyTestV2"
})
backtesting = Backtesting(default_conf)
backtesting._set_strategy(backtesting.strategylist[0])
pair = 'UNITTEST/BTC'
timerange = TimeRange('date', None, 1517227800, 0)
data = history.load_data(datadir=testdatadir, timeframe='5m', pairs=['UNITTEST/BTC'],
timerange=timerange)
backtesting.strategy.position_adjustment_enable = True
processed = backtesting.strategy.advise_all_indicators(data)
min_date, max_date = get_timerange(processed)
result = backtesting.backtest(
processed=deepcopy(processed),
start_date=min_date,
end_date=max_date,
max_open_trades=10,
position_stacking=False,
)
results = result['results']
assert not results.empty
assert len(results) == 2
expected = pd.DataFrame(
{'pair': [pair, pair],
'stake_amount': [500.0, 100.0],
'amount': [4806.87657523, 970.63960782],
'open_date': pd.to_datetime([Arrow(2018, 1, 29, 18, 40, 0).datetime,
Arrow(2018, 1, 30, 3, 30, 0).datetime], utc=True
),
'close_date': pd.to_datetime([Arrow(2018, 1, 29, 22, 00, 0).datetime,
Arrow(2018, 1, 30, 4, 10, 0).datetime], utc=True),
'open_rate': [0.10401764894444211, 0.10302485],
'close_rate': [0.10453904066847439, 0.103541],
'fee_open': [0.0025, 0.0025],
'fee_close': [0.0025, 0.0025],
'trade_duration': [200, 40],
'profit_ratio': [0.0, 0.0],
'profit_abs': [0.0, 0.0],
'sell_reason': [SellType.ROI.value, SellType.ROI.value],
'initial_stop_loss_abs': [0.0940005, 0.09272236],
'initial_stop_loss_ratio': [-0.1, -0.1],
'stop_loss_abs': [0.0940005, 0.09272236],
'stop_loss_ratio': [-0.1, -0.1],
'min_rate': [0.10370188, 0.10300000000000001],
'max_rate': [0.10481985, 0.1038888],
'is_open': [False, False],
'enter_tag': [None, None],
'is_short': [False, False],
})
pd.testing.assert_frame_equal(results, expected)
data_pair = processed[pair]
for _, t in results.iterrows():
ln = data_pair.loc[data_pair["date"] == t["open_date"]]
# Check open trade rate alignes to open rate
assert ln is not None
# check close trade rate alignes to close rate or is between high and low
ln = data_pair.loc[data_pair["date"] == t["close_date"]]
assert (round(ln.iloc[0]["open"], 6) == round(t["close_rate"], 6) or
round(ln.iloc[0]["low"], 6) < round(
t["close_rate"], 6) < round(ln.iloc[0]["high"], 6))

View File

@ -10,7 +10,7 @@ import rapidjson
from freqtrade.constants import FTHYPT_FILEVERSION from freqtrade.constants import FTHYPT_FILEVERSION
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.optimize.hyperopt_tools import HyperoptTools, hyperopt_serializer from freqtrade.optimize.hyperopt_tools import HyperoptTools, hyperopt_serializer
from tests.conftest import CURRENT_TEST_STRATEGY, log_has from tests.conftest import CURRENT_TEST_STRATEGY, log_has, log_has_re
# Functions for recurrent object patching # Functions for recurrent object patching
@ -24,6 +24,7 @@ def test_save_results_saves_epochs(hyperopt, tmpdir, caplog) -> None:
hyperopt.results_file = Path(tmpdir / 'ut_results.fthypt') hyperopt.results_file = Path(tmpdir / 'ut_results.fthypt')
hyperopt_epochs = HyperoptTools.load_filtered_results(hyperopt.results_file, {}) hyperopt_epochs = HyperoptTools.load_filtered_results(hyperopt.results_file, {})
assert log_has_re("Hyperopt file .* not found.", caplog)
assert hyperopt_epochs == ([], 0) assert hyperopt_epochs == ([], 0)
# Test writing to temp dir and reading again # Test writing to temp dir and reading again

View File

@ -86,6 +86,7 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir):
'rejected_signals': 20, 'rejected_signals': 20,
'backtest_start_time': Arrow.utcnow().int_timestamp, 'backtest_start_time': Arrow.utcnow().int_timestamp,
'backtest_end_time': Arrow.utcnow().int_timestamp, 'backtest_end_time': Arrow.utcnow().int_timestamp,
'run_id': '123',
} }
} }
timerange = TimeRange.parse_timerange('1510688220-1510700340') timerange = TimeRange.parse_timerange('1510688220-1510700340')
@ -135,6 +136,7 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir):
'rejected_signals': 20, 'rejected_signals': 20,
'backtest_start_time': Arrow.utcnow().int_timestamp, 'backtest_start_time': Arrow.utcnow().int_timestamp,
'backtest_end_time': Arrow.utcnow().int_timestamp, 'backtest_end_time': Arrow.utcnow().int_timestamp,
'run_id': '124',
} }
} }
@ -181,16 +183,16 @@ def test_store_backtest_stats(testdatadir, mocker):
dump_mock = mocker.patch('freqtrade.optimize.optimize_reports.file_dump_json') dump_mock = mocker.patch('freqtrade.optimize.optimize_reports.file_dump_json')
store_backtest_stats(testdatadir, {}) store_backtest_stats(testdatadir, {'metadata': {}})
assert dump_mock.call_count == 2 assert dump_mock.call_count == 3
assert isinstance(dump_mock.call_args_list[0][0][0], Path) assert isinstance(dump_mock.call_args_list[0][0][0], Path)
assert str(dump_mock.call_args_list[0][0][0]).startswith(str(testdatadir/'backtest-result')) assert str(dump_mock.call_args_list[0][0][0]).startswith(str(testdatadir/'backtest-result'))
dump_mock.reset_mock() dump_mock.reset_mock()
filename = testdatadir / 'testresult.json' filename = testdatadir / 'testresult.json'
store_backtest_stats(filename, {}) store_backtest_stats(filename, {'metadata': {}})
assert dump_mock.call_count == 2 assert dump_mock.call_count == 3
assert isinstance(dump_mock.call_args_list[0][0][0], Path) assert isinstance(dump_mock.call_args_list[0][0][0], Path)
# result will be testdatadir / testresult-<timestamp>.json # result will be testdatadir / testresult-<timestamp>.json
assert str(dump_mock.call_args_list[0][0][0]).startswith(str(testdatadir / 'testresult')) assert str(dump_mock.call_args_list[0][0][0]).startswith(str(testdatadir / 'testresult'))

View File

@ -228,11 +228,17 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None:
result, headers, fiat_profit_sum = rpc._rpc_status_table(default_conf['stake_currency'], 'USD') result, headers, fiat_profit_sum = rpc._rpc_status_table(default_conf['stake_currency'], 'USD')
assert "Since" in headers assert "Since" in headers
assert "Pair" in headers assert "Pair" in headers
assert len(result[0]) == 4
assert 'instantly' == result[0][2] assert 'instantly' == result[0][2]
assert 'ETH/BTC' in result[0][1] assert 'ETH/BTC' in result[0][1]
assert '-0.41% (-0.06)' == result[0][3] assert '-0.41% (-0.06)' == result[0][3]
assert '-0.06' == f'{fiat_profit_sum:.2f}' assert '-0.06' == f'{fiat_profit_sum:.2f}'
rpc._config['position_adjustment_enable'] = True
result, headers, fiat_profit_sum = rpc._rpc_status_table(default_conf['stake_currency'], 'USD')
assert "# Buys" in headers
assert len(result[0]) == 5
mocker.patch('freqtrade.exchange.Exchange.get_rate', mocker.patch('freqtrade.exchange.Exchange.get_rate',
MagicMock(side_effect=ExchangeError("Pair 'ETH/BTC' not available"))) MagicMock(side_effect=ExchangeError("Pair 'ETH/BTC' not available")))
result, headers, fiat_profit_sum = rpc._rpc_status_table(default_conf['stake_currency'], 'USD') result, headers, fiat_profit_sum = rpc._rpc_status_table(default_conf['stake_currency'], 'USD')
@ -1118,9 +1124,14 @@ def test_rpcforcebuy(mocker, default_conf, ticker, fee, limit_buy_order_open) ->
with pytest.raises(RPCException, with pytest.raises(RPCException,
match=r'Wrong pair selected. Only pairs with stake-currency.*'): match=r'Wrong pair selected. Only pairs with stake-currency.*'):
rpc._rpc_forcebuy('LTC/ETH', 0.0001) rpc._rpc_forcebuy('LTC/ETH', 0.0001)
pair = 'XRP/BTC'
# Test with defined stake_amount
pair = 'LTC/BTC'
trade = rpc._rpc_forcebuy(pair, 0.0001, order_type='limit', stake_amount=0.05)
assert trade.stake_amount == 0.05
# Test not buying # Test not buying
pair = 'XRP/BTC'
freqtradebot = get_patched_freqtradebot(mocker, default_conf) freqtradebot = get_patched_freqtradebot(mocker, default_conf)
freqtradebot.config['stake_amount'] = 0 freqtradebot.config['stake_amount'] = 0
patch_get_signal(freqtradebot) patch_get_signal(freqtradebot)

View File

@ -1411,7 +1411,7 @@ def test_sysinfo(botclient):
assert 'ram_pct' in result assert 'ram_pct' in result
def test_api_backtesting(botclient, mocker, fee, caplog): def test_api_backtesting(botclient, mocker, fee, caplog, tmpdir):
ftbot, client = botclient ftbot, client = botclient
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
@ -1432,6 +1432,11 @@ def test_api_backtesting(botclient, mocker, fee, caplog):
assert result['status'] == 'reset' assert result['status'] == 'reset'
assert not result['running'] assert not result['running']
assert result['status_msg'] == 'Backtest reset' assert result['status_msg'] == 'Backtest reset'
ftbot.config['export'] = 'trades'
ftbot.config['backtest_cache'] = 'none'
ftbot.config['user_data_dir'] = Path(tmpdir)
ftbot.config['exportfilename'] = Path(tmpdir) / "backtest_results"
ftbot.config['exportfilename'].mkdir()
# start backtesting # start backtesting
data = { data = {
@ -1506,6 +1511,14 @@ def test_api_backtesting(botclient, mocker, fee, caplog):
rc = client_post(client, f"{BASE_URI}/backtest", data=json.dumps(data)) rc = client_post(client, f"{BASE_URI}/backtest", data=json.dumps(data))
assert log_has("Backtesting caused an error: ", caplog) assert log_has("Backtesting caused an error: ", caplog)
ftbot.config['backtest_cache'] = 'day'
# Rerun backtest (should get previous result)
rc = client_post(client, f"{BASE_URI}/backtest", data=json.dumps(data))
assert_response(rc)
result = rc.json()
assert log_has_re('Reusing result of previous backtest.*', caplog)
# Delete backtesting to avoid leakage since the backtest-object may stick around. # Delete backtesting to avoid leakage since the backtest-object may stick around.
rc = client_delete(client, f"{BASE_URI}/backtest") rc = client_delete(client, f"{BASE_URI}/backtest")
assert_response(rc) assert_response(rc)

View File

@ -4,7 +4,7 @@
import logging import logging
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from functools import reduce from functools import reduce
from random import choice, randint from random import choice, randint
from string import ascii_uppercase from string import ascii_uppercase
@ -705,10 +705,12 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee,
mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', ticker_sell_up) mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', ticker_sell_up)
trade.update(limit_sell_order) trade.update(limit_sell_order)
trade.close_date = datetime.utcnow() trade.close_date = datetime.now(timezone.utc)
trade.is_open = False trade.is_open = False
Trade.commit()
telegram._profit(update=update, context=MagicMock()) context.args = [3]
telegram._profit(update=update, context=context)
assert msg_mock.call_count == 1 assert msg_mock.call_count == 1
assert '*ROI:* Closed trades' in msg_mock.call_args_list[-1][0][0] assert '*ROI:* Closed trades' in msg_mock.call_args_list[-1][0][0]
assert ('∙ `0.00006217 BTC (6.20%) (0.62 \N{GREEK CAPITAL LETTER SIGMA}%)`' assert ('∙ `0.00006217 BTC (6.20%) (0.62 \N{GREEK CAPITAL LETTER SIGMA}%)`'

View File

@ -1,9 +1,12 @@
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement # pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
from datetime import datetime
import talib.abstract as ta import talib.abstract as ta
from pandas import DataFrame from pandas import DataFrame
import freqtrade.vendor.qtpylib.indicators as qtpylib import freqtrade.vendor.qtpylib.indicators as qtpylib
from freqtrade.persistence import Trade
from freqtrade.strategy import IStrategy from freqtrade.strategy import IStrategy
@ -48,6 +51,9 @@ class StrategyTestV2(IStrategy):
'sell': 'gtc', 'sell': 'gtc',
} }
# By default this strategy does not use Position Adjustments
position_adjustment_enable = False
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.
@ -154,3 +160,12 @@ class StrategyTestV2(IStrategy):
), ),
'sell'] = 1 'sell'] = 1
return dataframe return dataframe
def adjust_trade_position(self, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, min_stake: float, max_stake: float, **kwargs):
if current_profit < -0.0075:
orders = trade.select_filled_orders('buy')
return round(orders[0].cost, 0)
return None

View File

@ -6,6 +6,7 @@ import talib.abstract as ta
from pandas import DataFrame from pandas import DataFrame
import freqtrade.vendor.qtpylib.indicators as qtpylib import freqtrade.vendor.qtpylib.indicators as qtpylib
from freqtrade.persistence import Trade
from freqtrade.strategy import (BooleanParameter, DecimalParameter, IntParameter, IStrategy, from freqtrade.strategy import (BooleanParameter, DecimalParameter, IntParameter, IStrategy,
RealParameter) RealParameter)
@ -178,3 +179,12 @@ class StrategyTestV3(IStrategy):
# Bot-logic must make sure it's an allowed leverage and eventually adjust accordingly. # Bot-logic must make sure it's an allowed leverage and eventually adjust accordingly.
return 3.0 return 3.0
def adjust_trade_position(self, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, min_stake: float, max_stake: float, **kwargs):
if current_profit < -0.0075:
orders = trade.select_filled_orders('buy')
return round(orders[0].cost, 0)
return None

View File

@ -38,6 +38,9 @@ def test_returns_latest_signal(ohlcv_history):
mocked_history['exit_long'] = 0 mocked_history['exit_long'] = 0
mocked_history['enter_short'] = 0 mocked_history['enter_short'] = 0
mocked_history['exit_short'] = 0 mocked_history['exit_short'] = 0
# Set tags in lines that don't matter to test nan in the sell line
mocked_history.loc[0, 'enter_tag'] = 'wrong_line'
mocked_history.loc[0, 'exit_tag'] = 'wrong_line'
mocked_history.loc[1, 'exit_long'] = 1 mocked_history.loc[1, 'exit_long'] = 1
assert _STRATEGY.get_entry_signal('ETH/BTC', '5m', mocked_history) == (None, None) assert _STRATEGY.get_entry_signal('ETH/BTC', '5m', mocked_history) == (None, None)

View File

@ -22,7 +22,7 @@ from freqtrade.configuration.load_config import load_config_file, load_file, log
from freqtrade.constants import DEFAULT_DB_DRYRUN_URL, DEFAULT_DB_PROD_URL, ENV_VAR_PREFIX from freqtrade.constants import DEFAULT_DB_DRYRUN_URL, DEFAULT_DB_PROD_URL, ENV_VAR_PREFIX
from freqtrade.enums import RunMode from freqtrade.enums import RunMode
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.loggers import _set_loggers, setup_logging, setup_logging_pre from freqtrade.loggers import FTBufferingHandler, _set_loggers, setup_logging, setup_logging_pre
from tests.conftest import (CURRENT_TEST_STRATEGY, log_has, log_has_re, from tests.conftest import (CURRENT_TEST_STRATEGY, log_has, log_has_re,
patched_configuration_load_config_file) patched_configuration_load_config_file)
@ -687,7 +687,7 @@ def test_set_loggers_syslog():
assert len(logger.handlers) == 3 assert len(logger.handlers) == 3
assert [x for x in logger.handlers if type(x) == logging.handlers.SysLogHandler] assert [x for x in logger.handlers if type(x) == logging.handlers.SysLogHandler]
assert [x for x in logger.handlers if type(x) == logging.StreamHandler] assert [x for x in logger.handlers if type(x) == logging.StreamHandler]
assert [x for x in logger.handlers if type(x) == logging.handlers.BufferingHandler] assert [x for x in logger.handlers if type(x) == FTBufferingHandler]
# setting up logging again should NOT cause the loggers to be added a second time. # setting up logging again should NOT cause the loggers to be added a second time.
setup_logging(config) setup_logging(config)
assert len(logger.handlers) == 3 assert len(logger.handlers) == 3
@ -710,7 +710,7 @@ def test_set_loggers_Filehandler(tmpdir):
assert len(logger.handlers) == 3 assert len(logger.handlers) == 3
assert [x for x in logger.handlers if type(x) == logging.handlers.RotatingFileHandler] assert [x for x in logger.handlers if type(x) == logging.handlers.RotatingFileHandler]
assert [x for x in logger.handlers if type(x) == logging.StreamHandler] assert [x for x in logger.handlers if type(x) == logging.StreamHandler]
assert [x for x in logger.handlers if type(x) == logging.handlers.BufferingHandler] assert [x for x in logger.handlers if type(x) == FTBufferingHandler]
# setting up logging again should NOT cause the loggers to be added a second time. # setting up logging again should NOT cause the loggers to be added a second time.
setup_logging(config) setup_logging(config)
assert len(logger.handlers) == 3 assert len(logger.handlers) == 3

View File

@ -5,6 +5,7 @@ import logging
import time import time
from copy import deepcopy from copy import deepcopy
from math import isclose from math import isclose
from typing import List
from unittest.mock import ANY, MagicMock, PropertyMock from unittest.mock import ANY, MagicMock, PropertyMock
import arrow import arrow
@ -4937,3 +4938,245 @@ def test_update_funding_fees(
trade.amount * trade.amount *
mark_prices[trade.pair].iloc[0:2]['open'] * funding_rates[trade.pair].iloc[0:2]['open'] mark_prices[trade.pair].iloc[0:2]['open'] * funding_rates[trade.pair].iloc[0:2]['open']
)) ))
def test_position_adjust(mocker, default_conf_usdt, fee) -> None:
patch_RPCManager(mocker)
patch_exchange(mocker)
patch_wallet(mocker, free=10000)
default_conf_usdt.update({
"position_adjustment_enable": True,
"dry_run": False,
"stake_amount": 10.0,
"dry_run_wallet": 1000.0,
})
freqtrade = FreqtradeBot(default_conf_usdt)
freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=True)
bid = 11
stake_amount = 10
buy_rate_mock = MagicMock(return_value=bid)
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
get_rate=buy_rate_mock,
fetch_ticker=MagicMock(return_value={
'bid': 10,
'ask': 12,
'last': 11
}),
get_min_pair_stake_amount=MagicMock(return_value=1),
get_fee=fee,
)
pair = 'ETH/USDT'
# Initial buy
closed_successful_buy_order = {
'pair': pair,
'ft_pair': pair,
'ft_order_side': 'buy',
'side': 'buy',
'type': 'limit',
'status': 'closed',
'price': bid,
'average': bid,
'cost': bid * stake_amount,
'amount': stake_amount,
'filled': stake_amount,
'ft_is_open': False,
'id': '650',
'order_id': '650'
}
mocker.patch('freqtrade.exchange.Exchange.create_order',
MagicMock(return_value=closed_successful_buy_order))
mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order',
MagicMock(return_value=closed_successful_buy_order))
assert freqtrade.execute_entry(pair, stake_amount)
# Should create an closed trade with an no open order id
# Order is filled and trade is open
orders = Order.query.all()
assert orders
assert len(orders) == 1
trade = Trade.query.first()
assert trade
assert trade.is_open is True
assert trade.open_order_id is None
assert trade.open_rate == 11
assert trade.stake_amount == 110
# Assume it does nothing since order is closed and trade is open
freqtrade.update_closed_trades_without_assigned_fees()
trade = Trade.query.first()
assert trade
assert trade.is_open is True
assert trade.open_order_id is None
assert trade.open_rate == 11
assert trade.stake_amount == 110
assert not trade.fee_updated('buy')
freqtrade.check_handle_timedout()
trade = Trade.query.first()
assert trade
assert trade.is_open is True
assert trade.open_order_id is None
assert trade.open_rate == 11
assert trade.stake_amount == 110
assert not trade.fee_updated('buy')
# First position adjustment buy.
open_dca_order_1 = {
'ft_pair': pair,
'ft_order_side': 'buy',
'side': 'buy',
'type': 'limit',
'status': None,
'price': 9,
'amount': 12,
'cost': 100,
'ft_is_open': True,
'id': '651',
'order_id': '651'
}
mocker.patch('freqtrade.exchange.Exchange.create_order',
MagicMock(return_value=open_dca_order_1))
mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order',
MagicMock(return_value=open_dca_order_1))
assert freqtrade.execute_entry(pair, stake_amount, trade=trade)
orders = Order.query.all()
assert orders
assert len(orders) == 2
trade = Trade.query.first()
assert trade
assert trade.open_order_id == '651'
assert trade.open_rate == 11
assert trade.amount == 10
assert trade.stake_amount == 110
assert not trade.fee_updated('buy')
trades: List[Trade] = Trade.get_open_trades_without_assigned_fees()
assert len(trades) == 1
assert trade.is_open
assert not trade.fee_updated('buy')
order = trade.select_order('buy', False)
assert order
assert order.order_id == '650'
def make_sure_its_651(*args, **kwargs):
if args[0] == '650':
return closed_successful_buy_order
if args[0] == '651':
return open_dca_order_1
return None
# Assume it does nothing since order is still open
fetch_order_mm = MagicMock(side_effect=make_sure_its_651)
mocker.patch('freqtrade.exchange.Exchange.create_order', fetch_order_mm)
mocker.patch('freqtrade.exchange.Exchange.fetch_order', fetch_order_mm)
mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order', fetch_order_mm)
freqtrade.update_closed_trades_without_assigned_fees()
orders = Order.query.all()
assert orders
assert len(orders) == 2
# Assert that the trade is found as open and without fees
trades: List[Trade] = Trade.get_open_trades_without_assigned_fees()
assert len(trades) == 1
# Assert trade is as expected
trade = Trade.query.first()
assert trade
assert trade.open_order_id == '651'
assert trade.open_rate == 11
assert trade.amount == 10
assert trade.stake_amount == 110
assert not trade.fee_updated('buy')
# Make sure the closed order is found as the first order.
order = trade.select_order('buy', False)
assert order.order_id == '650'
# Now close the order so it should update.
closed_dca_order_1 = {
'ft_pair': pair,
'ft_order_side': 'buy',
'side': 'buy',
'type': 'limit',
'status': 'closed',
'price': 9,
'average': 9,
'amount': 12,
'filled': 12,
'cost': 108,
'ft_is_open': False,
'id': '651',
'order_id': '651',
'datetime': arrow.utcnow().isoformat(),
}
mocker.patch('freqtrade.exchange.Exchange.create_order',
MagicMock(return_value=closed_dca_order_1))
mocker.patch('freqtrade.exchange.Exchange.fetch_order',
MagicMock(return_value=closed_dca_order_1))
mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order',
MagicMock(return_value=closed_dca_order_1))
freqtrade.check_handle_timedout()
# Assert trade is as expected (averaged dca)
trade = Trade.query.first()
assert trade
assert trade.open_order_id is None
assert pytest.approx(trade.open_rate) == 9.90909090909
assert trade.amount == 22
assert trade.stake_amount == 218
orders = Order.query.all()
assert orders
assert len(orders) == 2
# Make sure the closed order is found as the second order.
order = trade.select_order('buy', False)
assert order.order_id == '651'
# Assert that the trade is not found as open and without fees
trades: List[Trade] = Trade.get_open_trades_without_assigned_fees()
assert len(trades) == 1
# Add a second DCA
closed_dca_order_2 = {
'ft_pair': pair,
'status': 'closed',
'ft_order_side': 'buy',
'side': 'buy',
'type': 'limit',
'price': 7,
'average': 7,
'amount': 15,
'filled': 15,
'cost': 105,
'ft_is_open': False,
'id': '652',
'order_id': '652'
}
mocker.patch('freqtrade.exchange.Exchange.create_order',
MagicMock(return_value=closed_dca_order_2))
mocker.patch('freqtrade.exchange.Exchange.fetch_order',
MagicMock(return_value=closed_dca_order_2))
mocker.patch('freqtrade.exchange.Exchange.fetch_order_or_stoploss_order',
MagicMock(return_value=closed_dca_order_2))
assert freqtrade.execute_entry(pair, stake_amount, trade=trade)
# Assert trade is as expected (averaged dca)
trade = Trade.query.first()
assert trade
assert trade.open_order_id is None
assert pytest.approx(trade.open_rate) == 8.729729729729
assert trade.amount == 37
assert trade.stake_amount == 323
orders = Order.query.all()
assert orders
assert len(orders) == 3
# Make sure the closed order is found as the second order.
order = trade.select_order('buy', False)
assert order.order_id == '652'

View File

@ -127,8 +127,7 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
(1, 200), (1, 200),
(0.99, 198), (0.99, 198),
]) ])
def test_forcebuy_last_unlimited(default_conf, ticker, fee, limit_buy_order, mocker, balance_ratio, def test_forcebuy_last_unlimited(default_conf, ticker, fee, mocker, balance_ratio, result1) -> None:
result1) -> None:
""" """
Tests workflow unlimited stake-amount Tests workflow unlimited stake-amount
Buy 4 trades, forcebuy a 5th trade Buy 4 trades, forcebuy a 5th trade
@ -207,3 +206,71 @@ def test_forcebuy_last_unlimited(default_conf, ticker, fee, limit_buy_order, moc
assert len(bals2) == 5 assert len(bals2) == 5
assert 'LTC' in bals assert 'LTC' in bals
assert 'LTC' not in bals2 assert 'LTC' not in bals2
def test_dca_buying(default_conf_usdt, ticker_usdt, fee, mocker) -> None:
default_conf_usdt['position_adjustment_enable'] = True
freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
mocker.patch.multiple(
'freqtrade.exchange.Exchange',
fetch_ticker=ticker_usdt,
get_fee=fee,
amount_to_precision=lambda s, x, y: y,
price_to_precision=lambda s, x, y: y,
)
patch_get_signal(freqtrade)
freqtrade.enter_positions()
assert len(Trade.get_trades().all()) == 1
trade = Trade.get_trades().first()
assert len(trade.orders) == 1
assert trade.stake_amount == 60
assert trade.open_rate == 2.0
# No adjustment
freqtrade.process()
trade = Trade.get_trades().first()
assert len(trade.orders) == 1
assert trade.stake_amount == 60
# Reduce bid amount
ticker_usdt_modif = ticker_usdt.return_value
ticker_usdt_modif['bid'] = ticker_usdt_modif['bid'] * 0.995
mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', return_value=ticker_usdt_modif)
# additional buy order
freqtrade.process()
trade = Trade.get_trades().first()
assert len(trade.orders) == 2
assert trade.stake_amount == 120
# Open-rate averaged between 2.0 and 2.0 * 0.995
assert trade.open_rate < 2.0
assert trade.open_rate > 2.0 * 0.995
# No action - profit raised above 1% (the bar set in the strategy).
freqtrade.process()
trade = Trade.get_trades().first()
assert len(trade.orders) == 2
assert trade.stake_amount == 120
assert trade.orders[0].amount == 30
assert trade.orders[1].amount == 60 / ticker_usdt_modif['bid']
assert trade.amount == trade.orders[0].amount + trade.orders[1].amount
assert trade.nr_of_successful_buys == 2
# Sell
patch_get_signal(freqtrade, enter_long=False, exit_long=True)
freqtrade.process()
trade = Trade.get_trades().first()
assert trade.is_open is False
assert trade.orders[0].amount == 30
assert trade.orders[0].side == 'buy'
assert trade.orders[1].amount == 60 / ticker_usdt_modif['bid']
# Sold everything
assert trade.orders[-1].side == 'sell'
assert trade.orders[2].amount == trade.amount
assert trade.nr_of_successful_buys == 2

View File

@ -2146,3 +2146,367 @@ def test_Trade_object_idem():
and item not in ('trades', 'trades_open', 'total_profit') and item not in ('trades', 'trades_open', 'total_profit')
and type(getattr(LocalTrade, item)) not in (property, FunctionType)): and type(getattr(LocalTrade, item)) not in (property, FunctionType)):
assert item in trade assert item in trade
def test_recalc_trade_from_orders(fee):
o1_amount = 100
o1_rate = 1
o1_cost = o1_amount * o1_rate
o1_fee_cost = o1_cost * fee.return_value
o1_trade_val = o1_cost + o1_fee_cost
trade = Trade(
pair='ADA/USDT',
stake_amount=o1_cost,
open_date=arrow.utcnow().shift(hours=-2).datetime,
amount=o1_amount,
fee_open=fee.return_value,
fee_close=fee.return_value,
exchange='binance',
open_rate=o1_rate,
max_rate=o1_rate,
)
assert fee.return_value == 0.0025
assert trade._calc_open_trade_value() == o1_trade_val
assert trade.amount == o1_amount
assert trade.stake_amount == o1_cost
assert trade.open_rate == o1_rate
assert trade.open_trade_value == o1_trade_val
# Calling without orders should not throw exceptions and change nothing
trade.recalc_trade_from_orders()
assert trade.amount == o1_amount
assert trade.stake_amount == o1_cost
assert trade.open_rate == o1_rate
assert trade.open_trade_value == o1_trade_val
trade.update_fee(o1_fee_cost, 'BNB', fee.return_value, 'buy')
assert len(trade.orders) == 0
# Check with 1 order
order1 = Order(
ft_order_side='buy',
ft_pair=trade.pair,
ft_is_open=False,
status="closed",
symbol=trade.pair,
order_type="market",
side="buy",
price=o1_rate,
average=o1_rate,
filled=o1_amount,
remaining=0,
cost=o1_amount,
order_date=trade.open_date,
order_filled_date=trade.open_date,
)
trade.orders.append(order1)
trade.recalc_trade_from_orders()
# Calling recalc with single initial order should not change anything
assert trade.amount == o1_amount
assert trade.stake_amount == o1_amount
assert trade.open_rate == o1_rate
assert trade.fee_open_cost == o1_fee_cost
assert trade.open_trade_value == o1_trade_val
# One additional adjustment / DCA order
o2_amount = 125
o2_rate = 0.9
o2_cost = o2_amount * o2_rate
o2_fee_cost = o2_cost * fee.return_value
o2_trade_val = o2_cost + o2_fee_cost
order2 = Order(
ft_order_side='buy',
ft_pair=trade.pair,
ft_is_open=False,
status="closed",
symbol=trade.pair,
order_type="market",
side="buy",
price=o2_rate,
average=o2_rate,
filled=o2_amount,
remaining=0,
cost=o2_cost,
order_date=arrow.utcnow().shift(hours=-1).datetime,
order_filled_date=arrow.utcnow().shift(hours=-1).datetime,
)
trade.orders.append(order2)
trade.recalc_trade_from_orders()
# Validate that the trade now has new averaged open price and total values
avg_price = (o1_cost + o2_cost) / (o1_amount + o2_amount)
assert trade.amount == o1_amount + o2_amount
assert trade.stake_amount == o1_amount + o2_cost
assert trade.open_rate == avg_price
assert trade.fee_open_cost == o1_fee_cost + o2_fee_cost
assert trade.open_trade_value == o1_trade_val + o2_trade_val
# Let's try with multiple additional orders
o3_amount = 150
o3_rate = 0.85
o3_cost = o3_amount * o3_rate
o3_fee_cost = o3_cost * fee.return_value
o3_trade_val = o3_cost + o3_fee_cost
order3 = Order(
ft_order_side='buy',
ft_pair=trade.pair,
ft_is_open=False,
status="closed",
symbol=trade.pair,
order_type="market",
side="buy",
price=o3_rate,
average=o3_rate,
filled=o3_amount,
remaining=0,
cost=o3_cost,
order_date=arrow.utcnow().shift(hours=-1).datetime,
order_filled_date=arrow.utcnow().shift(hours=-1).datetime,
)
trade.orders.append(order3)
trade.recalc_trade_from_orders()
# Validate that the sum is still correct and open rate is averaged
avg_price = (o1_cost + o2_cost + o3_cost) / (o1_amount + o2_amount + o3_amount)
assert trade.amount == o1_amount + o2_amount + o3_amount
assert trade.stake_amount == o1_cost + o2_cost + o3_cost
assert trade.open_rate == avg_price
assert pytest.approx(trade.fee_open_cost) == o1_fee_cost + o2_fee_cost + o3_fee_cost
assert pytest.approx(trade.open_trade_value) == o1_trade_val + o2_trade_val + o3_trade_val
# Just to make sure sell orders are ignored, let's calculate one more time.
sell1 = Order(
ft_order_side='sell',
ft_pair=trade.pair,
ft_is_open=False,
status="closed",
symbol=trade.pair,
order_type="market",
side="sell",
price=avg_price + 0.95,
average=avg_price + 0.95,
filled=o1_amount + o2_amount + o3_amount,
remaining=0,
cost=o1_cost + o2_cost + o3_cost,
order_date=trade.open_date,
order_filled_date=trade.open_date,
)
trade.orders.append(sell1)
trade.recalc_trade_from_orders()
assert trade.amount == o1_amount + o2_amount + o3_amount
assert trade.stake_amount == o1_cost + o2_cost + o3_cost
assert trade.open_rate == avg_price
assert pytest.approx(trade.fee_open_cost) == o1_fee_cost + o2_fee_cost + o3_fee_cost
assert pytest.approx(trade.open_trade_value) == o1_trade_val + o2_trade_val + o3_trade_val
def test_recalc_trade_from_orders_ignores_bad_orders(fee):
o1_amount = 100
o1_rate = 1
o1_cost = o1_amount * o1_rate
o1_fee_cost = o1_cost * fee.return_value
o1_trade_val = o1_cost + o1_fee_cost
trade = Trade(
pair='ADA/USDT',
stake_amount=o1_cost,
open_date=arrow.utcnow().shift(hours=-2).datetime,
amount=o1_amount,
fee_open=fee.return_value,
fee_close=fee.return_value,
exchange='binance',
open_rate=o1_rate,
max_rate=o1_rate,
)
trade.update_fee(o1_fee_cost, 'BNB', fee.return_value, 'buy')
# Check with 1 order
order1 = Order(
ft_order_side='buy',
ft_pair=trade.pair,
ft_is_open=False,
status="closed",
symbol=trade.pair,
order_type="market",
side="buy",
price=o1_rate,
average=o1_rate,
filled=o1_amount,
remaining=0,
cost=o1_amount,
order_date=trade.open_date,
order_filled_date=trade.open_date,
)
trade.orders.append(order1)
trade.recalc_trade_from_orders()
# Calling recalc with single initial order should not change anything
assert trade.amount == o1_amount
assert trade.stake_amount == o1_amount
assert trade.open_rate == o1_rate
assert trade.fee_open_cost == o1_fee_cost
assert trade.open_trade_value == o1_trade_val
assert trade.nr_of_successful_buys == 1
order2 = Order(
ft_order_side='buy',
ft_pair=trade.pair,
ft_is_open=True,
status="open",
symbol=trade.pair,
order_type="market",
side="buy",
price=o1_rate,
average=o1_rate,
filled=o1_amount,
remaining=0,
cost=o1_cost,
order_date=arrow.utcnow().shift(hours=-1).datetime,
order_filled_date=arrow.utcnow().shift(hours=-1).datetime,
)
trade.orders.append(order2)
trade.recalc_trade_from_orders()
# Validate that the trade values have not been changed
assert trade.amount == o1_amount
assert trade.stake_amount == o1_amount
assert trade.open_rate == o1_rate
assert trade.fee_open_cost == o1_fee_cost
assert trade.open_trade_value == o1_trade_val
assert trade.nr_of_successful_buys == 1
# Let's try with some other orders
order3 = Order(
ft_order_side='buy',
ft_pair=trade.pair,
ft_is_open=False,
status="cancelled",
symbol=trade.pair,
order_type="market",
side="buy",
price=1,
average=2,
filled=0,
remaining=4,
cost=5,
order_date=arrow.utcnow().shift(hours=-1).datetime,
order_filled_date=arrow.utcnow().shift(hours=-1).datetime,
)
trade.orders.append(order3)
trade.recalc_trade_from_orders()
# Validate that the order values still are ignoring orders 2 and 3
assert trade.amount == o1_amount
assert trade.stake_amount == o1_amount
assert trade.open_rate == o1_rate
assert trade.fee_open_cost == o1_fee_cost
assert trade.open_trade_value == o1_trade_val
assert trade.nr_of_successful_buys == 1
order4 = Order(
ft_order_side='buy',
ft_pair=trade.pair,
ft_is_open=False,
status="closed",
symbol=trade.pair,
order_type="market",
side="buy",
price=o1_rate,
average=o1_rate,
filled=o1_amount,
remaining=0,
cost=o1_cost,
order_date=arrow.utcnow().shift(hours=-1).datetime,
order_filled_date=arrow.utcnow().shift(hours=-1).datetime,
)
trade.orders.append(order4)
trade.recalc_trade_from_orders()
# Validate that the trade values have been changed
assert trade.amount == 2 * o1_amount
assert trade.stake_amount == 2 * o1_amount
assert trade.open_rate == o1_rate
assert trade.fee_open_cost == 2 * o1_fee_cost
assert trade.open_trade_value == 2 * o1_trade_val
assert trade.nr_of_successful_buys == 2
# Just to make sure sell orders are ignored, let's calculate one more time.
sell1 = Order(
ft_order_side='sell',
ft_pair=trade.pair,
ft_is_open=False,
status="closed",
symbol=trade.pair,
order_type="market",
side="sell",
price=4,
average=3,
filled=2,
remaining=1,
cost=5,
order_date=trade.open_date,
order_filled_date=trade.open_date,
)
trade.orders.append(sell1)
trade.recalc_trade_from_orders()
assert trade.amount == 2 * o1_amount
assert trade.stake_amount == 2 * o1_amount
assert trade.open_rate == o1_rate
assert trade.fee_open_cost == 2 * o1_fee_cost
assert trade.open_trade_value == 2 * o1_trade_val
assert trade.nr_of_successful_buys == 2
@pytest.mark.usefixtures("init_persistence")
def test_select_filled_orders(fee):
create_mock_trades(fee)
trades = Trade.get_trades().all()
# Closed buy order, no sell order
orders = trades[0].select_filled_orders('buy')
assert orders is not None
assert len(orders) == 1
order = orders[0]
assert order.amount > 0
assert order.filled > 0
assert order.side == 'buy'
assert order.ft_order_side == 'buy'
assert order.status == 'closed'
orders = trades[0].select_filled_orders('sell')
assert orders is not None
assert len(orders) == 0
# closed buy order, and closed sell order
orders = trades[1].select_filled_orders('buy')
assert orders is not None
assert len(orders) == 1
orders = trades[1].select_filled_orders('sell')
assert orders is not None
assert len(orders) == 1
# Has open buy order
orders = trades[3].select_filled_orders('buy')
assert orders is not None
assert len(orders) == 0
orders = trades[3].select_filled_orders('sell')
assert orders is not None
assert len(orders) == 0
# Open sell order
orders = trades[4].select_filled_orders('buy')
assert orders is not None
assert len(orders) == 1
orders = trades[4].select_filled_orders('sell')
assert orders is not None
assert len(orders) == 0

View File

@ -171,7 +171,7 @@ def test_plot_trades(testdatadir, caplog):
assert len(trades) == len(trade_buy.x) assert len(trades) == len(trade_buy.x)
assert trade_buy.marker.color == 'cyan' assert trade_buy.marker.color == 'cyan'
assert trade_buy.marker.symbol == 'circle-open' assert trade_buy.marker.symbol == 'circle-open'
assert trade_buy.text[0] == '3.99%, roi, 15 min' assert trade_buy.text[0] == '3.99%, buy_tag, roi, 15 min'
trade_sell = find_trace_in_fig_data(figure.data, 'Sell - Profit') trade_sell = find_trace_in_fig_data(figure.data, 'Sell - Profit')
assert isinstance(trade_sell, go.Scatter) assert isinstance(trade_sell, go.Scatter)
@ -179,7 +179,7 @@ def test_plot_trades(testdatadir, caplog):
assert len(trades.loc[trades['profit_ratio'] > 0]) == len(trade_sell.x) assert len(trades.loc[trades['profit_ratio'] > 0]) == len(trade_sell.x)
assert trade_sell.marker.color == 'green' assert trade_sell.marker.color == 'green'
assert trade_sell.marker.symbol == 'square-open' assert trade_sell.marker.symbol == 'square-open'
assert trade_sell.text[0] == '3.99%, roi, 15 min' assert trade_sell.text[0] == '3.99%, buy_tag, roi, 15 min'
trade_sell_loss = find_trace_in_fig_data(figure.data, 'Sell - Loss') trade_sell_loss = find_trace_in_fig_data(figure.data, 'Sell - Loss')
assert isinstance(trade_sell_loss, go.Scatter) assert isinstance(trade_sell_loss, go.Scatter)

File diff suppressed because one or more lines are too long