Merge branch 'develop' into feat/short
This commit is contained in:
commit
f090dcc597
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- stable
|
||||
- develop
|
||||
- ci/*
|
||||
tags:
|
||||
release:
|
||||
types: [published]
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM python:3.10.0-slim-bullseye as base
|
||||
FROM python:3.9.9-slim-bullseye as base
|
||||
|
||||
# Setup env
|
||||
ENV LANG C.UTF-8
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
build_helpers/TA_Lib-0.4.24-cp310-cp310-win_amd64.whl
Normal file
BIN
build_helpers/TA_Lib-0.4.24-cp310-cp310-win_amd64.whl
Normal file
Binary file not shown.
BIN
build_helpers/TA_Lib-0.4.24-cp37-cp37m-win_amd64.whl
Normal file
BIN
build_helpers/TA_Lib-0.4.24-cp37-cp37m-win_amd64.whl
Normal file
Binary file not shown.
BIN
build_helpers/TA_Lib-0.4.24-cp38-cp38-win_amd64.whl
Normal file
BIN
build_helpers/TA_Lib-0.4.24-cp38-cp38-win_amd64.whl
Normal file
Binary file not shown.
BIN
build_helpers/TA_Lib-0.4.24-cp39-cp39-win_amd64.whl
Normal file
BIN
build_helpers/TA_Lib-0.4.24-cp39-cp39-win_amd64.whl
Normal file
Binary file not shown.
@ -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}')"
|
||||
|
||||
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') {
|
||||
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') {
|
||||
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') {
|
||||
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 -e .
|
||||
|
@ -9,7 +9,9 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"ask_last_balance": 0.0,
|
||||
|
@ -9,7 +9,9 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"use_order_book": true,
|
||||
|
@ -9,7 +9,9 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"ask_last_balance": 0.0,
|
||||
|
@ -28,7 +28,7 @@
|
||||
"stoploss": -0.10,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30,
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
|
@ -9,7 +9,9 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"use_order_book": true,
|
||||
|
@ -22,6 +22,7 @@ usage: freqtrade backtesting [-h] [-v] [--logfile FILE] [-V] [-c PATH]
|
||||
[--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]]
|
||||
[--export {none,trades}] [--export-filename PATH]
|
||||
[--breakdown {day,week,month} [{day,week,month} ...]]
|
||||
[--cache {none,day,week,month}]
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
@ -76,6 +77,9 @@ optional arguments:
|
||||
_today.json`
|
||||
--breakdown {day,week,month} [{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:
|
||||
-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.
|
||||
|
||||
### 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
|
||||
|
||||
To further analyze your backtest results, you can [export the trades](#exporting-trades-to-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()`.
|
||||
* 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.
|
||||
* 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).
|
||||
* 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.
|
||||
@ -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).
|
||||
* 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.
|
||||
* 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.
|
||||
* 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
|
||||
|
||||
!!! Note
|
||||
|
@ -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
|
||||
| `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
|
||||
| `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
|
||||
|
||||
@ -198,6 +199,7 @@ Values set in the configuration file always overwrite values set in the strategy
|
||||
* `sell_profit_offset`
|
||||
* `ignore_roi_if_buy_signal`
|
||||
* `ignore_buying_expired_candle_after`
|
||||
* `position_adjustment_enable`
|
||||
|
||||
### 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.
|
||||
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"
|
||||
|
||||
### Understand minimal_roi
|
||||
|
@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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:
|
||||
`freqtrade --config config.json --strategy SampleStrategy --hyperopt SampleHyperopt -e 1000 --timerange 20190601-20200601`
|
||||
|
@ -273,6 +273,9 @@ def plot_config(self):
|
||||
!!! Warning
|
||||
`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](assets/plot-profit.png)
|
||||
|
@ -1,4 +1,4 @@
|
||||
mkdocs==1.2.3
|
||||
mkdocs-material==8.1.4
|
||||
mkdocs-material==8.1.7
|
||||
mdx_truly_sane_lists==1.2
|
||||
pymdown-extensions==9.1
|
||||
|
@ -15,6 +15,7 @@ Currently available callbacks:
|
||||
* [`check_buy_timeout()` and `check_sell_timeout()](#custom-order-timeout-rules)
|
||||
* [`confirm_trade_entry()`](#trade-entry-buy-order-confirmation)
|
||||
* [`confirm_trade_exit()`](#trade-exit-sell-order-confirmation)
|
||||
* [`adjust_trade_position()`](#adjust-trade-position)
|
||||
|
||||
!!! Tip "Callback calling sequence"
|
||||
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
|
||||
|
||||
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 1.0
|
||||
```
|
||||
|
@ -838,7 +838,7 @@ In some situations it may be confusing to deal with stops relative to current ra
|
||||
|
||||
from datetime import datetime
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.strategy import IStrategy, stoploss_from_open
|
||||
from freqtrade.strategy import IStrategy, stoploss_from_absolute
|
||||
|
||||
class AwesomeStrategy(IStrategy):
|
||||
|
||||
|
@ -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).
|
||||
|
||||
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.
|
||||
Other versions must be downloaded from the above link.
|
||||
|
@ -24,7 +24,7 @@ ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
|
||||
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
|
||||
"enable_protections", "dry_run_wallet", "timeframe_detail",
|
||||
"strategy_list", "export", "exportfilename",
|
||||
"backtest_breakdown"]
|
||||
"backtest_breakdown", "backtest_cache"]
|
||||
|
||||
ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
|
||||
"position_stacking", "use_max_market_positions",
|
||||
|
@ -86,7 +86,7 @@ def ask_user_config() -> Dict[str, Any]:
|
||||
{
|
||||
"type": "select",
|
||||
"name": "timeframe_in_config",
|
||||
"message": "Tim",
|
||||
"message": "Time",
|
||||
"choices": ["Have the strategy define timeframe.", "Override in configuration."]
|
||||
},
|
||||
{
|
||||
|
@ -205,6 +205,12 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
nargs='+',
|
||||
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
|
||||
"stoploss_range": Arg(
|
||||
'--stoplosses',
|
||||
|
@ -276,6 +276,9 @@ class Configuration:
|
||||
self._args_to_config(config, argname='backtest_breakdown',
|
||||
logstring='Parameter --breakdown detected ...')
|
||||
|
||||
self._args_to_config(config, argname='backtest_cache',
|
||||
logstring='Parameter --cache={} detected ...')
|
||||
|
||||
self._args_to_config(config, argname='disableparamexport',
|
||||
logstring='Parameter --disableparamexport detected: {} ...')
|
||||
|
||||
|
@ -36,6 +36,8 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
|
||||
AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard']
|
||||
AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5']
|
||||
BACKTEST_BREAKDOWNS = ['day', 'week', 'month']
|
||||
BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month']
|
||||
BACKTEST_CACHE_DEFAULT = 'day'
|
||||
DRY_RUN_WALLET = 1000
|
||||
DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S'
|
||||
MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons
|
||||
|
@ -2,6 +2,8 @@
|
||||
Helpers when analyzing backtest data
|
||||
"""
|
||||
import logging
|
||||
from copy import copy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
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.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
|
||||
|
||||
|
||||
@ -100,10 +102,30 @@ def get_latest_hyperopt_file(directory: Union[Path, str], predef_filename: str =
|
||||
if isinstance(directory, str):
|
||||
directory = Path(directory)
|
||||
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 / 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]:
|
||||
"""
|
||||
Load backtest statistics file.
|
||||
@ -120,9 +142,80 @@ def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]:
|
||||
with filename.open() as file:
|
||||
data = json_load(file)
|
||||
|
||||
# Legacy list format does not contain metadata.
|
||||
if isinstance(data, dict):
|
||||
data['metadata'] = load_backtest_metadata(filename)
|
||||
|
||||
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:
|
||||
"""
|
||||
Load backtest data file.
|
||||
|
@ -752,8 +752,9 @@ class Exchange:
|
||||
'cost': _amount * rate,
|
||||
'type': ordertype,
|
||||
'side': side,
|
||||
'filled': 0,
|
||||
'remaining': _amount,
|
||||
'datetime': arrow.utcnow().isoformat(),
|
||||
'datetime': arrow.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
||||
'timestamp': arrow.utcnow().int_timestamp * 1000,
|
||||
'status': "closed" if ordertype == "market" else "open",
|
||||
'fee': None,
|
||||
@ -768,6 +769,7 @@ class Exchange:
|
||||
average = self.get_dry_market_fill_price(pair, side, amount, rate)
|
||||
dry_order.update({
|
||||
'average': average,
|
||||
'filled': _amount,
|
||||
'cost': (dry_order['amount'] * average) / leverage
|
||||
})
|
||||
dry_order = self.add_dry_order_fee(pair, dry_order)
|
||||
|
@ -17,7 +17,7 @@ from freqtrade.configuration import validate_config_consistency
|
||||
from freqtrade.data.converter import order_book_to_dataframe
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
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)
|
||||
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
|
||||
InvalidOrderException, PricingError)
|
||||
@ -202,6 +202,11 @@ class FreqtradeBot(LoggingMixin):
|
||||
# First process current opened trades (positions)
|
||||
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
|
||||
if self.get_free_open_trades():
|
||||
self.enter_positions()
|
||||
@ -328,7 +333,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
for trade in trades:
|
||||
if trade.is_open and not trade.fee_updated(trade.enter_side):
|
||||
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(
|
||||
f"Updating {trade.enter_side}-fee on trade {trade}"
|
||||
f"for order {order.order_id}."
|
||||
@ -500,6 +506,53 @@ class FreqtradeBot(LoggingMixin):
|
||||
else:
|
||||
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(
|
||||
self,
|
||||
pair: str,
|
||||
@ -578,7 +631,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
*,
|
||||
is_short: bool = False,
|
||||
ordertype: Optional[str] = None,
|
||||
enter_tag: Optional[str] = None
|
||||
enter_tag: Optional[str] = None,
|
||||
trade: Optional[Trade] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
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']
|
||||
trade_side = 'short' if is_short else 'long'
|
||||
pos_adjust = trade is not None
|
||||
|
||||
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:
|
||||
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)
|
||||
enter_limit_requested, stake_amount = self.get_valid_enter_price_and_stake(
|
||||
pair, price, stake_amount, side, trade_side, trade)
|
||||
|
||||
if not stake_amount:
|
||||
return False
|
||||
@ -643,16 +664,19 @@ class FreqtradeBot(LoggingMixin):
|
||||
) if self.trading_mode != TradingMode.SPOT else 1.0
|
||||
# Cap leverage between 1.0 and max_leverage.
|
||||
leverage = min(max(leverage, 1.0), max_leverage)
|
||||
|
||||
if pos_adjust:
|
||||
logger.info(f"Position adjust: about to create a new order for {pair} with stake: "
|
||||
f"{stake_amount} for {trade}")
|
||||
else:
|
||||
logger.info(
|
||||
f"{name} signal found: about create a new trade for {pair} with stake_amount: "
|
||||
f"{stake_amount} ..."
|
||||
)
|
||||
f"{stake_amount} ...")
|
||||
|
||||
amount = (stake_amount / enter_limit_requested) * leverage
|
||||
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,
|
||||
time_in_force=time_in_force, current_time=datetime.now(timezone.utc),
|
||||
side=trade_side
|
||||
@ -672,6 +696,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
order_obj = Order.parse_from_ccxt_object(order, pair, side)
|
||||
order_id = order['id']
|
||||
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
|
||||
enter_limit_filled_price = enter_limit_requested
|
||||
@ -717,7 +742,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker')
|
||||
open_date = datetime.now(timezone.utc)
|
||||
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,
|
||||
@ -741,15 +767,29 @@ class FreqtradeBot(LoggingMixin):
|
||||
trading_mode=self.trading_mode,
|
||||
funding_fees=funding_fees
|
||||
)
|
||||
trade.orders.append(order_obj)
|
||||
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.orders.append(order_obj)
|
||||
trade.recalc_trade_from_orders()
|
||||
Trade.query.session.add(trade)
|
||||
Trade.commit()
|
||||
|
||||
# Updating wallets
|
||||
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
|
||||
if order_status == 'closed':
|
||||
@ -757,7 +797,59 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
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:
|
||||
"""
|
||||
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
|
||||
else:
|
||||
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 = {
|
||||
'trade_id': trade.id,
|
||||
@ -776,15 +875,15 @@ class FreqtradeBot(LoggingMixin):
|
||||
'pair': trade.pair,
|
||||
'leverage': trade.leverage if trade.leverage else None,
|
||||
'direction': 'Short' if trade.is_short else 'Long',
|
||||
'limit': trade.open_rate, # Deprecated (?)
|
||||
'open_rate': trade.open_rate,
|
||||
'limit': open_rate, # Deprecated (?)
|
||||
'open_rate': open_rate,
|
||||
'order_type': order_type,
|
||||
'stake_amount': trade.stake_amount,
|
||||
'stake_currency': self.config['stake_currency'],
|
||||
'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(),
|
||||
'current_rate': trade.open_rate_requested,
|
||||
'current_rate': current_rate,
|
||||
}
|
||||
|
||||
# Send the message
|
||||
@ -1163,14 +1262,17 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Using filled to determine the filled amount
|
||||
filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled')
|
||||
if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC):
|
||||
logger.info(
|
||||
'%s order fully cancelled. Removing %s from database.',
|
||||
side, trade
|
||||
)
|
||||
# if trade is not partially completed, just delete the trade
|
||||
logger.info(f'{side} order fully cancelled. Removing {trade} from database.')
|
||||
# if trade is not partially completed and it's the only order, just delete the trade
|
||||
if len(trade.orders) <= 1:
|
||||
trade.delete()
|
||||
was_trade_fully_canceled = True
|
||||
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
|
||||
else:
|
||||
# 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:
|
||||
# if trade is partially complete, edit the stake details for the trade
|
||||
# and close the order
|
||||
@ -1303,13 +1405,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
|
||||
|
||||
# First cancelling stoploss on exchange ...
|
||||
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
|
||||
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}")
|
||||
trade = self.cancel_stoploss_on_exchange(trade)
|
||||
|
||||
order_type = ordertype or self.strategy.order_types[exit_type]
|
||||
if sell_reason.sell_type == SellType.EMERGENCY_SELL:
|
||||
@ -1476,7 +1572,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
return False
|
||||
|
||||
# Update trade with order values
|
||||
logger.info('Found open order for %s', trade)
|
||||
logger.info(f'Found open order for {trade}')
|
||||
try:
|
||||
order = action_order or self.exchange.fetch_order_or_stoploss_order(order_id,
|
||||
trade.pair,
|
||||
@ -1492,29 +1588,26 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Handling of this will happen in check_handle_timedout.
|
||||
return True
|
||||
|
||||
# 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)
|
||||
trade.recalc_open_trade_value()
|
||||
except DependencyException as exception:
|
||||
logger.warning("Could not update trade amount: %s", exception)
|
||||
order = self.handle_order_fee(trade, order)
|
||||
|
||||
trade.update(order)
|
||||
trade.recalc_trade_from_orders()
|
||||
Trade.commit()
|
||||
|
||||
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 send_msg and not stoploss_order and not trade.open_order_id:
|
||||
self._notify_exit(trade, '', True)
|
||||
self.handle_protections(trade.pair)
|
||||
self.wallets.update()
|
||||
elif send_msg and not trade.open_order_id:
|
||||
# Buy fill
|
||||
self._notify_enter(trade, fill=True)
|
||||
self._notify_enter(trade, order, fill=True)
|
||||
|
||||
return False
|
||||
|
||||
@ -1551,6 +1644,18 @@ class FreqtradeBot(LoggingMixin):
|
||||
return real_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:
|
||||
"""
|
||||
Detect and update trade fee.
|
||||
|
@ -7,11 +7,25 @@ from typing import Any, Dict
|
||||
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__)
|
||||
LOGFORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
|
||||
# Initialize bufferhandler - will be used for /log endpoints
|
||||
bufferHandler = BufferingHandler(1000)
|
||||
bufferHandler = FTBufferingHandler(1000)
|
||||
bufferHandler.setFormatter(Formatter(LOGFORMAT))
|
||||
|
||||
|
||||
|
@ -2,11 +2,13 @@
|
||||
Various tool function for Freqtrade and scripts
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, List
|
||||
from typing import Any, Iterator, List, Union
|
||||
from typing.io import IO
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -228,3 +230,34 @@ def parse_db_uri_for_logging(uri: str):
|
||||
return uri
|
||||
pwd = parsed_db_uri.netloc.split(':')[1].split('@')[0]
|
||||
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}')
|
||||
|
@ -12,20 +12,22 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from numpy import nan
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade import constants
|
||||
from freqtrade.configuration import TimeRange, validate_config_consistency
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT
|
||||
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.dataprovider import DataProvider
|
||||
from freqtrade.enums import BacktestState, CandleType, SellType, TradingMode
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
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.optimize.bt_progress import BTProgress
|
||||
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
|
||||
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.protectionmanager import ProtectionManager
|
||||
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
|
||||
@ -63,9 +65,10 @@ class Backtesting:
|
||||
|
||||
LoggingMixin.show_output = False
|
||||
self.config = config
|
||||
self.results: Optional[Dict[str, Any]] = None
|
||||
self.results: Dict[str, Any] = {}
|
||||
|
||||
config['dry_run'] = True
|
||||
self.run_ids: Dict[str, str] = {}
|
||||
self.strategylist: List[IStrategy] = []
|
||||
self.all_results: Dict[str, Dict] = {}
|
||||
self._exchange_name = self.config['exchange']['name']
|
||||
@ -373,12 +376,37 @@ class Backtesting:
|
||||
else:
|
||||
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,
|
||||
sell_row: Tuple) -> Optional[LocalTrade]:
|
||||
# TODO-lev: add interest / funding fees to trade object ->
|
||||
# Must be done either here, or one level higher ->
|
||||
# (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()
|
||||
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]
|
||||
@ -462,17 +490,14 @@ class Backtesting:
|
||||
else:
|
||||
return self._get_sell_trade_entry_for_candle(trade, sell_row)
|
||||
|
||||
def _enter_trade(self, pair: str, row: List, direction: str) -> Optional[LocalTrade]:
|
||||
try:
|
||||
stake_amount = self.wallets.get_trade_stake_amount(pair, None)
|
||||
except DependencyException:
|
||||
return None
|
||||
def _enter_trade(self, pair: str, row: Tuple, direction: str,
|
||||
stake_amount: Optional[float] = None,
|
||||
trade: Optional[LocalTrade] = None) -> Optional[LocalTrade]:
|
||||
current_time = row[DATE_IDX].to_pydatetime()
|
||||
|
||||
# let's call the custom entry price, using the open price as default price
|
||||
propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
|
||||
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
|
||||
|
||||
# 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
|
||||
max_stake_amount = self.wallets.get_available_stake_amount()
|
||||
|
||||
pos_adjust = trade is not None
|
||||
if not pos_adjust:
|
||||
try:
|
||||
stake_amount = self.wallets.get_trade_stake_amount(pair, None)
|
||||
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)
|
||||
|
||||
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)
|
||||
leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)(
|
||||
@ -506,6 +541,7 @@ class Backtesting:
|
||||
order_type = self.strategy.order_types['buy']
|
||||
time_in_force = self.strategy.order_time_in_force['sell']
|
||||
# Confirm trade entry:
|
||||
if not pos_adjust:
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate,
|
||||
time_in_force=time_in_force, current_time=current_time,
|
||||
@ -513,24 +549,46 @@ class Backtesting:
|
||||
return None
|
||||
|
||||
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
|
||||
amount = round(stake_amount / propose_rate, 8)
|
||||
if trade is None:
|
||||
# Enter trade
|
||||
has_enter_tag = len(row) >= ENTER_TAG_IDX + 1
|
||||
has_buy_tag = len(row) >= ENTER_TAG_IDX + 1
|
||||
trade = LocalTrade(
|
||||
pair=pair,
|
||||
open_rate=row[OPEN_IDX],
|
||||
open_rate=propose_rate,
|
||||
open_date=current_time,
|
||||
stake_amount=stake_amount,
|
||||
amount=round((stake_amount / propose_rate) * leverage, 8),
|
||||
fee_open=self.fee,
|
||||
fee_close=self.fee,
|
||||
is_open=True,
|
||||
enter_tag=row[ENTER_TAG_IDX] if has_enter_tag else None,
|
||||
enter_tag=row[ENTER_TAG_IDX] if has_buy_tag else None,
|
||||
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
|
||||
)
|
||||
trade.orders.append(order)
|
||||
if pos_adjust:
|
||||
trade.recalc_trade_from_orders()
|
||||
|
||||
return trade
|
||||
return None
|
||||
|
||||
def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
|
||||
data: Dict[str, List[Tuple]]) -> List[LocalTrade]:
|
||||
@ -734,6 +792,7 @@ class Backtesting:
|
||||
)
|
||||
backtest_end_time = datetime.now(timezone.utc)
|
||||
results.update({
|
||||
'run_id': self.run_ids.get(strat.get_strategy_name(), ''),
|
||||
'backtest_start_time': int(backtest_start_time.timestamp()),
|
||||
'backtest_end_time': int(backtest_end_time.timestamp()),
|
||||
})
|
||||
@ -741,6 +800,33 @@ class Backtesting:
|
||||
|
||||
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:
|
||||
"""
|
||||
Run backtesting end-to-end
|
||||
@ -752,15 +838,38 @@ class Backtesting:
|
||||
self.load_bt_data_detail()
|
||||
logger.info("Dataload complete. Calculating indicators")
|
||||
|
||||
for strat in self.strategylist:
|
||||
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
|
||||
if len(self.strategylist) > 0:
|
||||
self.load_prior_backtest()
|
||||
|
||||
self.results = generate_backtest_stats(data, self.all_results,
|
||||
min_date=min_date, max_date=max_date)
|
||||
for strat in self.strategylist:
|
||||
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':
|
||||
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(self.config, self.results)
|
||||
|
@ -34,7 +34,7 @@ class EdgeCli:
|
||||
self.config['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT
|
||||
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], 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)
|
||||
|
||||
|
@ -137,6 +137,7 @@ class HyperoptTools():
|
||||
}
|
||||
if not HyperoptTools._test_hyperopt_results_exist(results_file):
|
||||
# No file found.
|
||||
logger.warning(f"Hyperopt file {results_file} not found.")
|
||||
return [], 0
|
||||
|
||||
epochs = []
|
||||
|
@ -11,7 +11,8 @@ from tabulate import tabulate
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT
|
||||
from freqtrade.data.btanalysis import (calculate_csum, calculate_market_change,
|
||||
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__)
|
||||
@ -33,6 +34,11 @@ def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> N
|
||||
recordfilename.parent,
|
||||
f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
|
||||
).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)
|
||||
|
||||
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
|
||||
: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')
|
||||
metadata = {}
|
||||
pairlist = list(btdata.keys())
|
||||
for strategy, content in all_results.items():
|
||||
strat_stats = generate_strategy_stats(pairlist, strategy, content,
|
||||
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
|
||||
|
||||
strategy_results = generate_strategy_comparison(bt_stats=result['strategy'])
|
||||
|
||||
result['metadata'] = metadata
|
||||
result['strategy_comparison'] = strategy_results
|
||||
|
||||
return result
|
||||
|
@ -559,11 +559,11 @@ class LocalTrade():
|
||||
self.amount = float(safe_value_fallback(order, 'filled', 'amount'))
|
||||
if 'leverage' in order:
|
||||
self.leverage = order['leverage']
|
||||
self.recalc_open_trade_value()
|
||||
if self.is_open:
|
||||
payment = "SELL" if self.is_short else "BUY"
|
||||
logger.info(f'{order_type.upper()}_{payment} has been fulfilled for {self}.')
|
||||
self.open_order_id = None
|
||||
self.recalc_trade_from_orders()
|
||||
elif order_type in ('market', 'limit') and self.exit_side == order['side']:
|
||||
if self.is_open:
|
||||
payment = "BUY" if self.is_short else "SELL"
|
||||
@ -795,6 +795,37 @@ class LocalTrade():
|
||||
|
||||
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]:
|
||||
"""
|
||||
Finds latest order for this orderside and status
|
||||
@ -810,6 +841,34 @@ class LocalTrade():
|
||||
else:
|
||||
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
|
||||
def get_trades_proxy(*, pair: str = None, is_open: bool = None,
|
||||
open_date: datetime = None, close_date: datetime = None,
|
||||
@ -897,7 +956,7 @@ class Trade(_DECL_BASE, LocalTrade):
|
||||
|
||||
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)
|
||||
pair = Column(String(25), nullable=False, index=True)
|
||||
|
@ -235,8 +235,10 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
|
||||
# Trades can be empty
|
||||
if trades is not None and len(trades) > 0:
|
||||
# Create description for sell summarizing the trade
|
||||
trades['desc'] = trades.apply(lambda row: f"{row['profit_ratio']:.2%}, "
|
||||
f"{row['sell_reason']}, "
|
||||
trades['desc'] = trades.apply(
|
||||
lambda row: f"{row['profit_ratio']:.2%}, " +
|
||||
(f"{row['enter_tag']}, " if row['enter_tag'] is not None else "") +
|
||||
f"{row['sell_reason']}, " +
|
||||
f"{row['trade_duration']} min",
|
||||
axis=1)
|
||||
trade_buys = go.Scatter(
|
||||
|
@ -47,7 +47,7 @@ class SpreadFilter(IPairList):
|
||||
spread = 1 - ticker['bid'] / ticker['ask']
|
||||
if spread > self._max_spread_ratio:
|
||||
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)
|
||||
return False
|
||||
else:
|
||||
|
@ -96,7 +96,8 @@ class StrategyResolver(IResolver):
|
||||
("ignore_roi_if_buy_signal", False),
|
||||
("sell_profit_offset", 0.0),
|
||||
("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:
|
||||
StrategyResolver._override_attribute_helper(strategy, config,
|
||||
|
@ -39,7 +39,8 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
# Start backtesting
|
||||
# Initialize backtesting object
|
||||
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
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
try:
|
||||
@ -76,13 +77,25 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
lastconfig['enable_protections'] = btconfig.get('enable_protections')
|
||||
lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet')
|
||||
|
||||
ApiServer._bt.results = {}
|
||||
ApiServer._bt.load_prior_backtest()
|
||||
|
||||
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)
|
||||
|
||||
logger.info("Backtest finished.")
|
||||
|
||||
except DependencyException as e:
|
||||
|
@ -281,6 +281,7 @@ class ForceBuyPayload(BaseModel):
|
||||
pair: str
|
||||
price: Optional[float]
|
||||
ordertype: Optional[OrderTypeValues]
|
||||
stakeamount: Optional[float]
|
||||
|
||||
|
||||
class ForceSellPayload(BaseModel):
|
||||
|
@ -21,7 +21,7 @@ from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, Blac
|
||||
Stats, StatusMsg, StrategyListResponse,
|
||||
StrategyResponse, SysInfo, Version,
|
||||
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
|
||||
|
||||
|
||||
@ -32,7 +32,8 @@ logger = logging.getLogger(__name__)
|
||||
# Version increments should happen in "small" steps (1.1, 1.12, ...) unless big changes happen.
|
||||
# 1.11: forcebuy and forcesell accept ordertype
|
||||
# 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.
|
||||
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'])
|
||||
def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)):
|
||||
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:
|
||||
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'])
|
||||
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.update({
|
||||
'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'])
|
||||
|
@ -1,5 +1,7 @@
|
||||
from typing import Any, Dict, Iterator, Optional
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.rpc.rpc import RPC, RPCException
|
||||
|
||||
@ -28,3 +30,11 @@ def get_config() -> Dict[str, Any]:
|
||||
|
||||
def get_api_config() -> Dict[str, Any]:
|
||||
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
|
||||
|
@ -41,6 +41,8 @@ class ApiServer(RPCHandler):
|
||||
_has_rpc: bool = False
|
||||
_bgtask_running: bool = False
|
||||
_config: Dict[str, Any] = {}
|
||||
# Exchange - only available in webserver mode.
|
||||
_exchange = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""
|
||||
|
@ -243,19 +243,25 @@ class RPC:
|
||||
profit_str += f" ({fiat_profit:.2f})"
|
||||
fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \
|
||||
else fiat_profit_sum + fiat_profit
|
||||
trades_list.append([
|
||||
detail_trade = [
|
||||
f'{trade.id} {direction_str}',
|
||||
trade.pair + ('*' if (trade.open_order_id is not None
|
||||
and trade.close_rate_requested is None) else '')
|
||||
+ ('**' if (trade.close_rate_requested is not None) else ''),
|
||||
shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)),
|
||||
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"
|
||||
if self._fiat_converter:
|
||||
profitcol += " (" + fiat_display_currency + ")"
|
||||
|
||||
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
|
||||
|
||||
def _rpc_daily_profit(
|
||||
@ -598,6 +604,7 @@ class RPC:
|
||||
value = self._fiat_converter.convert_amount(
|
||||
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 = (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
|
||||
@ -614,6 +621,7 @@ class RPC:
|
||||
'starting_capital_fiat': starting_cap_fiat,
|
||||
'starting_capital_fiat_ratio': starting_cap_fiat_ratio,
|
||||
'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 ''
|
||||
}
|
||||
|
||||
@ -705,8 +713,8 @@ class RPC:
|
||||
self._freqtrade.wallets.update()
|
||||
return {'result': f'Created sell order for trade {trade_id}.'}
|
||||
|
||||
def _rpc_forcebuy(self, pair: str, price: Optional[float],
|
||||
order_type: Optional[str] = None) -> Optional[Trade]:
|
||||
def _rpc_forcebuy(self, pair: str, price: Optional[float], order_type: Optional[str] = None,
|
||||
stake_amount: Optional[float] = None) -> Optional[Trade]:
|
||||
"""
|
||||
Handler for forcebuy <asset> <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
|
||||
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
||||
if trade:
|
||||
if not self._freqtrade.strategy.position_adjustment_enable:
|
||||
raise RPCException(f'position for {pair} already open - id: {trade.id}')
|
||||
|
||||
if not stake_amount:
|
||||
# gen stake amount
|
||||
stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair)
|
||||
stake_amount = self._freqtrade.wallets.get_trade_stake_amount(pair)
|
||||
|
||||
# execute buy
|
||||
if not order_type:
|
||||
order_type = self._freqtrade.strategy.order_types.get(
|
||||
'forcebuy', self._freqtrade.strategy.order_types['buy'])
|
||||
if self._freqtrade.execute_entry(pair, stakeamount, price, ordertype=order_type):
|
||||
if self._freqtrade.execute_entry(pair, stake_amount, price,
|
||||
ordertype=order_type, trade=trade):
|
||||
Trade.commit()
|
||||
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
||||
return trade
|
||||
@ -992,7 +1003,7 @@ class RPC:
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
|
||||
_data = load_data(
|
||||
@ -1007,7 +1018,7 @@ class RPC:
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.resolvers.strategy_resolver import StrategyResolver
|
||||
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})
|
||||
|
||||
|
@ -85,12 +85,14 @@ class RPCManager:
|
||||
timeframe = config['timeframe']
|
||||
exchange_name = config['exchange']['name']
|
||||
strategy_name = config.get('strategy', '')
|
||||
pos_adjust_enabled = 'On' if config['position_adjustment_enable'] else 'Off'
|
||||
self.send_msg({
|
||||
'type': RPCMessageType.STARTUP,
|
||||
'status': f'*Exchange:* `{exchange_name}`\n'
|
||||
f'*Stake per trade:* `{stake_amount} {stake_currency}`\n'
|
||||
f'*Minimum ROI:* `{minimal_roi}`\n'
|
||||
f'*{"Trailing " if trailing_stop else ""}Stoploss:* `{stoploss}`\n'
|
||||
f'*Position adjustment:* `{pos_adjust_enabled}`\n'
|
||||
f'*Timeframe:* `{timeframe}`\n'
|
||||
f'*Strategy:* `{strategy_name}`'
|
||||
})
|
||||
|
@ -781,14 +781,17 @@ class Telegram(RPCHandler):
|
||||
f"(< {balance_dust_level} {result['stake']}):*\n"
|
||||
f"\t`Est. {result['stake']}: "
|
||||
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"
|
||||
f"\t`{result['stake']}: "
|
||||
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"{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",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
|
@ -106,6 +106,9 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
sell_profit_offset: float
|
||||
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
|
||||
ignore_buying_expired_candle_after: int = 0
|
||||
|
||||
@ -383,6 +386,28 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
"""
|
||||
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,
|
||||
proposed_leverage: float, max_leverage: float, side: str,
|
||||
**kwargs) -> float:
|
||||
@ -687,6 +712,8 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
enter = latest[SignalType.ENTER_LONG.value] == 1
|
||||
exit_ = latest.get(SignalType.EXIT_LONG.value, 0) == 1
|
||||
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}) "
|
||||
f"enter={enter} exit={exit_}")
|
||||
@ -726,6 +753,8 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
enter_signal = SignalDirection.SHORT
|
||||
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)
|
||||
|
||||
if self.ignore_expired_candle(
|
||||
|
@ -15,7 +15,8 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30,
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
|
@ -25,6 +25,9 @@ multi_line_output=0
|
||||
lines_after_imports=2
|
||||
skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools >= 46.4.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
@ -5,25 +5,25 @@
|
||||
|
||||
coveralls==3.3.1
|
||||
flake8==4.0.1
|
||||
flake8-tidy-imports==4.5.0
|
||||
mypy==0.930
|
||||
flake8-tidy-imports==4.6.0
|
||||
mypy==0.931
|
||||
pytest==6.2.5
|
||||
pytest-asyncio==0.16.0
|
||||
pytest-asyncio==0.17.1
|
||||
pytest-cov==3.0.0
|
||||
pytest-mock==3.6.1
|
||||
pytest-random-order==1.0.4
|
||||
isort==5.10.1
|
||||
# For datetime mocking
|
||||
time-machine==2.5.0
|
||||
time-machine==2.6.0
|
||||
|
||||
# Convert jupyter notebooks to markdown documents
|
||||
nbconvert==6.3.0
|
||||
nbconvert==6.4.0
|
||||
|
||||
# mypy types
|
||||
types-cachetools==4.2.7
|
||||
types-filelock==3.2.1
|
||||
types-requests==2.26.3
|
||||
types-tabulate==0.8.4
|
||||
types-cachetools==4.2.9
|
||||
types-filelock==3.2.4
|
||||
types-requests==2.27.7
|
||||
types-tabulate==0.8.5
|
||||
|
||||
# Extensions to datetime library
|
||||
types-python-dateutil==2.8.4
|
||||
types-python-dateutil==2.8.7
|
@ -7,4 +7,4 @@ scikit-learn==1.0.2
|
||||
scikit-optimize==0.9.0
|
||||
filelock==3.4.2
|
||||
joblib==1.1.0
|
||||
progressbar2==3.55.0
|
||||
progressbar2==4.0.0
|
||||
|
@ -1,20 +1,20 @@
|
||||
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-ta==0.3.14b
|
||||
|
||||
ccxt==1.66.32
|
||||
ccxt==1.68.20
|
||||
# Pin cryptography for now due to rust build errors with piwheels
|
||||
cryptography==36.0.1
|
||||
aiohttp==3.8.1
|
||||
SQLAlchemy==1.4.29
|
||||
python-telegram-bot==13.9
|
||||
python-telegram-bot==13.10
|
||||
arrow==1.2.1
|
||||
cachetools==4.2.2
|
||||
requests==2.26.0
|
||||
urllib3==1.26.7
|
||||
jsonschema==4.3.3
|
||||
TA-Lib==0.4.23
|
||||
requests==2.27.1
|
||||
urllib3==1.26.8
|
||||
jsonschema==4.4.0
|
||||
TA-Lib==0.4.24
|
||||
technical==1.3.0
|
||||
tabulate==0.8.9
|
||||
pycoingecko==2.2.0
|
||||
@ -32,8 +32,8 @@ python-rapidjson==1.5
|
||||
sdnotify==0.3.2
|
||||
|
||||
# API Server
|
||||
fastapi==0.70.1
|
||||
uvicorn==0.16.0
|
||||
fastapi==0.72.0
|
||||
uvicorn==0.17.0
|
||||
pyjwt==2.3.0
|
||||
aiofiles==0.8.0
|
||||
psutil==5.9.0
|
||||
|
2
setup.py
2
setup.py
@ -10,7 +10,6 @@ hyperopt = [
|
||||
'filelock',
|
||||
'joblib',
|
||||
'progressbar2',
|
||||
'psutil',
|
||||
]
|
||||
|
||||
develop = [
|
||||
@ -69,6 +68,7 @@ setup(
|
||||
'blosc',
|
||||
'fastapi',
|
||||
'uvicorn',
|
||||
'psutil',
|
||||
'pyjwt',
|
||||
'aiofiles',
|
||||
'schedule'
|
||||
|
@ -13,7 +13,8 @@ from freqtrade.data.btanalysis import (BT_DATA_COLUMNS, analyze_trade_parallelis
|
||||
calculate_underwater, combine_dataframes_with_mean,
|
||||
create_cum_profit, extract_trades_of_period,
|
||||
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.exceptions import OperationalException
|
||||
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)
|
||||
|
||||
|
||||
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')
|
||||
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))
|
||||
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):
|
||||
|
||||
|
@ -40,7 +40,7 @@ EXCHANGES = {
|
||||
},
|
||||
'ftx': {
|
||||
'pair': 'BTC/USD',
|
||||
'stake_currency': 'USDT',
|
||||
'stake_currency': 'USD',
|
||||
'hasQuoteVolume': True,
|
||||
'timeframe': '5m',
|
||||
'futures_pair': 'BTC/USD:USD',
|
||||
|
@ -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_closed['status'] == 'open'
|
||||
assert not order['fee']
|
||||
assert order_closed['filled'] == 0
|
||||
|
||||
order_book_l2_usd.reset_mock()
|
||||
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'])
|
||||
assert order_closed['status'] == 'closed'
|
||||
assert order['fee']
|
||||
assert order_closed['filled'] == 1
|
||||
assert order_closed['filled'] == order_closed['amount']
|
||||
|
||||
# Empty orderbook test
|
||||
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["symbol"] == "LTC/USDT"
|
||||
assert order['status'] == 'closed'
|
||||
assert order['filled'] == amount
|
||||
assert round(order["average"], 4) == round(endprice, 4)
|
||||
|
||||
|
||||
|
@ -11,6 +11,7 @@ import pandas as pd
|
||||
import pytest
|
||||
from arrow import Arrow
|
||||
|
||||
from freqtrade import constants
|
||||
from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_backtesting
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.data import history
|
||||
@ -20,6 +21,7 @@ from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.data.history import get_timerange
|
||||
from freqtrade.enums import RunMode, SellType
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
from freqtrade.misc import get_strategy_run_id
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
from freqtrade.persistence import LocalTrade
|
||||
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 'SELL REASON STATS' 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)
|
||||
|
83
tests/optimize/test_backtesting_adjust_position.py
Normal file
83
tests/optimize/test_backtesting_adjust_position.py
Normal 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))
|
@ -10,7 +10,7 @@ import rapidjson
|
||||
from freqtrade.constants import FTHYPT_FILEVERSION
|
||||
from freqtrade.exceptions import OperationalException
|
||||
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
|
||||
@ -24,6 +24,7 @@ def test_save_results_saves_epochs(hyperopt, tmpdir, caplog) -> None:
|
||||
hyperopt.results_file = Path(tmpdir / 'ut_results.fthypt')
|
||||
|
||||
hyperopt_epochs = HyperoptTools.load_filtered_results(hyperopt.results_file, {})
|
||||
assert log_has_re("Hyperopt file .* not found.", caplog)
|
||||
assert hyperopt_epochs == ([], 0)
|
||||
|
||||
# Test writing to temp dir and reading again
|
||||
|
@ -86,6 +86,7 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir):
|
||||
'rejected_signals': 20,
|
||||
'backtest_start_time': Arrow.utcnow().int_timestamp,
|
||||
'backtest_end_time': Arrow.utcnow().int_timestamp,
|
||||
'run_id': '123',
|
||||
}
|
||||
}
|
||||
timerange = TimeRange.parse_timerange('1510688220-1510700340')
|
||||
@ -135,6 +136,7 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir):
|
||||
'rejected_signals': 20,
|
||||
'backtest_start_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')
|
||||
|
||||
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 str(dump_mock.call_args_list[0][0][0]).startswith(str(testdatadir/'backtest-result'))
|
||||
|
||||
dump_mock.reset_mock()
|
||||
filename = testdatadir / 'testresult.json'
|
||||
store_backtest_stats(filename, {})
|
||||
assert dump_mock.call_count == 2
|
||||
store_backtest_stats(filename, {'metadata': {}})
|
||||
assert dump_mock.call_count == 3
|
||||
assert isinstance(dump_mock.call_args_list[0][0][0], Path)
|
||||
# result will be testdatadir / testresult-<timestamp>.json
|
||||
assert str(dump_mock.call_args_list[0][0][0]).startswith(str(testdatadir / 'testresult'))
|
||||
|
@ -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')
|
||||
assert "Since" in headers
|
||||
assert "Pair" in headers
|
||||
assert len(result[0]) == 4
|
||||
assert 'instantly' == result[0][2]
|
||||
assert 'ETH/BTC' in result[0][1]
|
||||
assert '-0.41% (-0.06)' == result[0][3]
|
||||
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',
|
||||
MagicMock(side_effect=ExchangeError("Pair 'ETH/BTC' not available")))
|
||||
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,
|
||||
match=r'Wrong pair selected. Only pairs with stake-currency.*'):
|
||||
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
|
||||
pair = 'XRP/BTC'
|
||||
freqtradebot = get_patched_freqtradebot(mocker, default_conf)
|
||||
freqtradebot.config['stake_amount'] = 0
|
||||
patch_get_signal(freqtradebot)
|
||||
|
@ -1411,7 +1411,7 @@ def test_sysinfo(botclient):
|
||||
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
|
||||
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 not result['running']
|
||||
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
|
||||
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))
|
||||
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.
|
||||
rc = client_delete(client, f"{BASE_URI}/backtest")
|
||||
assert_response(rc)
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import reduce
|
||||
from random import choice, randint
|
||||
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)
|
||||
trade.update(limit_sell_order)
|
||||
|
||||
trade.close_date = datetime.utcnow()
|
||||
trade.close_date = datetime.now(timezone.utc)
|
||||
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 '*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}%)`'
|
||||
|
@ -1,9 +1,12 @@
|
||||
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
import freqtrade.vendor.qtpylib.indicators as qtpylib
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.strategy import IStrategy
|
||||
|
||||
|
||||
@ -48,6 +51,9 @@ class StrategyTestV2(IStrategy):
|
||||
'sell': 'gtc',
|
||||
}
|
||||
|
||||
# By default this strategy does not use Position Adjustments
|
||||
position_adjustment_enable = False
|
||||
|
||||
def informative_pairs(self):
|
||||
"""
|
||||
Define additional, informative pair/interval combinations to be cached from the exchange.
|
||||
@ -154,3 +160,12 @@ class StrategyTestV2(IStrategy):
|
||||
),
|
||||
'sell'] = 1
|
||||
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
|
||||
|
@ -6,6 +6,7 @@ import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
|
||||
import freqtrade.vendor.qtpylib.indicators as qtpylib
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.strategy import (BooleanParameter, DecimalParameter, IntParameter, IStrategy,
|
||||
RealParameter)
|
||||
|
||||
@ -178,3 +179,12 @@ class StrategyTestV3(IStrategy):
|
||||
# Bot-logic must make sure it's an allowed leverage and eventually adjust accordingly.
|
||||
|
||||
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
|
||||
|
@ -38,6 +38,9 @@ def test_returns_latest_signal(ohlcv_history):
|
||||
mocked_history['exit_long'] = 0
|
||||
mocked_history['enter_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
|
||||
|
||||
assert _STRATEGY.get_entry_signal('ETH/BTC', '5m', mocked_history) == (None, None)
|
||||
|
@ -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.enums import RunMode
|
||||
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,
|
||||
patched_configuration_load_config_file)
|
||||
|
||||
@ -687,7 +687,7 @@ def test_set_loggers_syslog():
|
||||
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.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.
|
||||
setup_logging(config)
|
||||
assert len(logger.handlers) == 3
|
||||
@ -710,7 +710,7 @@ def test_set_loggers_Filehandler(tmpdir):
|
||||
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.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.
|
||||
setup_logging(config)
|
||||
assert len(logger.handlers) == 3
|
||||
|
@ -5,6 +5,7 @@ import logging
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from math import isclose
|
||||
from typing import List
|
||||
from unittest.mock import ANY, MagicMock, PropertyMock
|
||||
|
||||
import arrow
|
||||
@ -4937,3 +4938,245 @@ def test_update_funding_fees(
|
||||
trade.amount *
|
||||
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'
|
||||
|
@ -127,8 +127,7 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
|
||||
(1, 200),
|
||||
(0.99, 198),
|
||||
])
|
||||
def test_forcebuy_last_unlimited(default_conf, ticker, fee, limit_buy_order, mocker, balance_ratio,
|
||||
result1) -> None:
|
||||
def test_forcebuy_last_unlimited(default_conf, ticker, fee, mocker, balance_ratio, result1) -> None:
|
||||
"""
|
||||
Tests workflow unlimited stake-amount
|
||||
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 'LTC' in bals
|
||||
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
|
||||
|
@ -2146,3 +2146,367 @@ def test_Trade_object_idem():
|
||||
and item not in ('trades', 'trades_open', 'total_profit')
|
||||
and type(getattr(LocalTrade, item)) not in (property, FunctionType)):
|
||||
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
|
||||
|
@ -171,7 +171,7 @@ def test_plot_trades(testdatadir, caplog):
|
||||
assert len(trades) == len(trade_buy.x)
|
||||
assert trade_buy.marker.color == 'cyan'
|
||||
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')
|
||||
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 trade_sell.marker.color == 'green'
|
||||
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')
|
||||
assert isinstance(trade_sell_loss, go.Scatter)
|
||||
|
2
tests/testdata/backtest-result_new.json
vendored
2
tests/testdata/backtest-result_new.json
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user