diff --git a/Dockerfile b/Dockerfile index f2d7c8a40..804b1086b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.5-slim-buster as base +FROM python:3.9.6-slim-buster as base # Setup env ENV LANG C.UTF-8 diff --git a/README.md b/README.md index c665508dd..4082995f0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Please read the [exchange specific notes](docs/exchanges.md) to learn about even Exchanges confirmed working by the community: - [X] [Bitvavo](https://bitvavo.com/) +- [X] [Kukoin](https://www.kucoin.com/) ## Documentation diff --git a/TODO b/TODO deleted file mode 100644 index c89e7ddf0..000000000 --- a/TODO +++ /dev/null @@ -1,61 +0,0 @@ -List of things TODO to add margin trading - -Files to edit - freqtrade/freqtradebot.py - freqtrade/wallets.py - freqtrade/data/btanalysis.py - configuration - freqtrade/commands/deploy_commands.py - freqtrade/commands/arguments.py - freqtrade/strategy - freqtrade/constants.py - -Tests - tests/test_persistence.pys - init with - lev & bor - lev - bor - neither lev nor bor - adjust_stop_loss - short - leverage - is_opening_trade - short - long - shortBuy - longSell - is_closing_trade - short - long - shortBuy - longSell - update, close, update fee - possible to test? - calc_profit - * * create a few shorts, a few leveraged longs test correct ratio - calc_profit_ratio - * create a few shorts, a few leveraged longs test correct ratio - get_open_trades - * create a short, check if exists - - tests/test_freqtradebot.py - -later - freqtrade/commands/build_config_commands.py - freqtrade/commands/cli_options.py - freqtrade/commands/list_commands.py - freqtrade/commands/hyperopt_commands.py - config_binance.json.example - config_kraken.json.example - freqtrade/enums/selltype.py - -Did not look at these files - freqtrade/plot/plotting.py - freqtrade/plugins - freqtrade/resolvers/strategy_resolver.py - freqtrade/rpc - -Already Edited - freqtrade/persistence/migrations.py - freqtrade/persistence/models.py \ No newline at end of file diff --git a/config_binance.json.example b/config_binance.json.example index 4fa615d6d..938bc9342 100644 --- a/config_binance.json.example +++ b/config_binance.json.example @@ -13,7 +13,7 @@ }, "bid_strategy": { "ask_last_balance": 0.0, - "use_order_book": false, + "use_order_book": true, "order_book_top": 1, "check_depth_of_market": { "enabled": false, @@ -21,12 +21,8 @@ } }, "ask_strategy": { - "use_order_book": false, - "order_book_min": 1, - "order_book_max": 1, - "use_sell_signal": true, - "sell_profit_only": false, - "ignore_roi_if_buy_signal": false + "use_order_book": true, + "order_book_top": 1 }, "exchange": { "name": "binance", diff --git a/config_bittrex.json.example b/config_bittrex.json.example index 172cfcfc3..4352d8822 100644 --- a/config_bittrex.json.example +++ b/config_bittrex.json.example @@ -12,7 +12,7 @@ "sell": 30 }, "bid_strategy": { - "use_order_book": false, + "use_order_book": true, "ask_last_balance": 0.0, "order_book_top": 1, "check_depth_of_market": { @@ -21,12 +21,8 @@ } }, "ask_strategy":{ - "use_order_book": false, - "order_book_min": 1, - "order_book_max": 1, - "use_sell_signal": true, - "sell_profit_only": false, - "ignore_roi_if_buy_signal": false + "use_order_book": true, + "order_book_top": 1 }, "exchange": { "name": "bittrex", diff --git a/config_ftx.json.example b/config_ftx.json.example index facd54b25..48651f04c 100644 --- a/config_ftx.json.example +++ b/config_ftx.json.example @@ -13,7 +13,7 @@ }, "bid_strategy": { "ask_last_balance": 0.0, - "use_order_book": false, + "use_order_book": true, "order_book_top": 1, "check_depth_of_market": { "enabled": false, @@ -21,12 +21,8 @@ } }, "ask_strategy": { - "use_order_book": false, - "order_book_min": 1, - "order_book_max": 1, - "use_sell_signal": true, - "sell_profit_only": false, - "ignore_roi_if_buy_signal": false + "use_order_book": true, + "order_book_top": 1 }, "exchange": { "name": "ftx", diff --git a/config_full.json.example b/config_full.json.example index bc9f33f96..d404391a4 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -14,6 +14,10 @@ "trailing_stop_positive": 0.005, "trailing_stop_positive_offset": 0.0051, "trailing_only_offset_is_reached": false, + "use_sell_signal": true, + "sell_profit_only": false, + "sell_profit_offset": 0.0, + "ignore_roi_if_buy_signal": false, "minimal_roi": { "40": 0.0, "30": 0.01, @@ -28,7 +32,7 @@ }, "bid_strategy": { "price_side": "bid", - "use_order_book": false, + "use_order_book": true, "ask_last_balance": 0.0, "order_book_top": 1, "check_depth_of_market": { @@ -38,13 +42,8 @@ }, "ask_strategy":{ "price_side": "ask", - "use_order_book": false, - "order_book_min": 1, - "order_book_max": 1, - "use_sell_signal": true, - "sell_profit_only": false, - "sell_profit_offset": 0.0, - "ignore_roi_if_buy_signal": false + "use_order_book": true, + "order_book_top": 1 }, "order_types": { "buy": "limit", diff --git a/config_kraken.json.example b/config_kraken.json.example index 3cd90e5d3..bf3548568 100644 --- a/config_kraken.json.example +++ b/config_kraken.json.example @@ -12,7 +12,7 @@ "sell": 30 }, "bid_strategy": { - "use_order_book": false, + "use_order_book": true, "ask_last_balance": 0.0, "order_book_top": 1, "check_depth_of_market": { @@ -21,12 +21,8 @@ } }, "ask_strategy":{ - "use_order_book": false, - "order_book_min": 1, - "order_book_max": 1, - "use_sell_signal": true, - "sell_profit_only": false, - "ignore_roi_if_buy_signal": false + "use_order_book": true, + "order_book_top": 1 }, "exchange": { "name": "kraken", diff --git a/docs/advanced-hyperopt.md b/docs/advanced-hyperopt.md index 35fd3de4a..5e71df67c 100644 --- a/docs/advanced-hyperopt.md +++ b/docs/advanced-hyperopt.md @@ -32,6 +32,7 @@ class SuperDuperHyperOptLoss(IHyperOptLoss): def hyperopt_loss_function(results: DataFrame, trade_count: int, min_date: datetime, max_date: datetime, config: Dict, processed: Dict[str, DataFrame], + backtest_stats: Dict[str, Any], *args, **kwargs) -> float: """ Objective function, returns smaller number for better results @@ -53,7 +54,7 @@ class SuperDuperHyperOptLoss(IHyperOptLoss): Currently, the arguments are: -* `results`: DataFrame containing the result +* `results`: DataFrame containing the resulting trades. The following columns are available in results (corresponds to the output-file of backtesting when used with `--export trades`): `pair, profit_ratio, profit_abs, open_date, open_rate, fee_open, close_date, close_rate, fee_close, amount, trade_duration, is_open, sell_reason, stake_amount, min_rate, max_rate, stop_loss_ratio, stop_loss_abs` * `trade_count`: Amount of trades (identical to `len(results)`) @@ -61,6 +62,7 @@ Currently, the arguments are: * `min_date`: End date of the timerange used * `config`: Config object used (Note: Not all strategy-related parameters will be updated here if they are part of a hyperopt space). * `processed`: Dict of Dataframes with the pair as keys containing the data used for backtesting. +* `backtest_stats`: Backtesting statistics using the same format as the backtesting file "strategy" substructure. Available fields can be seen in `generate_strategy_stats()` in `optimize_reports.py`. This function needs to return a floating point number (`float`). Smaller numbers will be interpreted as better results. The parameters and balancing for this is up to you. diff --git a/docs/backtesting.md b/docs/backtesting.md index 4899b1dad..89980c670 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -302,7 +302,6 @@ A backtesting result will look like that: | Days win/draw/lose | 12 / 82 / 25 | | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | -| Zero Duration Trades | 4.6% (20) | | Rejected Buy signals | 3089 | | | | | Min balance | 0.00945123 BTC | @@ -390,7 +389,6 @@ It contains some useful key metrics about performance of your strategy on backte | Days win/draw/lose | 12 / 82 / 25 | | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | -| Zero Duration Trades | 4.6% (20) | | Rejected Buy signals | 3089 | | | | | Min balance | 0.00945123 BTC | @@ -420,7 +418,6 @@ It contains some useful key metrics about performance of your strategy on backte - `Best day` / `Worst day`: Best and worst day based on daily profit. - `Days win/draw/lose`: Winning / Losing days (draws are usually days without closed trade). - `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades. -- `Zero Duration Trades`: A number of trades that completed within same candle as they opened and had `trailing_stop_loss` sell reason. A significant amount of such trades may indicate that strategy is exploiting trailing stoploss behavior in backtesting and produces unrealistic results. - `Rejected Buy signals`: Buy signals that could not be acted upon due to max_open_trades being reached. - `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period. - `Drawdown`: Maximum drawdown experienced. For example, the value of 50% means that from highest to subsequent lowest point, a 50% drop was experienced). diff --git a/docs/configuration.md b/docs/configuration.md index 8b85e9e96..5c6236e58 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,19 +74,18 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `bid_strategy.price_side` | Select the side of the spread the bot should look at to get the buy rate. [More information below](#buy-price-side).
*Defaults to `bid`.*
**Datatype:** String (either `ask` or `bid`). | `bid_strategy.ask_last_balance` | **Required.** Interpolate the bidding price. More information [below](#buy-price-without-orderbook-enabled). | `bid_strategy.use_order_book` | Enable buying using the rates in [Order Book Bids](#buy-price-with-orderbook-enabled).
**Datatype:** Boolean -| `bid_strategy.order_book_top` | Bot will use the top N rate in Order Book Bids to buy. I.e. a value of 2 will allow the bot to pick the 2nd bid rate in [Order Book Bids](#buy-price-with-orderbook-enabled).
*Defaults to `1`.*
**Datatype:** Positive Integer +| `bid_strategy.order_book_top` | Bot will use the top N rate in Order Book "price_side" to buy. I.e. a value of 2 will allow the bot to pick the 2nd bid rate in [Order Book Bids](#buy-price-with-orderbook-enabled).
*Defaults to `1`.*
**Datatype:** Positive Integer | `bid_strategy. check_depth_of_market.enabled` | Do not buy if the difference of buy orders and sell orders is met in Order Book. [Check market depth](#check-depth-of-market).
*Defaults to `false`.*
**Datatype:** Boolean | `bid_strategy. check_depth_of_market.bids_to_ask_delta` | The difference ratio of buy orders and sell orders found in Order Book. A value below 1 means sell order size is greater, while value greater than 1 means buy order size is higher. [Check market depth](#check-depth-of-market)
*Defaults to `0`.*
**Datatype:** Float (as ratio) | `ask_strategy.price_side` | Select the side of the spread the bot should look at to get the sell rate. [More information below](#sell-price-side).
*Defaults to `ask`.*
**Datatype:** String (either `ask` or `bid`). | `ask_strategy.bid_last_balance` | Interpolate the selling price. More information [below](#sell-price-without-orderbook-enabled). | `ask_strategy.use_order_book` | Enable selling of open trades using [Order Book Asks](#sell-price-with-orderbook-enabled).
**Datatype:** Boolean -| `ask_strategy.order_book_min` | Bot will scan from the top min to max Order Book Asks searching for a profitable rate.
*Defaults to `1`.*
**Datatype:** Positive Integer -| `ask_strategy.order_book_max` | Bot will scan from the top min to max Order Book Asks searching for a profitable rate.
*Defaults to `1`.*
**Datatype:** Positive Integer -| `ask_strategy.use_sell_signal` | Use sell signals produced by the strategy in addition to the `minimal_roi`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `true`.*
**Datatype:** Boolean -| `ask_strategy.sell_profit_only` | Wait until the bot reaches `ask_strategy.sell_profit_offset` before taking a sell decision. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean -| `ask_strategy.sell_profit_offset` | Sell-signal is only active above this value. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0.0`.*
**Datatype:** Float (as ratio) -| `ask_strategy.ignore_roi_if_buy_signal` | Do not sell if the buy signal is still active. This setting takes preference over `minimal_roi` and `use_sell_signal`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean -| `ask_strategy.ignore_buying_expired_candle_after` | Specifies the number of seconds until a buy signal is no longer used.
**Datatype:** Integer +| `ask_strategy.order_book_top` | Bot will use the top N rate in Order Book "price_side" to sell. I.e. a value of 2 will allow the bot to pick the 2nd ask rate in [Order Book Asks](#sell-price-with-orderbook-enabled)
*Defaults to `1`.*
**Datatype:** Positive Integer +| `use_sell_signal` | Use sell signals produced by the strategy in addition to the `minimal_roi`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `true`.*
**Datatype:** Boolean +| `sell_profit_only` | Wait until the bot reaches `sell_profit_offset` before taking a sell decision. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean +| `sell_profit_offset` | Sell-signal is only active above this value. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0.0`.*
**Datatype:** Float (as ratio) +| `ignore_roi_if_buy_signal` | Do not sell if the buy signal is still active. This setting takes preference over `minimal_roi` and `use_sell_signal`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean +| `ignore_buying_expired_candle_after` | Specifies the number of seconds until a buy signal is no longer used.
**Datatype:** Integer | `order_types` | Configure order-types depending on the action (`"buy"`, `"sell"`, `"stoploss"`, `"stoploss_on_exchange"`). [More information below](#understand-order_types). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict | `order_time_in_force` | Configure time in force for buy and sell orders. [More information below](#understand-order_time_in_force). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict | `exchange.name` | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename).
**Datatype:** String @@ -141,7 +140,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi ### Parameters in the strategy -The following parameters can be set in either configuration file or strategy. +The following parameters can be set in configuration file or strategy. Values set in the configuration file always overwrite values set in the strategy. * `minimal_roi` @@ -157,11 +156,11 @@ Values set in the configuration file always overwrite values set in the strategy * `order_time_in_force` * `unfilledtimeout` * `disable_dataframe_checks` -* `use_sell_signal` (ask_strategy) -* `sell_profit_only` (ask_strategy) -* `sell_profit_offset` (ask_strategy) -* `ignore_roi_if_buy_signal` (ask_strategy) -* `ignore_buying_expired_candle_after` (ask_strategy) +* `use_sell_signal` +* `sell_profit_only` +* `sell_profit_offset` +* `ignore_roi_if_buy_signal` +* `ignore_buying_expired_candle_after` ### Configuring amount per trade @@ -170,7 +169,7 @@ There are several methods to configure how much of the stake currency the bot wi #### Minimum trade stake The minimum stake amount will depend by exchange and pair, and is usually listed in the exchange support pages. -Assuming the minimum tradable amount for XRP/USD is 20 XRP (given by the exchange), and the price is 0.4$. +Assuming the minimum tradable amount for XRP/USD is 20 XRP (given by the exchange), and the price is 0.6$. The minimum stake amount to buy this pair is therefore `20 * 0.6 ~= 12`. This exchange has also a limit on USD - where all orders must be > 10$ - which however does not apply in this case. @@ -292,16 +291,16 @@ See [the telegram documentation](telegram-usage.md) for details on usage. When working with larger timeframes (for example 1h or more) and using a low `max_open_trades` value, the last candle can be processed as soon as a trade slot becomes available. When processing the last candle, this can lead to a situation where it may not be desirable to use the buy signal on that candle. For example, when using a condition in your strategy where you use a cross-over, that point may have passed too long ago for you to start a trade on it. -In these situations, you can enable the functionality to ignore candles that are beyond a specified period by setting `ask_strategy.ignore_buying_expired_candle_after` to a positive number, indicating the number of seconds after which the buy signal becomes expired. +In these situations, you can enable the functionality to ignore candles that are beyond a specified period by setting `ignore_buying_expired_candle_after` to a positive number, indicating the number of seconds after which the buy signal becomes expired. For example, if your strategy is using a 1h timeframe, and you only want to buy within the first 5 minutes when a new candle comes in, you can add the following configuration to your strategy: ``` json - "ask_strategy":{ + { + //... "ignore_buying_expired_candle_after": 300, - "price_side": "bid", // ... - }, + } ``` !!! Note @@ -503,7 +502,8 @@ Once you will be happy with your bot performance running in the Dry-run mode, yo * API-keys may or may not be provided. Only Read-Only operations (i.e. operations that do not alter account state) on the exchange are performed in dry-run mode. * Wallets (`/balance`) are simulated based on `dry_run_wallet`. * Orders are simulated, and will not be posted to the exchange. -* Orders are assumed to fill immediately, and will never time out. +* Market orders fill based on orderbook volume the moment the order is placed. +* Limit orders fill once price reaches the defined level - or time out based on `unfilledtimeout` settings. * In combination with `stoploss_on_exchange`, the stop_loss price is assumed to be filled. * Open orders (not trades, which are stored in the database) are reset on bot restart. diff --git a/docs/data-download.md b/docs/data-download.md index 01561c89b..0ca86b0d3 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -271,7 +271,7 @@ mkdir -p user_data/data/binance cp tests/testdata/pairs.json user_data/data/binance ``` -If you your configuration directory `user_data` was made by docker, you may get the following error: +If your configuration directory `user_data` was made by docker, you may get the following error: ``` cp: cannot create regular file 'user_data/data/binance/pairs.json': Permission denied diff --git a/docs/deprecated.md b/docs/deprecated.md index 312f2c74f..b7ad847e6 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -33,3 +33,8 @@ The old section of configuration parameters (`"pairlist"`) has been deprecated i ### deprecation of bidVolume and askVolume from volume-pairlist Since only quoteVolume can be compared between assets, the other options (bidVolume, askVolume) have been deprecated in 2020.4, and have been removed in 2020.9. + +### Using order book steps for sell price + +Using `order_book_min` and `order_book_max` used to allow stepping the orderbook and trying to find the next ROI slot - trying to place sell-orders early. +As this does however increase risk and provides no benefit, it's been removed for maintainability purposes in 2021.7. diff --git a/docs/hyperopt.md b/docs/hyperopt.md index a117ac1ce..4fba925d0 100644 --- a/docs/hyperopt.md +++ b/docs/hyperopt.md @@ -51,7 +51,7 @@ usage: freqtrade hyperopt [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--spaces {all,buy,sell,roi,stoploss,trailing,default} [{all,buy,sell,roi,stoploss,trailing,default} ...]] [--print-all] [--no-color] [--print-json] [-j JOBS] [--random-state INT] [--min-trades INT] - [--hyperopt-loss NAME] + [--hyperopt-loss NAME] [--disable-param-export] optional arguments: -h, --help show this help message and exit @@ -118,6 +118,8 @@ optional arguments: ShortTradeDurHyperOptLoss, OnlyProfitHyperOptLoss, SharpeHyperOptLoss, SharpeHyperOptLossDaily, SortinoHyperOptLoss, SortinoHyperOptLossDaily + --disable-param-export + Disable automatic hyperopt parameter export. Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). @@ -403,6 +405,9 @@ While this strategy is most likely too simple to provide consistent profit, it s !!! Note `self.buy_ema_short.range` will act differently between hyperopt and other modes. For hyperopt, the above example may generate 48 new columns, however for all other modes (backtesting, dry/live), it will only generate the column for the selected value. You should therefore avoid using the resulting column with explicit values (values other than `self.buy_ema_short.value`). +!!! Note + `range` property may also be used with `DecimalParameter` and `CategoricalParameter`. `RealParameter` does not provide this property due to infinite search space. + ??? Hint "Performance tip" By doing the calculation of all possible indicators in `populate_indicators()`, the calculation of the indicator happens only once for every parameter. While this may slow down the hyperopt startup speed, the overall performance will increase as the Hyperopt execution itself may pick the same value for multiple epochs (changing other values). @@ -509,7 +514,13 @@ You should understand this result like: * You should not use ADX because `'buy_adx_enabled': False`. * You should **consider** using the RSI indicator (`'buy_rsi_enabled': True`) and the best value is `29.0` (`'buy_rsi': 29.0`) -Your strategy class can immediately take advantage of these results. Simply copy hyperopt results block and paste them at class level, replacing old parameters (if any). New parameters will automatically be loaded next time strategy is executed. +### Automatic parameter application to the strategy + +When using Hyperoptable parameters, the result of your hyperopt-run will be written to a json file next to your strategy (so for `MyAwesomeStrategy.py`, the file would be `MyAwesomeStrategy.json`). +This file is also updated when using the `hyperopt-show` sub-command, unless `--disable-param-export` is provided to either of the 2 commands. + + +Your strategy class can also contain these results explicitly. Simply copy hyperopt results block and paste them at class level, replacing old parameters (if any). New parameters will automatically be loaded next time strategy is executed. Transferring your whole hyperopt result to your strategy would then look like: @@ -525,6 +536,10 @@ class MyAwesomeStrategy(IStrategy): } ``` +!!! Note + Values in the configuration file will overwrite Parameter-file level parameters - and both will overwrite parameters within the strategy. + The prevalence is therefore: config > parameter file > strategy + ### Understand Hyperopt ROI results If you are optimizing ROI (i.e. if optimization search-space contains 'all', 'default' or 'roi'), your result will look as follows and include a ROI table: diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index f19c5a181..8727cc3fc 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -23,6 +23,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`StaticPairList`](#static-pair-list) (default, if not configured differently) * [`VolumePairList`](#volume-pair-list) * [`AgeFilter`](#agefilter) +* [`OffsetFilter`](#offsetfilter) * [`PerformanceFilter`](#performancefilter) * [`PrecisionFilter`](#precisionfilter) * [`PriceFilter`](#pricefilter) @@ -63,17 +64,56 @@ The `refresh_period` setting allows to define the period (in seconds), at which The pairlist cache (`refresh_period`) on `VolumePairList` is only applicable to generating pairlists. Filtering instances (not the first position in the list) will not apply any cache and will always use up-to-date data. -`VolumePairList` is based on the ticker data from exchange, as reported by the ccxt library: +`VolumePairList` is per default based on the ticker data from exchange, as reported by the ccxt library: * The `quoteVolume` is the amount of quote (stake) currency traded (bought or sold) in last 24 hours. ```json -"pairlists": [{ +"pairlists": [ + { "method": "VolumePairList", "number_assets": 20, "sort_key": "quoteVolume", "refresh_period": 1800 -}], + } +], +``` + +`VolumePairList` can also operate in an advanced mode to build volume over a given timerange of specified candle size. It utilizes exchange historical candle data, builds a typical price (calculated by (open+high+low)/3) and multiplies the typical price with every candle's volume. The sum is the `quoteVolume` over the given range. This allows different scenarios, for a more smoothened volume, when using longer ranges with larger candle sizes, or the opposite when using a short range with small candles. + +For convenience `lookback_days` can be specified, which will imply that 1d candles will be used for the lookback. In the example below the pairlist would be created based on the last 7 days: + +```json +"pairlists": [ + { + "method": "VolumePairList", + "number_assets": 20, + "sort_key": "quoteVolume", + "refresh_period": 86400, + "lookback_days": 7 + } +], +``` + +!!! Warning "Range look back and refresh period" + When used in conjunction with `lookback_days` and `lookback_timeframe` the `refresh_period` can not be smaller than the candle size in seconds. As this will result in unnecessary requests to the exchanges API. + +!!! Warning "Performance implications when using lookback range" + If used in first position in combination with lookback, the computation of the range based volume can be time and resource consuming, as it downloads candles for all tradable pairs. Hence it's highly advised to use the standard approach with `VolumeFilter` to narrow the pairlist down for further range volume calculation. + +More sophisticated approach can be used, by using `lookback_timeframe` for candle size and `lookback_period` which specifies the amount of candles. This example will build the volume pairs based on a rolling period of 3 days of 1h candles: + +```json +"pairlists": [ + { + "method": "VolumePairList", + "number_assets": 20, + "sort_key": "quoteVolume", + "refresh_period": 3600, + "lookback_timeframe": "1h", + "lookback_period": 72 + } +], ``` !!! Note @@ -81,13 +121,39 @@ Filtering instances (not the first position in the list) will not apply any cach #### AgeFilter -Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`). +Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`) or more than `max_days_listed` days (defaults `None` mean infinity). When pairs are first listed on an exchange they can suffer huge price drops and volatility in the first few days while the pair goes through its price-discovery period. Bots can often be caught out buying before the pair has finished dropping in price. -This filter allows freqtrade to ignore pairs until they have been listed for at least `min_days_listed` days. +This filter allows freqtrade to ignore pairs until they have been listed for at least `min_days_listed` days and listed before `max_days_listed`. + +#### OffsetFilter + +Offsets an incoming pairlist by a given `offset` value. + +As an example it can be used in conjunction with `VolumeFilter` to remove the top X volume pairs. Or to split +a larger pairlist on two bot instances. + +Example to remove the first 10 pairs from the pairlist: + +```json +"pairlists": [ + { + "method": "OffsetFilter", + "offset": 10 + } +], +``` + +!!! Warning + When `OffsetFilter` is used to split a larger pairlist among multiple bots in combination with `VolumeFilter` + it can not be guaranteed that pairs won't overlap due to slightly different refresh intervals for the + `VolumeFilter`. + +!!! Note + An offset larger then the total length of the incoming pairlist will result in an empty pairlist. #### PerformanceFilter diff --git a/docs/includes/pricing.md b/docs/includes/pricing.md index bdf27eb20..ed8a45e68 100644 --- a/docs/includes/pricing.md +++ b/docs/includes/pricing.md @@ -47,7 +47,7 @@ Also, prices at the "ask" side of the spread are higher than prices at the "bid" #### Buy price with Orderbook enabled -When buying with the orderbook enabled (`bid_strategy.use_order_book=True`), Freqtrade fetches the `bid_strategy.order_book_top` entries from the orderbook and then uses the entry specified as `bid_strategy.order_book_top` on the configured side (`bid_strategy.price_side`) of the orderbook. 1 specifies the topmost entry in the orderbook, while 2 would use the 2nd entry in the orderbook, and so on. +When buying with the orderbook enabled (`bid_strategy.use_order_book=True`), Freqtrade fetches the `bid_strategy.order_book_top` entries from the orderbook and uses the entry specified as `bid_strategy.order_book_top` on the configured side (`bid_strategy.price_side`) of the orderbook. 1 specifies the topmost entry in the orderbook, while 2 would use the 2nd entry in the orderbook, and so on. #### Buy price without Orderbook enabled @@ -82,22 +82,9 @@ In line with that, if `ask_strategy.price_side` is set to `"bid"`, then the bot #### Sell price with Orderbook enabled -When selling with the orderbook enabled (`ask_strategy.use_order_book=True`), Freqtrade fetches the `ask_strategy.order_book_max` entries in the orderbook. Then each of the orderbook steps between `ask_strategy.order_book_min` and `ask_strategy.order_book_max` on the configured orderbook side are validated for a profitable sell-possibility based on the strategy configuration (`minimal_roi` conditions) and the sell order is placed at the first profitable spot. +When selling with the orderbook enabled (`ask_strategy.use_order_book=True`), Freqtrade fetches the `ask_strategy.order_book_top` entries in the orderbook and uses the entry specified as `ask_strategy.order_book_top` from the configured side (`ask_strategy.price_side`) as selling price. -!!! Note - Using `order_book_max` higher than `order_book_min` only makes sense when ask_strategy.price_side is set to `"ask"`. - -The idea here is to place the sell order early, to be ahead in the queue. - -A fixed slot (mirroring `bid_strategy.order_book_top`) can be defined by setting `ask_strategy.order_book_min` and `ask_strategy.order_book_max` to the same number. - -!!! Warning "Order_book_max > 1 - increased risks for stoplosses!" - Using `ask_strategy.order_book_max` higher than 1 will increase the risk the stoploss on exchange is cancelled too early, since an eventual [stoploss on exchange](#understand-order_types) will be cancelled as soon as the order is placed. - Also, the sell order will remain on the exchange for `unfilledtimeout.sell` (or until it's filled) - which can lead to missed stoplosses (with or without using stoploss on exchange). - -!!! Warning "Order_book_max > 1 in dry-run" - Using `ask_strategy.order_book_max` higher than 1 will result in improper dry-run results (significantly better than real orders executed on exchange), since dry-run assumes orders to be filled almost instantly. - It is therefore advised to not use this setting for dry-runs. +1 specifies the topmost entry in the orderbook, while 2 would use the 2nd entry in the orderbook, and so on. #### Sell price without Orderbook enabled diff --git a/docs/index.md b/docs/index.md index 871172cfc..8ecb085de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,6 +47,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual, Exchanges confirmed working by the community: - [X] [Bitvavo](https://bitvavo.com/) +- [X] [Kukoin](https://www.kucoin.com/) ## Requirements diff --git a/docs/installation.md b/docs/installation.md index 5c6ac001f..5e4a19d88 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -203,6 +203,8 @@ sed -i.bak "s|0.00000001|0.000000000000000001 |g" src/ta_func/ta_utility.h ./configure --prefix=/usr/local make sudo make install +# On debian based systems (debian, ubuntu, ...) - updating ldconfig might be necessary. +sudo ldconfig cd .. rm -rf ./ta-lib* ``` diff --git a/docs/leverage.md b/docs/leverage.md new file mode 100644 index 000000000..9a420e573 --- /dev/null +++ b/docs/leverage.md @@ -0,0 +1,3 @@ +For shorts, the currency which pays the interest fee for the `borrowed` currency is purchased at the same time of the closing trade (This means that the amount purchased in short closing trades is greater than the amount sold in short opening trades). + +For longs, the currency which pays the interest fee for the `borrowed` will already be owned by the user and does not need to be purchased. The interest is subtracted from the close_value of the trade. diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 5b116de4b..dfc5264be 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -1,5 +1,41 @@ {% extends "base.html" %} + + +{% block site_nav %} + + + {% if nav %} + {% if page and page.meta and page.meta.hide %} + {% set hidden = "hidden" if "navigation" in page.meta.hide %} + {% endif %} + + {% endif %} + + + {% if page.toc and not "toc.integrate" in features %} + {% if page and page.meta and page.meta.hide %} + {% set hidden = "hidden" if "toc" in page.meta.hide %} + {% endif %} + + {% endif %} +{% endblock %} + {% block footer %} {{ super() }} @@ -7,4 +43,26 @@ + + + + + {% endblock %} diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 352103f8b..d11e5ea4e 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,4 +1,4 @@ mkdocs==1.2.1 -mkdocs-material==7.1.8 +mkdocs-material==7.1.9 mdx_truly_sane_lists==1.2 pymdown-extensions==8.2 diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index 3436604a9..b06cf3ecb 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -55,7 +55,7 @@ class AwesomeStrategy(IStrategy): dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) # Obtain last available candle. Do not use current_time to look up latest candle, because - # current_time points to curret incomplete candle whose data is not available. + # current_time points to current incomplete candle whose data is not available. last_candle = dataframe.iloc[-1].squeeze() # <...> @@ -83,7 +83,7 @@ It is possible to define custom sell signals, indicating that specified position For example you could implement a 1:2 risk-reward ROI with `custom_sell()`. -Using custom_sell() signals in place of stoplosses though *is not recommended*. It is a inferior method to using `custom_stoploss()` in this regard - which also allows you to keep the stoploss on exchange. +Using custom_sell() signals in place of stoploss though *is not recommended*. It is a inferior method to using `custom_stoploss()` in this regard - which also allows you to keep the stoploss on exchange. !!! Note Returning a `string` or `True` from this method is equal to setting sell signal on a candle at specified time. This method is not called when sell signal is set already, or if sell signals are disabled (`use_sell_signal=False` or `sell_profit_only=True` while profit is below `sell_profit_offset`). `string` max length is 64 characters. Exceeding this limit will cause the message to be truncated to 64 characters. @@ -243,7 +243,7 @@ class AwesomeStrategy(IStrategy): current_rate: float, current_profit: float, **kwargs) -> float: if current_profit < 0.04: - return -1 # return a value bigger than the inital stoploss to keep using the inital stoploss + return -1 # return a value bigger than the initial stoploss to keep using the initial stoploss # After reaching the desired offset, allow the stoploss to trail by half the profit desired_stoploss = current_profit / 2 diff --git a/docs/strategy_analysis_example.md b/docs/strategy_analysis_example.md index 4c938500c..27192aa2f 100644 --- a/docs/strategy_analysis_example.md +++ b/docs/strategy_analysis_example.md @@ -130,6 +130,44 @@ trades = load_backtest_data(backtest_dir) trades.groupby("pair")["sell_reason"].value_counts() ``` +## Plotting daily profit / equity line + + +```python +# Plotting equity line (starting with 0 on day 1 and adding daily profit for each backtested day) + +from freqtrade.configuration import Configuration +from freqtrade.data.btanalysis import load_backtest_data, load_backtest_stats +import plotly.express as px +import pandas as pd + +# strategy = 'SampleStrategy' +# config = Configuration.from_files(["user_data/config.json"]) +# backtest_dir = config["user_data_dir"] / "backtest_results" + +stats = load_backtest_stats(backtest_dir) +strategy_stats = stats['strategy'][strategy] + +dates = [] +profits = [] +for date_profit in strategy_stats['daily_profit']: + dates.append(date_profit[0]) + profits.append(date_profit[1]) + +equity = 0 +equity_daily = [] +for daily_profit in profits: + equity_daily.append(equity) + equity += float(daily_profit) + + +df = pd.DataFrame({'dates': dates,'equity_daily': equity_daily}) + +fig = px.line(df, x="dates", y="equity_daily") +fig.show() + +``` + ### Load live trading results into a pandas dataframe In case you did already some trading and want to analyze your performance diff --git a/docs/stylesheets/ft.extra.css b/docs/stylesheets/ft.extra.css index 3369fa177..f9ad980c6 100644 --- a/docs/stylesheets/ft.extra.css +++ b/docs/stylesheets/ft.extra.css @@ -11,3 +11,18 @@ .rst-versions .rst-other-versions { color: white; } + + +#widget-wrapper { + height: calc(220px * 0.5625 + 18px); + width: 220px; + margin: 0 auto 16px auto; + border-style: solid; + border-color: var(--md-code-bg-color); + border-width: 1px; + border-radius: 5px; +} + +@media screen and (max-width: calc(76.25em - 1px)) { + #widget-wrapper { display: none; } +} diff --git a/docs/utils.md b/docs/utils.md index 8ef12e1c9..524fefc21 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -702,7 +702,8 @@ You can show the details of any hyperoptimization epoch previously evaluated by usage: freqtrade hyperopt-show [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [--best] [--profitable] [-n INT] [--print-json] - [--hyperopt-filename PATH] [--no-header] + [--hyperopt-filename FILENAME] [--no-header] + [--disable-param-export] optional arguments: -h, --help show this help message and exit @@ -714,6 +715,8 @@ optional arguments: Hyperopt result filename.Example: `--hyperopt- filename=hyperopt_results_2020-09-27_16-20-48.pickle` --no-header Do not print epoch details header. + --disable-param-export + Disable automatic hyperopt parameter export. Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index fd9da30da..163d9b018 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -29,7 +29,7 @@ ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path", "epochs", "spaces", "print_all", "print_colorized", "print_json", "hyperopt_jobs", "hyperopt_random_state", "hyperopt_min_trades", - "hyperopt_loss"] + "hyperopt_loss", "disableparamexport"] ARGS_EDGE = ARGS_COMMON_OPTIMIZE + ["stoploss_range"] @@ -85,7 +85,8 @@ ARGS_HYPEROPT_LIST = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperoptexportfilename", "export_csv"] ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperopt_show_index", - "print_json", "hyperoptexportfilename", "hyperopt_show_no_header"] + "print_json", "hyperoptexportfilename", "hyperopt_show_no_header", + "disableparamexport"] NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes", "list-markets", "list-pairs", "list-strategies", "list-data", diff --git a/freqtrade/commands/build_config_commands.py b/freqtrade/commands/build_config_commands.py index 0d3cb1b88..b3fdbab09 100644 --- a/freqtrade/commands/build_config_commands.py +++ b/freqtrade/commands/build_config_commands.py @@ -184,7 +184,7 @@ def deploy_new_config(config_path: Path, selections: Dict[str, Any]) -> None: """ Applies selections to the template and writes the result to config_path :param config_path: Path object for new config file. Should not exist yet - :param selecions: Dict containing selections taken by the user. + :param selections: Dict containing selections taken by the user. """ from jinja2.exceptions import TemplateNotFound try: @@ -214,7 +214,7 @@ def deploy_new_config(config_path: Path, selections: Dict[str, Any]) -> None: def start_new_config(args: Dict[str, Any]) -> None: """ Create a new strategy from a template - Asking the user questions to fill out the templateaccordingly. + Asking the user questions to fill out the template accordingly. """ config_path = Path(args['config'][0]) diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index 2bf6d6dac..b6f326559 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -178,6 +178,11 @@ AVAILABLE_CLI_OPTIONS = { 'Example: `--export-filename=user_data/backtest_results/backtest_today.json`', metavar='PATH', ), + "disableparamexport": Arg( + '--disable-param-export', + help="Disable automatic hyperopt parameter export.", + action='store_true', + ), "fee": Arg( '--fee', help='Specify fee ratio. Will be applied twice (on trade entry and exit).', diff --git a/freqtrade/commands/hyperopt_commands.py b/freqtrade/commands/hyperopt_commands.py index 822828f26..86c1b6098 100755 --- a/freqtrade/commands/hyperopt_commands.py +++ b/freqtrade/commands/hyperopt_commands.py @@ -129,9 +129,12 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None: metrics = val['results_metrics'] if 'strategy_name' in metrics: - show_backtest_result(metrics['strategy_name'], metrics, + strategy_name = metrics['strategy_name'] + show_backtest_result(strategy_name, metrics, metrics['stake_currency']) + HyperoptTools.try_export_params(config, strategy_name, val) + HyperoptTools.show_epoch_details(val, total_epochs, print_json, no_header, header_str="Epoch details") diff --git a/freqtrade/commands/optimize_commands.py b/freqtrade/commands/optimize_commands.py index a84b3b3bd..08174bde6 100644 --- a/freqtrade/commands/optimize_commands.py +++ b/freqtrade/commands/optimize_commands.py @@ -15,6 +15,7 @@ def setup_optimize_configuration(args: Dict[str, Any], method: RunMode) -> Dict[ """ Prepare the configuration for the Hyperopt module :param args: Cli args from Arguments() + :param method: Bot running mode :return: Configuration """ config = setup_utils_configuration(args, method) diff --git a/freqtrade/configuration/config_setup.py b/freqtrade/configuration/config_setup.py index cd8464ead..22836ab19 100644 --- a/freqtrade/configuration/config_setup.py +++ b/freqtrade/configuration/config_setup.py @@ -15,6 +15,7 @@ def setup_utils_configuration(args: Dict[str, Any], method: RunMode) -> Dict[str """ Prepare the configuration for utils subcommands :param args: Cli args from Arguments() + :param method: Bot running mode :return: Configuration """ configuration = Configuration(args, method) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 3004d6bf7..aad03e983 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -79,6 +79,7 @@ def validate_config_consistency(conf: Dict[str, Any]) -> None: _validate_whitelist(conf) _validate_protections(conf) _validate_unlimited_amount(conf) + _validate_ask_orderbook(conf) # validate configuration before returning logger.info('Validating configuration ...') @@ -149,7 +150,7 @@ def _validate_edge(conf: Dict[str, Any]) -> None: if not conf.get('edge', {}).get('enabled'): return - if not conf.get('ask_strategy', {}).get('use_sell_signal', True): + if not conf.get('use_sell_signal', True): raise OperationalException( "Edge requires `use_sell_signal` to be True, otherwise no sells will happen." ) @@ -186,3 +187,23 @@ def _validate_protections(conf: Dict[str, Any]) -> None: "Protections must specify either `lookback_period` or `lookback_period_candles`.\n" f"Please fix the protection {prot.get('method')}" ) + + +def _validate_ask_orderbook(conf: Dict[str, Any]) -> None: + ask_strategy = conf.get('ask_strategy', {}) + ob_min = ask_strategy.get('order_book_min') + ob_max = ask_strategy.get('order_book_max') + if ob_min is not None and ob_max is not None and ask_strategy.get('use_order_book'): + if ob_min != ob_max: + raise OperationalException( + "Using order_book_max != order_book_min in ask_strategy is no longer supported." + "Please pick one value and use `order_book_top` in the future." + ) + else: + # Move value to order_book_top + ask_strategy['order_book_top'] = ob_min + logger.warning( + "DEPRECATED: " + "Please use `order_book_top` instead of `order_book_min` and `order_book_max` " + "for your `ask_strategy` configuration." + ) diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index bfeb2da5c..1d2e3f802 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -260,6 +260,8 @@ class Configuration: self._args_to_config(config, argname='export', logstring='Parameter --export detected: {} ...') + self._args_to_config(config, argname='disableparamexport', + logstring='Parameter --disableparamexport detected: {} ...') # Edge section: if 'stoploss_range' in self.args and self.args["stoploss_range"]: txt_range = eval(self.args["stoploss_range"]) @@ -460,7 +462,7 @@ class Configuration: pairs_file = Path(self.args["pairs_file"]) logger.info(f'Reading pairs file "{pairs_file}".') # Download pairs from the pairs file if no config is specified - # or if pairs file is specified explicitely + # or if pairs file is specified explicitly if not pairs_file.exists(): raise OperationalException(f'No pairs file found with path "{pairs_file}".') config['pairs'] = load_file(pairs_file) diff --git a/freqtrade/configuration/deprecated_settings.py b/freqtrade/configuration/deprecated_settings.py index 6b2a20c8c..1b162f7c9 100644 --- a/freqtrade/configuration/deprecated_settings.py +++ b/freqtrade/configuration/deprecated_settings.py @@ -3,7 +3,7 @@ Functions to handle deprecated settings """ import logging -from typing import Any, Dict +from typing import Any, Dict, Optional from freqtrade.exceptions import OperationalException @@ -12,23 +12,24 @@ logger = logging.getLogger(__name__) def check_conflicting_settings(config: Dict[str, Any], - section1: str, name1: str, - section2: str, name2: str) -> None: - section1_config = config.get(section1, {}) - section2_config = config.get(section2, {}) - if name1 in section1_config and name2 in section2_config: + section_old: str, name_old: str, + section_new: Optional[str], name_new: str) -> None: + section_new_config = config.get(section_new, {}) if section_new else config + section_old_config = config.get(section_old, {}) + if name_new in section_new_config and name_old in section_old_config: + new_name = f"{section_new}.{name_new}" if section_new else f"{name_new}" raise OperationalException( - f"Conflicting settings `{section1}.{name1}` and `{section2}.{name2}` " + f"Conflicting settings `{new_name}` and `{section_old}.{name_old}` " "(DEPRECATED) detected in the configuration file. " "This deprecated setting will be removed in the next versions of Freqtrade. " - f"Please delete it from your configuration and use the `{section1}.{name1}` " + f"Please delete it from your configuration and use the `{new_name}` " "setting instead." ) def process_removed_setting(config: Dict[str, Any], section1: str, name1: str, - section2: str, name2: str) -> None: + section2: Optional[str], name2: str) -> None: """ :param section1: Removed section :param name1: Removed setting name @@ -37,27 +38,32 @@ def process_removed_setting(config: Dict[str, Any], """ section1_config = config.get(section1, {}) if name1 in section1_config: + section_2 = f"{section2}.{name2}" if section2 else f"{name2}" raise OperationalException( - f"Setting `{section1}.{name1}` has been moved to `{section2}.{name2}. " - f"Please delete it from your configuration and use the `{section2}.{name2}` " + f"Setting `{section1}.{name1}` has been moved to `{section_2}. " + f"Please delete it from your configuration and use the `{section_2}` " "setting instead." ) def process_deprecated_setting(config: Dict[str, Any], - section1: str, name1: str, - section2: str, name2: str) -> None: - section2_config = config.get(section2, {}) + section_old: str, name_old: str, + section_new: Optional[str], name_new: str + ) -> None: + check_conflicting_settings(config, section_old, name_old, section_new, name_new) + section_old_config = config.get(section_old, {}) - if name2 in section2_config: + if name_old in section_old_config: + section_2 = f"{section_new}.{name_new}" if section_new else f"{name_new}" logger.warning( "DEPRECATED: " - f"The `{section2}.{name2}` setting is deprecated and " + f"The `{section_old}.{name_old}` setting is deprecated and " "will be removed in the next versions of Freqtrade. " - f"Please use the `{section1}.{name1}` setting in your configuration instead." + f"Please use the `{section_2}` setting in your configuration instead." ) - section1_config = config.get(section1, {}) - section1_config[name1] = section2_config[name2] + + section_new_config = config.get(section_new, {}) if section_new else config + section_new_config[name_new] = section_old_config[name_old] def process_temporary_deprecated_settings(config: Dict[str, Any]) -> None: @@ -65,15 +71,24 @@ def process_temporary_deprecated_settings(config: Dict[str, Any]) -> None: # Kept for future deprecated / moved settings # check_conflicting_settings(config, 'ask_strategy', 'use_sell_signal', # 'experimental', 'use_sell_signal') - # process_deprecated_setting(config, 'ask_strategy', 'use_sell_signal', - # 'experimental', 'use_sell_signal') + process_deprecated_setting(config, 'ask_strategy', 'use_sell_signal', + None, 'use_sell_signal') + process_deprecated_setting(config, 'ask_strategy', 'sell_profit_only', + None, 'sell_profit_only') + process_deprecated_setting(config, 'ask_strategy', 'sell_profit_offset', + None, 'sell_profit_offset') + process_deprecated_setting(config, 'ask_strategy', 'ignore_roi_if_buy_signal', + None, 'ignore_roi_if_buy_signal') + process_deprecated_setting(config, 'ask_strategy', 'ignore_buying_expired_candle_after', + None, 'ignore_buying_expired_candle_after') + # Legacy way - having them in experimental ... process_removed_setting(config, 'experimental', 'use_sell_signal', - 'ask_strategy', 'use_sell_signal') + None, 'use_sell_signal') process_removed_setting(config, 'experimental', 'sell_profit_only', - 'ask_strategy', 'sell_profit_only') + None, 'sell_profit_only') process_removed_setting(config, 'experimental', 'ignore_roi_if_buy_signal', - 'ask_strategy', 'ignore_roi_if_buy_signal') + None, 'ignore_roi_if_buy_signal') if (config.get('edge', {}).get('enabled', False) and 'capital_available_percentage' in config.get('edge', {})): diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 013e9df41..acd143708 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -26,9 +26,9 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', 'SharpeHyperOptLoss', 'SharpeHyperOptLossDaily', 'SortinoHyperOptLoss', 'SortinoHyperOptLossDaily'] AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', - 'AgeFilter', 'PerformanceFilter', 'PrecisionFilter', - 'PriceFilter', 'RangeStabilityFilter', 'ShuffleFilter', - 'SpreadFilter', 'VolatilityFilter'] + 'AgeFilter', 'OffsetFilter', 'PerformanceFilter', + 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', + 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] DRY_RUN_WALLET = 1000 @@ -40,6 +40,7 @@ DEFAULT_DATAFRAME_COLUMNS = ['date', 'open', 'high', 'low', 'close', 'volume'] DEFAULT_TRADES_COLUMNS = ['timestamp', 'id', 'type', 'side', 'price', 'amount', 'cost'] LAST_BT_RESULT_FN = '.last_result.json' +FTHYPT_FILEVERSION = 'fthypt_fileversion' USERPATH_HYPEROPTS = 'hyperopts' USERPATH_STRATEGIES = 'strategies' @@ -62,7 +63,7 @@ DUST_PER_COIN = { } -# Soure files with destination directories within user-directory +# Source files with destination directories within user-directory USER_DATA_FILES = { 'sample_strategy.py': USERPATH_STRATEGIES, 'sample_hyperopt_advanced.py': USERPATH_HYPEROPTS, @@ -134,6 +135,11 @@ CONF_SCHEMA = { 'trailing_stop_positive': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'trailing_stop_positive_offset': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'trailing_only_offset_is_reached': {'type': 'boolean'}, + 'use_sell_signal': {'type': 'boolean'}, + 'sell_profit_only': {'type': 'boolean'}, + 'sell_profit_offset': {'type': 'number'}, + 'ignore_roi_if_buy_signal': {'type': 'boolean'}, + 'ignore_buying_expired_candle_after': {'type': 'number'}, 'bot_name': {'type': 'string'}, 'unfilledtimeout': { 'type': 'object', @@ -154,7 +160,7 @@ CONF_SCHEMA = { }, 'price_side': {'type': 'string', 'enum': ORDERBOOK_SIDES, 'default': 'bid'}, 'use_order_book': {'type': 'boolean'}, - 'order_book_top': {'type': 'integer', 'maximum': 20, 'minimum': 1}, + 'order_book_top': {'type': 'integer', 'minimum': 1, 'maximum': 50, }, 'check_depth_of_market': { 'type': 'object', 'properties': { @@ -163,7 +169,7 @@ CONF_SCHEMA = { } }, }, - 'required': ['ask_last_balance'] + 'required': ['price_side'] }, 'ask_strategy': { 'type': 'object', @@ -176,13 +182,9 @@ CONF_SCHEMA = { 'exclusiveMaximum': False, }, 'use_order_book': {'type': 'boolean'}, - 'order_book_min': {'type': 'integer', 'minimum': 1}, - 'order_book_max': {'type': 'integer', 'minimum': 1, 'maximum': 50}, - 'use_sell_signal': {'type': 'boolean'}, - 'sell_profit_only': {'type': 'boolean'}, - 'sell_profit_offset': {'type': 'number'}, - 'ignore_roi_if_buy_signal': {'type': 'boolean'} - } + 'order_book_top': {'type': 'integer', 'minimum': 1, 'maximum': 50, }, + }, + 'required': ['price_side'] }, 'order_types': { 'type': 'object', @@ -311,6 +313,7 @@ CONF_SCHEMA = { }, 'db_url': {'type': 'string'}, 'export': {'type': 'string', 'enum': EXPORT_OPTIONS, 'default': 'trades'}, + 'disableparamexport': {'type': 'boolean'}, 'initial_state': {'type': 'string', 'enum': ['running', 'stopped']}, 'forcebuy_enable': {'type': 'boolean'}, 'disable_dataframe_checks': {'type': 'boolean'}, diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index ffee0c52c..040f58d62 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -49,7 +49,7 @@ def clean_ohlcv_dataframe(data: DataFrame, timeframe: str, pair: str, *, fill_missing: bool = True, drop_incomplete: bool = True) -> DataFrame: """ - Clense a OHLCV dataframe by + Cleanse a OHLCV dataframe by * Grouping it by date (removes duplicate tics) * dropping last candles if requested * Filling up missing data (if requested) diff --git a/freqtrade/data/history/hdf5datahandler.py b/freqtrade/data/history/hdf5datahandler.py index e80cfeba2..dd60530aa 100644 --- a/freqtrade/data/history/hdf5datahandler.py +++ b/freqtrade/data/history/hdf5datahandler.py @@ -52,8 +52,8 @@ class HDF5DataHandler(IDataHandler): """ Store data in hdf5 file. :param pair: Pair - used to generate filename - :timeframe: Timeframe - used to generate filename - :data: Dataframe containing OHLCV data + :param timeframe: Timeframe - used to generate filename + :param data: Dataframe containing OHLCV data :return: None """ key = self._pair_ohlcv_key(pair, timeframe) diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index 86e9f75e6..1459dfd78 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -113,6 +113,7 @@ def refresh_data(datadir: Path, :param timeframe: Timeframe (e.g. "5m") :param pairs: List of pairs to load :param exchange: Exchange object + :param data_format: dataformat to use :param timerange: Limit data to be loaded to this timerange """ data_handler = get_datahandler(datadir, data_format) @@ -193,8 +194,8 @@ def _download_pair_history(datadir: Path, new_data = exchange.get_historic_ohlcv(pair=pair, timeframe=timeframe, since_ms=since_ms if since_ms else - int(arrow.utcnow().shift( - days=-new_pairs_days).float_timestamp) * 1000 + arrow.utcnow().shift( + days=-new_pairs_days).int_timestamp * 1000 ) # TODO: Maybe move parsing to exchange class (?) new_dataframe = ohlcv_to_dataframe(new_data, timeframe, pair, @@ -271,7 +272,7 @@ def _download_trades_history(exchange: Exchange, if timerange.stoptype == 'date': until = timerange.stopts * 1000 else: - since = int(arrow.utcnow().shift(days=-new_pairs_days).float_timestamp) * 1000 + since = arrow.utcnow().shift(days=-new_pairs_days).int_timestamp * 1000 trades = data_handler.trades_load(pair) diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 070d9039d..05052b2d7 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -49,8 +49,8 @@ class IDataHandler(ABC): """ Store ohlcv data. :param pair: Pair - used to generate filename - :timeframe: Timeframe - used to generate filename - :data: Dataframe containing OHLCV data + :param timeframe: Timeframe - used to generate filename + :param data: Dataframe containing OHLCV data :return: None """ @@ -245,8 +245,8 @@ def get_datahandler(datadir: Path, data_format: str = None, data_handler: IDataHandler = None) -> IDataHandler: """ :param datadir: Folder to save data - :data_format: dataformat to use - :data_handler: returns this datahandler if it exists or initializes a new one + :param data_format: dataformat to use + :param data_handler: returns this datahandler if it exists or initializes a new one """ if not data_handler: diff --git a/freqtrade/data/history/jsondatahandler.py b/freqtrade/data/history/jsondatahandler.py index 301d228a8..990e75bd9 100644 --- a/freqtrade/data/history/jsondatahandler.py +++ b/freqtrade/data/history/jsondatahandler.py @@ -55,8 +55,8 @@ class JsonDataHandler(IDataHandler): format looks as follows: [[,,,,]] :param pair: Pair - used to generate filename - :timeframe: Timeframe - used to generate filename - :data: Dataframe containing OHLCV data + :param timeframe: Timeframe - used to generate filename + :param data: Dataframe containing OHLCV data :return: None """ filename = self._pair_data_filename(self._datadir, pair, timeframe) diff --git a/freqtrade/edge/edge_positioning.py b/freqtrade/edge/edge_positioning.py index 9466a1649..977b7e4ec 100644 --- a/freqtrade/edge/edge_positioning.py +++ b/freqtrade/edge/edge_positioning.py @@ -301,7 +301,7 @@ class Edge: def _process_expectancy(self, results: DataFrame) -> Dict[str, Any]: """ This calculates WinRate, Required Risk Reward, Risk Reward and Expectancy of all pairs - The calulation will be done per pair and per strategy. + The calculation will be done per pair and per strategy. """ # Removing pairs having less than min_trades_number min_trades_number = self.edge_config.get('min_trade_number', 10) diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index 78163d86f..c83db0e13 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -1,4 +1,5 @@ # flake8: noqa: F401 +from freqtrade.enums.interestmode import InterestMode from freqtrade.enums.rpcmessagetype import RPCMessageType from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.selltype import SellType diff --git a/freqtrade/enums/interestmode.py b/freqtrade/enums/interestmode.py new file mode 100644 index 000000000..4128fc7a0 --- /dev/null +++ b/freqtrade/enums/interestmode.py @@ -0,0 +1,30 @@ +from decimal import Decimal +from enum import Enum +from math import ceil + +from freqtrade.exceptions import OperationalException + + +one = Decimal(1.0) +four = Decimal(4.0) +twenty_four = Decimal(24.0) + + +class InterestMode(Enum): + """Equations to calculate interest""" + + HOURSPERDAY = "HOURSPERDAY" + HOURSPER4 = "HOURSPER4" # Hours per 4 hour segment + NONE = "NONE" + + def __call__(self, *args, **kwargs): + + borrowed, rate, hours = kwargs["borrowed"], kwargs["rate"], kwargs["hours"] + + if self.name == "HOURSPERDAY": + return borrowed * rate * ceil(hours)/twenty_four + elif self.name == "HOURSPER4": + # Probably rounded based on https://kraken-fees-calculator.github.io/ + return borrowed * rate * (1+ceil(hours/four)) + else: + raise OperationalException("Leverage not available on this exchange with freqtrade") diff --git a/freqtrade/exceptions.py b/freqtrade/exceptions.py index caf970606..056be8720 100644 --- a/freqtrade/exceptions.py +++ b/freqtrade/exceptions.py @@ -47,7 +47,7 @@ class InvalidOrderException(ExchangeError): class RetryableOrderError(InvalidOrderException): """ This is returned when the order is not found. - This Error will be repeated with increasing backof (in line with DDosError). + This Error will be repeated with increasing backoff (in line with DDosError). """ @@ -75,6 +75,6 @@ class DDosProtection(TemporaryError): class StrategyError(FreqtradeException): """ - Errors with custom user-code deteced. + Errors with custom user-code detected. Usually caused by errors in the strategy. """ diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 07ac337fc..42e86db3e 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -235,7 +235,7 @@ class Exchange: def ohlcv_candle_limit(self, timeframe: str) -> int: """ Exchange ohlcv candle limit - Uses ohlcv_candle_limit_per_timeframe if the exchange has different limts + Uses ohlcv_candle_limit_per_timeframe if the exchange has different limits per timeframe (e.g. bittrex), otherwise falls back to ohlcv_candle_limit :param timeframe: Timeframe to check :return: Candle limit as integer @@ -475,11 +475,11 @@ class Exchange: return endpoint in self._api.has and self._api.has[endpoint] def amount_to_precision(self, pair: str, amount: float) -> float: - ''' + """ Returns the amount to buy or sell to a precision the Exchange accepts Re-implementation of ccxt internal methods - ensuring we can test the result is correct based on our definitions. - ''' + """ if self.markets[pair]['precision']['amount']: amount = float(decimal_to_precision(amount, rounding_mode=TRUNCATE, precision=self.markets[pair]['precision']['amount'], @@ -489,14 +489,14 @@ class Exchange: return amount def price_to_precision(self, pair: str, price: float) -> float: - ''' + """ Returns the price rounded up to the precision the Exchange accepts. Partial Re-implementation of ccxt internal method decimal_to_precision(), which does not support rounding up TODO: If ccxt supports ROUND_UP for decimal_to_precision(), we could remove this and align with amount_to_precision(). Rounds up - ''' + """ if self.markets[pair]['precision']['price']: # price = float(decimal_to_precision(price, rounding_mode=ROUND, # precision=self.markets[pair]['precision']['price'], @@ -567,7 +567,7 @@ class Exchange: rate: float, params: Dict = {}) -> Dict[str, Any]: order_id = f'dry_run_{side}_{datetime.now().timestamp()}' _amount = self.amount_to_precision(pair, amount) - dry_order = { + dry_order: Dict[str, Any] = { 'id': order_id, 'symbol': pair, 'price': rate, @@ -578,31 +578,99 @@ class Exchange: 'side': side, 'remaining': _amount, 'datetime': arrow.utcnow().isoformat(), - 'timestamp': int(arrow.utcnow().int_timestamp * 1000), + 'timestamp': arrow.utcnow().int_timestamp * 1000, 'status': "closed" if ordertype == "market" else "open", 'fee': None, 'info': {} } - self._store_dry_order(dry_order, pair) + if dry_order["type"] in ["stop_loss_limit", "stop-loss-limit"]: + dry_order["info"] = {"stopPrice": dry_order["price"]} + + if dry_order["type"] == "market": + # Update market order pricing + average = self.get_dry_market_fill_price(pair, side, amount, rate) + dry_order.update({ + 'average': average, + 'cost': dry_order['amount'] * average, + }) + dry_order = self.add_dry_order_fee(pair, dry_order) + + dry_order = self.check_dry_limit_order_filled(dry_order) + + self._dry_run_open_orders[dry_order["id"]] = dry_order # Copy order and close it - so the returned order is open unless it's a market order return dry_order - def _store_dry_order(self, dry_order: Dict, pair: str) -> None: - closed_order = dry_order.copy() - if closed_order['type'] in ["market", "limit"]: - closed_order.update({ - 'status': 'closed', - 'filled': closed_order['amount'], - 'remaining': 0, - 'fee': { - 'currency': self.get_pair_quote_currency(pair), - 'cost': dry_order['cost'] * self.get_fee(pair), - 'rate': self.get_fee(pair) - } - }) - if closed_order["type"] in ["stop_loss_limit", "stop-loss-limit"]: - closed_order["info"].update({"stopPrice": closed_order["price"]}) - self._dry_run_open_orders[closed_order["id"]] = closed_order + def add_dry_order_fee(self, pair: str, dry_order: Dict[str, Any]) -> Dict[str, Any]: + dry_order.update({ + 'fee': { + 'currency': self.get_pair_quote_currency(pair), + 'cost': dry_order['cost'] * self.get_fee(pair), + 'rate': self.get_fee(pair) + } + }) + return dry_order + + def get_dry_market_fill_price(self, pair: str, side: str, amount: float, rate: float) -> float: + """ + Get the market order fill price based on orderbook interpolation + """ + if self.exchange_has('fetchL2OrderBook'): + ob = self.fetch_l2_order_book(pair, 20) + ob_type = 'asks' if side == 'buy' else 'bids' + + remaining_amount = amount + filled_amount = 0 + for book_entry in ob[ob_type]: + book_entry_price = book_entry[0] + book_entry_coin_volume = book_entry[1] + if remaining_amount > 0: + if remaining_amount < book_entry_coin_volume: + filled_amount += remaining_amount * book_entry_price + else: + filled_amount += book_entry_coin_volume * book_entry_price + remaining_amount -= book_entry_coin_volume + else: + break + else: + # If remaining_amount wasn't consumed completely (break was not called) + filled_amount += remaining_amount * book_entry_price + forecast_avg_filled_price = filled_amount / amount + return self.price_to_precision(pair, forecast_avg_filled_price) + + return rate + + def _is_dry_limit_order_filled(self, pair: str, side: str, limit: float) -> bool: + if not self.exchange_has('fetchL2OrderBook'): + return True + ob = self.fetch_l2_order_book(pair, 1) + if side == 'buy': + price = ob['asks'][0][0] + logger.debug(f"{pair} checking dry buy-order: price={price}, limit={limit}") + if limit >= price: + return True + else: + price = ob['bids'][0][0] + logger.debug(f"{pair} checking dry sell-order: price={price}, limit={limit}") + if limit <= price: + return True + return False + + def check_dry_limit_order_filled(self, order: Dict[str, Any]) -> Dict[str, Any]: + """ + Check dry-run limit order fill and update fee (if it filled). + """ + if order['status'] != "closed" and order['type'] in ["limit"]: + pair = order['symbol'] + if self._is_dry_limit_order_filled(pair, order['side'], order['price']): + order.update({ + 'status': 'closed', + 'filled': order['amount'], + 'remaining': 0, + }) + self.add_dry_order_fee(pair, order) + + return order def fetch_dry_run_order(self, order_id) -> Dict[str, Any]: """ @@ -611,6 +679,7 @@ class Exchange: """ try: order = self._dry_run_open_orders[order_id] + order = self.check_dry_limit_order_filled(order) return order except KeyError as e: # Gracefully handle errors with dry-run orders. @@ -727,6 +796,8 @@ class Exchange: """ Simple wrapper calling either fetch_order or fetch_stoploss_order depending on the stoploss_order parameter + :param order_id: OrderId to fetch order + :param pair: Pair corresponding to order_id :param stoploss_order: If true, uses fetch_stoploss_order, otherwise fetch_order. """ if stoploss_order: @@ -928,15 +999,6 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e - def _order_book_gen(self, pair: str, side: str, order_book_max: int = 1, - order_book_min: int = 1): - """ - Helper generator to query orderbook in loop (used for early sell-order placing) - """ - order_book = self.fetch_l2_order_book(pair, order_book_max) - for i in range(order_book_min, order_book_max + 1): - yield order_book[side][i - 1][0] - def get_buy_rate(self, pair: str, refresh: bool) -> float: """ Calculates bid target between current ask price and last price @@ -1001,14 +1063,18 @@ class Exchange: ask_strategy = self._config.get('ask_strategy', {}) if ask_strategy.get('use_order_book', False): - # This code is only used for notifications, selling uses the generator directly - logger.info( + logger.debug( f"Getting price from order book {ask_strategy['price_side'].capitalize()} side." ) + order_book_top = ask_strategy.get('order_book_top', 1) + order_book = self.fetch_l2_order_book(pair, order_book_top) try: - rate = next(self._order_book_gen(pair, f"{ask_strategy['price_side']}s")) + rate = order_book[f"{ask_strategy['price_side']}s"][order_book_top - 1][0] except (IndexError, KeyError) as e: - logger.warning("Sell Price at location from orderbook could not be determined.") + logger.warning( + f"Sell Price at location {order_book_top} from orderbook could not be " + f"determined. Orderbook: {order_book}" + ) raise PricingError from e else: ticker = self.fetch_ticker(pair) diff --git a/freqtrade/exchange/kraken.py b/freqtrade/exchange/kraken.py index 8f7cbe590..1b069aa6c 100644 --- a/freqtrade/exchange/kraken.py +++ b/freqtrade/exchange/kraken.py @@ -49,7 +49,7 @@ class Kraken(Exchange): orders = self._api.fetch_open_orders() order_list = [(x["symbol"].split("/")[0 if x["side"] == "sell" else 1], x["remaining"] if x["side"] == "sell" else x["remaining"] * x["price"], - # Don't remove the below comment, this can be important for debuggung + # Don't remove the below comment, this can be important for debugging # x["side"], x["amount"], ) for x in orders] for bal in balances: diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 1dc7de179..b33d532e6 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -98,7 +98,7 @@ class FreqtradeBot(LoggingMixin): initial_state = self.config.get('initial_state') self.state = State[initial_state.upper()] if initial_state else State.STOPPED - # Protect sell-logic from forcesell and viceversa + # Protect sell-logic from forcesell and vice versa self._sell_lock = Lock() LoggingMixin.__init__(self, logger, timeframe_to_seconds(self.strategy.timeframe)) @@ -472,6 +472,7 @@ class FreqtradeBot(LoggingMixin): """ Executes a limit buy for the given pair :param pair: pair for which we want to create a LIMIT_BUY + :param stake_amount: amount of stake-currency for the pair :return: True if a buy order is created, false if it fails. """ time_in_force = self.strategy.order_time_in_force['buy'] @@ -683,46 +684,17 @@ class FreqtradeBot(LoggingMixin): (buy, sell) = (False, False) - config_ask_strategy = self.config.get('ask_strategy', {}) - - if (config_ask_strategy.get('use_sell_signal', True) or - config_ask_strategy.get('ignore_roi_if_buy_signal', False)): + if (self.config.get('use_sell_signal', True) or + self.config.get('ignore_roi_if_buy_signal', False)): analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair, self.strategy.timeframe) (buy, sell) = self.strategy.get_signal(trade.pair, self.strategy.timeframe, analyzed_df) - if config_ask_strategy.get('use_order_book', False): - order_book_min = config_ask_strategy.get('order_book_min', 1) - order_book_max = config_ask_strategy.get('order_book_max', 1) - logger.debug(f'Using order book between {order_book_min} and {order_book_max} ' - f'for selling {trade.pair}...') - - order_book = self.exchange._order_book_gen( - trade.pair, f"{config_ask_strategy['price_side']}s", - order_book_min=order_book_min, order_book_max=order_book_max) - for i in range(order_book_min, order_book_max + 1): - try: - sell_rate = next(order_book) - except (IndexError, KeyError) as e: - logger.warning( - f"Sell Price at location {i} from orderbook could not be determined." - ) - raise PricingError from e - logger.debug(f" order book {config_ask_strategy['price_side']} top {i}: " - f"{sell_rate:0.8f}") - # Assign sell-rate to cache - otherwise sell-rate is never updated in the cache, - # resulting in outdated RPC messages - self.exchange._sell_rate_cache[trade.pair] = sell_rate - - if self._check_and_execute_sell(trade, sell_rate, buy, sell): - return True - - else: - logger.debug('checking sell') - sell_rate = self.exchange.get_sell_rate(trade.pair, True) - if self._check_and_execute_sell(trade, sell_rate, buy, sell): - return True + logger.debug('checking sell') + sell_rate = self.exchange.get_sell_rate(trade.pair, True) + if self._check_and_execute_sell(trade, sell_rate, buy, sell): + return True logger.debug('Found no sell signal for %s.', trade) return False @@ -834,7 +806,7 @@ class FreqtradeBot(LoggingMixin): """ Check to see if stoploss on exchange should be updated in case of trailing stoploss on exchange - :param Trade: Corresponding Trade + :param trade: Corresponding Trade :param order: Current on exchange stoploss order :return: None """ diff --git a/freqtrade/misc.py b/freqtrade/misc.py index 2e255901e..967f08299 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -56,6 +56,7 @@ def file_dump_json(filename: Path, data: Any, is_zip: bool = False, log: bool = """ Dump JSON data into a file :param filename: file to create + :param is_zip: if file should be zip :param data: JSON Data to save :return: """ diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 8b75fe438..8f818047d 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -116,6 +116,7 @@ class Backtesting: # Get maximum required startup period self.required_startup = max([strat.startup_candle_count for strat in self.strategylist]) + self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe) def __del__(self): LoggingMixin.show_output = True @@ -228,16 +229,20 @@ class Backtesting: # Special case: trailing triggers within same candle as trade opened. Assume most # pessimistic price movement, which is moving just enough to arm stoploss and # immediately going down to stop price. - if (sell.sell_type == SellType.TRAILING_STOP_LOSS and trade_dur == 0 - and self.strategy.trailing_stop_positive): - if self.strategy.trailing_only_offset_is_reached: + if sell.sell_type == SellType.TRAILING_STOP_LOSS and trade_dur == 0: + if ( + not self.strategy.use_custom_stoploss and self.strategy.trailing_stop + and self.strategy.trailing_only_offset_is_reached + and self.strategy.trailing_stop_positive_offset is not None + and self.strategy.trailing_stop_positive + ): # Worst case: price reaches stop_positive_offset and dives down. stop_rate = (sell_row[OPEN_IDX] * (1 + abs(self.strategy.trailing_stop_positive_offset) - abs(self.strategy.trailing_stop_positive))) else: # Worst case: price ticks tiny bit above open and dives down. - stop_rate = sell_row[OPEN_IDX] * (1 - abs(self.strategy.trailing_stop_positive)) + stop_rate = sell_row[OPEN_IDX] * (1 - abs(trade.stop_loss_pct)) assert stop_rate < sell_row[HIGH_IDX] return stop_rate @@ -445,7 +450,7 @@ class Backtesting: for trade in open_trades[pair]: # also check the buying candle for sell conditions. trade_entry = self._get_sell_trade_entry(trade, row) - # Sell occured + # Sell occurred if trade_entry: # logger.debug(f"{pair} - Backtesting sell {trade}") open_trade_count -= 1 diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index c2b2b93cb..80ae8886e 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -12,7 +12,6 @@ from math import ceil from pathlib import Path from typing import Any, Dict, List, Optional -import numpy as np import progressbar import rapidjson from colorama import Fore, Style @@ -20,16 +19,16 @@ from colorama import init as colorama_init from joblib import Parallel, cpu_count, delayed, dump, load, wrap_non_picklable_objects from pandas import DataFrame -from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN +from freqtrade.constants import DATETIME_PRINT_FORMAT, FTHYPT_FILEVERSION, LAST_BT_RESULT_FN from freqtrade.data.converter import trim_dataframes from freqtrade.data.history import get_timerange -from freqtrade.misc import file_dump_json, plural +from freqtrade.misc import deep_merge_dicts, file_dump_json, plural from freqtrade.optimize.backtesting import Backtesting # Import IHyperOpt and IHyperOptLoss to allow unpickling classes from these modules from freqtrade.optimize.hyperopt_auto import HyperOptAuto from freqtrade.optimize.hyperopt_interface import IHyperOpt # noqa: F401 from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss # noqa: F401 -from freqtrade.optimize.hyperopt_tools import HyperoptTools +from freqtrade.optimize.hyperopt_tools import HyperoptTools, hyperopt_serializer from freqtrade.optimize.optimize_reports import generate_strategy_stats from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver, HyperOptResolver @@ -78,8 +77,11 @@ class Hyperopt: if not self.config.get('hyperopt'): self.custom_hyperopt = HyperOptAuto(self.config) + self.auto_hyperopt = True else: self.custom_hyperopt = HyperOptResolver.load_hyperopt(self.config) + self.auto_hyperopt = False + self.backtesting._set_strategy(self.backtesting.strategylist[0]) self.custom_hyperopt.strategy = self.backtesting.strategy @@ -163,13 +165,9 @@ class Hyperopt: While not a valid json object - this allows appending easily. :param epoch: result dictionary for this epoch. """ - def default_parser(x): - if isinstance(x, np.integer): - return int(x) - return str(x) - + epoch[FTHYPT_FILEVERSION] = 2 with self.results_file.open('a') as f: - rapidjson.dump(epoch, f, default=default_parser, + rapidjson.dump(epoch, f, default=hyperopt_serializer, number_mode=rapidjson.NM_NATIVE | rapidjson.NM_NAN) f.write("\n") @@ -201,6 +199,25 @@ class Hyperopt: return result + def _get_no_optimize_details(self) -> Dict[str, Any]: + """ + Get non-optimized parameters + """ + result: Dict[str, Any] = {} + strategy = self.backtesting.strategy + if not HyperoptTools.has_space(self.config, 'roi'): + result['roi'] = {str(k): v for k, v in strategy.minimal_roi.items()} + if not HyperoptTools.has_space(self.config, 'stoploss'): + result['stoploss'] = {'stoploss': strategy.stoploss} + if not HyperoptTools.has_space(self.config, 'trailing'): + result['trailing'] = { + 'trailing_stop': strategy.trailing_stop, + 'trailing_stop_positive': strategy.trailing_stop_positive, + 'trailing_stop_positive_offset': strategy.trailing_stop_positive_offset, + 'trailing_only_offset_is_reached': strategy.trailing_only_offset_is_reached, + } + return result + def print_results(self, results) -> None: """ Log results if it is better than any previous evaluation @@ -310,7 +327,8 @@ class Hyperopt: results_explanation = HyperoptTools.format_results_explanation_string( strat_stats, self.config['stake_currency']) - not_optimized = self.backtesting.strategy.get_params_dict() + not_optimized = self.backtesting.strategy.get_no_optimize_params() + not_optimized = deep_merge_dicts(not_optimized, self._get_no_optimize_details()) trade_count = strat_stats['total_trades'] total_profit = strat_stats['profit_total'] @@ -324,7 +342,8 @@ class Hyperopt: loss = self.calculate_loss(results=backtesting_results['results'], trade_count=trade_count, min_date=min_date, max_date=max_date, - config=self.config, processed=processed) + config=self.config, processed=processed, + backtest_stats=strat_stats) return { 'loss': loss, 'params_dict': params_dict, @@ -469,6 +488,12 @@ class Hyperopt: f"saved to '{self.results_file}'.") if self.current_best_epoch: + if self.auto_hyperopt: + HyperoptTools.try_export_params( + self.config, + self.backtesting.strategy.get_strategy_name(), + self.current_best_epoch) + HyperoptTools.show_epoch_details(self.current_best_epoch, self.total_epochs, self.print_json) else: diff --git a/freqtrade/optimize/hyperopt_loss_interface.py b/freqtrade/optimize/hyperopt_loss_interface.py index b5aa588b2..ac8239b75 100644 --- a/freqtrade/optimize/hyperopt_loss_interface.py +++ b/freqtrade/optimize/hyperopt_loss_interface.py @@ -5,7 +5,7 @@ This module defines the interface for the loss-function for hyperopt from abc import ABC, abstractmethod from datetime import datetime -from typing import Dict +from typing import Any, Dict from pandas import DataFrame @@ -22,6 +22,7 @@ class IHyperOptLoss(ABC): def hyperopt_loss_function(results: DataFrame, trade_count: int, min_date: datetime, max_date: datetime, config: Dict, processed: Dict[str, DataFrame], + backtest_stats: Dict[str, Any], *args, **kwargs) -> float: """ Objective function, returns smaller number for better results diff --git a/freqtrade/optimize/hyperopt_tools.py b/freqtrade/optimize/hyperopt_tools.py index 9eee42a8d..439016c14 100755 --- a/freqtrade/optimize/hyperopt_tools.py +++ b/freqtrade/optimize/hyperopt_tools.py @@ -1,23 +1,82 @@ import io import logging +from copy import deepcopy +from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional +import numpy as np import rapidjson import tabulate from colorama import Fore, Style from pandas import isna, json_normalize +from freqtrade.constants import FTHYPT_FILEVERSION, USERPATH_STRATEGIES from freqtrade.exceptions import OperationalException -from freqtrade.misc import round_coin_value, round_dict +from freqtrade.misc import deep_merge_dicts, round_coin_value, round_dict, safe_value_fallback2 logger = logging.getLogger(__name__) +NON_OPT_PARAM_APPENDIX = " # value loaded from strategy" + + +def hyperopt_serializer(x): + if isinstance(x, np.integer): + return int(x) + if isinstance(x, np.bool_): + return bool(x) + + return str(x) + class HyperoptTools(): + @staticmethod + def get_strategy_filename(config: Dict, strategy_name: str) -> Optional[Path]: + """ + Get Strategy-location (filename) from strategy_name + """ + from freqtrade.resolvers.strategy_resolver import StrategyResolver + directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES)) + strategy_objs = StrategyResolver.search_all_objects(directory, False) + strategies = [s for s in strategy_objs if s['name'] == strategy_name] + if strategies: + strategy = strategies[0] + + return Path(strategy['location']) + return None + + @staticmethod + def export_params(params, strategy_name: str, filename: Path): + """ + Generate files + """ + final_params = deepcopy(params['params_not_optimized']) + final_params = deep_merge_dicts(params['params_details'], final_params) + final_params = { + 'strategy_name': strategy_name, + 'params': final_params, + 'ft_stratparam_v': 1, + 'export_time': datetime.now(timezone.utc), + } + logger.info(f"Dumping parameters to {filename}") + rapidjson.dump(final_params, filename.open('w'), indent=2, + default=hyperopt_serializer, + number_mode=rapidjson.NM_NATIVE | rapidjson.NM_NAN + ) + + @staticmethod + def try_export_params(config: Dict[str, Any], strategy_name: str, params: Dict): + if params.get(FTHYPT_FILEVERSION, 1) >= 2 and not config.get('disableparamexport', False): + # Export parameters ... + fn = HyperoptTools.get_strategy_filename(config, strategy_name) + if fn: + HyperoptTools.export_params(params, strategy_name, fn.with_suffix('.json')) + else: + logger.warning("Strategy not found, not exporting parameter file.") + @staticmethod def has_space(config: Dict[str, Any], space: str) -> bool: """ @@ -99,9 +158,9 @@ class HyperoptTools(): non_optimized) HyperoptTools._params_pretty_print(params, 'sell', "Sell hyperspace params:", non_optimized) - HyperoptTools._params_pretty_print(params, 'roi', "ROI table:") - HyperoptTools._params_pretty_print(params, 'stoploss', "Stoploss:") - HyperoptTools._params_pretty_print(params, 'trailing', "Trailing stop:") + HyperoptTools._params_pretty_print(params, 'roi', "ROI table:", non_optimized) + HyperoptTools._params_pretty_print(params, 'stoploss', "Stoploss:", non_optimized) + HyperoptTools._params_pretty_print(params, 'trailing', "Trailing stop:", non_optimized) @staticmethod def _params_update_for_json(result_dict, params, non_optimized, space: str) -> None: @@ -127,23 +186,34 @@ class HyperoptTools(): def _params_pretty_print(params, space: str, header: str, non_optimized={}) -> None: if space in params or space in non_optimized: space_params = HyperoptTools._space_params(params, space, 5) + no_params = HyperoptTools._space_params(non_optimized, space, 5) + appendix = '' + if not space_params and not no_params: + # No parameters - don't print + return + if not space_params: + # Not optimized parameters - append string + appendix = NON_OPT_PARAM_APPENDIX + result = f"\n# {header}\n" - if space == 'stoploss': - result += f"stoploss = {space_params.get('stoploss')}" - elif space == 'roi': + if space == "stoploss": + stoploss = safe_value_fallback2(space_params, no_params, space, space) + result += (f"stoploss = {stoploss}{appendix}") + + elif space == "roi": + result = result[:-1] + f'{appendix}\n' minimal_roi_result = rapidjson.dumps({ - str(k): v for k, v in space_params.items() + str(k): v for k, v in (space_params or no_params).items() }, default=str, indent=4, number_mode=rapidjson.NM_NATIVE) result += f"minimal_roi = {minimal_roi_result}" - elif space == 'trailing': - - for k, v in space_params.items(): - result += f'{k} = {v}\n' + elif space == "trailing": + for k, v in (space_params or no_params).items(): + result += f"{k} = {v}{appendix}\n" else: - no_params = HyperoptTools._space_params(non_optimized, space, 5) + # Buy / sell parameters - result += f"{space}_params = {HyperoptTools._pprint(space_params, no_params)}" + result += f"{space}_params = {HyperoptTools._pprint_dict(space_params, no_params)}" result = result.replace("\n", "\n ") print(result) @@ -157,7 +227,7 @@ class HyperoptTools(): return {} @staticmethod - def _pprint(params, non_optimized, indent: int = 4): + def _pprint_dict(params, non_optimized, indent: int = 4): """ Pretty-print hyperopt results (based on 2 dicts - with add. comment) """ @@ -169,7 +239,7 @@ class HyperoptTools(): result += " " * indent + f'"{k}": ' result += f'"{param}",' if isinstance(param, str) else f'{param},' if k in non_optimized: - result += " # value loaded from strategy" + result += NON_OPT_PARAM_APPENDIX result += "\n" result += '}' return result diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index df7f721ec..eefacbbab 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -21,7 +21,7 @@ def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> N Stores backtest results :param recordfilename: Path object, which can either be a filename or a directory. Filenames will be appended with a timestamp right before the suffix - while for diectories, /backtest-result-.json will be used as filename + while for directories, /backtest-result-.json will be used as filename :param stats: Dataframe containing the backtesting statistics """ if recordfilename.is_dir(): @@ -229,8 +229,6 @@ def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: winning_trades = results.loc[results['profit_ratio'] > 0] draw_trades = results.loc[results['profit_ratio'] == 0] losing_trades = results.loc[results['profit_ratio'] < 0] - zero_duration_trades = len(results.loc[(results['trade_duration'] == 0) & - (results['sell_reason'] == 'trailing_stop_loss')]) holding_avg = (timedelta(minutes=round(results['trade_duration'].mean())) if not results.empty else timedelta()) @@ -249,7 +247,6 @@ def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: 'winner_holding_avg_s': winner_holding_avg.total_seconds(), 'loser_holding_avg': loser_holding_avg, 'loser_holding_avg_s': loser_holding_avg.total_seconds(), - 'zero_duration_trades': zero_duration_trades, } @@ -264,6 +261,7 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]: 'winning_days': 0, 'draw_days': 0, 'losing_days': 0, + 'daily_profit_list': [], } daily_profit_rel = results.resample('1d', on='close_date')['profit_ratio'].sum() daily_profit = results.resample('1d', on='close_date')['profit_abs'].sum().round(10) @@ -274,6 +272,7 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]: winning_days = sum(daily_profit > 0) draw_days = sum(daily_profit == 0) losing_days = sum(daily_profit < 0) + daily_profit_list = [(str(idx.date()), val) for idx, val in daily_profit.iteritems()] return { 'backtest_best_day': best_rel, @@ -283,6 +282,7 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]: 'winning_days': winning_days, 'draw_days': draw_days, 'losing_days': losing_days, + 'daily_profit': daily_profit_list, } @@ -300,7 +300,7 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame], :param min_date: Backtest start date :param max_date: Backtest end date :param market_change: float indicating the market change - :return: Dictionary containing results per strategy and a stratgy summary. + :return: Dictionary containing results per strategy and a strategy summary. """ results: Dict[str, DataFrame] = content['results'] if not isinstance(results, DataFrame): @@ -325,8 +325,9 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame], key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'], key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None - results['open_timestamp'] = results['open_date'].astype(int64) // 1e6 - results['close_timestamp'] = results['close_date'].astype(int64) // 1e6 + if not results.empty: + results['open_timestamp'] = results['open_date'].view(int64) // 1e6 + results['close_timestamp'] = results['close_date'].view(int64) // 1e6 backtest_days = (max_date - min_date).days strat_stats = { @@ -378,10 +379,10 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame], 'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False), 'use_custom_stoploss': config.get('use_custom_stoploss', False), 'minimal_roi': config['minimal_roi'], - 'use_sell_signal': config['ask_strategy']['use_sell_signal'], - 'sell_profit_only': config['ask_strategy']['sell_profit_only'], - 'sell_profit_offset': config['ask_strategy']['sell_profit_offset'], - 'ignore_roi_if_buy_signal': config['ask_strategy']['ignore_roi_if_buy_signal'], + 'use_sell_signal': config['use_sell_signal'], + 'sell_profit_only': config['sell_profit_only'], + 'sell_profit_offset': config['sell_profit_offset'], + 'ignore_roi_if_buy_signal': config['ignore_roi_if_buy_signal'], **daily_stats, **trade_stats } @@ -436,7 +437,7 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame], { Strategy: {'results: results, 'config: config}}. :param min_date: Backtest start date :param max_date: Backtest end date - :return: Dictionary containing results per strategy and a stratgy summary. + :return: Dictionary containing results per strategy and a strategy summary. """ result: Dict[str, Any] = {'strategy': {}} market_change = calculate_market_change(btdata, 'close') @@ -507,9 +508,8 @@ def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_curren def text_table_strategy(strategy_results, stake_currency: str) -> str: """ Generate summary table per strategy + :param strategy_results: Dict of containing results for all strategies :param stake_currency: stake-currency - used to correctly name headers - :param max_open_trades: Maximum allowed open trades used for backtest - :param all_results: Dict of containing results for all strategies :return: pretty printed table with tabulate as string """ floatfmt = _get_line_floatfmt(stake_currency) @@ -543,14 +543,6 @@ def text_table_add_metrics(strat_results: Dict) -> str: # Newly added fields should be ignored if they are missing in strat_results. hyperopt-show # command stores these results and newer version of freqtrade must be able to handle old # results with missing new fields. - zero_duration_trades = '--' - - if 'zero_duration_trades' in strat_results: - zero_duration_trades_per = \ - 100.0 / strat_results['total_trades'] * strat_results['zero_duration_trades'] - zero_duration_trades = f'{zero_duration_trades_per:.2f}% ' \ - f'({strat_results["zero_duration_trades"]})' - metrics = [ ('Backtesting from', strat_results['backtest_start']), ('Backtesting to', strat_results['backtest_end']), @@ -586,7 +578,6 @@ def text_table_add_metrics(strat_results: Dict) -> str: f"{strat_results['draw_days']} / {strat_results['losing_days']}"), ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"), ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"), - ('Zero Duration Trades', zero_duration_trades), ('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')), ('', ''), # Empty line to improve readability @@ -664,6 +655,8 @@ def show_backtest_results(config: Dict, backtest_stats: Dict): # Print Strategy summary table table = text_table_strategy(backtest_stats['strategy_comparison'], stake_currency) + print(f"{results['backtest_start']} -> {results['backtest_end']} |" + f" Max open trades : {results['max_open_trades']}") print(' STRATEGY SUMMARY '.center(len(table.splitlines()[0]), '=')) print(table) print('=' * len(table.splitlines()[0])) diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index c4e6368c5..c3b07d1b1 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -48,13 +48,11 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col sell_reason = get_column_def(cols, 'sell_reason', 'null') strategy = get_column_def(cols, 'strategy', 'null') - leverage = get_column_def(cols, 'leverage', '0.0') - borrowed = get_column_def(cols, 'borrowed', '0.0') - borrowed_currency = get_column_def(cols, 'borrowed_currency', 'null') - collateral_currency = get_column_def(cols, 'collateral_currency', 'null') + leverage = get_column_def(cols, 'leverage', '1.0') interest_rate = get_column_def(cols, 'interest_rate', '0.0') liquidation_price = get_column_def(cols, 'liquidation_price', 'null') is_short = get_column_def(cols, 'is_short', 'False') + interest_mode = get_column_def(cols, 'interest_mode', 'null') # If ticker-interval existed use that, else null. if has_column(cols, 'ticker_interval'): timeframe = get_column_def(cols, 'timeframe', 'ticker_interval') @@ -91,7 +89,7 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col stoploss_order_id, stoploss_last_update, max_rate, min_rate, sell_reason, sell_order_status, strategy, timeframe, open_trade_value, close_profit_abs, - leverage, borrowed, borrowed_currency, collateral_currency, interest_rate, liquidation_price, is_short + leverage, interest_rate, liquidation_price, is_short, interest_mode ) select id, lower(exchange), case @@ -115,14 +113,12 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col {sell_order_status} sell_order_status, {strategy} strategy, {timeframe} timeframe, {open_trade_value} open_trade_value, {close_profit_abs} close_profit_abs, - {leverage} leverage, {borrowed} borrowed, {borrowed_currency} borrowed_currency, - {collateral_currency} collateral_currency, {interest_rate} interest_rate, - {liquidation_price} liquidation_price, {is_short} is_short + {leverage} leverage, {interest_rate} interest_rate, + {liquidation_price} liquidation_price, {is_short} is_short, + {interest_mode} interest_mode from {table_back_name} """)) -# TODO: Does leverage go in here? - def migrate_open_orders_to_trades(engine): with engine.begin() as connection: @@ -152,14 +148,18 @@ def migrate_orders_table(decl_base, inspector, engine, table_back_name: str, col # let SQLAlchemy create the schema as required decl_base.metadata.create_all(engine) + leverage = get_column_def(cols, 'leverage', '1.0') + is_short = get_column_def(cols, 'is_short', 'False') + # TODO-mg: Should liquidation price go in here? with engine.begin() as connection: connection.execute(text(f""" insert into orders ( id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, symbol, order_type, side, price, amount, filled, average, remaining, cost, - order_date, order_filled_date, order_update_date, leverage) + order_date, order_filled_date, order_update_date, leverage, is_short) select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, symbol, order_type, side, price, amount, filled, null average, remaining, cost, - order_date, order_filled_date, order_update_date, leverage + order_date, order_filled_date, order_update_date, + {leverage} leverage, {is_short} is_short from {table_back_name} """)) @@ -188,9 +188,11 @@ def check_migrate(engine, decl_base, previous_tables) -> None: else: cols_order = inspector.get_columns('orders') - if not has_column(cols_order, 'average'): + # Last added column of order table + # To determine if migrations need to run + if not has_column(cols_order, 'leverage'): tabs = get_table_names_for_table(inspector, 'orders') # Empty for now - as there is only one iteration of the orders table so far. table_back_name = get_backup_name(tabs, 'orders_bak') - migrate_orders_table(decl_base, inspector, engine, table_back_name, cols) + migrate_orders_table(decl_base, inspector, engine, table_back_name, cols_order) diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 168cfa6d7..a4c37346e 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone from decimal import Decimal from typing import Any, Dict, List, Optional -from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer, String, +from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, create_engine, desc, func, inspect) from sqlalchemy.exc import NoSuchModuleError from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session, sessionmaker @@ -14,7 +14,7 @@ from sqlalchemy.pool import StaticPool from sqlalchemy.sql.schema import UniqueConstraint from freqtrade.constants import DATETIME_PRINT_FORMAT -from freqtrade.enums import SellType +from freqtrade.enums import InterestMode, SellType from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.misc import safe_value_fallback from freqtrade.persistence.migrations import check_migrate @@ -133,8 +133,10 @@ class Order(_DECL_BASE): order_update_date = Column(DateTime, nullable=True) leverage = Column(Float, nullable=True, default=1.0) + is_short = Column(Boolean, nullable=True, default=False) def __repr__(self): + return (f'Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, ' f'side={self.side}, order_type={self.order_type}, status={self.status})') @@ -157,6 +159,7 @@ class Order(_DECL_BASE): self.remaining = order.get('remaining', self.remaining) self.cost = order.get('cost', self.cost) self.leverage = order.get('leverage', self.leverage) + # TODO-mg: is_short? if 'timestamp' in order and order['timestamp'] is not None: self.order_date = datetime.fromtimestamp(order['timestamp'] / 1000, tz=timezone.utc) @@ -228,7 +231,6 @@ class LocalTrade(): fee_close_currency: str = '' open_rate: float = 0.0 open_rate_requested: Optional[float] = None - # open_trade_value - calculated via _calc_open_trade_value open_trade_value: float = 0.0 close_rate: Optional[float] = None @@ -263,39 +265,30 @@ class LocalTrade(): timeframe: Optional[int] = None # Margin trading properties - leverage: Optional[float] = 1.0 - borrowed: float = 0.0 - borrowed_currency: str = None - collateral_currency: str = None interest_rate: float = 0.0 - liquidation_price: float = None + liquidation_price: Optional[float] = None is_short: bool = False - # End of margin trading properties + leverage: float = 1.0 + interest_mode: InterestMode = InterestMode.NONE - def __init__(self, **kwargs): - lev = kwargs.get('leverage') - bor = kwargs.get('borrowed') - amount = kwargs.get('amount') - if lev and bor: - # TODO: should I raise an error? - raise OperationalException('Cannot pass both borrowed and leverage to Trade') - elif lev: - self.amount = amount * lev - self.borrowed = amount * (lev-1) - elif bor: - self.lev = (bor + amount)/amount + @property + def has_no_leverage(self) -> bool: + """Returns true if this is a non-leverage, non-short trade""" + return (self.leverage == 1.0 and not self.is_short) or self.leverage is None - for key in kwargs: - setattr(self, key, kwargs[key]) - if not self.is_short: - self.is_short = False - self.recalc_open_trade_value() - - def __repr__(self): - open_since = self.open_date.strftime(DATETIME_PRINT_FORMAT) if self.is_open else 'closed' - - return (f'Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, ' - f'open_rate={self.open_rate:.8f}, open_since={open_since})') + @property + def borrowed(self) -> float: + """ + The amount of currency borrowed from the exchange for leverage trades + If a long trade, the amount is in base currency + If a short trade, the amount is in the other currency being traded + """ + if self.has_no_leverage: + return 0.0 + elif not self.is_short: + return self.stake_amount * (self.leverage-1) + else: + return self.amount @property def open_date_utc(self): @@ -305,6 +298,54 @@ class LocalTrade(): def close_date_utc(self): return self.close_date.replace(tzinfo=timezone.utc) + def __init__(self, **kwargs): + for key in kwargs: + setattr(self, key, kwargs[key]) + self.set_liquidation_price(self.liquidation_price) + self.recalc_open_trade_value() + + def set_stop_loss_helper(self, stop_loss: Optional[float], liquidation_price: Optional[float]): + """Helper function for set_liquidation_price and set_stop_loss""" + # Stoploss would be better as a computed variable, + # but that messes up the database so it might not be possible + + if liquidation_price is not None: + if stop_loss is not None: + if self.is_short: + self.stop_loss = min(stop_loss, liquidation_price) + else: + self.stop_loss = max(stop_loss, liquidation_price) + else: + self.stop_loss = liquidation_price + self.initial_stop_loss = liquidation_price + self.liquidation_price = liquidation_price + else: + # programmming error check: 1 of liqudication_price or stop_loss must be set + assert stop_loss is not None + if not self.stop_loss: + self.initial_stop_loss = stop_loss + self.stop_loss = stop_loss + + def set_stop_loss(self, stop_loss: float): + """ + Method you should use to set self.stop_loss. + Assures stop_loss is not passed the liquidation price + """ + self.set_stop_loss_helper(stop_loss=stop_loss, liquidation_price=self.liquidation_price) + + def set_liquidation_price(self, liquidation_price: float): + """ + Method you should use to set self.liquidation price. + Assures stop_loss is not passed the liquidation price + """ + self.set_stop_loss_helper(stop_loss=self.stop_loss, liquidation_price=liquidation_price) + + def __repr__(self): + open_since = self.open_date.strftime(DATETIME_PRINT_FORMAT) if self.is_open else 'closed' + + return (f'Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, ' + f'open_rate={self.open_rate:.8f}, open_since={open_since})') + def to_json(self) -> Dict[str, Any]: return { 'trade_id': self.id, @@ -368,9 +409,6 @@ class LocalTrade(): 'max_rate': self.max_rate, 'leverage': self.leverage, - 'borrowed': self.borrowed, - 'borrowed_currency': self.borrowed_currency, - 'collateral_currency': self.collateral_currency, 'interest_rate': self.interest_rate, 'liquidation_price': self.liquidation_price, 'is_short': self.is_short, @@ -396,8 +434,11 @@ class LocalTrade(): def _set_new_stoploss(self, new_loss: float, stoploss: float): """Assign new stop value""" - self.stop_loss = new_loss - self.stop_loss_pct = -1 * abs(stoploss) + self.set_stop_loss(new_loss) + if self.is_short: + self.stop_loss_pct = abs(stoploss) + else: + self.stop_loss_pct = -1 * abs(stoploss) self.stoploss_last_update = datetime.utcnow() def adjust_stop_loss(self, current_price: float, stoploss: float, @@ -413,22 +454,37 @@ class LocalTrade(): # Don't modify if called with initial and nothing to do return - new_loss = float(current_price * (1 - abs(stoploss))) - # TODO: Could maybe move this if into the new stoploss if branch - if (self.liquidation_price): # If trading on margin, don't set the stoploss below the liquidation price - new_loss = min(self.liquidation_price, new_loss) + if self.is_short: + new_loss = float(current_price * (1 + abs(stoploss))) + # If trading on margin, don't set the stoploss below the liquidation price + if self.liquidation_price: + new_loss = min(self.liquidation_price, new_loss) + else: + new_loss = float(current_price * (1 - abs(stoploss))) + # If trading on margin, don't set the stoploss below the liquidation price + if self.liquidation_price: + new_loss = max(self.liquidation_price, new_loss) # no stop loss assigned yet if not self.stop_loss: logger.debug(f"{self.pair} - Assigning new stoploss...") self._set_new_stoploss(new_loss, stoploss) self.initial_stop_loss = new_loss - self.initial_stop_loss_pct = -1 * abs(stoploss) + if self.is_short: + self.initial_stop_loss_pct = abs(stoploss) + else: + self.initial_stop_loss_pct = -1 * abs(stoploss) # evaluate if the stop loss needs to be updated else: - # stop losses only walk up, never down!, #TODO: But adding more to a margin account would create a lower liquidation price, decreasing the minimum stoploss - if (new_loss > self.stop_loss and not self.is_short) or (new_loss < self.stop_loss and self.is_short): + + higher_stop = new_loss > self.stop_loss + lower_stop = new_loss < self.stop_loss + + # stop losses only walk up, never down!, + # ? But adding more to a margin account would create a lower liquidation price, + # ? decreasing the minimum stoploss + if (higher_stop and not self.is_short) or (lower_stop and self.is_short): logger.debug(f"{self.pair} - Adjusting stoploss...") self._set_new_stoploss(new_loss, stoploss) else: @@ -463,6 +519,11 @@ class LocalTrade(): :return: None """ order_type = order['type'] + + if 'is_short' in order and order['side'] == 'sell': + # Only set's is_short on opening trades, ignores non-shorts + self.is_short = order['is_short'] + # Ignore open and cancelled orders if order['status'] == 'open' or safe_value_fallback(order, 'average', 'price') is None: return @@ -473,6 +534,8 @@ class LocalTrade(): # Update open rate and actual amount self.open_rate = float(safe_value_fallback(order, 'average', 'price')) 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" @@ -481,8 +544,10 @@ class LocalTrade(): elif order_type in ('market', 'limit') and self.is_closing_trade(order['side']): if self.is_open: payment = "BUY" if self.is_short else "SELL" + # TODO-mg: On shorts, you buy a little bit more than the amount (amount + interest) + # This wll only print the original amount logger.info(f'{order_type.upper()}_{payment} has been fulfilled for {self}.') - self.close(safe_value_fallback(order, 'average', 'price')) # TODO: Double check this + self.close(safe_value_fallback(order, 'average', 'price')) # TODO-mg: Double check this elif order_type in ('stop_loss_limit', 'stop-loss', 'stop-loss-limit', 'stop'): self.stoploss_order_id = None self.close_rate_requested = self.stop_loss @@ -551,7 +616,7 @@ class LocalTrade(): """ open_trade = Decimal(self.amount) * Decimal(self.open_rate) fees = open_trade * Decimal(self.fee_open) - if (self.is_short): + if self.is_short: return float(open_trade - fees) else: return float(open_trade + fees) @@ -563,77 +628,77 @@ class LocalTrade(): """ self.open_trade_value = self._calc_open_trade_value() - def calculate_interest(self) -> Decimal: - # TODO-mg: Need to set other conditions because sometimes self.open_date is not defined, but why would it ever not be set - if not self.interest_rate or not (self.borrowed): - return Decimal(0.0) + def calculate_interest(self, interest_rate: Optional[float] = None) -> Decimal: + """ + : param interest_rate: interest_charge for borrowing this coin(optional). + If interest_rate is not set self.interest_rate will be used + """ - try: - open_date = self.open_date.replace(tzinfo=None) - now = datetime.now() - secPerDay = 86400 - days = Decimal((now - open_date).total_seconds()/secPerDay) or 0.0 - hours = days/24 - except: - raise OperationalException("Time isn't calculated properly") + zero = Decimal(0.0) + # If nothing was borrowed + if self.has_no_leverage: + return zero - rate = Decimal(self.interest_rate) + open_date = self.open_date.replace(tzinfo=None) + now = (self.close_date or datetime.now(timezone.utc)).replace(tzinfo=None) + sec_per_hour = Decimal(3600) + total_seconds = Decimal((now - open_date).total_seconds()) + hours = total_seconds/sec_per_hour or zero + + rate = Decimal(interest_rate or self.interest_rate) borrowed = Decimal(self.borrowed) - if self.exchange == 'binance': - # Rate is per day but accrued hourly or something - # binance: https://www.binance.com/en-AU/support/faq/360030157812 - return borrowed * (rate/24) * max(hours, 1.0) # TODO-mg: Is hours rounded? - elif self.exchange == 'kraken': - # https://support.kraken.com/hc/en-us/articles/206161568-What-are-the-fees-for-margin-trading- - opening_fee = borrowed * rate - roll_over_fee = borrowed * rate * max(0, (hours-4)/4) - return opening_fee + roll_over_fee - elif self.exchange == 'binance_usdm_futures': - # ! TODO-mg: This is incorrect, I didn't look it up - return borrowed * (rate/24) * max(hours, 1.0) - elif self.exchange == 'binance_coinm_futures': - # ! TODO-mg: This is incorrect, I didn't look it up - return borrowed * (rate/24) * max(hours, 1.0) - else: - # TODO-mg: make sure this breaks and can't be squelched - raise OperationalException("Leverage not available on this exchange") + return self.interest_mode(borrowed=borrowed, rate=rate, hours=hours) def calc_close_trade_value(self, rate: Optional[float] = None, - fee: Optional[float] = None) -> float: + + fee: Optional[float] = None, + interest_rate: Optional[float] = None) -> float: """ Calculate the close_rate including fee :param fee: fee to use on the close rate (optional). If rate is not set self.fee will be used :param rate: rate to compare with (optional). If rate is not set self.close_rate will be used + :param interest_rate: interest_charge for borrowing this coin (optional). + If interest_rate is not set self.interest_rate will be used :return: Price in BTC of the open trade """ if rate is None and not self.close_rate: return 0.0 - close_trade = Decimal(self.amount) * Decimal(rate or self.close_rate) # type: ignore - fees = close_trade * Decimal(fee or self.fee_close) - interest = self.calculate_interest() + interest = self.calculate_interest(interest_rate) + if self.is_short: + amount = Decimal(self.amount) + Decimal(interest) + else: + # Currency already owned for longs, no need to purchase + amount = Decimal(self.amount) - if (self.is_short): - return float(close_trade + fees + interest) + close_trade = Decimal(amount) * Decimal(rate or self.close_rate) # type: ignore + fees = close_trade * Decimal(fee or self.fee_close) + + if self.is_short: + return float(close_trade + fees) else: return float(close_trade - fees - interest) def calc_profit(self, rate: Optional[float] = None, - fee: Optional[float] = None) -> float: + fee: Optional[float] = None, + interest_rate: Optional[float] = None) -> float: """ Calculate the absolute profit in stake currency between Close and Open trade :param fee: fee to use on the close rate (optional). If fee is not set self.fee will be used :param rate: close rate to compare with (optional). If rate is not set self.close_rate will be used + :param interest_rate: interest_charge for borrowing this coin (optional). + If interest_rate is not set self.interest_rate will be used :return: profit in stake currency as float """ close_trade_value = self.calc_close_trade_value( rate=(rate or self.close_rate), - fee=(fee or self.fee_close) + fee=(fee or self.fee_close), + interest_rate=(interest_rate or self.interest_rate) ) if self.is_short: @@ -643,29 +708,36 @@ class LocalTrade(): return float(f"{profit:.8f}") def calc_profit_ratio(self, rate: Optional[float] = None, - fee: Optional[float] = None) -> float: + fee: Optional[float] = None, + interest_rate: Optional[float] = None) -> float: """ Calculates the profit as ratio (including fee). :param rate: rate to compare with (optional). If rate is not set self.close_rate will be used :param fee: fee to use on the close rate (optional). + :param interest_rate: interest_charge for borrowing this coin (optional). + If interest_rate is not set self.interest_rate will be used :return: profit ratio as float """ + close_trade_value = self.calc_close_trade_value( rate=(rate or self.close_rate), - fee=(fee or self.fee_close) + fee=(fee or self.fee_close), + interest_rate=(interest_rate or self.interest_rate) ) - if self.is_short: - if close_trade_value == 0.0: - return 0.0 - else: - profit_ratio = (self.open_trade_value / close_trade_value) - 1 + short_close_zero = (self.is_short and close_trade_value == 0.0) + long_close_zero = (not self.is_short and self.open_trade_value == 0.0) + leverage = self.leverage or 1.0 + + if (short_close_zero or long_close_zero): + return 0.0 else: - if self.open_trade_value == 0.0: - return 0.0 + if self.is_short: + profit_ratio = (1 - (close_trade_value/self.open_trade_value)) * leverage else: - profit_ratio = (close_trade_value / self.open_trade_value) - 1 + profit_ratio = ((close_trade_value/self.open_trade_value) - 1) * leverage + return float(f"{profit_ratio:.8f}") def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]: @@ -683,7 +755,7 @@ class LocalTrade(): else: return None - @ staticmethod + @staticmethod def get_trades_proxy(*, pair: str = None, is_open: bool = None, open_date: datetime = None, close_date: datetime = None, ) -> List['LocalTrade']: @@ -717,27 +789,27 @@ class LocalTrade(): return sel_trades - @ staticmethod + @staticmethod def close_bt_trade(trade): LocalTrade.trades_open.remove(trade) LocalTrade.trades.append(trade) LocalTrade.total_profit += trade.close_profit_abs - @ staticmethod + @staticmethod def add_bt_trade(trade): if trade.is_open: LocalTrade.trades_open.append(trade) else: LocalTrade.trades.append(trade) - @ staticmethod + @staticmethod def get_open_trades() -> List[Any]: """ Query trades from persistence layer """ return Trade.get_trades_proxy(is_open=True) - @ staticmethod + @staticmethod def stoploss_reinitialization(desired_stoploss): """ Adjust initial Stoploss to desired stoploss for all open trades. @@ -811,19 +883,17 @@ class Trade(_DECL_BASE, LocalTrade): max_rate = Column(Float, nullable=True, default=0.0) # Lowest price reached min_rate = Column(Float, nullable=True) - sell_reason = Column(String(100), nullable=True) # TODO: Change to close_reason - sell_order_status = Column(String(100), nullable=True) + sell_reason = Column(String(100), nullable=True) # TODO-mg: Change to close_reason + sell_order_status = Column(String(100), nullable=True) # TODO-mg: Change to close_order_status strategy = Column(String(100), nullable=True) timeframe = Column(Integer, nullable=True) # Margin trading properties leverage = Column(Float, nullable=True, default=1.0) - borrowed = Column(Float, nullable=False, default=0.0) - borrowed_currency = Column(Float, nullable=True) - collateral_currency = Column(String(25), nullable=True) interest_rate = Column(Float, nullable=False, default=0.0) liquidation_price = Column(Float, nullable=True) is_short = Column(Boolean, nullable=False, default=False) + interest_mode = Column(Enum(InterestMode), nullable=True) # End of margin trading properties def __init__(self, **kwargs): @@ -838,11 +908,11 @@ class Trade(_DECL_BASE, LocalTrade): Trade.query.session.delete(self) Trade.commit() - @ staticmethod + @staticmethod def commit(): Trade.query.session.commit() - @ staticmethod + @staticmethod def get_trades_proxy(*, pair: str = None, is_open: bool = None, open_date: datetime = None, close_date: datetime = None, ) -> List['LocalTrade']: @@ -872,7 +942,7 @@ class Trade(_DECL_BASE, LocalTrade): close_date=close_date ) - @ staticmethod + @staticmethod def get_trades(trade_filter=None) -> Query: """ Helper function to query Trades using filters. @@ -892,7 +962,7 @@ class Trade(_DECL_BASE, LocalTrade): else: return Trade.query - @ staticmethod + @staticmethod def get_open_order_trades(): """ Returns all open trades @@ -900,7 +970,7 @@ class Trade(_DECL_BASE, LocalTrade): """ return Trade.get_trades(Trade.open_order_id.isnot(None)).all() - @ staticmethod + @staticmethod def get_open_trades_without_assigned_fees(): """ Returns all open trades which don't have open fees set correctly @@ -911,7 +981,7 @@ class Trade(_DECL_BASE, LocalTrade): Trade.is_open.is_(True), ]).all() - @ staticmethod + @staticmethod def get_closed_trades_without_assigned_fees(): """ Returns all closed trades which don't have fees set correctly @@ -922,7 +992,7 @@ class Trade(_DECL_BASE, LocalTrade): Trade.is_open.is_(False), ]).all() - @ staticmethod + @staticmethod def total_open_trades_stakes() -> float: """ Calculates total invested amount in open trades @@ -936,7 +1006,7 @@ class Trade(_DECL_BASE, LocalTrade): t.stake_amount for t in LocalTrade.get_trades_proxy(is_open=True)) return total_open_stake_amount or 0 - @ staticmethod + @staticmethod def get_overall_performance() -> List[Dict[str, Any]]: """ Returns List of dicts containing all Trades, including profit and trade count @@ -961,8 +1031,8 @@ class Trade(_DECL_BASE, LocalTrade): for pair, profit, profit_abs, count in pair_rates ] - @ staticmethod - def get_best_pair(): + @staticmethod + def get_best_pair(start_date: datetime = datetime.fromtimestamp(0)): """ Get best pair with closed trade. NOTE: Not supported in Backtesting. @@ -970,7 +1040,7 @@ class Trade(_DECL_BASE, LocalTrade): """ best_pair = Trade.query.with_entities( Trade.pair, func.sum(Trade.close_profit).label('profit_sum') - ).filter(Trade.is_open.is_(False)) \ + ).filter(Trade.is_open.is_(False) & (Trade.close_date >= start_date)) \ .group_by(Trade.pair) \ .order_by(desc('profit_sum')).first() return best_pair @@ -999,7 +1069,7 @@ class PairLock(_DECL_BASE): return (f'PairLock(id={self.id}, pair={self.pair}, lock_time={lock_time}, ' f'lock_end_time={lock_end_time})') - @ staticmethod + @staticmethod def query_pair_locks(pair: Optional[str], now: datetime) -> Query: """ Get all currently active locks for this pair diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index c1b1232c2..061460975 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -288,8 +288,8 @@ def plot_area(fig, row: int, data: pd.DataFrame, indicator_a: str, :param fig: Plot figure to append to :param row: row number for this plot :param data: candlestick DataFrame - :param indicator_a: indicator name as populated in stragetie - :param indicator_b: indicator name as populated in stragetie + :param indicator_a: indicator name as populated in strategy + :param indicator_b: indicator name as populated in strategy :param label: label for the filled area :param fill_color: color to be used for the filled area :return: fig with added filled_traces plot @@ -373,6 +373,7 @@ def generate_candlestick_graph(pair: str, data: pd.DataFrame, trades: pd.DataFra for i, name in enumerate(plot_config['subplots']): fig['layout'][f'yaxis{3 + i}'].update(title=name) fig['layout']['xaxis']['rangeslider'].update(visible=False) + fig.update_layout(modebar_add=["v1hovermode", "toggleSpikeLines"]) # Common information candles = go.Candlestick( @@ -452,6 +453,7 @@ def generate_candlestick_graph(pair: str, data: pd.DataFrame, trades: pd.DataFra data=data) # fill area between indicators ( 'fill_to': 'other_indicator') fig = add_areas(fig, row, data, sub_config) + return fig @@ -484,6 +486,7 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame], fig['layout']['yaxis2'].update(title=f'Profit {stake_currency}') fig['layout']['yaxis3'].update(title=f'Profit {stake_currency}') fig['layout']['xaxis']['rangeslider'].update(visible=False) + fig.update_layout(modebar_add=["v1hovermode", "toggleSpikeLines"]) fig.add_trace(avgclose, 1, 1) fig = add_profit(fig, 2, df_comb, 'cum_profit', 'Profit') @@ -497,7 +500,6 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame], fig = add_profit(fig, 3, df_comb, profit_col, f"Profit {pair}") except ValueError: pass - return fig diff --git a/freqtrade/plugins/pairlist/AgeFilter.py b/freqtrade/plugins/pairlist/AgeFilter.py index 8f623b062..dc5cab31e 100644 --- a/freqtrade/plugins/pairlist/AgeFilter.py +++ b/freqtrade/plugins/pairlist/AgeFilter.py @@ -27,6 +27,7 @@ class AgeFilter(IPairList): super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) self._min_days_listed = pairlistconfig.get('min_days_listed', 10) + self._max_days_listed = pairlistconfig.get('max_days_listed', None) if self._min_days_listed < 1: raise OperationalException("AgeFilter requires min_days_listed to be >= 1") @@ -34,6 +35,12 @@ class AgeFilter(IPairList): raise OperationalException("AgeFilter requires min_days_listed to not exceed " "exchange max request size " f"({exchange.ohlcv_candle_limit('1d')})") + if self._max_days_listed and self._max_days_listed <= self._min_days_listed: + raise OperationalException("AgeFilter max_days_listed <= min_days_listed not permitted") + if self._max_days_listed and self._max_days_listed > exchange.ohlcv_candle_limit('1d'): + raise OperationalException("AgeFilter requires max_days_listed to not exceed " + "exchange max request size " + f"({exchange.ohlcv_candle_limit('1d')})") @property def needstickers(self) -> bool: @@ -48,8 +55,13 @@ class AgeFilter(IPairList): """ Short whitelist method description - used for startup-messages """ - return (f"{self.name} - Filtering pairs with age less than " - f"{self._min_days_listed} {plural(self._min_days_listed, 'day')}.") + return ( + f"{self.name} - Filtering pairs with age less than " + f"{self._min_days_listed} {plural(self._min_days_listed, 'day')}" + ) + (( + " or more than " + f"{self._max_days_listed} {plural(self._max_days_listed, 'day')}" + ) if self._max_days_listed else '') def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: """ @@ -61,9 +73,12 @@ class AgeFilter(IPairList): if not needed_pairs: return pairlist + since_days = -( + self._max_days_listed if self._max_days_listed else self._min_days_listed + ) - 1 since_ms = int(arrow.utcnow() .floor('day') - .shift(days=-self._min_days_listed - 1) + .shift(days=since_days) .float_timestamp) * 1000 candles = self._exchange.refresh_latest_ohlcv(needed_pairs, since_ms=since_ms, cache=False) if self._enabled: @@ -86,14 +101,22 @@ class AgeFilter(IPairList): return True if daily_candles is not None: - if len(daily_candles) >= self._min_days_listed: + if ( + len(daily_candles) >= self._min_days_listed + and (not self._max_days_listed or len(daily_candles) <= self._max_days_listed) + ): # We have fetched at least the minimum required number of daily candles # Add to cache, store the time we last checked this symbol - self._symbolsChecked[pair] = int(arrow.utcnow().float_timestamp) * 1000 + self._symbolsChecked[pair] = arrow.utcnow().int_timestamp * 1000 return True else: - self.log_once(f"Removed {pair} from whitelist, because age " - f"{len(daily_candles)} is less than {self._min_days_listed} " - f"{plural(self._min_days_listed, 'day')}", logger.info) + self.log_once(( + f"Removed {pair} from whitelist, because age " + f"{len(daily_candles)} is less than {self._min_days_listed} " + f"{plural(self._min_days_listed, 'day')}" + ) + (( + " or more than " + f"{self._max_days_listed} {plural(self._max_days_listed, 'day')}" + ) if self._max_days_listed else ''), logger.info) return False return False diff --git a/freqtrade/plugins/pairlist/OffsetFilter.py b/freqtrade/plugins/pairlist/OffsetFilter.py new file mode 100644 index 000000000..573a573a6 --- /dev/null +++ b/freqtrade/plugins/pairlist/OffsetFilter.py @@ -0,0 +1,54 @@ +""" +Offset pair list filter +""" +import logging +from typing import Any, Dict, List + +from freqtrade.exceptions import OperationalException +from freqtrade.plugins.pairlist.IPairList import IPairList + + +logger = logging.getLogger(__name__) + + +class OffsetFilter(IPairList): + + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + self._offset = pairlistconfig.get('offset', 0) + + if self._offset < 0: + raise OperationalException("OffsetFilter requires offset to be >= 0") + + @property + def needstickers(self) -> bool: + """ + Boolean property defining if tickers are necessary. + If no Pairlist requires tickers, an empty Dict is passed + as tickers argument to filter_pairlist + """ + return False + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + """ + return f"{self.name} - Offseting pairs by {self._offset}." + + def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: + """ + Filters and sorts pairlist and returns the whitelist again. + Called on each bot iteration - please use internal caching if necessary + :param pairlist: pairlist to filter or sort + :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :return: new whitelist + """ + if self._offset > len(pairlist): + self.log_once(f"Offset of {self._offset} is larger than " + + f"pair count of {len(pairlist)}", logger.warning) + pairs = pairlist[self._offset:] + self.log_once(f"Searching {len(pairs)} pairs: {pairs}", logger.info) + return pairs diff --git a/freqtrade/plugins/pairlist/PerformanceFilter.py b/freqtrade/plugins/pairlist/PerformanceFilter.py index bf474cb21..46a289ae6 100644 --- a/freqtrade/plugins/pairlist/PerformanceFilter.py +++ b/freqtrade/plugins/pairlist/PerformanceFilter.py @@ -19,7 +19,7 @@ class PerformanceFilter(IPairList): def needstickers(self) -> bool: """ Boolean property defining if tickers are necessary. - If no Pairlist requries tickers, an empty List is passed + If no Pairlist requires tickers, an empty List is passed as tickers argument to filter_pairlist """ return False diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index bc617a1db..9383e5d06 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -20,9 +20,9 @@ logger = logging.getLogger(__name__) class VolatilityFilter(IPairList): - ''' + """ Filters pairs by volatility - ''' + """ def __init__(self, exchange, pairlistmanager, config: Dict[str, Any], pairlistconfig: Dict[str, Any], @@ -69,10 +69,10 @@ class VolatilityFilter(IPairList): """ needed_pairs = [(p, '1d') for p in pairlist if p not in self._pair_cache] - since_ms = int(arrow.utcnow() - .floor('day') - .shift(days=-self._days - 1) - .float_timestamp) * 1000 + since_ms = (arrow.utcnow() + .floor('day') + .shift(days=-self._days - 1) + .int_timestamp) * 1000 # Get all candles candles = {} if needed_pairs: diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 8eff137b0..d6b8aaaa3 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -6,9 +6,12 @@ Provides dynamic pair list based on trade volumes import logging from typing import Any, Dict, List +import arrow from cachetools.ttl import TTLCache from freqtrade.exceptions import OperationalException +from freqtrade.exchange import timeframe_to_minutes +from freqtrade.misc import format_ms_time from freqtrade.plugins.pairlist.IPairList import IPairList @@ -36,6 +39,35 @@ class VolumePairList(IPairList): self._min_value = self._pairlistconfig.get('min_value', 0) self._refresh_period = self._pairlistconfig.get('refresh_period', 1800) self._pair_cache: TTLCache = TTLCache(maxsize=1, ttl=self._refresh_period) + self._lookback_days = self._pairlistconfig.get('lookback_days', 0) + self._lookback_timeframe = self._pairlistconfig.get('lookback_timeframe', '1d') + self._lookback_period = self._pairlistconfig.get('lookback_period', 0) + + if (self._lookback_days > 0) & (self._lookback_period > 0): + raise OperationalException( + 'Ambigous configuration: lookback_days and lookback_period both set in pairlist ' + 'config. Please set lookback_days only or lookback_period and lookback_timeframe ' + 'and restart the bot.' + ) + + # overwrite lookback timeframe and days when lookback_days is set + if self._lookback_days > 0: + self._lookback_timeframe = '1d' + self._lookback_period = self._lookback_days + + # get timeframe in minutes and seconds + self._tf_in_min = timeframe_to_minutes(self._lookback_timeframe) + self._tf_in_sec = self._tf_in_min * 60 + + # wether to use range lookback or not + self._use_range = (self._tf_in_min > 0) & (self._lookback_period > 0) + + if self._use_range & (self._refresh_period < self._tf_in_sec): + raise OperationalException( + f'Refresh period of {self._refresh_period} seconds is smaller than one ' + f'timeframe of {self._lookback_timeframe}. Please adjust refresh_period ' + f'to at least {self._tf_in_sec} and restart the bot.' + ) if not self._exchange.exchange_has('fetchTickers'): raise OperationalException( @@ -47,6 +79,13 @@ class VolumePairList(IPairList): raise OperationalException( f'key {self._sort_key} not in {SORT_VALUES}') + if self._lookback_period < 0: + raise OperationalException("VolumeFilter requires lookback_period to be >= 0") + if self._lookback_period > exchange.ohlcv_candle_limit(self._lookback_timeframe): + raise OperationalException("VolumeFilter requires lookback_period to not " + "exceed exchange max request size " + f"({exchange.ohlcv_candle_limit(self._lookback_timeframe)})") + @property def needstickers(self) -> bool: """ @@ -78,7 +117,6 @@ class VolumePairList(IPairList): # Item found - no refresh necessary return pairlist else: - # Use fresh pairlist # Check if pair quote currency equals to the stake currency. filtered_tickers = [ @@ -103,6 +141,60 @@ class VolumePairList(IPairList): # Use the incoming pairlist. filtered_tickers = [v for k, v in tickers.items() if k in pairlist] + # get lookback period in ms, for exchange ohlcv fetch + if self._use_range: + since_ms = int(arrow.utcnow() + .floor('minute') + .shift(minutes=-(self._lookback_period * self._tf_in_min) + - self._tf_in_min) + .int_timestamp) * 1000 + + to_ms = int(arrow.utcnow() + .floor('minute') + .shift(minutes=-self._tf_in_min) + .int_timestamp) * 1000 + + # todo: utc date output for starting date + self.log_once(f"Using volume range of {self._lookback_period} candles, timeframe: " + f"{self._lookback_timeframe}, starting from {format_ms_time(since_ms)} " + f"till {format_ms_time(to_ms)}", logger.info) + needed_pairs = [ + (p, self._lookback_timeframe) for p in + [ + s['symbol'] for s in filtered_tickers + ] if p not in self._pair_cache + ] + + # Get all candles + candles = {} + if needed_pairs: + candles = self._exchange.refresh_latest_ohlcv( + needed_pairs, since_ms=since_ms, cache=False + ) + for i, p in enumerate(filtered_tickers): + pair_candles = candles[ + (p['symbol'], self._lookback_timeframe) + ] if (p['symbol'], self._lookback_timeframe) in candles else None + # in case of candle data calculate typical price and quoteVolume for candle + if pair_candles is not None and not pair_candles.empty: + pair_candles['typical_price'] = (pair_candles['high'] + pair_candles['low'] + + pair_candles['close']) / 3 + pair_candles['quoteVolume'] = ( + pair_candles['volume'] * pair_candles['typical_price'] + ) + + # ensure that a rolling sum over the lookback_period is built + # if pair_candles contains more candles than lookback_period + quoteVolume = (pair_candles['quoteVolume'] + .rolling(self._lookback_period) + .sum() + .iloc[-1]) + + # replace quoteVolume with range quoteVolume sum calculated above + filtered_tickers[i]['quoteVolume'] = quoteVolume + else: + filtered_tickers[i]['quoteVolume'] = 0 + if self._min_value > 0: filtered_tickers = [ v for v in filtered_tickers if v[self._sort_key] > self._min_value] diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 8be61166b..a6d1820de 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -62,10 +62,10 @@ class RangeStabilityFilter(IPairList): """ needed_pairs = [(p, '1d') for p in pairlist if p not in self._pair_cache] - since_ms = int(arrow.utcnow() - .floor('day') - .shift(days=-self._days - 1) - .float_timestamp) * 1000 + since_ms = (arrow.utcnow() + .floor('day') + .shift(days=-self._days - 1) + .int_timestamp) * 1000 # Get all candles candles = {} if needed_pairs: diff --git a/freqtrade/resolvers/exchange_resolver.py b/freqtrade/resolvers/exchange_resolver.py index ed6715d15..4dfbf445b 100644 --- a/freqtrade/resolvers/exchange_resolver.py +++ b/freqtrade/resolvers/exchange_resolver.py @@ -21,6 +21,7 @@ class ExchangeResolver(IResolver): def load_exchange(exchange_name: str, config: dict, validate: bool = True) -> Exchange: """ Load the custom class from config parameter + :param exchange_name: name of the Exchange to load :param config: configuration dictionary """ # Map exchange name to avoid duplicate classes for identical exchanges diff --git a/freqtrade/resolvers/iresolver.py b/freqtrade/resolvers/iresolver.py index 5b6977b4b..2cccec70a 100644 --- a/freqtrade/resolvers/iresolver.py +++ b/freqtrade/resolvers/iresolver.py @@ -135,7 +135,7 @@ class IResolver: extra_dir: Optional[str] = None) -> Any: """ Search and loads the specified object as configured in hte child class. - :param objectname: name of the module to import + :param object_name: name of the module to import :param config: configuration dictionary :param extra_dir: additional directory to search for the given pairlist :raises: OperationalException if the class is invalid or does not exist. @@ -163,7 +163,7 @@ class IResolver: :param directory: Path to search :param enum_failed: If True, will return None for modules which fail. Otherwise, failing modules are skipped. - :return: List of dicts containing 'name', 'class' and 'location' entires + :return: List of dicts containing 'name', 'class' and 'location' entries """ logger.debug(f"Searching for {cls.object_type.__name__} '{directory}'") objects = [] diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index e76d1e3e5..1239b78b3 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -45,10 +45,6 @@ class StrategyResolver(IResolver): strategy_name, config=config, extra_dir=config.get('strategy_path')) - # make sure ask_strategy dict is available - if 'ask_strategy' not in config: - config['ask_strategy'] = {} - if hasattr(strategy, 'ticker_interval') and not hasattr(strategy, 'timeframe'): # Assign ticker_interval to timeframe to keep compatibility if 'timeframe' not in config: @@ -57,45 +53,54 @@ class StrategyResolver(IResolver): ) strategy.timeframe = strategy.ticker_interval + if strategy._ft_params_from_file: + # Set parameters from Hyperopt results file + params = strategy._ft_params_from_file + strategy.minimal_roi = params.get('roi', strategy.minimal_roi) + + strategy.stoploss = params.get('stoploss', {}).get('stoploss', strategy.stoploss) + trailing = params.get('trailing', {}) + strategy.trailing_stop = trailing.get('trailing_stop', strategy.trailing_stop) + strategy.trailing_stop_positive = trailing.get('trailing_stop_positive', + strategy.trailing_stop_positive) + strategy.trailing_stop_positive_offset = trailing.get( + 'trailing_stop_positive_offset', strategy.trailing_stop_positive_offset) + strategy.trailing_only_offset_is_reached = trailing.get( + 'trailing_only_offset_is_reached', strategy.trailing_only_offset_is_reached) + # Set attributes # Check if we need to override configuration # (Attribute name, default, subkey) - attributes = [("minimal_roi", {"0": 10.0}, None), - ("timeframe", None, None), - ("stoploss", None, None), - ("trailing_stop", None, None), - ("trailing_stop_positive", None, None), - ("trailing_stop_positive_offset", 0.0, None), - ("trailing_only_offset_is_reached", None, None), - ("use_custom_stoploss", None, None), - ("process_only_new_candles", None, None), - ("order_types", None, None), - ("order_time_in_force", None, None), - ("stake_currency", None, None), - ("stake_amount", None, None), - ("protections", None, None), - ("startup_candle_count", None, None), - ("unfilledtimeout", None, None), - ("use_sell_signal", True, 'ask_strategy'), - ("sell_profit_only", False, 'ask_strategy'), - ("ignore_roi_if_buy_signal", False, 'ask_strategy'), - ("sell_profit_offset", 0.0, 'ask_strategy'), - ("disable_dataframe_checks", False, None), - ("ignore_buying_expired_candle_after", 0, 'ask_strategy') + attributes = [("minimal_roi", {"0": 10.0}), + ("timeframe", None), + ("stoploss", None), + ("trailing_stop", None), + ("trailing_stop_positive", None), + ("trailing_stop_positive_offset", 0.0), + ("trailing_only_offset_is_reached", None), + ("use_custom_stoploss", None), + ("process_only_new_candles", None), + ("order_types", None), + ("order_time_in_force", None), + ("stake_currency", None), + ("stake_amount", None), + ("protections", None), + ("startup_candle_count", None), + ("unfilledtimeout", None), + ("use_sell_signal", True), + ("sell_profit_only", False), + ("ignore_roi_if_buy_signal", False), + ("sell_profit_offset", 0.0), + ("disable_dataframe_checks", False), + ("ignore_buying_expired_candle_after", 0) ] - for attribute, default, subkey in attributes: - if subkey: - StrategyResolver._override_attribute_helper(strategy, config.get(subkey, {}), - attribute, default) - else: - StrategyResolver._override_attribute_helper(strategy, config, - attribute, default) + for attribute, default in attributes: + StrategyResolver._override_attribute_helper(strategy, config, + attribute, default) # Loop this list again to have output combined - for attribute, _, subkey in attributes: - if subkey and attribute in config[subkey]: - logger.info("Strategy using %s: %s", attribute, config[subkey][attribute]) - elif attribute in config: + for attribute, _ in attributes: + if attribute in config: logger.info("Strategy using %s: %s", attribute, config[attribute]) StrategyResolver._normalize_attributes(strategy) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 4d06d3ecf..a0f1c05a6 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -115,6 +115,7 @@ class ShowConfig(BaseModel): dry_run: bool stake_currency: str stake_amount: Union[float, str] + stake_currency_decimals: int max_open_trades: int minimal_roi: Dict[str, Any] stoploss: float diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index e907b92f0..965664028 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -162,8 +162,8 @@ def delete_lock_pair(payload: DeleteLockRequest, rpc: RPC = Depends(get_rpc)): @router.get('/logs', response_model=Logs, tags=['info']) -def logs(limit: Optional[int] = None, rpc: RPC = Depends(get_rpc)): - return rpc._rpc_get_logs(limit) +def logs(limit: Optional[int] = None): + return RPC._rpc_get_logs(limit) @router.post('/start', response_model=StatusMsg, tags=['botcontrol']) diff --git a/freqtrade/rpc/api_server/web_ui.py b/freqtrade/rpc/api_server/web_ui.py index a8c737e04..76c8ed8f2 100644 --- a/freqtrade/rpc/api_server/web_ui.py +++ b/freqtrade/rpc/api_server/web_ui.py @@ -18,6 +18,17 @@ async def fallback(): return FileResponse(str(Path(__file__).parent / 'ui/fallback_file.html')) +@router_ui.get('/ui_version', include_in_schema=False) +async def ui_version(): + from freqtrade.commands.deploy_commands import read_ui_version + uibase = Path(__file__).parent / 'ui/installed/' + version = read_ui_version(uibase) + + return { + "version": version if version else "not_installed", + } + + @router_ui.get('/{rest_of_path:path}', include_in_schema=False) async def index_html(rest_of_path: str): """ diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 8a5c958e9..a43d4abe6 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -115,14 +115,12 @@ class ApiServer(RPCHandler): logger.info('Starting Local Rest Server.') verbosity = self._config['api_server'].get('verbosity', 'error') - log_config = uvicorn.config.LOGGING_CONFIG - # Change logging of access logs to stderr - log_config["handlers"]["access"]["stream"] = log_config["handlers"]["default"]["stream"] + uvconfig = uvicorn.Config(self.app, port=rest_port, host=rest_ip, use_colors=False, - log_config=log_config, + log_config=None, access_log=True if verbosity != 'error' else False, ) try: diff --git a/freqtrade/rpc/fiat_convert.py b/freqtrade/rpc/fiat_convert.py index 5ae20afa1..199e6a7db 100644 --- a/freqtrade/rpc/fiat_convert.py +++ b/freqtrade/rpc/fiat_convert.py @@ -102,7 +102,7 @@ class CryptoToFiatConverter: inverse = True symbol = f"{crypto_symbol}/{fiat_symbol}" - # Check if the fiat convertion you want is supported + # Check if the fiat conversion you want is supported if not self._is_supported_fiat(fiat=fiat_symbol): raise ValueError(f'The fiat {fiat_symbol} is not supported.') @@ -135,7 +135,7 @@ class CryptoToFiatConverter: :param fiat_symbol: FIAT currency you want to convert to (e.g usd) :return: float, price of the crypto-currency in Fiat """ - # Check if the fiat convertion you want is supported + # Check if the fiat conversion you want is supported if not self._is_supported_fiat(fiat=fiat_symbol): raise ValueError(f'The fiat {fiat_symbol} is not supported.') @@ -146,7 +146,7 @@ class CryptoToFiatConverter: if self._cryptomap == {}: if self._backoff <= datetime.datetime.now().timestamp(): self._load_cryptomap() - # return 0.0 if we still dont have data to check, no reason to proceed + # return 0.0 if we still don't have data to check, no reason to proceed if self._cryptomap == {}: return 0.0 else: diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 2a7721af0..e0aaefe50 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -18,7 +18,7 @@ from freqtrade.enums import SellType, State from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler -from freqtrade.misc import shorten_date +from freqtrade.misc import decimals_per_coin, shorten_date from freqtrade.persistence import PairLocks, Trade from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -104,6 +104,7 @@ class RPC: val = { 'dry_run': config['dry_run'], 'stake_currency': config['stake_currency'], + 'stake_currency_decimals': decimals_per_coin(config['stake_currency']), 'stake_amount': config['stake_amount'], 'max_open_trades': (config['max_open_trades'] if config['max_open_trades'] != float('inf') else -1), @@ -180,9 +181,9 @@ class RPC: base_currency=self._freqtrade.config['stake_currency'], close_profit=trade.close_profit if trade.close_profit is not None else None, current_rate=current_rate, - current_profit=current_profit, # Deprectated - current_profit_pct=round(current_profit * 100, 2), # Deprectated - current_profit_abs=current_profit_abs, # Deprectated + current_profit=current_profit, # Deprecated + current_profit_pct=round(current_profit * 100, 2), # Deprecated + current_profit_abs=current_profit_abs, # Deprecated profit_ratio=current_profit, profit_pct=round(current_profit * 100, 2), profit_abs=current_profit_abs, @@ -339,7 +340,9 @@ class RPC: self, stake_currency: str, fiat_display_currency: str, start_date: datetime = datetime.fromtimestamp(0)) -> Dict[str, Any]: """ Returns cumulative profit statistics """ - trades = Trade.get_trades([Trade.open_date >= start_date]).order_by(Trade.id).all() + trade_filter = ((Trade.is_open.is_(False) & (Trade.close_date >= start_date)) | + Trade.is_open.is_(True)) + trades = Trade.get_trades(trade_filter).order_by(Trade.id).all() profit_all_coin = [] profit_all_ratio = [] @@ -378,7 +381,7 @@ class RPC: ) profit_all_ratio.append(profit_ratio) - best_pair = Trade.get_best_pair() + best_pair = Trade.get_best_pair(start_date) # Prepare data to display profit_closed_coin_sum = round(sum(profit_closed_coin), 8) @@ -758,7 +761,7 @@ class RPC: sell_signals = 0 if has_content: - dataframe.loc[:, '__date_ts'] = dataframe.loc[:, 'date'].astype(int64) // 1000 // 1000 + dataframe.loc[:, '__date_ts'] = dataframe.loc[:, 'date'].view(int64) // 1000 // 1000 # Move open to seperate column when signal for easy plotting if 'buy' in dataframe.columns: buy_mask = (dataframe['buy'] == 1) @@ -822,8 +825,11 @@ class RPC: ) if pair not in _data: raise RPCException(f"No data for {pair}, {timeframe} in {timerange} found.") + 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) + df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair}) return RPC._convert_dataframe_to_dict(strategy.get_strategy_name(), pair, timeframe, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 6cb48aef1..319a6c9c0 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -24,7 +24,7 @@ from freqtrade.__init__ import __version__ from freqtrade.constants import DUST_PER_COIN from freqtrade.enums import RPCMessageType from freqtrade.exceptions import OperationalException -from freqtrade.misc import chunks, round_coin_value +from freqtrade.misc import chunks, plural, round_coin_value from freqtrade.rpc import RPC, RPCException, RPCHandler @@ -103,7 +103,7 @@ class Telegram(RPCHandler): # do not allow commands with mandatory arguments and critical cmds # like /forcesell and /forcebuy # TODO: DRY! - its not good to list all valid cmds here. But otherwise - # this needs refacoring of the whole telegram module (same + # this needs refactoring of the whole telegram module (same # problem in _help()). valid_keys: List[str] = [r'/start$', r'/stop$', r'/status$', r'/status table$', r'/trades$', r'/performance$', r'/daily$', r'/daily \d+$', @@ -482,7 +482,7 @@ class Telegram(RPCHandler): timescale = None try: if context.args: - timescale = int(context.args[0]) + timescale = int(context.args[0]) - 1 today_start = datetime.combine(date.today(), datetime.min.time()) start_date = today_start - timedelta(days=timescale) except (TypeError, ValueError, IndexError): @@ -598,7 +598,10 @@ class Telegram(RPCHandler): "Starting capital: " f"`{self._config['dry_run_wallet']}` {self._config['stake_currency']}.\n" ) + total_dust_balance = 0 + total_dust_currencies = 0 for curr in result['currencies']: + curr_output = '' if curr['est_stake'] > balance_dust_level: curr_output = ( f"*{curr['currency']}:*\n" @@ -607,17 +610,25 @@ class Telegram(RPCHandler): f"\t`Pending: {curr['used']:.8f}`\n" f"\t`Est. {curr['stake']}: " f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n") - else: - curr_output = (f"*{curr['currency']}:* not showing <{balance_dust_level} " - f"{curr['stake']} amount \n") + elif curr['est_stake'] <= balance_dust_level: + total_dust_balance += curr['est_stake'] + total_dust_currencies += 1 - # Handle overflowing messsage length + # Handle overflowing message length if len(output + curr_output) >= MAX_TELEGRAM_MESSAGE_LENGTH: self._send_msg(output) output = curr_output else: output += curr_output + if total_dust_balance > 0: + output += ( + f"*{total_dust_currencies} Other " + f"{plural(total_dust_currencies, 'Currency', 'Currencies')} " + f"(< {balance_dust_level} {result['stake']}):*\n" + f"\t`Est. {result['stake']}: " + f"{round_coin_value(total_dust_balance, result['stake'], False)}`\n") + output += ("\n*Estimated Value*:\n" f"\t`{result['stake']}: {result['total']: .8f}`\n" f"\t`{result['symbol']}: " diff --git a/freqtrade/strategy/hyper.py b/freqtrade/strategy/hyper.py index e487ffeff..fa3384660 100644 --- a/freqtrade/strategy/hyper.py +++ b/freqtrade/strategy/hyper.py @@ -5,8 +5,10 @@ This module defines a base class for auto-hyperoptable strategies. import logging from abc import ABC, abstractmethod from contextlib import suppress +from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union +from freqtrade.misc import deep_merge_dicts, json_load from freqtrade.optimize.hyperopt_tools import HyperoptTools @@ -205,6 +207,21 @@ class DecimalParameter(NumericParameter): return SKDecimal(low=self.low, high=self.high, decimals=self._decimals, name=name, **self._space_params) + @property + def range(self): + """ + Get each value in this space as list. + Returns a List from low to high (inclusive) in Hyperopt mode. + Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid + calculating 100ds of indicators. + """ + if self.in_space and self.optimize: + low = int(self.low * pow(10, self._decimals)) + high = int(self.high * pow(10, self._decimals)) + 1 + return [round(n * pow(0.1, self._decimals), self._decimals) for n in range(low, high)] + else: + return [self.value] + class CategoricalParameter(BaseParameter): default: Any @@ -239,10 +256,23 @@ class CategoricalParameter(BaseParameter): """ return Categorical(self.opt_range, name=name, **self._space_params) + @property + def range(self): + """ + Get each value in this space as list. + Returns a List of categories in Hyperopt mode. + Returns a List with 1 item (`value`) in "non-hyperopt" mode, to avoid + calculating 100ds of indicators. + """ + if self.in_space and self.optimize: + return self.opt_range + else: + return [self.value] + class HyperStrategyMixin(object): """ - A helper base class which allows HyperOptAuto class to reuse implementations of of buy/sell + A helper base class which allows HyperOptAuto class to reuse implementations of buy/sell strategy logic. """ @@ -258,7 +288,7 @@ class HyperStrategyMixin(object): def enumerate_parameters(self, category: str = None) -> Iterator[Tuple[str, BaseParameter]]: """ - Find all optimizeable parameters and return (name, attr) iterator. + Find all optimizable parameters and return (name, attr) iterator. :param category: :return: """ @@ -305,12 +335,38 @@ class HyperStrategyMixin(object): """ Load Hyperoptable parameters """ - self._load_params(getattr(self, 'buy_params', None), 'buy', hyperopt) - self._load_params(getattr(self, 'sell_params', None), 'sell', hyperopt) + params = self.load_params_from_file() + params = params.get('params', {}) + self._ft_params_from_file = params + buy_params = deep_merge_dicts(params.get('buy', {}), getattr(self, 'buy_params', None)) + sell_params = deep_merge_dicts(params.get('sell', {}), getattr(self, 'sell_params', None)) - def _load_params(self, params: dict, space: str, hyperopt: bool = False) -> None: + self._load_params(buy_params, 'buy', hyperopt) + self._load_params(sell_params, 'sell', hyperopt) + + def load_params_from_file(self) -> Dict: + filename_str = getattr(self, '__file__', '') + if not filename_str: + return {} + filename = Path(filename_str).with_suffix('.json') + + if filename.is_file(): + logger.info(f"Loading parameters from file {filename}") + try: + params = json_load(filename.open('r')) + if params.get('strategy_name') != self.__class__.__name__: + raise OperationalException('Invalid parameter file provided.') + return params + except ValueError: + logger.warning("Invalid parameter file format.") + return {} + logger.info("Found no parameter file.") + + return {} + + def _load_params(self, params: Dict, space: str, hyperopt: bool = False) -> None: """ - Set optimizeable parameter values. + Set optimizable parameter values. :param params: Dictionary with new parameter values. """ if not params: @@ -335,7 +391,7 @@ class HyperStrategyMixin(object): else: logger.info(f'Strategy Parameter(default): {attr_name} = {attr.value}') - def get_params_dict(self): + def get_no_optimize_params(self): """ Returns list of Parameters that are not part of the current optimize job """ diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 65e27a2c2..26bcb0369 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -62,6 +62,7 @@ class IStrategy(ABC, HyperStrategyMixin): _populate_fun_len: int = 0 _buy_fun_len: int = 0 _sell_fun_len: int = 0 + _ft_params_from_file: Dict = {} # associated minimal roi minimal_roi: Dict @@ -97,6 +98,11 @@ class IStrategy(ABC, HyperStrategyMixin): # run "populate_indicators" only for new candle process_only_new_candles: bool = False + use_sell_signal: bool + sell_profit_only: bool + sell_profit_offset: float + ignore_roi_if_buy_signal: bool + # Number of seconds after which the candle will no longer result in a buy on expired candles ignore_buying_expired_candle_after: int = 0 @@ -270,7 +276,7 @@ class IStrategy(ABC, HyperStrategyMixin): :param current_rate: Rate, calculated based on pricing settings in ask_strategy. :param current_profit: Current profit (as ratio), calculated based on current_rate. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. - :return float: New stoploss value, relative to the currentrate + :return float: New stoploss value, relative to the current_rate """ return self.stoploss @@ -301,7 +307,7 @@ class IStrategy(ABC, HyperStrategyMixin): def informative_pairs(self) -> ListPairsWithTimeframes: """ Define additional, informative pair/interval combinations to be cached from the exchange. - These pair/interval combinations are non-tradeable, unless they are part + These pair/interval combinations are non-tradable, unless they are part of the whitelist as well. For more information, please consult the documentation :return: List of tuples in the format (pair, interval) @@ -349,7 +355,7 @@ class IStrategy(ABC, HyperStrategyMixin): The 2nd, optional parameter ensures that locks are applied until the new candle arrives, and not stop at 14:00:00 - while the next candle arrives at 14:00:02 leaving a gap of 2 seconds for a buy to happen on an old signal. - :param: pair: "Pair to check" + :param pair: "Pair to check" :param candle_date: Date of the last candle. Optional, defaults to current date :returns: locking state of the pair in question. """ @@ -543,10 +549,9 @@ class IStrategy(ABC, HyperStrategyMixin): # Set current rate to high for backtesting sell current_rate = high or rate current_profit = trade.calc_profit_ratio(current_rate) - ask_strategy = self.config.get('ask_strategy', {}) # if buy signal and ignore_roi is set, we don't need to evaluate min_roi. - roi_reached = (not (buy and ask_strategy.get('ignore_roi_if_buy_signal', False)) + roi_reached = (not (buy and self.ignore_roi_if_buy_signal) and self.min_roi_reached(trade=trade, current_profit=current_profit, current_time=date)) @@ -556,11 +561,10 @@ class IStrategy(ABC, HyperStrategyMixin): current_rate = rate current_profit = trade.calc_profit_ratio(current_rate) - if (ask_strategy.get('sell_profit_only', False) - and current_profit <= ask_strategy.get('sell_profit_offset', 0)): + if (self.sell_profit_only and current_profit <= self.sell_profit_offset): # sell_profit_only and profit doesn't reach the offset - ignore sell signal pass - elif ask_strategy.get('use_sell_signal', True) and not buy: + elif self.use_sell_signal and not buy: if sell: sell_signal = SellType.SELL_SIGNAL else: @@ -733,7 +737,8 @@ class IStrategy(ABC, HyperStrategyMixin): Based on TA indicators, populates the buy signal for the given dataframe This method should not be overridden. :param dataframe: DataFrame - :param pair: Additional information, like the currently traded pair + :param metadata: Additional information dictionary, with details like the + currently traded pair :return: DataFrame with buy column """ logger.debug(f"Populating buy signals for pair {metadata.get('pair')}.") @@ -750,7 +755,8 @@ class IStrategy(ABC, HyperStrategyMixin): Based on TA indicators, populates the sell signal for the given dataframe This method should not be overridden. :param dataframe: DataFrame - :param pair: Additional information, like the currently traded pair + :param metadata: Additional information dictionary, with details like the + currently traded pair :return: DataFrame with sell column """ logger.debug(f"Populating sell signals for pair {metadata.get('pair')}.") diff --git a/freqtrade/templates/base_config.json.j2 b/freqtrade/templates/base_config.json.j2 index 8933ebc6a..03a6c4855 100644 --- a/freqtrade/templates/base_config.json.j2 +++ b/freqtrade/templates/base_config.json.j2 @@ -15,7 +15,7 @@ "bid_strategy": { "price_side": "bid", "ask_last_balance": 0.0, - "use_order_book": false, + "use_order_book": true, "order_book_top": 1, "check_depth_of_market": { "enabled": false, @@ -24,12 +24,8 @@ }, "ask_strategy": { "price_side": "ask", - "use_order_book": false, - "order_book_min": 1, - "order_book_max": 1, - "use_sell_signal": true, - "sell_profit_only": false, - "ignore_roi_if_buy_signal": false + "use_order_book": true, + "order_book_top": 1, }, {{ exchange | indent(4) }}, "pairlists": [ diff --git a/freqtrade/templates/strategy_analysis_example.ipynb b/freqtrade/templates/strategy_analysis_example.ipynb index 0bc593e2d..99720ae6e 100644 --- a/freqtrade/templates/strategy_analysis_example.ipynb +++ b/freqtrade/templates/strategy_analysis_example.ipynb @@ -188,6 +188,52 @@ "trades.groupby(\"pair\")[\"sell_reason\"].value_counts()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Plotting daily profit / equity line" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Plotting equity line (starting with 0 on day 1 and adding daily profit for each backtested day)\n", + "\n", + "from freqtrade.configuration import Configuration\n", + "from freqtrade.data.btanalysis import load_backtest_data, load_backtest_stats\n", + "import plotly.express as px\n", + "import pandas as pd\n", + "\n", + "# strategy = 'SampleStrategy'\n", + "# config = Configuration.from_files([\"user_data/config.json\"])\n", + "# backtest_dir = config[\"user_data_dir\"] / \"backtest_results\"\n", + "\n", + "stats = load_backtest_stats(backtest_dir)\n", + "strategy_stats = stats['strategy'][strategy]\n", + "\n", + "dates = []\n", + "profits = []\n", + "for date_profit in strategy_stats['daily_profit']:\n", + " dates.append(date_profit[0])\n", + " profits.append(date_profit[1])\n", + "\n", + "equity = 0\n", + "equity_daily = []\n", + "for daily_profit in profits:\n", + " equity_daily.append(equity)\n", + " equity += float(daily_profit)\n", + "\n", + "\n", + "df = pd.DataFrame({'dates': dates,'equity_daily': equity_daily})\n", + "\n", + "fig = px.line(df, x=\"dates\", y=\"equity_daily\")\n", + "fig.show()\n" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -329,7 +375,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.4" + "version": "3.8.5" }, "mimetype": "text/x-python", "name": "python", diff --git a/freqtrade/worker.py b/freqtrade/worker.py index 4fa9166bf..5c0de86ff 100755 --- a/freqtrade/worker.py +++ b/freqtrade/worker.py @@ -61,7 +61,7 @@ class Worker: def _notify(self, message: str) -> None: """ - Removes the need to verify in all occurances if sd_notify is enabled + Removes the need to verify in all occurrences if sd_notify is enabled :param message: Message to send to systemd if it's enabled. """ if self._sd_notify: diff --git a/mkdocs.yml b/mkdocs.yml index e3e1ade86..854939ca0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,8 +44,18 @@ theme: favicon: 'images/logo.png' custom_dir: 'docs/overrides' palette: - primary: 'blue grey' - accent: 'tear' + - scheme: default + primary: 'blue grey' + accent: 'tear' + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode + - scheme: slate + primary: 'blue grey' + accent: 'tear' + toggle: + icon: material/toggle-switch-off-outline + name: Switch to dark mode extra_css: - 'stylesheets/ft.extra.css' extra_javascript: diff --git a/requirements-dev.txt b/requirements-dev.txt index 30044058b..c73acbe9c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,19 +7,19 @@ coveralls==3.1.0 flake8==3.9.2 flake8-type-annotations==0.1.0 flake8-tidy-imports==4.3.0 -mypy==0.902 +mypy==0.910 pytest==6.2.4 pytest-asyncio==0.15.1 pytest-cov==2.12.1 pytest-mock==3.6.1 pytest-random-order==1.0.4 -isort==5.8.0 +isort==5.9.1 # Convert jupyter notebooks to markdown documents -nbconvert==6.0.7 +nbconvert==6.1.0 # mypy types -types-cachetools==0.1.8 +types-cachetools==0.1.9 types-filelock==0.1.4 -types-requests==0.1.13 +types-requests==2.25.0 types-tabulate==0.1.1 diff --git a/requirements-plot.txt b/requirements-plot.txt index 6693a593d..e03fd4d66 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,5 +1,5 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==4.14.3 +plotly==5.1.0 diff --git a/requirements.txt b/requirements.txt index 039441f36..528dc2ce6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,16 @@ -numpy==1.20.3 -pandas==1.2.4 +numpy==1.21.0 +pandas==1.3.0 -ccxt==1.51.77 +ccxt==1.52.40 # Pin cryptography for now due to rust build errors with piwheels cryptography==3.4.7 aiohttp==3.7.4.post0 -SQLAlchemy==1.4.18 -python-telegram-bot==13.6 -arrow==1.1.0 +SQLAlchemy==1.4.20 +python-telegram-bot==13.7 +arrow==1.1.1 cachetools==4.2.2 requests==2.25.1 -urllib3==1.26.5 +urllib3==1.26.6 wrapt==1.12.1 jsonschema==3.2.0 TA-Lib==0.4.20 @@ -25,13 +25,13 @@ blosc==1.10.4 py_find_1st==1.1.5 # Load ticker files 30% faster -python-rapidjson==1.0 +python-rapidjson==1.4 # Notify systemd sdnotify==0.3.2 # API Server -fastapi==0.65.2 +fastapi==0.66.0 uvicorn==0.14.0 pyjwt==2.1.0 aiofiles==0.7.0 diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 47f298ad7..dcceb3ea1 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -1168,6 +1168,7 @@ def test_hyperopt_show(mocker, capsys, saved_hyperopt_results): 'freqtrade.optimize.hyperopt_tools.HyperoptTools.load_previous_results', MagicMock(return_value=saved_hyperopt_results) ) + mocker.patch('freqtrade.commands.hyperopt_commands.show_backtest_result') args = [ "hyperopt-show", diff --git a/tests/conftest.py b/tests/conftest.py index deabb2ac7..eb0c14a45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,8 +23,8 @@ from freqtrade.freqtradebot import FreqtradeBot from freqtrade.persistence import LocalTrade, Trade, init_db from freqtrade.resolvers import ExchangeResolver from freqtrade.worker import Worker -from tests.conftest_trades import (mock_trade_1, mock_trade_2, mock_trade_3, mock_trade_4, - mock_trade_5, mock_trade_6, short_trade, leverage_trade) +from tests.conftest_trades import (leverage_trade, mock_trade_1, mock_trade_2, mock_trade_3, + mock_trade_4, mock_trade_5, mock_trade_6, short_trade) logging.getLogger('').setLevel(logging.INFO) @@ -205,7 +205,32 @@ def create_mock_trades(fee, use_db: bool = True): # Simulate dry_run entries trade = mock_trade_1(fee) add_trade(trade) + trade = mock_trade_2(fee) + add_trade(trade) + trade = mock_trade_3(fee) + add_trade(trade) + trade = mock_trade_4(fee) + add_trade(trade) + trade = mock_trade_5(fee) + add_trade(trade) + trade = mock_trade_6(fee) + add_trade(trade) + if use_db: + Trade.query.session.flush() + +def create_mock_trades_with_leverage(fee, use_db: bool = True): + """ + Create some fake trades ... + """ + def add_trade(trade): + if use_db: + Trade.query.session.add(trade) + else: + LocalTrade.add_bt_trade(trade) + # Simulate dry_run entries + trade = mock_trade_1(fee) + add_trade(trade) trade = mock_trade_2(fee) add_trade(trade) @@ -221,13 +246,10 @@ def create_mock_trades(fee, use_db: bool = True): trade = mock_trade_6(fee) add_trade(trade) - # TODO: margin trades - # trade = short_trade(fee) - # add_trade(trade) - - # trade = leverage_trade(fee) - # add_trade(trade) - + trade = short_trade(fee) + add_trade(trade) + trade = leverage_trade(fee) + add_trade(trade) if use_db: Trade.query.session.flush() @@ -257,7 +279,6 @@ def patch_coingekko(mocker) -> None: @pytest.fixture(scope='function') def init_persistence(default_conf): init_db(default_conf['db_url'], default_conf['dry_run']) - # TODO-mg: trade with leverage and/or borrowed? @pytest.fixture(scope="function") @@ -298,8 +319,7 @@ def get_default_conf(testdatadir): }, "ask_strategy": { "use_order_book": False, - "order_book_min": 1, - "order_book_max": 1 + "order_book_top": 1, }, "exchange": { "name": "binance", @@ -333,6 +353,7 @@ def get_default_conf(testdatadir): "verbosity": 3, "strategy_path": str(Path(__file__).parent / "strategy" / "strats"), "strategy": "DefaultStrategy", + "disableparamexport": True, "internals": {}, "export": "none", } @@ -922,17 +943,18 @@ def limit_sell_order_old(): @pytest.fixture def limit_buy_order_old_partial(): - return {'id': 'mocked_limit_buy_old_partial', - 'type': 'limit', - 'side': 'buy', - 'symbol': 'ETH/BTC', - 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), - 'price': 0.00001099, - 'amount': 90.99181073, - 'filled': 23.0, - 'remaining': 67.99181073, - 'status': 'open' - } + return { + 'id': 'mocked_limit_buy_old_partial', + 'type': 'limit', + 'side': 'buy', + 'symbol': 'ETH/BTC', + 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'price': 0.00001099, + 'amount': 90.99181073, + 'filled': 23.0, + 'remaining': 67.99181073, + 'status': 'open' + } @pytest.fixture @@ -1095,6 +1117,40 @@ def order_book_l2(): }) +@pytest.fixture +def order_book_l2_usd(): + return MagicMock(return_value={ + 'symbol': 'LTC/USDT', + 'bids': [ + [25.563, 49.269], + [25.562, 83.0], + [25.56, 106.0], + [25.559, 15.381], + [25.558, 29.299], + [25.557, 34.624], + [25.556, 10.0], + [25.555, 14.684], + [25.554, 45.91], + [25.553, 50.0] + ], + 'asks': [ + [25.566, 14.27], + [25.567, 48.484], + [25.568, 92.349], + [25.572, 31.48], + [25.573, 23.0], + [25.574, 20.0], + [25.575, 89.606], + [25.576, 262.016], + [25.577, 178.557], + [25.578, 78.614] + ], + 'timestamp': None, + 'datetime': None, + 'nonce': 2372149736 + }) + + @pytest.fixture def ohlcv_history_list(): return [ @@ -1742,7 +1798,6 @@ def rpc_balance(): 'used': 0.0 }, } - # TODO-mg: Add shorts and leverage? @pytest.fixture @@ -1928,12 +1983,13 @@ def saved_hyperopt_results(): 'params_dict': { 'mfi-value': 15, 'fastd-value': 20, 'adx-value': 25, 'rsi-value': 28, 'mfi-enabled': False, 'fastd-enabled': True, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal', 'sell-mfi-value': 88, 'sell-fastd-value': 97, 'sell-adx-value': 51, 'sell-rsi-value': 67, 'sell-mfi-enabled': False, 'sell-fastd-enabled': False, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-bb_upper', 'roi_t1': 1190, 'roi_t2': 541, 'roi_t3': 408, 'roi_p1': 0.026035863879169705, 'roi_p2': 0.12508730043628782, 'roi_p3': 0.27766427921605896, 'stoploss': -0.2562930402099556}, # noqa: E501 'params_details': {'buy': {'mfi-value': 15, 'fastd-value': 20, 'adx-value': 25, 'rsi-value': 28, 'mfi-enabled': False, 'fastd-enabled': True, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal'}, 'sell': {'sell-mfi-value': 88, 'sell-fastd-value': 97, 'sell-adx-value': 51, 'sell-rsi-value': 67, 'sell-mfi-enabled': False, 'sell-fastd-enabled': False, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-bb_upper'}, 'roi': {0: 0.4287874435315165, 408: 0.15112316431545753, 949: 0.026035863879169705, 2139: 0}, 'stoploss': {'stoploss': -0.2562930402099556}}, # noqa: E501 - 'results_metrics': {'total_trades': 2, 'wins': 0, 'draws': 0, 'losses': 2, 'profit_mean': -0.01254995, 'profit_median': -0.012222, 'profit_total': -0.00125625, 'profit_total_abs': -2.50999, 'holding_avg': timedelta(minutes=3930.0)}, # noqa: E501 + 'results_metrics': {'total_trades': 2, 'wins': 0, 'draws': 0, 'losses': 2, 'profit_mean': -0.01254995, 'profit_median': -0.012222, 'profit_total': -0.00125625, 'profit_total_abs': -2.50999, 'holding_avg': timedelta(minutes=3930.0), 'stake_currency': 'BTC', 'strategy_name': 'SampleStrategy'}, # noqa: E501 'results_explanation': ' 2 trades. Avg profit -1.25%. Total profit -0.00125625 BTC ( -2.51Σ%). Avg duration 3930.0 min.', # noqa: E501 'total_profit': -0.00125625, 'current_epoch': 1, 'is_initial_point': True, - 'is_best': True + 'is_best': True, + }, { 'loss': 20.0, 'params_dict': { @@ -2055,98 +2111,191 @@ def saved_hyperopt_results(): for res in hyperopt_res: res['results_metrics']['holding_avg_s'] = res['results_metrics']['holding_avg' ].total_seconds() - return hyperopt_res - # * Margin Tests -# TODO-mg: fill in these tests with something useful - -@pytest.fixture -def leveraged_fee(): - return - - -@pytest.fixture -def short_fee(): - return - - -@pytest.fixture -def ticker_short(): - return - - -@pytest.fixture -def ticker_exit_short_up(): - return - - -@pytest.fixture -def ticker_exit_short_down(): - return - - -@pytest.fixture -def leveraged_markets(): - return @pytest.fixture(scope='function') def limit_short_order_open(): - return - - -@pytest.fixture(scope='function') -def limit_short_order(limit_short_order_open): - return - - -@pytest.fixture(scope='function') -def market_short_order(): - return - - -@pytest.fixture -def market_short_exit_order(): - return - - -@pytest.fixture -def limit_short_order_old(): - return - - -@pytest.fixture -def limit_exit_short_order_old(): - return - - -@pytest.fixture -def limit_short_order_old_partial(): - return - - -@pytest.fixture -def limit_short_order_old_partial_canceled(limit_short_order_old_partial): - return - - -@pytest.fixture(scope='function') -def limit_short_order_canceled_empty(request): - return + return { + 'id': 'mocked_limit_short', + 'type': 'limit', + 'side': 'sell', + 'symbol': 'mocked', + 'datetime': arrow.utcnow().isoformat(), + 'timestamp': arrow.utcnow().int_timestamp, + 'price': 0.00001173, + 'amount': 90.99181073, + 'leverage': 1.0, + 'filled': 0.0, + 'cost': 0.00106733393, + 'remaining': 90.99181073, + 'status': 'open', + 'is_short': True + } @pytest.fixture def limit_exit_short_order_open(): - return + return { + 'id': 'mocked_limit_exit_short', + 'type': 'limit', + 'side': 'buy', + 'pair': 'mocked', + 'datetime': arrow.utcnow().isoformat(), + 'timestamp': arrow.utcnow().int_timestamp, + 'price': 0.00001099, + 'amount': 90.99370639272354, + 'filled': 0.0, + 'remaining': 90.99370639272354, + 'status': 'open', + 'leverage': 1.0 + } + + +@pytest.fixture(scope='function') +def limit_short_order(limit_short_order_open): + order = deepcopy(limit_short_order_open) + order['status'] = 'closed' + order['filled'] = order['amount'] + order['remaining'] = 0.0 + return order @pytest.fixture -def limit_exit_short_order(limit_sell_order_open): - return +def limit_exit_short_order(limit_exit_short_order_open): + order = deepcopy(limit_exit_short_order_open) + order['remaining'] = 0.0 + order['filled'] = order['amount'] + order['status'] = 'closed' + return order + + +@pytest.fixture(scope='function') +def market_short_order(): + return { + 'id': 'mocked_market_short', + 'type': 'market', + 'side': 'sell', + 'symbol': 'mocked', + 'datetime': arrow.utcnow().isoformat(), + 'price': 0.00004173, + 'amount': 275.97543219, + 'filled': 275.97543219, + 'remaining': 0.0, + 'status': 'closed', + 'is_short': True, + 'leverage': 3.0, + } @pytest.fixture -def short_order_fee(): - return +def market_exit_short_order(): + return { + 'id': 'mocked_limit_exit_short', + 'type': 'market', + 'side': 'buy', + 'symbol': 'mocked', + 'datetime': arrow.utcnow().isoformat(), + 'price': 0.00004099, + 'amount': 276.113419906095, + 'filled': 276.113419906095, + 'remaining': 0.0, + 'status': 'closed', + 'leverage': 3.0 + } + + +# leverage 3x +@pytest.fixture(scope='function') +def limit_lev_buy_order_open(): + return { + 'id': 'mocked_limit_buy', + 'type': 'limit', + 'side': 'buy', + 'symbol': 'mocked', + 'datetime': arrow.utcnow().isoformat(), + 'timestamp': arrow.utcnow().int_timestamp, + 'price': 0.00001099, + 'amount': 272.97543219, + 'filled': 0.0, + 'cost': 0.0009999999999226999, + 'remaining': 272.97543219, + 'leverage': 3.0, + 'status': 'open', + 'exchange': 'binance', + } + + +@pytest.fixture(scope='function') +def limit_lev_buy_order(limit_lev_buy_order_open): + order = deepcopy(limit_lev_buy_order_open) + order['status'] = 'closed' + order['filled'] = order['amount'] + order['remaining'] = 0.0 + return order + + +@pytest.fixture +def limit_lev_sell_order_open(): + return { + 'id': 'mocked_limit_sell', + 'type': 'limit', + 'side': 'sell', + 'pair': 'mocked', + 'datetime': arrow.utcnow().isoformat(), + 'timestamp': arrow.utcnow().int_timestamp, + 'price': 0.00001173, + 'amount': 272.97543219, + 'filled': 0.0, + 'remaining': 272.97543219, + 'leverage': 3.0, + 'status': 'open', + 'exchange': 'binance' + } + + +@pytest.fixture +def limit_lev_sell_order(limit_lev_sell_order_open): + order = deepcopy(limit_lev_sell_order_open) + order['remaining'] = 0.0 + order['filled'] = order['amount'] + order['status'] = 'closed' + return order + + +@pytest.fixture(scope='function') +def market_lev_buy_order(): + return { + 'id': 'mocked_market_buy', + 'type': 'market', + 'side': 'buy', + 'symbol': 'mocked', + 'datetime': arrow.utcnow().isoformat(), + 'price': 0.00004099, + 'amount': 275.97543219, + 'filled': 275.97543219, + 'remaining': 0.0, + 'status': 'closed', + 'exchange': 'kraken', + 'leverage': 3.0 + } + + +@pytest.fixture +def market_lev_sell_order(): + return { + 'id': 'mocked_limit_sell', + 'type': 'market', + 'side': 'sell', + 'symbol': 'mocked', + 'datetime': arrow.utcnow().isoformat(), + 'price': 0.00004173, + 'amount': 275.97543219, + 'filled': 275.97543219, + 'remaining': 0.0, + 'status': 'closed', + 'leverage': 3.0, + 'exchange': 'kraken' + } diff --git a/tests/conftest_trades.py b/tests/conftest_trades.py index 2aa1d6b4c..00ffd3fe4 100644 --- a/tests/conftest_trades.py +++ b/tests/conftest_trades.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone from freqtrade.persistence.models import Order, Trade -MOCK_TRADE_COUNT = 6 # TODO-mg: Increase for short and leverage +MOCK_TRADE_COUNT = 6 def mock_order_1(): @@ -305,12 +305,9 @@ def mock_trade_6(fee): return trade -#! TODO Currently the following short_trade test and leverage_trade test will fail - - def short_order(): return { - 'id': '1235', + 'id': '1236', 'symbol': 'ETC/BTC', 'status': 'closed', 'side': 'sell', @@ -319,14 +316,12 @@ def short_order(): 'amount': 123.0, 'filled': 123.0, 'remaining': 0.0, - 'leverage': 5.0, - 'isShort': True } def exit_short_order(): return { - 'id': '12366', + 'id': '12367', 'symbol': 'ETC/BTC', 'status': 'closed', 'side': 'buy', @@ -335,36 +330,60 @@ def exit_short_order(): 'amount': 123.0, 'filled': 123.0, 'remaining': 0.0, - 'leverage': 5.0, - 'isShort': True } def short_trade(fee): """ - Closed trade... + 10 minute short limit trade on binance + + Short trade + fee: 0.25% base + interest_rate: 0.05% per day + open_rate: 0.123 base + close_rate: 0.128 base + amount: 123.0 crypto + stake_amount: 15.129 base + borrowed: 123.0 crypto + time-periods: 10 minutes(rounds up to 1/24 time-period of 1 day) + interest: borrowed * interest_rate * time-periods + = 123.0 * 0.0005 * 1/24 = 0.0025625 crypto + open_value: (amount * open_rate) - (amount * open_rate * fee) + = (123 * 0.123) - (123 * 0.123 * 0.0025) + = 15.091177499999999 + amount_closed: amount + interest = 123 + 0.0025625 = 123.0025625 + close_value: (amount_closed * close_rate) + (amount_closed * close_rate * fee) + = (123.0025625 * 0.128) + (123.0025625 * 0.128 * 0.0025) + = 15.78368882 + total_profit = open_value - close_value + = 15.091177499999999 - 15.78368882 + = -0.6925113200000013 + total_profit_percentage = total_profit / stake_amount + = -0.6925113200000013 / 15.129 + = -0.04577376693766946 + """ trade = Trade( pair='ETC/BTC', - stake_amount=0.001, - amount=123.0, # TODO-mg: In BTC? + stake_amount=15.129, + amount=123.0, amount_requested=123.0, fee_open=fee.return_value, fee_close=fee.return_value, open_rate=0.123, - close_rate=0.128, - close_profit=0.005, # TODO-mg: Would this be -0.005 or -0.025 - close_profit_abs=0.000584127, + # close_rate=0.128, + # close_profit=-0.04577376693766946, + # close_profit_abs=-0.6925113200000013, exchange='binance', - is_open=False, + is_open=True, open_order_id='dry_run_exit_short_12345', strategy='DefaultStrategy', timeframe=5, sell_reason='sell_signal', # TODO-mg: Update to exit/close reason open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), - close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=2), + # close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=2), # borrowed= - isShort=True + is_short=True ) o = Order.parse_from_ccxt_object(short_order(), 'ETC/BTC', 'sell') trade.orders.append(o) @@ -375,8 +394,8 @@ def short_trade(fee): def leverage_order(): return { - 'id': '1235', - 'symbol': 'ETC/BTC', + 'id': '1237', + 'symbol': 'DOGE/BTC', 'status': 'closed', 'side': 'buy', 'type': 'limit', @@ -390,8 +409,8 @@ def leverage_order(): def leverage_order_sell(): return { - 'id': '12366', - 'symbol': 'ETC/BTC', + 'id': '12368', + 'symbol': 'DOGE/BTC', 'status': 'closed', 'side': 'sell', 'type': 'limit', @@ -399,38 +418,63 @@ def leverage_order_sell(): 'amount': 123.0, 'filled': 123.0, 'remaining': 0.0, - 'leverage': 5.0, - 'isShort': True } def leverage_trade(fee): """ - Closed trade... + 5 hour short limit trade on kraken + + Short trade + fee: 0.25% base + interest_rate: 0.05% per day + open_rate: 0.123 base + close_rate: 0.128 base + amount: 615 crypto + stake_amount: 15.129 base + borrowed: 60.516 base + leverage: 5 + hours: 5 + interest: borrowed * interest_rate * ceil(1 + hours/4) + = 60.516 * 0.0005 * ceil(1 + 5/4) = 0.090774 base + open_value: (amount * open_rate) + (amount * open_rate * fee) + = (615.0 * 0.123) + (615.0 * 0.123 * 0.0025) + = 75.83411249999999 + + close_value: (amount_closed * close_rate) - (amount_closed * close_rate * fee) - interest + = (615.0 * 0.128) - (615.0 * 0.128 * 0.0025) - 0.090774 + = 78.432426 + total_profit = close_value - open_value + = 78.432426 - 75.83411249999999 + = 2.5983135000000175 + total_profit_percentage = ((close_value/open_value)-1) * leverage + = ((78.432426/75.83411249999999)-1) * 5 + = 0.1713156134055116 """ trade = Trade( - pair='ETC/BTC', - stake_amount=0.001, + pair='DOGE/BTC', + stake_amount=15.129, amount=615.0, + leverage=5.0, amount_requested=615.0, fee_open=fee.return_value, fee_close=fee.return_value, open_rate=0.123, close_rate=0.128, - close_profit=0.005, # TODO-mg: Would this be -0.005 or -0.025 - close_profit_abs=0.000584127, - exchange='binance', + close_profit=0.1713156134055116, + close_profit_abs=2.5983135000000175, + exchange='kraken', is_open=False, open_order_id='dry_run_leverage_sell_12345', strategy='DefaultStrategy', timeframe=5, sell_reason='sell_signal', # TODO-mg: Update to exit/close reason - open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), - close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=2), - # borrowed= + open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=300), + close_date=datetime.now(tz=timezone.utc), + interest_rate=0.0005 ) - o = Order.parse_from_ccxt_object(leverage_order(), 'ETC/BTC', 'sell') + o = Order.parse_from_ccxt_object(leverage_order(), 'DOGE/BTC', 'sell') trade.orders.append(o) - o = Order.parse_from_ccxt_object(leverage_order_sell(), 'ETC/BTC', 'sell') + o = Order.parse_from_ccxt_object(leverage_order_sell(), 'DOGE/BTC', 'sell') trade.orders.append(o) return trade diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index f5becc274..524dc873c 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -947,6 +947,72 @@ def test_create_dry_run_order(default_conf, mocker, side, exchange_name): assert order["symbol"] == "ETH/BTC" +@pytest.mark.parametrize("side,startprice,endprice", [ + ("buy", 25.563, 25.566), + ("sell", 25.566, 25.563) +]) +@pytest.mark.parametrize("exchange_name", EXCHANGES) +def test_create_dry_run_order_limit_fill(default_conf, mocker, side, startprice, endprice, + exchange_name, order_book_l2_usd): + default_conf['dry_run'] = True + exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) + mocker.patch.multiple('freqtrade.exchange.Exchange', + exchange_has=MagicMock(return_value=True), + fetch_l2_order_book=order_book_l2_usd, + ) + + order = exchange.create_dry_run_order( + pair='LTC/USDT', ordertype='limit', side=side, amount=1, rate=startprice) + assert order_book_l2_usd.call_count == 1 + assert 'id' in order + assert f'dry_run_{side}_' in order["id"] + assert order["side"] == side + assert order["type"] == "limit" + assert order["symbol"] == "LTC/USDT" + order_book_l2_usd.reset_mock() + + order_closed = exchange.fetch_dry_run_order(order['id']) + assert order_book_l2_usd.call_count == 1 + assert order_closed['status'] == 'open' + assert not order['fee'] + + order_book_l2_usd.reset_mock() + order_closed['price'] = endprice + + order_closed = exchange.fetch_dry_run_order(order['id']) + assert order_closed['status'] == 'closed' + assert order['fee'] + + +@pytest.mark.parametrize("side,amount,endprice", [ + ("buy", 1, 25.566), + ("buy", 100, 25.5672), # Requires interpolation + ("buy", 1000, 25.575), # More than orderbook return + ("sell", 1, 25.563), + ("sell", 100, 25.5625), # Requires interpolation + ("sell", 1000, 25.5555), # More than orderbook return +]) +@pytest.mark.parametrize("exchange_name", EXCHANGES) +def test_create_dry_run_order_market_fill(default_conf, mocker, side, amount, endprice, + exchange_name, order_book_l2_usd): + default_conf['dry_run'] = True + exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) + mocker.patch.multiple('freqtrade.exchange.Exchange', + exchange_has=MagicMock(return_value=True), + fetch_l2_order_book=order_book_l2_usd, + ) + + order = exchange.create_dry_run_order( + pair='LTC/USDT', ordertype='market', side=side, amount=amount, rate=25.5) + assert 'id' in order + assert f'dry_run_{side}_' in order["id"] + assert order["side"] == side + assert order["type"] == "market" + assert order["symbol"] == "LTC/USDT" + assert order['status'] == 'closed' + assert round(order["average"], 4) == round(endprice, 4) + + @pytest.mark.parametrize("side", [ ("buy"), ("sell") @@ -1778,8 +1844,7 @@ def test_get_sell_rate_orderbook(default_conf, mocker, caplog, side, expected, o # Test orderbook mode default_conf['ask_strategy']['price_side'] = side default_conf['ask_strategy']['use_order_book'] = True - default_conf['ask_strategy']['order_book_min'] = 1 - default_conf['ask_strategy']['order_book_max'] = 2 + default_conf['ask_strategy']['order_book_top'] = 1 pair = "ETH/BTC" mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', order_book_l2) exchange = get_patched_exchange(mocker, default_conf) @@ -1796,8 +1861,7 @@ def test_get_sell_rate_orderbook_exception(default_conf, mocker, caplog): # Test orderbook mode default_conf['ask_strategy']['price_side'] = 'ask' default_conf['ask_strategy']['use_order_book'] = True - default_conf['ask_strategy']['order_book_min'] = 1 - default_conf['ask_strategy']['order_book_max'] = 2 + default_conf['ask_strategy']['order_book_top'] = 1 pair = "ETH/BTC" # Test What happens if the exchange returns an empty orderbook. mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', @@ -1805,7 +1869,8 @@ def test_get_sell_rate_orderbook_exception(default_conf, mocker, caplog): exchange = get_patched_exchange(mocker, default_conf) with pytest.raises(PricingError): exchange.get_sell_rate(pair, True) - assert log_has("Sell Price at location from orderbook could not be determined.", caplog) + assert log_has_re(r"Sell Price at location 1 from orderbook could not be determined\..*", + caplog) def test_get_sell_rate_exception(default_conf, mocker, caplog): @@ -2117,6 +2182,7 @@ def test_get_historic_trades_notsupported(default_conf, mocker, caplog, exchange def test_cancel_order_dry_run(default_conf, mocker, exchange_name): default_conf['dry_run'] = True exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) + mocker.patch('freqtrade.exchange.Exchange._is_dry_limit_order_filled', return_value=True) assert exchange.cancel_order(order_id='123', pair='TKN/BTC') == {} assert exchange.cancel_stoploss_order(order_id='123', pair='TKN/BTC') == {} diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index 7cca2b1a8..ca91019e6 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -34,6 +34,7 @@ class BTContainer(NamedTuple): trailing_stop_positive: Optional[float] = None trailing_stop_positive_offset: float = 0.0 use_sell_signal: bool = False + use_custom_stoploss: bool = False def _get_frame_time_from_offset(offset): diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 488425323..0bf197739 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -501,6 +501,21 @@ tc31 = BTContainer(data=[ trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=1)] ) +# Test 32: trailing_stop should be triggered immediately on trade open candle. +# stop-loss: 1%, ROI: 10% (should not apply) +tc32 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 5000, 4900, 6172, 0, 0], # enter trade (signal on last candle) and stop + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=-0.01, trailing_stop=True, + trailing_only_offset_is_reached=True, trailing_stop_positive_offset=0.02, + trailing_stop_positive=0.01, use_custom_stoploss=True, + trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=1)] +) + TESTS = [ tc0, tc1, @@ -534,6 +549,7 @@ TESTS = [ tc29, tc30, tc31, + tc32, ] @@ -551,7 +567,7 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: if data.trailing_stop_positive is not None: default_conf["trailing_stop_positive"] = data.trailing_stop_positive default_conf["trailing_stop_positive_offset"] = data.trailing_stop_positive_offset - default_conf["ask_strategy"] = {"use_sell_signal": data.use_sell_signal} + default_conf["use_sell_signal"] = data.use_sell_signal mocker.patch("freqtrade.exchange.Exchange.get_fee", return_value=0.0) mocker.patch("freqtrade.exchange.Exchange.get_min_pair_stake_amount", return_value=0.00001) @@ -561,6 +577,7 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: backtesting._set_strategy(backtesting.strategylist[0]) backtesting.strategy.advise_buy = lambda a, m: frame backtesting.strategy.advise_sell = lambda a, m: frame + backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss caplog.set_level(logging.DEBUG) pair = "UNITTEST/BTC" diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 60bd82d71..30d86f979 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -465,7 +465,7 @@ def test_backtesting_pairlist_list(default_conf, mocker, caplog, testdatadir, ti def test_backtest__enter_trade(default_conf, fee, mocker) -> None: - default_conf['ask_strategy']['use_sell_signal'] = False + 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) @@ -511,7 +511,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None: def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: - default_conf['ask_strategy']['use_sell_signal'] = False + 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) @@ -574,7 +574,7 @@ def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: def test_backtest_1min_timeframe(default_conf, fee, mocker, testdatadir) -> None: - default_conf['ask_strategy']['use_sell_signal'] = False + 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) @@ -819,7 +819,7 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): @pytest.mark.filterwarnings("ignore:deprecated") def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): - default_conf['ask_strategy'].update({ + default_conf.update({ "use_sell_signal": True, "sell_profit_only": False, "sell_profit_offset": 0.0, @@ -894,7 +894,7 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): @pytest.mark.filterwarnings("ignore:deprecated") def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdatadir, capsys): - default_conf['ask_strategy'].update({ + default_conf.update({ "use_sell_signal": True, "sell_profit_only": False, "sell_profit_offset": 0.0, @@ -993,4 +993,5 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat assert 'BACKTESTING REPORT' in captured.out assert 'SELL REASON STATS' in captured.out assert 'LEFT OPEN TRADES REPORT' in captured.out + assert '2017-11-14 21:17:00 -> 2017-11-14 22:58:00 | Max open trades : 1' in captured.out assert 'STRATEGY SUMMARY' in captured.out diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index 10e99395d..14fea573f 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -1,9 +1,6 @@ # pragma pylint: disable=missing-docstring,W0212,C0103 -import logging -import re from datetime import datetime from pathlib import Path -from typing import Dict, List from unittest.mock import ANY, MagicMock import pandas as pd @@ -28,12 +25,6 @@ from tests.conftest import (get_args, log_has, log_has_re, patch_exchange, from .hyperopts.default_hyperopt import DefaultHyperOpt -# Functions for recurrent object patching -def create_results() -> List[Dict]: - - return [{'loss': 1, 'result': 'foo', 'params': {}, 'is_best': True}] - - def test_setup_hyperopt_configuration_without_arguments(mocker, default_conf, caplog) -> None: patched_configuration_load_config_file(mocker, default_conf) @@ -303,52 +294,6 @@ def test_no_log_if_loss_does_not_improve(hyperopt, caplog) -> None: assert caplog.record_tuples == [] -def test_save_results_saves_epochs(mocker, hyperopt, tmpdir, caplog) -> None: - # Test writing to temp dir and reading again - epochs = create_results() - hyperopt.results_file = Path(tmpdir / 'ut_results.fthypt') - - caplog.set_level(logging.DEBUG) - - for epoch in epochs: - hyperopt._save_result(epoch) - assert log_has(f"1 epoch saved to '{hyperopt.results_file}'.", caplog) - - hyperopt._save_result(epochs[0]) - assert log_has(f"2 epochs saved to '{hyperopt.results_file}'.", caplog) - - hyperopt_epochs = HyperoptTools.load_previous_results(hyperopt.results_file) - assert len(hyperopt_epochs) == 2 - - -def test_load_previous_results(testdatadir, caplog) -> None: - - results_file = testdatadir / 'hyperopt_results_SampleStrategy.pickle' - - hyperopt_epochs = HyperoptTools.load_previous_results(results_file) - - assert len(hyperopt_epochs) == 5 - assert log_has_re(r"Reading pickled epochs from .*", caplog) - - caplog.clear() - - # Modern version - results_file = testdatadir / 'strategy_SampleStrategy.fthypt' - - hyperopt_epochs = HyperoptTools.load_previous_results(results_file) - - assert len(hyperopt_epochs) == 5 - assert log_has_re(r"Reading epochs from .*", caplog) - - -def test_load_previous_results2(mocker, testdatadir, caplog) -> None: - mocker.patch('freqtrade.optimize.hyperopt_tools.HyperoptTools._read_results_pickle', - return_value=[{'asdf': '222'}]) - results_file = testdatadir / 'hyperopt_results_SampleStrategy.pickle' - with pytest.raises(OperationalException, match=r"The file .* incompatible.*"): - HyperoptTools.load_previous_results(results_file) - - def test_roi_table_generation(hyperopt) -> None: params = { 'roi_t1': 5, @@ -362,6 +307,18 @@ def test_roi_table_generation(hyperopt) -> None: assert hyperopt.custom_hyperopt.generate_roi_table(params) == {0: 6, 15: 3, 25: 1, 30: 0} +def test_params_no_optimize_details(hyperopt) -> None: + hyperopt.config['spaces'] = ['buy'] + res = hyperopt._get_no_optimize_details() + assert isinstance(res, dict) + assert "trailing" in res + assert res["trailing"]['trailing_stop'] is False + assert "roi" in res + assert res['roi']['0'] == 0.04 + assert "stoploss" in res + assert res['stoploss']['stoploss'] == -0.1 + + def test_start_calls_optimizer(mocker, hyperopt_conf, capsys) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump') dumper2 = mocker.patch('freqtrade.optimize.hyperopt.Hyperopt._save_result') @@ -467,40 +424,6 @@ def test_hyperopt_format_results(hyperopt): assert '0:50:00 min' in result -@pytest.mark.parametrize("spaces, expected_results", [ - (['buy'], - {'buy': True, 'sell': False, 'roi': False, 'stoploss': False, 'trailing': False}), - (['sell'], - {'buy': False, 'sell': True, 'roi': False, 'stoploss': False, 'trailing': False}), - (['roi'], - {'buy': False, 'sell': False, 'roi': True, 'stoploss': False, 'trailing': False}), - (['stoploss'], - {'buy': False, 'sell': False, 'roi': False, 'stoploss': True, 'trailing': False}), - (['trailing'], - {'buy': False, 'sell': False, 'roi': False, 'stoploss': False, 'trailing': True}), - (['buy', 'sell', 'roi', 'stoploss'], - {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': False}), - (['buy', 'sell', 'roi', 'stoploss', 'trailing'], - {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': True}), - (['buy', 'roi'], - {'buy': True, 'sell': False, 'roi': True, 'stoploss': False, 'trailing': False}), - (['all'], - {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': True}), - (['default'], - {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': False}), - (['default', 'trailing'], - {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': True}), - (['all', 'buy'], - {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': True}), - (['default', 'buy'], - {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': False}), -]) -def test_has_space(hyperopt_conf, spaces, expected_results): - for s in ['buy', 'sell', 'roi', 'stoploss', 'trailing']: - hyperopt_conf.update({'spaces': spaces}) - assert HyperoptTools.has_space(hyperopt_conf, s) == expected_results[s] - - def test_populate_indicators(hyperopt, testdatadir) -> None: data = load_data(testdatadir, '1m', ['UNITTEST/BTC'], fill_up_missing=True) dataframes = hyperopt.backtesting.strategy.ohlcvdata_to_dataframe(data) @@ -686,6 +609,8 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: def test_clean_hyperopt(mocker, hyperopt_conf, caplog): patch_exchange(mocker) + mocker.patch("freqtrade.strategy.hyper.HyperStrategyMixin.load_params_from_file", + MagicMock(return_value={})) mocker.patch("freqtrade.optimize.hyperopt.Path.is_file", MagicMock(return_value=True)) unlinkmock = mocker.patch("freqtrade.optimize.hyperopt.Path.unlink", MagicMock()) h = Hyperopt(hyperopt_conf) @@ -1068,42 +993,6 @@ def test_simplified_interface_failed(mocker, hyperopt_conf, method, space) -> No hyperopt.start() -def test_show_epoch_details(capsys): - test_result = { - 'params_details': { - 'trailing': { - 'trailing_stop': True, - 'trailing_stop_positive': 0.02, - 'trailing_stop_positive_offset': 0.04, - 'trailing_only_offset_is_reached': True - }, - 'roi': { - 0: 0.18, - 90: 0.14, - 225: 0.05, - 430: 0}, - }, - 'results_explanation': 'foo result', - 'is_initial_point': False, - 'total_profit': 0, - 'current_epoch': 2, # This starts from 1 (in a human-friendly manner) - 'is_best': True - } - - HyperoptTools.show_epoch_details(test_result, 5, False, no_header=True) - captured = capsys.readouterr() - assert '# Trailing stop:' in captured.out - # re.match(r"Pairs for .*", captured.out) - assert re.search(r'^\s+trailing_stop = True$', captured.out, re.MULTILINE) - assert re.search(r'^\s+trailing_stop_positive = 0.02$', captured.out, re.MULTILINE) - assert re.search(r'^\s+trailing_stop_positive_offset = 0.04$', captured.out, re.MULTILINE) - assert re.search(r'^\s+trailing_only_offset_is_reached = True$', captured.out, re.MULTILINE) - - assert '# ROI table:' in captured.out - assert re.search(r'^\s+minimal_roi = \{$', captured.out, re.MULTILINE) - assert re.search(r'^\s+\"90\"\:\s0.14,\s*$', captured.out, re.MULTILINE) - - def test_in_strategy_auto_hyperopt(mocker, hyperopt_conf, tmpdir, fee) -> None: patch_exchange(mocker) mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) @@ -1143,17 +1032,3 @@ def test_SKDecimal(): assert space.transform([2.0]) == [200] assert space.transform([1.0]) == [100] assert space.transform([1.5, 1.6]) == [150, 160] - - -def test___pprint(): - params = {'buy_std': 1.2, 'buy_rsi': 31, 'buy_enable': True, 'buy_what': 'asdf'} - non_params = {'buy_notoptimied': 55} - - x = HyperoptTools._pprint(params, non_params) - assert x == """{ - "buy_std": 1.2, - "buy_rsi": 31, - "buy_enable": True, - "buy_what": "asdf", - "buy_notoptimied": 55, # value loaded from strategy -}""" diff --git a/tests/optimize/test_hyperopt_tools.py b/tests/optimize/test_hyperopt_tools.py new file mode 100644 index 000000000..44b4a7a03 --- /dev/null +++ b/tests/optimize/test_hyperopt_tools.py @@ -0,0 +1,317 @@ +import logging +import re +from pathlib import Path +from typing import Dict, List + +import numpy as np +import pytest +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 log_has, log_has_re + + +# Functions for recurrent object patching +def create_results() -> List[Dict]: + + return [{'loss': 1, 'result': 'foo', 'params': {}, 'is_best': True}] + + +def test_save_results_saves_epochs(hyperopt, tmpdir, caplog) -> None: + # Test writing to temp dir and reading again + epochs = create_results() + hyperopt.results_file = Path(tmpdir / 'ut_results.fthypt') + + caplog.set_level(logging.DEBUG) + + for epoch in epochs: + hyperopt._save_result(epoch) + assert log_has(f"1 epoch saved to '{hyperopt.results_file}'.", caplog) + + hyperopt._save_result(epochs[0]) + assert log_has(f"2 epochs saved to '{hyperopt.results_file}'.", caplog) + + hyperopt_epochs = HyperoptTools.load_previous_results(hyperopt.results_file) + assert len(hyperopt_epochs) == 2 + + +def test_load_previous_results(testdatadir, caplog) -> None: + + results_file = testdatadir / 'hyperopt_results_SampleStrategy.pickle' + + hyperopt_epochs = HyperoptTools.load_previous_results(results_file) + + assert len(hyperopt_epochs) == 5 + assert log_has_re(r"Reading pickled epochs from .*", caplog) + + caplog.clear() + + # Modern version + results_file = testdatadir / 'strategy_SampleStrategy.fthypt' + + hyperopt_epochs = HyperoptTools.load_previous_results(results_file) + + assert len(hyperopt_epochs) == 5 + assert log_has_re(r"Reading epochs from .*", caplog) + + +def test_load_previous_results2(mocker, testdatadir, caplog) -> None: + mocker.patch('freqtrade.optimize.hyperopt_tools.HyperoptTools._read_results_pickle', + return_value=[{'asdf': '222'}]) + results_file = testdatadir / 'hyperopt_results_SampleStrategy.pickle' + with pytest.raises(OperationalException, match=r"The file .* incompatible.*"): + HyperoptTools.load_previous_results(results_file) + + +@pytest.mark.parametrize("spaces, expected_results", [ + (['buy'], + {'buy': True, 'sell': False, 'roi': False, 'stoploss': False, 'trailing': False}), + (['sell'], + {'buy': False, 'sell': True, 'roi': False, 'stoploss': False, 'trailing': False}), + (['roi'], + {'buy': False, 'sell': False, 'roi': True, 'stoploss': False, 'trailing': False}), + (['stoploss'], + {'buy': False, 'sell': False, 'roi': False, 'stoploss': True, 'trailing': False}), + (['trailing'], + {'buy': False, 'sell': False, 'roi': False, 'stoploss': False, 'trailing': True}), + (['buy', 'sell', 'roi', 'stoploss'], + {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': False}), + (['buy', 'sell', 'roi', 'stoploss', 'trailing'], + {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': True}), + (['buy', 'roi'], + {'buy': True, 'sell': False, 'roi': True, 'stoploss': False, 'trailing': False}), + (['all'], + {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': True}), + (['default'], + {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': False}), + (['default', 'trailing'], + {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': True}), + (['all', 'buy'], + {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': True}), + (['default', 'buy'], + {'buy': True, 'sell': True, 'roi': True, 'stoploss': True, 'trailing': False}), +]) +def test_has_space(hyperopt_conf, spaces, expected_results): + for s in ['buy', 'sell', 'roi', 'stoploss', 'trailing']: + hyperopt_conf.update({'spaces': spaces}) + assert HyperoptTools.has_space(hyperopt_conf, s) == expected_results[s] + + +def test_show_epoch_details(capsys): + test_result = { + 'params_details': { + 'trailing': { + 'trailing_stop': True, + 'trailing_stop_positive': 0.02, + 'trailing_stop_positive_offset': 0.04, + 'trailing_only_offset_is_reached': True + }, + 'roi': { + 0: 0.18, + 90: 0.14, + 225: 0.05, + 430: 0}, + }, + 'results_explanation': 'foo result', + 'is_initial_point': False, + 'total_profit': 0, + 'current_epoch': 2, # This starts from 1 (in a human-friendly manner) + 'is_best': True + } + + HyperoptTools.show_epoch_details(test_result, 5, False, no_header=True) + captured = capsys.readouterr() + assert '# Trailing stop:' in captured.out + # re.match(r"Pairs for .*", captured.out) + assert re.search(r'^\s+trailing_stop = True$', captured.out, re.MULTILINE) + assert re.search(r'^\s+trailing_stop_positive = 0.02$', captured.out, re.MULTILINE) + assert re.search(r'^\s+trailing_stop_positive_offset = 0.04$', captured.out, re.MULTILINE) + assert re.search(r'^\s+trailing_only_offset_is_reached = True$', captured.out, re.MULTILINE) + + assert '# ROI table:' in captured.out + assert re.search(r'^\s+minimal_roi = \{$', captured.out, re.MULTILINE) + assert re.search(r'^\s+\"90\"\:\s0.14,\s*$', captured.out, re.MULTILINE) + + +def test__pprint_dict(): + params = {'buy_std': 1.2, 'buy_rsi': 31, 'buy_enable': True, 'buy_what': 'asdf'} + non_params = {'buy_notoptimied': 55} + + x = HyperoptTools._pprint_dict(params, non_params) + assert x == """{ + "buy_std": 1.2, + "buy_rsi": 31, + "buy_enable": True, + "buy_what": "asdf", + "buy_notoptimied": 55, # value loaded from strategy +}""" + + +def test_get_strategy_filename(default_conf): + + x = HyperoptTools.get_strategy_filename(default_conf, 'DefaultStrategy') + assert isinstance(x, Path) + assert x == Path(__file__).parents[1] / 'strategy/strats/default_strategy.py' + + x = HyperoptTools.get_strategy_filename(default_conf, 'NonExistingStrategy') + assert x is None + + +def test_export_params(tmpdir): + + filename = Path(tmpdir) / "DefaultStrategy.json" + assert not filename.is_file() + params = { + "params_details": { + "buy": { + "buy_rsi": 30 + }, + "sell": { + "sell_rsi": 70 + }, + "roi": { + "0": 0.528, + "346": 0.08499, + "507": 0.049, + "1595": 0 + } + }, + "params_not_optimized": { + "stoploss": -0.05, + "trailing": { + "trailing_stop": False, + "trailing_stop_positive": 0.05, + "trailing_stop_positive_offset": 0.1, + "trailing_only_offset_is_reached": True + }, + } + + } + HyperoptTools.export_params(params, "DefaultStrategy", filename) + + assert filename.is_file() + + content = rapidjson.load(filename.open('r')) + assert content['strategy_name'] == 'DefaultStrategy' + assert 'params' in content + assert "buy" in content["params"] + assert "sell" in content["params"] + assert "roi" in content["params"] + assert "stoploss" in content["params"] + assert "trailing" in content["params"] + + +def test_try_export_params(default_conf, tmpdir, caplog, mocker): + default_conf['disableparamexport'] = False + export_mock = mocker.patch("freqtrade.optimize.hyperopt_tools.HyperoptTools.export_params") + + filename = Path(tmpdir) / "DefaultStrategy.json" + assert not filename.is_file() + params = { + "params_details": { + "buy": { + "buy_rsi": 30 + }, + "sell": { + "sell_rsi": 70 + }, + "roi": { + "0": 0.528, + "346": 0.08499, + "507": 0.049, + "1595": 0 + } + }, + "params_not_optimized": { + "stoploss": -0.05, + "trailing": { + "trailing_stop": False, + "trailing_stop_positive": 0.05, + "trailing_stop_positive_offset": 0.1, + "trailing_only_offset_is_reached": True + }, + }, + FTHYPT_FILEVERSION: 2, + + } + HyperoptTools.try_export_params(default_conf, "DefaultStrategy22", params) + + assert log_has("Strategy not found, not exporting parameter file.", caplog) + assert export_mock.call_count == 0 + caplog.clear() + + HyperoptTools.try_export_params(default_conf, "DefaultStrategy", params) + + assert export_mock.call_count == 1 + assert export_mock.call_args_list[0][0][1] == 'DefaultStrategy' + assert export_mock.call_args_list[0][0][2].name == 'default_strategy.json' + + +def test_params_print(capsys): + + params = { + "buy": { + "buy_rsi": 30 + }, + "sell": { + "sell_rsi": 70 + }, + } + non_optimized = { + "buy": { + "buy_adx": 44 + }, + "sell": { + "sell_adx": 65 + }, + "stoploss": { + "stoploss": -0.05, + }, + "roi": { + "0": 0.05, + "20": 0.01, + }, + "trailing": { + "trailing_stop": False, + "trailing_stop_positive": 0.05, + "trailing_stop_positive_offset": 0.1, + "trailing_only_offset_is_reached": True + }, + + } + HyperoptTools._params_pretty_print(params, 'buy', 'No header', non_optimized) + + captured = capsys.readouterr() + assert re.search("# No header", captured.out) + assert re.search('"buy_rsi": 30,\n', captured.out) + assert re.search('"buy_adx": 44, # value loaded.*\n', captured.out) + assert not re.search("sell", captured.out) + + HyperoptTools._params_pretty_print(params, 'sell', 'Sell Header', non_optimized) + captured = capsys.readouterr() + assert re.search("# Sell Header", captured.out) + assert re.search('"sell_rsi": 70,\n', captured.out) + assert re.search('"sell_adx": 65, # value loaded.*\n', captured.out) + + HyperoptTools._params_pretty_print(params, 'roi', 'ROI Table:', non_optimized) + captured = capsys.readouterr() + assert re.search("# ROI Table: # value loaded.*\n", captured.out) + assert re.search('minimal_roi = {\n', captured.out) + assert re.search('"20": 0.01\n', captured.out) + + HyperoptTools._params_pretty_print(params, 'trailing', 'Trailing stop:', non_optimized) + captured = capsys.readouterr() + assert re.search("# Trailing stop:", captured.out) + assert re.search('trailing_stop = False # value loaded.*\n', captured.out) + assert re.search('trailing_stop_positive = 0.05 # value loaded.*\n', captured.out) + assert re.search('trailing_stop_positive_offset = 0.1 # value loaded.*\n', captured.out) + assert re.search('trailing_only_offset_is_reached = True # value loaded.*\n', captured.out) + + +def test_hyperopt_serializer(): + + assert isinstance(hyperopt_serializer(np.int_(5)), int) + assert isinstance(hyperopt_serializer(np.bool_(True)), bool) + assert isinstance(hyperopt_serializer(np.bool_(False)), bool) diff --git a/tests/test_persistence.py b/tests/persistence/test_persistence.py similarity index 97% rename from tests/test_persistence.py rename to tests/persistence/test_persistence.py index 30798e60c..645947f76 100644 --- a/tests/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -63,6 +63,48 @@ def test_init_dryrun_db(default_conf, tmpdir): assert Path(filename).is_file() +@pytest.mark.usefixtures("init_persistence") +def test_is_opening_closing_trade(fee): + trade = Trade( + id=2, + pair='ETH/BTC', + stake_amount=0.001, + open_rate=0.01, + amount=5, + is_open=True, + open_date=arrow.utcnow().datetime, + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + is_short=False, + leverage=2.0 + ) + assert trade.is_opening_trade('buy') is True + assert trade.is_opening_trade('sell') is False + assert trade.is_closing_trade('buy') is False + assert trade.is_closing_trade('sell') is True + + trade = Trade( + id=2, + pair='ETH/BTC', + stake_amount=0.001, + open_rate=0.01, + amount=5, + is_open=True, + open_date=arrow.utcnow().datetime, + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + is_short=True, + leverage=2.0 + ) + + assert trade.is_opening_trade('buy') is False + assert trade.is_opening_trade('sell') is True + assert trade.is_closing_trade('buy') is True + assert trade.is_closing_trade('sell') is False + + @pytest.mark.usefixtures("init_persistence") def test_update_with_binance(limit_buy_order, limit_sell_order, fee, caplog): """ @@ -129,9 +171,6 @@ def test_update_with_binance(limit_buy_order, limit_sell_order, fee, caplog): r"pair=ETH/BTC, amount=90.99181073, open_rate=0.00001099, open_since=.*\).", caplog) - # TODO-mg: create a short order - # TODO-mg: create a leveraged long order - @pytest.mark.usefixtures("init_persistence") def test_update_market_order(market_buy_order, market_sell_order, fee, caplog): @@ -170,9 +209,6 @@ def test_update_market_order(market_buy_order, market_sell_order, fee, caplog): r"pair=ETH/BTC, amount=91.99181073, open_rate=0.00004099, open_since=.*\).", caplog) - # TODO-mg: market short - # TODO-mg: market leveraged long - @pytest.mark.usefixtures("init_persistence") def test_calc_open_close_trade_price(limit_buy_order, limit_sell_order, fee): @@ -202,6 +238,7 @@ def test_calc_open_close_trade_price(limit_buy_order, limit_sell_order, fee): @pytest.mark.usefixtures("init_persistence") def test_trade_close(limit_buy_order, limit_sell_order, fee): + # TODO: limit_buy_order and limit_sell_order aren't used, remove them probably trade = Trade( pair='ETH/BTC', stake_amount=0.001, @@ -665,13 +702,11 @@ def test_migrate_new(mocker, default_conf, fee, caplog): order_date DATETIME, order_filled_date DATETIME, order_update_date DATETIME, - leverage FLOAT, PRIMARY KEY (id), CONSTRAINT _order_pair_order_id UNIQUE (ft_pair, order_id), FOREIGN KEY(ft_trade_id) REFERENCES trades (id) ) """)) - # TODO-mg: Had to add field leverage to this table, check that this is correct connection.execute(text(""" insert into orders ( id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, @@ -695,6 +730,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog): assert orders[1].order_id == 'stop_order_id222' assert orders[1].ft_order_side == 'stoploss' + assert orders[0].is_short is False def test_migrate_mid_state(mocker, default_conf, fee, caplog): @@ -920,11 +956,7 @@ def test_to_json(default_conf, fee): 'strategy': None, 'timeframe': None, 'exchange': 'binance', - 'leverage': None, - 'borrowed': None, - 'borrowed_currency': None, - 'collateral_currency': None, 'interest_rate': None, 'liquidation_price': None, 'is_short': None, @@ -993,11 +1025,7 @@ def test_to_json(default_conf, fee): 'strategy': None, 'timeframe': None, 'exchange': 'binance', - 'leverage': None, - 'borrowed': None, - 'borrowed_currency': None, - 'collateral_currency': None, 'interest_rate': None, 'liquidation_price': None, 'is_short': None, diff --git a/tests/persistence/test_persistence_leverage.py b/tests/persistence/test_persistence_leverage.py new file mode 100644 index 000000000..a5b5178d1 --- /dev/null +++ b/tests/persistence/test_persistence_leverage.py @@ -0,0 +1,638 @@ +from datetime import datetime, timedelta +from math import isclose + +import pytest + +from freqtrade.enums import InterestMode +from freqtrade.persistence import Trade +from tests.conftest import log_has_re + + +@pytest.mark.usefixtures("init_persistence") +def test_interest_kraken_lev(market_lev_buy_order, fee): + """ + Market trade on Kraken at 3x and 5x leverage + Short trade + interest_rate: 0.05%, 0.25% per 4 hrs + open_rate: 0.00004099 base + close_rate: 0.00004173 base + stake_amount: 0.0037707443218227 + borrowed: 0.0075414886436454 + amount: + 275.97543219 crypto + 459.95905365 crypto + borrowed: + 0.0075414886436454 base + 0.0150829772872908 base + time-periods: 10 minutes(rounds up to 1 time-period of 4hrs) + 5 hours = 5/4 + + interest: borrowed * interest_rate * ceil(1 + time-periods) + = 0.0075414886436454 * 0.0005 * ceil(2) = 7.5414886436454e-06 base + = 0.0075414886436454 * 0.00025 * ceil(9/4) = 5.65611648273405e-06 base + = 0.0150829772872908 * 0.0005 * ceil(9/4) = 2.26244659309362e-05 base + = 0.0150829772872908 * 0.00025 * ceil(2) = 7.5414886436454e-06 base + """ + + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0037707443218227, + amount=275.97543219, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='kraken', + leverage=3.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPER4 + ) + + assert float(trade.calculate_interest()) == 7.5414886436454e-06 + trade.open_date = datetime.utcnow() - timedelta(hours=4, minutes=55) + assert float(round(trade.calculate_interest(interest_rate=0.00025), 11) + ) == round(5.65611648273405e-06, 11) + + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0037707443218227, + amount=459.95905365, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=4, minutes=55), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='kraken', + leverage=5.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPER4 + ) + + assert float(round(trade.calculate_interest(), 11) + ) == round(2.26244659309362e-05, 11) + trade.open_date = datetime.utcnow() - timedelta(hours=0, minutes=10) + trade.interest_rate = 0.00025 + assert float(trade.calculate_interest(interest_rate=0.00025)) == 7.5414886436454e-06 + + +@pytest.mark.usefixtures("init_persistence") +def test_interest_binance_lev(market_lev_buy_order, fee): + """ + Market trade on Kraken at 3x and 5x leverage + Short trade + interest_rate: 0.05%, 0.25% per 4 hrs + open_rate: 0.00001099 base + close_rate: 0.00001173 base + stake_amount: 0.0009999999999226999 + borrowed: 0.0019999999998453998 + amount: + 90.99181073 * leverage(3) = 272.97543219 crypto + 90.99181073 * leverage(5) = 454.95905365 crypto + borrowed: + 0.0019999999998453998 base + 0.0039999999996907995 base + time-periods: 10 minutes(rounds up to 1/24 time-period of 24hrs) + 5 hours = 5/24 + + interest: borrowed * interest_rate * time-periods + = 0.0019999999998453998 * 0.00050 * 1/24 = 4.166666666344583e-08 base + = 0.0019999999998453998 * 0.00025 * 5/24 = 1.0416666665861459e-07 base + = 0.0039999999996907995 * 0.00050 * 5/24 = 4.1666666663445834e-07 base + = 0.0039999999996907995 * 0.00025 * 1/24 = 4.166666666344583e-08 base + """ + + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0009999999999226999, + amount=272.97543219, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + leverage=3.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPERDAY + ) + # 10 minutes round up to 4 hours evenly on kraken so we can predict the them more accurately + assert round(float(trade.calculate_interest()), 22) == round(4.166666666344583e-08, 22) + trade.open_date = datetime.utcnow() - timedelta(hours=4, minutes=55) + # All trade > 5 hours will vary slightly due to execution time and interest calculated + assert float(round(trade.calculate_interest(interest_rate=0.00025), 14) + ) == round(1.0416666665861459e-07, 14) + + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0009999999999226999, + amount=459.95905365, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=4, minutes=55), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + leverage=5.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPERDAY + ) + + assert float(round(trade.calculate_interest(), 14)) == round(4.1666666663445834e-07, 14) + trade.open_date = datetime.utcnow() - timedelta(hours=0, minutes=10) + assert float(round(trade.calculate_interest(interest_rate=0.00025), 22) + ) == round(4.166666666344583e-08, 22) + + +@pytest.mark.usefixtures("init_persistence") +def test_update_open_order_lev(limit_lev_buy_order): + trade = Trade( + pair='ETH/BTC', + stake_amount=1.00, + open_rate=0.01, + amount=5, + fee_open=0.1, + fee_close=0.1, + interest_rate=0.0005, + exchange='binance', + interest_mode=InterestMode.HOURSPERDAY + ) + assert trade.open_order_id is None + assert trade.close_profit is None + assert trade.close_date is None + limit_lev_buy_order['status'] = 'open' + trade.update(limit_lev_buy_order) + assert trade.open_order_id is None + assert trade.close_profit is None + assert trade.close_date is None + + +@pytest.mark.usefixtures("init_persistence") +def test_calc_open_trade_value_lev(market_lev_buy_order, fee): + """ + 10 minute leveraged market trade on Kraken at 3x leverage + Short trade + fee: 0.25% base + interest_rate: 0.05% per 4 hrs + open_rate: 0.00004099 base + close_rate: 0.00004173 base + amount: 91.99181073 * leverage(3) = 275.97543219 crypto + stake_amount: 0.0037707443218227 + borrowed: 0.0075414886436454 base + time-periods: 10 minutes(rounds up to 1 time-period of 4hrs) + interest: borrowed * interest_rate * time-periods + = 0.0075414886436454 * 0.0005 * 1 = 7.5414886436454e-06 crypto + open_value: (amount * open_rate) + (amount * open_rate * fee) + = (275.97543219 * 0.00004099) + (275.97543219 * 0.00004099 * 0.0025) + = 0.01134051354788177 + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=5, + open_rate=0.00004099, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + interest_rate=0.0005, + exchange='kraken', + leverage=3, + interest_mode=InterestMode.HOURSPER4 + ) + trade.open_order_id = 'open_trade' + trade.update(market_lev_buy_order) # Buy @ 0.00001099 + # Get the open rate price with the standard fee rate + assert trade._calc_open_trade_value() == 0.01134051354788177 + trade.fee_open = 0.003 + # Get the open rate price with a custom fee rate + assert trade._calc_open_trade_value() == 0.011346169664364504 + + +@pytest.mark.usefixtures("init_persistence") +def test_calc_open_close_trade_price_lev(limit_lev_buy_order, limit_lev_sell_order, fee): + """ + 5 hour leveraged trade on Binance + + fee: 0.25% base + interest_rate: 0.05% per day + open_rate: 0.00001099 base + close_rate: 0.00001173 base + amount: 272.97543219 crypto + stake_amount: 0.0009999999999226999 base + borrowed: 0.0019999999998453998 base + time-periods: 5 hours(rounds up to 5/24 time-period of 1 day) + interest: borrowed * interest_rate * time-periods + = 0.0019999999998453998 * 0.0005 * 5/24 = 2.0833333331722917e-07 base + open_value: (amount * open_rate) + (amount * open_rate * fee) + = (272.97543219 * 0.00001099) + (272.97543219 * 0.00001099 * 0.0025) + = 0.0030074999997675204 + close_value: ((amount_closed * close_rate) - (amount_closed * close_rate * fee)) - interest + = (272.97543219 * 0.00001173) + - (272.97543219 * 0.00001173 * 0.0025) + - 2.0833333331722917e-07 + = 0.003193788481706411 + stake_value: (amount/lev * open_rate) + (amount/lev * open_rate * fee) + = (272.97543219/3 * 0.00001099) + (272.97543219/3 * 0.00001099 * 0.0025) + = 0.0010024999999225066 + total_profit = close_value - open_value + = 0.003193788481706411 - 0.0030074999997675204 + = 0.00018628848193889044 + total_profit_percentage = total_profit / stake_value + = 0.00018628848193889054 / 0.0010024999999225066 + = 0.18582392214792087 + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0009999999999226999, + open_rate=0.01, + amount=5, + open_date=datetime.utcnow() - timedelta(hours=4, minutes=55), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPERDAY + ) + trade.open_order_id = 'something' + trade.update(limit_lev_buy_order) + assert trade._calc_open_trade_value() == 0.00300749999976752 + trade.update(limit_lev_sell_order) + + # Is slightly different due to compilation time changes. Interest depends on time + assert round(trade.calc_close_trade_value(), 11) == round(0.003193788481706411, 11) + # Profit in BTC + assert round(trade.calc_profit(), 8) == round(0.00018628848193889054, 8) + # Profit in percent + assert round(trade.calc_profit_ratio(), 8) == round(0.18582392214792087, 8) + + +@pytest.mark.usefixtures("init_persistence") +def test_trade_close_lev(fee): + """ + 5 hour leveraged market trade on Kraken at 3x leverage + fee: 0.25% base + interest_rate: 0.05% per 4 hrs + open_rate: 0.1 base + close_rate: 0.2 base + amount: 5 * leverage(3) = 15 crypto + stake_amount: 0.5 + borrowed: 1 base + time-periods: 5/4 periods of 4hrs + interest: borrowed * interest_rate * ceil(1 + time-periods) + = 1 * 0.0005 * ceil(9/4) = 0.0015 crypto + open_value: (amount * open_rate) + (amount * open_rate * fee) + = (15 * 0.1) + (15 * 0.1 * 0.0025) + = 1.50375 + close_value: (amount * close_rate) + (amount * close_rate * fee) - interest + = (15 * 0.2) - (15 * 0.2 * 0.0025) - 0.0015 + = 2.991 + total_profit = close_value - open_value + = 2.991 - 1.50375 + = 1.4872500000000002 + total_profit_ratio = ((close_value/open_value) - 1) * leverage + = ((2.991/1.50375) - 1) * 3 + = 2.96708229426434 + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.5, + open_rate=0.1, + amount=15, + is_open=True, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_date=datetime.utcnow() - timedelta(hours=4, minutes=55), + exchange='kraken', + leverage=3.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPER4 + ) + assert trade.close_profit is None + assert trade.close_date is None + assert trade.is_open is True + trade.close(0.2) + assert trade.is_open is False + assert trade.close_profit == round(2.96708229426434, 8) + assert trade.close_date is not None + + # TODO-mg: Remove these comments probably + # new_date = arrow.Arrow(2020, 2, 2, 15, 6, 1).datetime, + # assert trade.close_date != new_date + # # Close should NOT update close_date if the trade has been closed already + # assert trade.is_open is False + # trade.close_date = new_date + # trade.close(0.02) + # assert trade.close_date == new_date + + +@pytest.mark.usefixtures("init_persistence") +def test_calc_close_trade_price_lev(market_lev_buy_order, market_lev_sell_order, fee): + """ + 10 minute leveraged market trade on Kraken at 3x leverage + Short trade + fee: 0.25% base + interest_rate: 0.05% per 4 hrs + open_rate: 0.00004099 base + close_rate: 0.00004173 base + amount: 91.99181073 * leverage(3) = 275.97543219 crypto + stake_amount: 0.0037707443218227 + borrowed: 0.0075414886436454 base + time-periods: 10 minutes = 2 + interest: borrowed * interest_rate * time-periods + = 0.0075414886436454 * 0.0005 * 2 = 7.5414886436454e-06 crypto + open_value: (amount * open_rate) + (amount * open_rate * fee) + = (275.97543219 * 0.00004099) + (275.97543219 * 0.00004099 * 0.0025) + = 0.01134051354788177 + close_value: (amount_closed * close_rate) - (amount_closed * close_rate * fee) - interest + = (275.97543219 * 0.00001234) - (275.97543219 * 0.00001234 * 0.0025) - 7.5414886436454e-06 + = 0.0033894815024978933 + = (275.97543219 * 0.00001234) - (275.97543219 * 0.00001234 * 0.003) - 7.5414886436454e-06 + = 0.003387778734081281 + = (275.97543219 * 0.00004173) - (275.97543219 * 0.00004173 * 0.005) - 7.5414886436454e-06 + = 0.011451331022718612 + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0037707443218227, + amount=5, + open_rate=0.00004099, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + interest_rate=0.0005, + leverage=3.0, + exchange='kraken', + interest_mode=InterestMode.HOURSPER4 + ) + trade.open_order_id = 'close_trade' + trade.update(market_lev_buy_order) # Buy @ 0.00001099 + # Get the close rate price with a custom close rate and a regular fee rate + assert isclose(trade.calc_close_trade_value(rate=0.00001234), 0.0033894815024978933) + # Get the close rate price with a custom close rate and a custom fee rate + assert isclose(trade.calc_close_trade_value(rate=0.00001234, fee=0.003), 0.003387778734081281) + # Test when we apply a Sell order, and ask price with a custom fee rate + trade.update(market_lev_sell_order) + assert isclose(trade.calc_close_trade_value(fee=0.005), 0.011451331022718612) + + +@pytest.mark.usefixtures("init_persistence") +def test_update_limit_order_lev(limit_lev_buy_order, limit_lev_sell_order, fee, caplog): + """ + 10 minute leveraged limit trade on binance at 3x leverage + + Leveraged trade + fee: 0.25% base + interest_rate: 0.05% per day + open_rate: 0.00001099 base + close_rate: 0.00001173 base + amount: 272.97543219 crypto + stake_amount: 0.0009999999999226999 base + borrowed: 0.0019999999998453998 base + time-periods: 10 minutes(rounds up to 1/24 time-period of 1 day) + interest: borrowed * interest_rate * time-periods + = 0.0019999999998453998 * 0.0005 * 1/24 = 4.166666666344583e-08 base + open_value: (amount * open_rate) + (amount * open_rate * fee) + = (272.97543219 * 0.00001099) + (272.97543219 * 0.00001099 * 0.0025) + = 0.0030074999997675204 + stake_value = (amount/lev * open_rate) + (amount/lev * open_rate * fee) + = (272.97543219 * 0.00001099) + (272.97543219 * 0.00001099 * 0.0025) + = 0.0010024999999225066 + close_value: (amount_closed * close_rate) - (amount_closed * close_rate * fee) + = (272.97543219 * 0.00001173) - (272.97543219 * 0.00001173 * 0.0025) + = 0.003193996815039728 + stake_value: (amount/lev * open_rate) + (amount/lev * open_rate * fee) + = (272.97543219/3 * 0.00001099) + (272.97543219/3 * 0.00001099 * 0.0025) + = 0.0010024999999225066 + total_profit = close_value - open_value - interest + = 0.003193996815039728 - 0.0030074999997675204 - 4.166666666344583e-08 + = 0.00018645514860554435 + total_profit_percentage = total_profit / stake_value + = 0.00018645514860554435 / 0.0010024999999225066 + = 0.1859901731869899 + + """ + trade = Trade( + id=2, + pair='ETH/BTC', + stake_amount=0.0009999999999226999, + open_rate=0.01, + amount=5, + is_open=True, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + interest_rate=0.0005, + exchange='binance', + interest_mode=InterestMode.HOURSPERDAY + ) + # assert trade.open_order_id is None + assert trade.close_profit is None + assert trade.close_date is None + + # trade.open_order_id = 'something' + trade.update(limit_lev_buy_order) + # assert trade.open_order_id is None + assert trade.open_rate == 0.00001099 + assert trade.close_profit is None + assert trade.close_date is None + assert trade.borrowed == 0.0019999999998453998 + assert log_has_re(r"LIMIT_BUY has been fulfilled for Trade\(id=2, " + r"pair=ETH/BTC, amount=272.97543219, open_rate=0.00001099, open_since=.*\).", + caplog) + caplog.clear() + # trade.open_order_id = 'something' + trade.update(limit_lev_sell_order) + # assert trade.open_order_id is None + assert trade.close_rate == 0.00001173 + assert trade.close_profit == round(0.1859901731869899, 8) + assert trade.close_date is not None + assert log_has_re(r"LIMIT_SELL has been fulfilled for Trade\(id=2, " + r"pair=ETH/BTC, amount=272.97543219, open_rate=0.00001099, open_since=.*\).", + caplog) + + +@pytest.mark.usefixtures("init_persistence") +def test_update_market_order_lev(market_lev_buy_order, market_lev_sell_order, fee, caplog): + """ + 10 minute leveraged market trade on Kraken at 3x leverage + Short trade + fee: 0.25% base + interest_rate: 0.05% per 4 hrs + open_rate: 0.00004099 base + close_rate: 0.00004173 base + amount: = 275.97543219 crypto + stake_amount: 0.0037707443218227 + borrowed: 0.0075414886436454 base + interest: borrowed * interest_rate * 1+ceil(hours) + = 0.0075414886436454 * 0.0005 * (1+ceil(1)) = 7.5414886436454e-06 crypto + open_value: (amount * open_rate) + (amount * open_rate * fee) + = (275.97543219 * 0.00004099) + (275.97543219 * 0.00004099 * 0.0025) + = 0.01134051354788177 + close_value: (amount_closed * close_rate) - (amount_closed * close_rate * fee) - interest + = (275.97543219 * 0.00004173) - (275.97543219 * 0.00004173 * 0.0025) - 7.5414886436454e-06 + = 0.011480122159681833 + stake_value: (amount/lev * open_rate) + (amount/lev * open_rate * fee) + = (275.97543219/3 * 0.00004099) + (275.97543219/3 * 0.00004099 * 0.0025) + = 0.0037801711826272568 + total_profit = close_value - open_value + = 0.011480122159681833 - 0.01134051354788177 + = 0.00013960861180006392 + total_profit_percentage = ((close_value/open_value) - 1) * leverage + = ((0.011480122159681833 / 0.01134051354788177)-1) * 3 + = 0.036931822675563275 + """ + trade = Trade( + id=1, + pair='ETH/BTC', + stake_amount=0.0037707443218227, + amount=5, + open_rate=0.00004099, + is_open=True, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + interest_rate=0.0005, + exchange='kraken', + interest_mode=InterestMode.HOURSPER4 + ) + trade.open_order_id = 'something' + trade.update(market_lev_buy_order) + assert trade.leverage == 3.0 + assert trade.open_order_id is None + assert trade.open_rate == 0.00004099 + assert trade.close_profit is None + assert trade.close_date is None + assert trade.interest_rate == 0.0005 + # TODO: Uncomment the next assert and make it work. + # The logger also has the exact same but there's some spacing in there + assert log_has_re(r"MARKET_BUY has been fulfilled for Trade\(id=1, " + r"pair=ETH/BTC, amount=275.97543219, open_rate=0.00004099, open_since=.*\).", + caplog) + caplog.clear() + trade.is_open = True + trade.open_order_id = 'something' + trade.update(market_lev_sell_order) + assert trade.open_order_id is None + assert trade.close_rate == 0.00004173 + assert trade.close_profit == round(0.036931822675563275, 8) + assert trade.close_date is not None + # TODO: The amount should maybe be the opening amount + the interest + # TODO: Uncomment the next assert and make it work. + # The logger also has the exact same but there's some spacing in there + assert log_has_re(r"MARKET_SELL has been fulfilled for Trade\(id=1, " + r"pair=ETH/BTC, amount=275.97543219, open_rate=0.00004099, open_since=.*\).", + caplog) + + +@pytest.mark.usefixtures("init_persistence") +def test_calc_close_trade_price_exception_lev(limit_lev_buy_order, fee): + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + open_rate=0.1, + amount=5, + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + interest_rate=0.0005, + leverage=3.0, + interest_mode=InterestMode.HOURSPERDAY + ) + trade.open_order_id = 'something' + trade.update(limit_lev_buy_order) + assert trade.calc_close_trade_value() == 0.0 + + +@pytest.mark.usefixtures("init_persistence") +def test_calc_profit_lev(market_lev_buy_order, market_lev_sell_order, fee): + """ + Leveraged trade on Kraken at 3x leverage + fee: 0.25% base or 0.3% + interest_rate: 0.05%, 0.25% per 4 hrs + open_rate: 0.00004099 base + close_rate: 0.00004173 base + stake_amount: 0.0037707443218227 + amount: 91.99181073 * leverage(3) = 275.97543219 crypto + borrowed: 0.0075414886436454 base + hours: 1/6, 5 hours + + interest: borrowed * interest_rate * ceil(1+hours/4) + = 0.0075414886436454 * 0.0005 * ceil(1+((1/6)/4)) = 7.5414886436454e-06 crypto + = 0.0075414886436454 * 0.00025 * ceil(1+(5/4)) = 5.65611648273405e-06 crypto + = 0.0075414886436454 * 0.0005 * ceil(1+(5/4)) = 1.13122329654681e-05 crypto + = 0.0075414886436454 * 0.00025 * ceil(1+((1/6)/4)) = 3.7707443218227e-06 crypto + open_value: (amount * open_rate) + (amount * open_rate * fee) + = (275.97543219 * 0.00004099) + (275.97543219 * 0.00004099 * 0.0025) + = 0.01134051354788177 + close_value: (amount_closed * close_rate) - (amount_closed * close_rate * fee) - interest + (275.97543219 * 0.00005374) - (275.97543219 * 0.00005374 * 0.0025) - 7.5414886436454e-06 + = 0.014786300937932227 + (275.97543219 * 0.00000437) - (275.97543219 * 0.00000437 * 0.0025) - 5.65611648273405e-06 + = 0.0011973414905908902 + (275.97543219 * 0.00005374) - (275.97543219 * 0.00005374 * 0.003) - 1.13122329654681e-05 + = 0.01477511473374746 + (275.97543219 * 0.00000437) - (275.97543219 * 0.00000437 * 0.003) - 3.7707443218227e-06 + = 0.0011986238564324662 + stake_value: (amount/lev * open_rate) + (amount/lev * open_rate * fee) + = (275.97543219/3 * 0.00004099) + (275.97543219/3 * 0.00004099 * 0.0025) + = 0.0037801711826272568 + total_profit = close_value - open_value + = 0.014786300937932227 - 0.01134051354788177 = 0.0034457873900504577 + = 0.0011973414905908902 - 0.01134051354788177 = -0.01014317205729088 + = 0.01477511473374746 - 0.01134051354788177 = 0.00343460118586569 + = 0.0011986238564324662 - 0.01134051354788177 = -0.010141889691449303 + total_profit_percentage = ((close_value/open_value) - 1) * leverage + ((0.014786300937932227/0.01134051354788177) - 1) * 3 = 0.9115426851266561 + ((0.0011973414905908902/0.01134051354788177) - 1) * 3 = -2.683257336045103 + ((0.01477511473374746/0.01134051354788177) - 1) * 3 = 0.908583505860866 + ((0.0011986238564324662/0.01134051354788177) - 1) * 3 = -2.6829181011851926 + + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0037707443218227, + amount=5, + open_rate=0.00004099, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='kraken', + leverage=3.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPER4 + ) + trade.open_order_id = 'something' + trade.update(market_lev_buy_order) # Buy @ 0.00001099 + # Custom closing rate and regular fee rate + + # Higher than open rate + assert trade.calc_profit(rate=0.00005374, interest_rate=0.0005) == round( + 0.0034457873900504577, 8) + assert trade.calc_profit_ratio( + rate=0.00005374, interest_rate=0.0005) == round(0.9115426851266561, 8) + + # Lower than open rate + trade.open_date = datetime.utcnow() - timedelta(hours=4, minutes=55) + assert trade.calc_profit( + rate=0.00000437, interest_rate=0.00025) == round(-0.01014317205729088, 8) + assert trade.calc_profit_ratio( + rate=0.00000437, interest_rate=0.00025) == round(-2.683257336045103, 8) + + # Custom closing rate and custom fee rate + # Higher than open rate + assert trade.calc_profit(rate=0.00005374, fee=0.003, + interest_rate=0.0005) == round(0.00343460118586569, 8) + assert trade.calc_profit_ratio(rate=0.00005374, fee=0.003, + interest_rate=0.0005) == round(0.908583505860866, 8) + + # Lower than open rate + trade.open_date = datetime.utcnow() - timedelta(hours=0, minutes=10) + assert trade.calc_profit(rate=0.00000437, fee=0.003, + interest_rate=0.00025) == round(-0.010141889691449303, 8) + assert trade.calc_profit_ratio(rate=0.00000437, fee=0.003, + interest_rate=0.00025) == round(-2.6829181011851926, 8) + + # Test when we apply a Sell order. Sell higher than open rate @ 0.00001173 + trade.update(market_lev_sell_order) + assert trade.calc_profit() == round(0.00013960861180006392, 8) + assert trade.calc_profit_ratio() == round(0.036931822675563275, 8) + + # Test with a custom fee rate on the close trade + # assert trade.calc_profit(fee=0.003) == 0.00006163 + # assert trade.calc_profit_ratio(fee=0.003) == 0.06147824 diff --git a/tests/persistence/test_persistence_short.py b/tests/persistence/test_persistence_short.py new file mode 100644 index 000000000..2a1e46615 --- /dev/null +++ b/tests/persistence/test_persistence_short.py @@ -0,0 +1,780 @@ +from datetime import datetime, timedelta +from math import isclose + +import arrow +import pytest + +from freqtrade.enums import InterestMode +from freqtrade.persistence import Trade, init_db +from tests.conftest import create_mock_trades_with_leverage, log_has_re + + +@pytest.mark.usefixtures("init_persistence") +def test_interest_kraken_short(market_short_order, fee): + """ + Market trade on Kraken at 3x and 8x leverage + Short trade + interest_rate: 0.05%, 0.25% per 4 hrs + open_rate: 0.00004173 base + close_rate: 0.00004099 base + amount: + 275.97543219 crypto + 459.95905365 crypto + borrowed: + 275.97543219 crypto + 459.95905365 crypto + time-periods: 10 minutes(rounds up to 1 time-period of 4hrs) + 5 hours = 5/4 + + interest: borrowed * interest_rate * ceil(1 + time-periods) + = 275.97543219 * 0.0005 * ceil(1+1) = 0.27597543219 crypto + = 275.97543219 * 0.00025 * ceil(9/4) = 0.20698157414249999 crypto + = 459.95905365 * 0.0005 * ceil(9/4) = 0.689938580475 crypto + = 459.95905365 * 0.00025 * ceil(1+1) = 0.229979526825 crypto + """ + + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=275.97543219, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='kraken', + is_short=True, + leverage=3.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPER4 + ) + + assert float(round(trade.calculate_interest(), 8)) == round(0.27597543219, 8) + trade.open_date = datetime.utcnow() - timedelta(hours=4, minutes=55) + assert float(round(trade.calculate_interest(interest_rate=0.00025), 8) + ) == round(0.20698157414249999, 8) + + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=459.95905365, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=4, minutes=55), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='kraken', + is_short=True, + leverage=5.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPER4 + ) + + assert float(round(trade.calculate_interest(), 8)) == round(0.689938580475, 8) + trade.open_date = datetime.utcnow() - timedelta(hours=0, minutes=10) + assert float(round(trade.calculate_interest(interest_rate=0.00025), 8) + ) == round(0.229979526825, 8) + + +@ pytest.mark.usefixtures("init_persistence") +def test_interest_binance_short(market_short_order, fee): + """ + Market trade on Binance at 3x and 5x leverage + Short trade + interest_rate: 0.05%, 0.25% per 1 day + open_rate: 0.00004173 base + close_rate: 0.00004099 base + amount: + 91.99181073 * leverage(3) = 275.97543219 crypto + 91.99181073 * leverage(5) = 459.95905365 crypto + borrowed: + 275.97543219 crypto + 459.95905365 crypto + time-periods: 10 minutes(rounds up to 1/24 time-period of 1 day) + 5 hours = 5/24 + + interest: borrowed * interest_rate * time-periods + = 275.97543219 * 0.0005 * 1/24 = 0.005749488170625 crypto + = 275.97543219 * 0.00025 * 5/24 = 0.0143737204265625 crypto + = 459.95905365 * 0.0005 * 5/24 = 0.047912401421875 crypto + = 459.95905365 * 0.00025 * 1/24 = 0.0047912401421875 crypto + """ + + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=275.97543219, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + is_short=True, + leverage=3.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPERDAY + ) + + assert float(round(trade.calculate_interest(), 8)) == 0.00574949 + trade.open_date = datetime.utcnow() - timedelta(hours=4, minutes=55) + assert float(round(trade.calculate_interest(interest_rate=0.00025), 8)) == 0.01437372 + + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=459.95905365, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=4, minutes=55), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + is_short=True, + leverage=5.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPERDAY + ) + + assert float(round(trade.calculate_interest(), 8)) == 0.04791240 + trade.open_date = datetime.utcnow() - timedelta(hours=0, minutes=10) + assert float(round(trade.calculate_interest(interest_rate=0.00025), 8)) == 0.00479124 + + +@ pytest.mark.usefixtures("init_persistence") +def test_calc_open_trade_value_short(market_short_order, fee): + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=5, + open_rate=0.00004173, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + interest_rate=0.0005, + is_short=True, + leverage=3.0, + exchange='kraken', + interest_mode=InterestMode.HOURSPER4 + ) + trade.open_order_id = 'open_trade' + trade.update(market_short_order) # Buy @ 0.00001099 + # Get the open rate price with the standard fee rate + assert trade._calc_open_trade_value() == 0.011487663648325479 + trade.fee_open = 0.003 + # Get the open rate price with a custom fee rate + assert trade._calc_open_trade_value() == 0.011481905420932834 + + +@ pytest.mark.usefixtures("init_persistence") +def test_update_open_order_short(limit_short_order): + trade = Trade( + pair='ETH/BTC', + stake_amount=1.00, + open_rate=0.01, + amount=5, + leverage=3.0, + fee_open=0.1, + fee_close=0.1, + interest_rate=0.0005, + is_short=True, + exchange='binance', + interest_mode=InterestMode.HOURSPERDAY + ) + assert trade.open_order_id is None + assert trade.close_profit is None + assert trade.close_date is None + limit_short_order['status'] = 'open' + trade.update(limit_short_order) + assert trade.open_order_id is None + assert trade.close_profit is None + assert trade.close_date is None + + +@ pytest.mark.usefixtures("init_persistence") +def test_calc_close_trade_price_exception_short(limit_short_order, fee): + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + open_rate=0.1, + amount=15.0, + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + interest_rate=0.0005, + leverage=3.0, + is_short=True, + interest_mode=InterestMode.HOURSPERDAY + ) + trade.open_order_id = 'something' + trade.update(limit_short_order) + assert trade.calc_close_trade_value() == 0.0 + + +@ pytest.mark.usefixtures("init_persistence") +def test_calc_close_trade_price_short(market_short_order, market_exit_short_order, fee): + """ + 10 minute short market trade on Kraken at 3x leverage + Short trade + fee: 0.25% base + interest_rate: 0.05% per 4 hrs + open_rate: 0.00004173 base + close_rate: 0.00001234 base + amount: = 275.97543219 crypto + borrowed: 275.97543219 crypto + hours: 10 minutes = 1/6 + interest: borrowed * interest_rate * ceil(1 + hours/4) + = 275.97543219 * 0.0005 * ceil(1 + ((1/6)/4)) = 0.27597543219 crypto + amount_closed: amount + interest = 275.97543219 + 0.27597543219 = 276.25140762219 + close_value: (amount_closed * close_rate) + (amount_closed * close_rate * fee) + = (276.25140762219 * 0.00004099) + (276.25140762219 * 0.00004099 * 0.005) + = 0.011380162924425737 + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=5, + open_rate=0.00001099, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + interest_rate=0.0005, + is_short=True, + leverage=3.0, + exchange='kraken', + interest_mode=InterestMode.HOURSPER4 + ) + trade.open_order_id = 'close_trade' + trade.update(market_short_order) # Buy @ 0.00001099 + # Get the close rate price with a custom close rate and a regular fee rate + assert isclose(trade.calc_close_trade_value(rate=0.00001234), 0.0034174647259) + # Get the close rate price with a custom close rate and a custom fee rate + assert isclose(trade.calc_close_trade_value(rate=0.00001234, fee=0.003), 0.0034191691971679986) + # Test when we apply a Sell order, and ask price with a custom fee rate + trade.update(market_exit_short_order) + assert isclose(trade.calc_close_trade_value(fee=0.005), 0.011380162924425737) + + +@ pytest.mark.usefixtures("init_persistence") +def test_calc_open_close_trade_price_short(limit_short_order, limit_exit_short_order, fee): + """ + 5 hour short trade on Binance + Short trade + fee: 0.25% base + interest_rate: 0.05% per day + open_rate: 0.00001173 base + close_rate: 0.00001099 base + amount: 90.99181073 crypto + borrowed: 90.99181073 crypto + stake_amount: 0.0010673339398629 + time-periods: 5 hours = 5/24 + interest: borrowed * interest_rate * time-periods + = 90.99181073 * 0.0005 * 5/24 = 0.009478313617708333 crypto + open_value: (amount * open_rate) - (amount * open_rate * fee) + = (90.99181073 * 0.00001173) - (90.99181073 * 0.00001173 * 0.0025) + = 0.0010646656050132426 + amount_closed: amount + interest = 90.99181073 + 0.009478313617708333 = 91.0012890436177 + close_value: (amount_closed * close_rate) + (amount_closed * close_rate * fee) + = (91.0012890436177 * 0.00001099) + (91.0012890436177 * 0.00001099 * 0.0025) + = 0.001002604427005832 + stake_value = (amount/lev * open_rate) - (amount/lev * open_rate * fee) + = 0.0010646656050132426 + total_profit = open_value - close_value + = 0.0010646656050132426 - 0.001002604427005832 + = 0.00006206117800741065 + total_profit_percentage = (close_value - open_value) / stake_value + = (0.0010646656050132426 - 0.001002604427005832)/0.0010646656050132426 + = 0.05829170935473088 + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0010673339398629, + open_rate=0.01, + amount=5, + open_date=datetime.utcnow() - timedelta(hours=4, minutes=55), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPERDAY + ) + trade.open_order_id = 'something' + trade.update(limit_short_order) + assert trade._calc_open_trade_value() == 0.0010646656050132426 + trade.update(limit_exit_short_order) + + # Is slightly different due to compilation time. Interest depends on time + assert round(trade.calc_close_trade_value(), 11) == round(0.001002604427005832, 11) + # Profit in BTC + assert round(trade.calc_profit(), 8) == round(0.00006206117800741065, 8) + # Profit in percent + assert round(trade.calc_profit_ratio(), 8) == round(0.05829170935473088, 8) + + +@ pytest.mark.usefixtures("init_persistence") +def test_trade_close_short(fee): + """ + Five hour short trade on Kraken at 3x leverage + Short trade + Exchange: Kraken + fee: 0.25% base + interest_rate: 0.05% per 4 hours + open_rate: 0.02 base + close_rate: 0.01 base + leverage: 3.0 + amount: 15 crypto + borrowed: 15 crypto + time-periods: 5 hours = 5/4 + + interest: borrowed * interest_rate * time-periods + = 15 * 0.0005 * ceil(1 + 5/4) = 0.0225 crypto + open_value: (amount * open_rate) - (amount * open_rate * fee) + = (15 * 0.02) - (15 * 0.02 * 0.0025) + = 0.29925 + amount_closed: amount + interest = 15 + 0.009375 = 15.0225 + close_value: (amount_closed * close_rate) + (amount_closed * close_rate * fee) + = (15.0225 * 0.01) + (15.0225 * 0.01 * 0.0025) + = 0.15060056250000003 + total_profit = open_value - close_value + = 0.29925 - 0.15060056250000003 + = 0.14864943749999998 + total_profit_percentage = (1-(close_value/open_value)) * leverage + = (1 - (0.15060056250000003/0.29925)) * 3 + = 1.4902199248120298 + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.1, + open_rate=0.02, + amount=15, + is_open=True, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_date=datetime.utcnow() - timedelta(hours=4, minutes=55), + exchange='kraken', + is_short=True, + leverage=3.0, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPER4 + ) + assert trade.close_profit is None + assert trade.close_date is None + assert trade.is_open is True + trade.close(0.01) + assert trade.is_open is False + assert trade.close_profit == round(1.4902199248120298, 8) + assert trade.close_date is not None + + # TODO-mg: Remove these comments probably + # new_date = arrow.Arrow(2020, 2, 2, 15, 6, 1).datetime, + # assert trade.close_date != new_date + # # Close should NOT update close_date if the trade has been closed already + # assert trade.is_open is False + # trade.close_date = new_date + # trade.close(0.02) + # assert trade.close_date == new_date + + +@ pytest.mark.usefixtures("init_persistence") +def test_update_with_binance_short(limit_short_order, limit_exit_short_order, fee, caplog): + """ + 10 minute short limit trade on binance + + Short trade + fee: 0.25% base + interest_rate: 0.05% per day + open_rate: 0.00001173 base + close_rate: 0.00001099 base + amount: 90.99181073 crypto + stake_amount: 0.0010673339398629 base + borrowed: 90.99181073 crypto + time-periods: 10 minutes(rounds up to 1/24 time-period of 1 day) + interest: borrowed * interest_rate * time-periods + = 90.99181073 * 0.0005 * 1/24 = 0.0018956627235416667 crypto + open_value: (amount * open_rate) - (amount * open_rate * fee) + = 90.99181073 * 0.00001173 - 90.99181073 * 0.00001173 * 0.0025 + = 0.0010646656050132426 + amount_closed: amount + interest = 90.99181073 + 0.0018956627235416667 = 90.99370639272354 + close_value: (amount_closed * close_rate) + (amount_closed * close_rate * fee) + = (90.99370639272354 * 0.00001099) + (90.99370639272354 * 0.00001099 * 0.0025) + = 0.0010025208853391716 + total_profit = open_value - close_value + = 0.0010646656050132426 - 0.0010025208853391716 + = 0.00006214471967407108 + total_profit_percentage = (1 - (close_value/open_value)) * leverage + = (1 - (0.0010025208853391716/0.0010646656050132426)) * 1 + = 0.05837017687191848 + + """ + trade = Trade( + id=2, + pair='ETH/BTC', + stake_amount=0.0010673339398629, + open_rate=0.01, + amount=5, + is_open=True, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + # borrowed=90.99181073, + interest_rate=0.0005, + exchange='binance', + interest_mode=InterestMode.HOURSPERDAY + ) + # assert trade.open_order_id is None + assert trade.close_profit is None + assert trade.close_date is None + assert trade.borrowed == 0.0 + assert trade.is_short is None + # trade.open_order_id = 'something' + trade.update(limit_short_order) + # assert trade.open_order_id is None + assert trade.open_rate == 0.00001173 + assert trade.close_profit is None + assert trade.close_date is None + assert trade.borrowed == 90.99181073 + assert trade.is_short is True + assert log_has_re(r"LIMIT_SELL has been fulfilled for Trade\(id=2, " + r"pair=ETH/BTC, amount=90.99181073, open_rate=0.00001173, open_since=.*\).", + caplog) + caplog.clear() + # trade.open_order_id = 'something' + trade.update(limit_exit_short_order) + # assert trade.open_order_id is None + assert trade.close_rate == 0.00001099 + assert trade.close_profit == round(0.05837017687191848, 8) + assert trade.close_date is not None + assert log_has_re(r"LIMIT_BUY has been fulfilled for Trade\(id=2, " + r"pair=ETH/BTC, amount=90.99181073, open_rate=0.00001173, open_since=.*\).", + caplog) + + +@ pytest.mark.usefixtures("init_persistence") +def test_update_market_order_short( + market_short_order, + market_exit_short_order, + fee, + caplog +): + """ + 10 minute short market trade on Kraken at 3x leverage + Short trade + fee: 0.25% base + interest_rate: 0.05% per 4 hrs + open_rate: 0.00004173 base + close_rate: 0.00004099 base + amount: = 275.97543219 crypto + stake_amount: 0.0038388182617629 + borrowed: 275.97543219 crypto + time-periods: 10 minutes(rounds up to 1 time-period of 4hrs) + interest: borrowed * interest_rate * time-periods + = 275.97543219 * 0.0005 * 2 = 0.27597543219 crypto + open_value: (amount * open_rate) - (amount * open_rate * fee) + = 275.97543219 * 0.00004173 - 275.97543219 * 0.00004173 * 0.0025 + = 0.011487663648325479 + amount_closed: amount + interest = 275.97543219 + 0.27597543219 = 276.25140762219 + close_value: (amount_closed * close_rate) + (amount_closed * close_rate * fee) + = (276.25140762219 * 0.00004099) + (276.25140762219 * 0.00004099 * 0.0025) + = 0.0034174647259 + total_profit = open_value - close_value + = 0.011487663648325479 - 0.0034174647259 + = 0.00013580958689582596 + total_profit_percentage = total_profit / stake_amount + = (1 - (close_value/open_value)) * leverage + = (1 - (0.0034174647259/0.011487663648325479)) * 3 + = 0.03546663387440563 + """ + trade = Trade( + id=1, + pair='ETH/BTC', + stake_amount=0.0038388182617629, + amount=5, + open_rate=0.01, + is_open=True, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + interest_rate=0.0005, + exchange='kraken', + interest_mode=InterestMode.HOURSPER4 + ) + trade.open_order_id = 'something' + trade.update(market_short_order) + assert trade.leverage == 3.0 + assert trade.is_short is True + assert trade.open_order_id is None + assert trade.open_rate == 0.00004173 + assert trade.close_profit is None + assert trade.close_date is None + assert trade.interest_rate == 0.0005 + # The logger also has the exact same but there's some spacing in there + assert log_has_re(r"MARKET_SELL has been fulfilled for Trade\(id=1, " + r"pair=ETH/BTC, amount=275.97543219, open_rate=0.00004173, open_since=.*\).", + caplog) + caplog.clear() + trade.is_open = True + trade.open_order_id = 'something' + trade.update(market_exit_short_order) + assert trade.open_order_id is None + assert trade.close_rate == 0.00004099 + assert trade.close_profit == round(0.03546663387440563, 8) + assert trade.close_date is not None + # TODO-mg: The amount should maybe be the opening amount + the interest + # TODO-mg: Uncomment the next assert and make it work. + # The logger also has the exact same but there's some spacing in there + assert log_has_re(r"MARKET_BUY has been fulfilled for Trade\(id=1, " + r"pair=ETH/BTC, amount=275.97543219, open_rate=0.00004173, open_since=.*\).", + caplog) + + +@ pytest.mark.usefixtures("init_persistence") +def test_calc_profit_short(market_short_order, market_exit_short_order, fee): + """ + Market trade on Kraken at 3x leverage + Short trade + fee: 0.25% base or 0.3% + interest_rate: 0.05%, 0.025% per 4 hrs + open_rate: 0.00004173 base + close_rate: 0.00004099 base + stake_amount: 0.0038388182617629 + amount: = 275.97543219 crypto + borrowed: 275.97543219 crypto + time-periods: 10 minutes(rounds up to 1 time-period of 4hrs) + 5 hours = 5/4 + + interest: borrowed * interest_rate * time-periods + = 275.97543219 * 0.0005 * ceil(1+1) = 0.27597543219 crypto + = 275.97543219 * 0.00025 * ceil(1+5/4) = 0.20698157414249999 crypto + = 275.97543219 * 0.0005 * ceil(1+5/4) = 0.41396314828499997 crypto + = 275.97543219 * 0.00025 * ceil(1+1) = 0.27597543219 crypto + = 275.97543219 * 0.00025 * ceil(1+1) = 0.27597543219 crypto + open_value: (amount * open_rate) - (amount * open_rate * fee) + = (275.97543219 * 0.00004173) - (275.97543219 * 0.00004173 * 0.0025) + = 0.011487663648325479 + amount_closed: amount + interest + = 275.97543219 + 0.27597543219 = 276.25140762219 + = 275.97543219 + 0.20698157414249999 = 276.1824137641425 + = 275.97543219 + 0.41396314828499997 = 276.389395338285 + = 275.97543219 + 0.27597543219 = 276.25140762219 + close_value: (amount_closed * close_rate) + (amount_closed * close_rate * fee) + (276.25140762219 * 0.00004374) + (276.25140762219 * 0.00004374 * 0.0025) + = 0.012113444660818078 + (276.1824137641425 * 0.00000437) + (276.1824137641425 * 0.00000437 * 0.0025) + = 0.0012099344410196758 + (276.389395338285 * 0.00004374) + (276.389395338285 * 0.00004374 * 0.003) + = 0.012125539968552874 + (276.25140762219 * 0.00000437) + (276.25140762219 * 0.00000437 * 0.003) + = 0.0012102354919246037 + (276.25140762219 * 0.00004099) + (276.25140762219 * 0.00004099 * 0.0025) + = 0.011351854061429653 + total_profit = open_value - close_value + = 0.011487663648325479 - 0.012113444660818078 = -0.0006257810124925996 + = 0.011487663648325479 - 0.0012099344410196758 = 0.010277729207305804 + = 0.011487663648325479 - 0.012125539968552874 = -0.0006378763202273957 + = 0.011487663648325479 - 0.0012102354919246037 = 0.010277428156400875 + = 0.011487663648325479 - 0.011351854061429653 = 0.00013580958689582596 + total_profit_percentage = (1-(close_value/open_value)) * leverage + (1-(0.012113444660818078 /0.011487663648325479))*3 = -0.16342252828332549 + (1-(0.0012099344410196758/0.011487663648325479))*3 = 2.6840259748040123 + (1-(0.012125539968552874 /0.011487663648325479))*3 = -0.16658121435868578 + (1-(0.0012102354919246037/0.011487663648325479))*3 = 2.68394735544829 + (1-(0.011351854061429653/0.011487663648325479))*3 = 0.03546663387440563 + """ + trade = Trade( + pair='ETH/BTC', + stake_amount=0.0038388182617629, + amount=5, + open_rate=0.00001099, + open_date=datetime.utcnow() - timedelta(hours=0, minutes=10), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='kraken', + is_short=True, + interest_rate=0.0005, + interest_mode=InterestMode.HOURSPER4 + ) + trade.open_order_id = 'something' + trade.update(market_short_order) # Buy @ 0.00001099 + # Custom closing rate and regular fee rate + + # Higher than open rate + assert trade.calc_profit( + rate=0.00004374, interest_rate=0.0005) == round(-0.0006257810124925996, 8) + assert trade.calc_profit_ratio( + rate=0.00004374, interest_rate=0.0005) == round(-0.16342252828332549, 8) + + # Lower than open rate + trade.open_date = datetime.utcnow() - timedelta(hours=4, minutes=55) + assert trade.calc_profit(rate=0.00000437, interest_rate=0.00025) == round( + 0.010277729207305804, 8) + assert trade.calc_profit_ratio( + rate=0.00000437, interest_rate=0.00025) == round(2.6840259748040123, 8) + + # Custom closing rate and custom fee rate + # Higher than open rate + assert trade.calc_profit(rate=0.00004374, fee=0.003, + interest_rate=0.0005) == round(-0.0006378763202273957, 8) + assert trade.calc_profit_ratio(rate=0.00004374, fee=0.003, + interest_rate=0.0005) == round(-0.16658121435868578, 8) + + # Lower than open rate + trade.open_date = datetime.utcnow() - timedelta(hours=0, minutes=10) + assert trade.calc_profit(rate=0.00000437, fee=0.003, + interest_rate=0.00025) == round(0.010277428156400875, 8) + assert trade.calc_profit_ratio(rate=0.00000437, fee=0.003, + interest_rate=0.00025) == round(2.68394735544829, 8) + + # Test when we apply a exit short order. + trade.update(market_exit_short_order) + assert trade.calc_profit(rate=0.00004099) == round(0.00013580958689582596, 8) + assert trade.calc_profit_ratio() == round(0.03546663387440563, 8) + + # Test with a custom fee rate on the close trade + # assert trade.calc_profit(fee=0.003) == 0.00006163 + # assert trade.calc_profit_ratio(fee=0.003) == 0.06147824 + + +def test_adjust_stop_loss_short(fee): + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=5, + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='binance', + open_rate=1, + max_rate=1, + is_short=True, + interest_mode=InterestMode.HOURSPERDAY + ) + trade.adjust_stop_loss(trade.open_rate, 0.05, True) + assert trade.stop_loss == 1.05 + assert trade.stop_loss_pct == 0.05 + assert trade.initial_stop_loss == 1.05 + assert trade.initial_stop_loss_pct == 0.05 + # Get percent of profit with a lower rate + trade.adjust_stop_loss(1.04, 0.05) + assert trade.stop_loss == 1.05 + assert trade.stop_loss_pct == 0.05 + assert trade.initial_stop_loss == 1.05 + assert trade.initial_stop_loss_pct == 0.05 + # Get percent of profit with a custom rate (Higher than open rate) + trade.adjust_stop_loss(0.7, 0.1) + # If the price goes down to 0.7, with a trailing stop of 0.1, + # the new stoploss at 0.1 above 0.7 would be 0.7*0.1 higher + assert round(trade.stop_loss, 8) == 0.77 + assert trade.stop_loss_pct == 0.1 + assert trade.initial_stop_loss == 1.05 + assert trade.initial_stop_loss_pct == 0.05 + # current rate lower again ... should not change + trade.adjust_stop_loss(0.8, -0.1) + assert round(trade.stop_loss, 8) == 0.77 + assert trade.initial_stop_loss == 1.05 + assert trade.initial_stop_loss_pct == 0.05 + # current rate higher... should raise stoploss + trade.adjust_stop_loss(0.6, -0.1) + assert round(trade.stop_loss, 8) == 0.66 + assert trade.initial_stop_loss == 1.05 + assert trade.initial_stop_loss_pct == 0.05 + # Initial is true but stop_loss set - so doesn't do anything + trade.adjust_stop_loss(0.3, -0.1, True) + assert round(trade.stop_loss, 8) == 0.66 # TODO-mg: What is this test? + assert trade.initial_stop_loss == 1.05 + assert trade.initial_stop_loss_pct == 0.05 + assert trade.stop_loss_pct == 0.1 + trade.set_liquidation_price(0.63) + trade.adjust_stop_loss(0.59, -0.1) + assert trade.stop_loss == 0.63 + assert trade.liquidation_price == 0.63 + + # TODO-mg: Do a test with a trade that has a liquidation price + + +@ pytest.mark.usefixtures("init_persistence") +@ pytest.mark.parametrize('use_db', [True, False]) +def test_get_open_short(fee, use_db): + Trade.use_db = use_db + Trade.reset_trades() + create_mock_trades_with_leverage(fee, use_db) + assert len(Trade.get_open_trades()) == 5 + Trade.use_db = True + + +def test_stoploss_reinitialization_short(default_conf, fee): + init_db(default_conf['db_url']) + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + fee_open=fee.return_value, + open_date=arrow.utcnow().shift(hours=-2).datetime, + amount=10, + fee_close=fee.return_value, + exchange='binance', + open_rate=1, + max_rate=1, + is_short=True, + leverage=3.0, + interest_mode=InterestMode.HOURSPERDAY + ) + trade.adjust_stop_loss(trade.open_rate, -0.05, True) + assert trade.stop_loss == 1.05 + assert trade.stop_loss_pct == 0.05 + assert trade.initial_stop_loss == 1.05 + assert trade.initial_stop_loss_pct == 0.05 + Trade.query.session.add(trade) + # Lower stoploss + Trade.stoploss_reinitialization(-0.06) + trades = Trade.get_open_trades() + assert len(trades) == 1 + trade_adj = trades[0] + assert trade_adj.stop_loss == 1.06 + assert trade_adj.stop_loss_pct == 0.06 + assert trade_adj.initial_stop_loss == 1.06 + assert trade_adj.initial_stop_loss_pct == 0.06 + # Raise stoploss + Trade.stoploss_reinitialization(-0.04) + trades = Trade.get_open_trades() + assert len(trades) == 1 + trade_adj = trades[0] + assert trade_adj.stop_loss == 1.04 + assert trade_adj.stop_loss_pct == 0.04 + assert trade_adj.initial_stop_loss == 1.04 + assert trade_adj.initial_stop_loss_pct == 0.04 + # Trailing stoploss + trade.adjust_stop_loss(0.98, -0.04) + assert trade_adj.stop_loss == 1.0192 + assert trade_adj.initial_stop_loss == 1.04 + Trade.stoploss_reinitialization(-0.04) + trades = Trade.get_open_trades() + assert len(trades) == 1 + trade_adj = trades[0] + # Stoploss should not change in this case. + assert trade_adj.stop_loss == 1.0192 + assert trade_adj.stop_loss_pct == 0.04 + assert trade_adj.initial_stop_loss == 1.04 + assert trade_adj.initial_stop_loss_pct == 0.04 + # Stoploss can't go above liquidation price + trade_adj.set_liquidation_price(1.0) + trade.adjust_stop_loss(0.97, -0.04) + assert trade_adj.stop_loss == 1.0 + assert trade_adj.stop_loss == 1.0 + + +@ pytest.mark.usefixtures("init_persistence") +@ pytest.mark.parametrize('use_db', [True, False]) +def test_total_open_trades_stakes_short(fee, use_db): + Trade.use_db = use_db + Trade.reset_trades() + res = Trade.total_open_trades_stakes() + assert res == 0 + create_mock_trades_with_leverage(fee, use_db) + res = Trade.total_open_trades_stakes() + assert res == 15.133 + Trade.use_db = True + + +@ pytest.mark.usefixtures("init_persistence") +def test_get_best_pair_short(fee): + res = Trade.get_best_pair() + assert res is None + create_mock_trades_with_leverage(fee) + res = Trade.get_best_pair() + assert len(res) == 2 + assert res[0] == 'DOGE/BTC' + assert res[1] == 0.1713156134055116 diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index ae8f6e958..b15126a33 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -79,7 +79,8 @@ def whitelist_conf_agefilter(default_conf): }, { "method": "AgeFilter", - "min_days_listed": 2 + "min_days_listed": 2, + "max_days_listed": 100 } ] return default_conf @@ -302,7 +303,7 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): # No pair for ETH, all handlers ([{"method": "StaticPairList"}, {"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, - {"method": "AgeFilter", "min_days_listed": 2}, + {"method": "AgeFilter", "min_days_listed": 2, "max_days_listed": None}, {"method": "PrecisionFilter"}, {"method": "PriceFilter", "low_price_ratio": 0.03}, {"method": "SpreadFilter", "max_spread_ratio": 0.005}, @@ -310,12 +311,24 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): "ETH", []), # AgeFilter and VolumePairList (require 2 days only, all should pass age test) ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, - {"method": "AgeFilter", "min_days_listed": 2}], + {"method": "AgeFilter", "min_days_listed": 2, "max_days_listed": 100}], "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC', 'HOT/BTC']), # AgeFilter and VolumePairList (require 10 days, all should fail age test) ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, - {"method": "AgeFilter", "min_days_listed": 10}], + {"method": "AgeFilter", "min_days_listed": 10, "max_days_listed": None}], "BTC", []), + # AgeFilter and VolumePairList (all pair listed > 2, all should fail age test) + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "AgeFilter", "min_days_listed": 1, "max_days_listed": 2}], + "BTC", []), + # AgeFilter and VolumePairList LTC/BTC has 6 candles - removes all + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "AgeFilter", "min_days_listed": 4, "max_days_listed": 5}], + "BTC", []), + # AgeFilter and VolumePairList LTC/BTC has 6 candles - passes + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "AgeFilter", "min_days_listed": 4, "max_days_listed": 10}], + "BTC", ["LTC/BTC"]), # Precisionfilter and quote volume ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, {"method": "PrecisionFilter"}], @@ -417,7 +430,19 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): ([{"method": "StaticPairList"}, {"method": "VolatilityFilter", "lookback_days": 3, "min_volatility": 0.002, "max_volatility": 0.004, "refresh_period": 1440}], - "BTC", ['ETH/BTC', 'TKN/BTC']) + "BTC", ['ETH/BTC', 'TKN/BTC']), + # VolumePairList with no offset = unchanged pairlist + ([{"method": "VolumePairList", "number_assets": 20, "sort_key": "quoteVolume"}, + {"method": "OffsetFilter", "offset": 0}], + "USDT", ['ETH/USDT', 'NANO/USDT', 'ADAHALF/USDT', 'ADADOUBLE/USDT']), + # VolumePairList with offset = 2 + ([{"method": "VolumePairList", "number_assets": 20, "sort_key": "quoteVolume"}, + {"method": "OffsetFilter", "offset": 2}], + "USDT", ['ADAHALF/USDT', 'ADADOUBLE/USDT']), + # VolumePairList with higher offset, than total pairlist + ([{"method": "VolumePairList", "number_assets": 20, "sort_key": "quoteVolume"}, + {"method": "OffsetFilter", "offset": 100}], + "USDT", []) ]) def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, tickers, ohlcv_history, pairlists, base_currency, @@ -431,7 +456,7 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t ohlcv_data = { ('ETH/BTC', '1d'): ohlcv_history, ('TKN/BTC', '1d'): ohlcv_history, - ('LTC/BTC', '1d'): ohlcv_history, + ('LTC/BTC', '1d'): ohlcv_history.append(ohlcv_history), ('XRP/BTC', '1d'): ohlcv_history, ('HOT/BTC', '1d'): ohlcv_history_high_vola, } @@ -480,9 +505,13 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t for pairlist in pairlists: if pairlist['method'] == 'AgeFilter' and pairlist['min_days_listed'] and \ - len(ohlcv_history) <= pairlist['min_days_listed']: + len(ohlcv_history) < pairlist['min_days_listed']: assert log_has_re(r'^Removed .* from whitelist, because age .* is less than ' r'.* day.*', caplog) + if pairlist['method'] == 'AgeFilter' and pairlist['max_days_listed'] and \ + len(ohlcv_history) > pairlist['max_days_listed']: + assert log_has_re(r'^Removed .* from whitelist, because age .* is less than ' + r'.* day.* or more than .* day', caplog) if pairlist['method'] == 'PrecisionFilter' and whitelist_result: assert log_has_re(r'^Removed .* from whitelist, because stop price .* ' r'would be <= stop limit.*', caplog) @@ -507,6 +536,105 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t assert log_has_re(r'^Removed .* from whitelist, because volatility.*$', caplog) +@pytest.mark.parametrize("pairlists,base_currency,volumefilter_result", [ + # default refresh of 1800 to small for daily candle lookback + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", + "lookback_days": 1}], + "BTC", "default_refresh_too_short"), # OperationalException expected + # ambigous configuration with lookback days and period + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", + "lookback_days": 1, "lookback_period": 1}], + "BTC", "lookback_days_and_period"), # OperationalException expected + # negative lookback period + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", + "lookback_timeframe": "1d", "lookback_period": -1}], + "BTC", "lookback_period_negative"), # OperationalException expected + # lookback range exceedes exchange limit + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", + "lookback_timeframe": "1m", "lookback_period": 2000, "refresh_period": 3600}], + "BTC", 'lookback_exceeds_exchange_request_size'), # OperationalException expected + # expecing pairs as given + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", + "lookback_timeframe": "1d", "lookback_period": 1, "refresh_period": 86400}], + "BTC", ['HOT/BTC', 'LTC/BTC', 'ETH/BTC', 'TKN/BTC', 'XRP/BTC']), + # expecting pairs from default tickers, because 1h candles are not available + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", + "lookback_timeframe": "1h", "lookback_period": 2, "refresh_period": 3600}], + "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'HOT/BTC', 'FUEL/BTC']), +]) +def test_VolumePairList_range(mocker, whitelist_conf, shitcoinmarkets, tickers, ohlcv_history, + pairlists, base_currency, volumefilter_result, caplog) -> None: + whitelist_conf['pairlists'] = pairlists + whitelist_conf['stake_currency'] = base_currency + + ohlcv_history_high_vola = ohlcv_history.copy() + ohlcv_history_high_vola.loc[ohlcv_history_high_vola.index == 1, 'close'] = 0.00090 + + # create candles for medium overall volume with last candle high volume + ohlcv_history_medium_volume = ohlcv_history.copy() + ohlcv_history_medium_volume.loc[ohlcv_history_medium_volume.index == 2, 'volume'] = 5 + + # create candles for high volume with all candles high volume + ohlcv_history_high_volume = ohlcv_history.copy() + ohlcv_history_high_volume.loc[:, 'volume'] = 10 + + ohlcv_data = { + ('ETH/BTC', '1d'): ohlcv_history, + ('TKN/BTC', '1d'): ohlcv_history, + ('LTC/BTC', '1d'): ohlcv_history_medium_volume, + ('XRP/BTC', '1d'): ohlcv_history_high_vola, + ('HOT/BTC', '1d'): ohlcv_history_high_volume, + } + + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + + if volumefilter_result == 'default_refresh_too_short': + with pytest.raises(OperationalException, + match=r'Refresh period of [0-9]+ seconds is smaller than one timeframe ' + r'of [0-9]+.*\. Please adjust refresh_period to at least [0-9]+ ' + r'and restart the bot\.'): + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + return + elif volumefilter_result == 'lookback_days_and_period': + with pytest.raises(OperationalException, + match=r'Ambigous configuration: lookback_days and lookback_period both ' + r'set in pairlist config\..*'): + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + elif volumefilter_result == 'lookback_period_negative': + with pytest.raises(OperationalException, + match=r'VolumeFilter requires lookback_period to be >= 0'): + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + elif volumefilter_result == 'lookback_exceeds_exchange_request_size': + with pytest.raises(OperationalException, + match=r'VolumeFilter requires lookback_period to not exceed ' + r'exchange max request size \([0-9]+\)'): + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + else: + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_tickers=tickers, + markets=PropertyMock(return_value=shitcoinmarkets) + ) + + # remove ohlcv when looback_timeframe != 1d + # to enforce fallback to ticker data + if 'lookback_timeframe' in pairlists[0]: + if pairlists[0]['lookback_timeframe'] != '1d': + ohlcv_data = [] + + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + refresh_latest_ohlcv=MagicMock(return_value=ohlcv_data), + ) + + freqtrade.pairlists.refresh_pairlist() + whitelist = freqtrade.pairlists.whitelist + + assert isinstance(whitelist, list) + assert whitelist == volumefilter_result + + def test_PrecisionFilter_error(mocker, whitelist_conf) -> None: whitelist_conf['pairlists'] = [{"method": "StaticPairList"}, {"method": "PrecisionFilter"}] del whitelist_conf['stoploss'] @@ -650,6 +778,22 @@ def test_agefilter_min_days_listed_too_small(mocker, default_conf, markets, tick get_patched_freqtradebot(mocker, default_conf) +def test_agefilter_max_days_lower_than_min_days(mocker, default_conf, markets, tickers): + default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, + {'method': 'AgeFilter', 'min_days_listed': 3, + "max_days_listed": 2}] + + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + with pytest.raises(OperationalException, + match=r'AgeFilter max_days_listed <= min_days_listed not permitted'): + get_patched_freqtradebot(mocker, default_conf) + + def test_agefilter_min_days_listed_too_large(mocker, default_conf, markets, tickers): default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, {'method': 'AgeFilter', 'min_days_listed': 99999}] @@ -695,6 +839,18 @@ def test_agefilter_caching(mocker, markets, whitelist_conf_agefilter, tickers, o assert freqtrade.exchange.refresh_latest_ohlcv.call_count == previous_call_count + 1 +def test_OffsetFilter_error(mocker, whitelist_conf) -> None: + whitelist_conf['pairlists'] = ( + [{"method": "StaticPairList"}, {"method": "OffsetFilter", "offset": -1}] + ) + + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + + with pytest.raises(OperationalException, + match=r'OffsetFilter requires offset to be >= 0'): + PairListManager(MagicMock, whitelist_conf) + + def test_rangestabilityfilter_checks(mocker, default_conf, markets, tickers): default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, {'method': 'RangeStabilityFilter', 'lookback_days': 99999}] diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 1e7cfb102..4b76fd812 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -107,11 +107,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'stoploss_entry_dist_ratio': -0.10448878, 'open_order': None, 'exchange': 'binance', - 'leverage': 1.0, - 'borrowed': 0.0, - 'borrowed_currency': None, - 'collateral_currency': None, 'interest_rate': 0.0, 'liquidation_price': None, 'is_short': False, @@ -181,15 +177,10 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'stoploss_entry_dist_ratio': -0.10448878, 'open_order': None, 'exchange': 'binance', - 'leverage': 1.0, - 'borrowed': 0.0, - 'borrowed_currency': None, - 'collateral_currency': None, 'interest_rate': 0.0, 'liquidation_price': None, 'is_short': False, - } @@ -696,6 +687,7 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: 'filled': 0.0, } ), + _is_dry_limit_order_filled=MagicMock(return_value=True), get_fee=fee, ) mocker.patch('freqtrade.wallets.Wallets.get_free', return_value=1000) @@ -720,8 +712,8 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: assert msg == {'result': 'Created sell orders for all open trades.'} freqtradebot.enter_positions() - msg = rpc._rpc_forcesell('1') - assert msg == {'result': 'Created sell order for trade 1.'} + msg = rpc._rpc_forcesell('2') + assert msg == {'result': 'Created sell order for trade 2.'} freqtradebot.state = State.STOPPED with pytest.raises(RPCException, match=r'.*trader is not running*'): @@ -732,9 +724,11 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: freqtradebot.state = State.RUNNING assert cancel_order_mock.call_count == 0 + mocker.patch( + 'freqtrade.exchange.Exchange._is_dry_limit_order_filled', MagicMock(return_value=False)) freqtradebot.enter_positions() # make an limit-buy open trade - trade = Trade.query.filter(Trade.id == '1').first() + trade = Trade.query.filter(Trade.id == '3').first() filled_amount = trade.amount / 2 # Fetch order - it's open first, and closed after cancel_order is called. mocker.patch( @@ -755,7 +749,7 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: ) # check that the trade is called, which is done by ensuring exchange.cancel_order is called # and trade amount is updated - rpc._rpc_forcesell('1') + rpc._rpc_forcesell('3') assert cancel_order_mock.call_count == 1 assert trade.amount == filled_amount @@ -783,8 +777,8 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: } ) # check that the trade is called, which is done by ensuring exchange.cancel_order is called - msg = rpc._rpc_forcesell('2') - assert msg == {'result': 'Created sell order for trade 2.'} + msg = rpc._rpc_forcesell('4') + assert msg == {'result': 'Created sell order for trade 4.'} assert cancel_order_mock.call_count == 2 assert trade.amount == amount diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 8d48e2286..89da68da7 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -105,6 +105,15 @@ def test_api_ui_fallback(botclient): assert rc.status_code == 200 +def test_api_ui_version(botclient, mocker): + ftbot, client = botclient + + mocker.patch('freqtrade.commands.deploy_commands.read_ui_version', return_value='0.1.2') + rc = client_get(client, "/ui_version") + assert rc.status_code == 200 + assert rc.json()['version'] == '0.1.2' + + def test_api_auth(): with pytest.raises(ValueError): create_token({'identity': {'u': 'Freqtrade'}}, 'secret1234', token_type="NotATokenType") @@ -996,7 +1005,8 @@ def test_api_forcesell(botclient, mocker, ticker, fee, markets): get_balances=MagicMock(return_value=ticker), fetch_ticker=ticker, get_fee=fee, - markets=PropertyMock(return_value=markets) + markets=PropertyMock(return_value=markets), + _is_dry_limit_order_filled=MagicMock(return_value=False), ) patch_get_signal(ftbot, (True, False)) diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 39ef6a1ab..4784f1172 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -218,6 +218,7 @@ def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=True), ) status_table = MagicMock() mocker.patch.multiple( @@ -518,12 +519,15 @@ def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance, tick assert msg_mock.call_count == 1 assert '*BTC:*' in result assert '*ETH:*' not in result - assert '*USDT:*' in result - assert '*EUR:*' in result + assert '*USDT:*' not in result + assert '*EUR:*' not in result + assert '*LTC:*' in result + assert '*XRP:*' not in result assert 'Balance:' in result assert 'Est. BTC:' in result assert 'BTC: 12.00000000' in result - assert '*XRP:* not showing <0.0001 BTC amount' in result + assert "*3 Other Currencies (< 0.0001 BTC):*" in result + assert 'BTC: 0.00000309' in result def test_balance_handle_empty_response(default_conf, update, mocker) -> None: @@ -666,6 +670,7 @@ def test_telegram_forcesell_handle(default_conf, update, ticker, fee, 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=True), ) freqtradebot = FreqtradeBot(default_conf) @@ -724,6 +729,7 @@ def test_telegram_forcesell_down_handle(default_conf, update, ticker, fee, 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=True), ) freqtradebot = FreqtradeBot(default_conf) @@ -784,6 +790,7 @@ def test_forcesell_all_handle(default_conf, update, ticker, fee, mocker) -> None 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=True), ) default_conf['max_open_trades'] = 4 freqtradebot = FreqtradeBot(default_conf) @@ -800,9 +807,9 @@ def test_forcesell_all_handle(default_conf, update, ticker, fee, mocker) -> None context.args = ["all"] telegram._forcesell(update=update, context=context) - # Called for each trade 4 times - assert msg_mock.call_count == 12 - msg = msg_mock.call_args_list[2][0][0] + # Called for each trade 2 times + assert msg_mock.call_count == 8 + msg = msg_mock.call_args_list[1][0][0] assert { 'type': RPCMessageType.SELL, 'trade_id': 1, diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index 04d12a51f..62a638ed3 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -1,6 +1,7 @@ # pragma pylint: disable=missing-docstring, C0103 import logging from datetime import datetime, timedelta, timezone +from pathlib import Path from unittest.mock import MagicMock import arrow @@ -12,6 +13,7 @@ from freqtrade.data.dataprovider import DataProvider from freqtrade.data.history import load_data from freqtrade.enums import SellType from freqtrade.exceptions import OperationalException, StrategyError +from freqtrade.optimize.space import SKDecimal from freqtrade.persistence import PairLocks, Trade from freqtrade.resolvers import StrategyResolver from freqtrade.strategy.hyper import (BaseParameter, CategoricalParameter, DecimalParameter, @@ -657,17 +659,31 @@ def test_hyperopt_parameters(): assert list(intpar.range) == [0, 1, 2, 3, 4, 5] fltpar = RealParameter(low=0.0, high=5.5, default=1.0, space='buy') + assert fltpar.value == 1 assert isinstance(fltpar.get_space(''), Real) - assert fltpar.value == 1 - fltpar = DecimalParameter(low=0.0, high=5.5, default=1.0004, decimals=3, space='buy') - assert isinstance(fltpar.get_space(''), Integer) - assert fltpar.value == 1 + fltpar = DecimalParameter(low=0.0, high=0.5, default=0.14, decimals=1, space='buy') + assert fltpar.value == 0.1 + assert isinstance(fltpar.get_space(''), SKDecimal) + assert isinstance(fltpar.range, list) + assert len(list(fltpar.range)) == 1 + # Range contains ONLY the default / value. + assert list(fltpar.range) == [fltpar.value] + fltpar.in_space = True + assert len(list(fltpar.range)) == 6 + assert list(fltpar.range) == [0.0, 0.1, 0.2, 0.3, 0.4, 0.5] catpar = CategoricalParameter(['buy_rsi', 'buy_macd', 'buy_none'], default='buy_macd', space='buy') - assert isinstance(catpar.get_space(''), Categorical) assert catpar.value == 'buy_macd' + assert isinstance(catpar.get_space(''), Categorical) + assert isinstance(catpar.range, list) + assert len(list(catpar.range)) == 1 + # Range contains ONLY the default / value. + assert list(catpar.range) == [catpar.value] + catpar.in_space = True + assert len(list(catpar.range)) == 3 + assert list(catpar.range) == ['buy_rsi', 'buy_macd', 'buy_none'] def test_auto_hyperopt_interface(default_conf): @@ -692,3 +708,50 @@ def test_auto_hyperopt_interface(default_conf): with pytest.raises(OperationalException, match=r"Inconclusive parameter.*"): [x for x in strategy.detect_parameters('sell')] + + +def test_auto_hyperopt_interface_loadparams(default_conf, mocker, caplog): + default_conf.update({'strategy': 'HyperoptableStrategy'}) + del default_conf['stoploss'] + del default_conf['minimal_roi'] + mocker.patch.object(Path, 'is_file', MagicMock(return_value=True)) + mocker.patch.object(Path, 'open') + expected_result = { + "strategy_name": "HyperoptableStrategy", + "params": { + "stoploss": { + "stoploss": -0.05, + }, + "roi": { + "0": 0.2, + "1200": 0.01 + } + } + } + mocker.patch('freqtrade.strategy.hyper.json_load', return_value=expected_result) + PairLocks.timeframe = default_conf['timeframe'] + strategy = StrategyResolver.load_strategy(default_conf) + assert strategy.stoploss == -0.05 + assert strategy.minimal_roi == {0: 0.2, 1200: 0.01} + + expected_result = { + "strategy_name": "HyperoptableStrategy_No", + "params": { + "stoploss": { + "stoploss": -0.05, + }, + "roi": { + "0": 0.2, + "1200": 0.01 + } + } + } + + mocker.patch('freqtrade.strategy.hyper.json_load', return_value=expected_result) + with pytest.raises(OperationalException, match="Invalid parameter file provided."): + StrategyResolver.load_strategy(default_conf) + + mocker.patch('freqtrade.strategy.hyper.json_load', MagicMock(side_effect=ValueError())) + + StrategyResolver.load_strategy(default_conf) + assert log_has("Invalid parameter file format.", caplog) diff --git a/tests/strategy/test_strategy_loading.py b/tests/strategy/test_strategy_loading.py index bd192ecb5..115a2fbde 100644 --- a/tests/strategy/test_strategy_loading.py +++ b/tests/strategy/test_strategy_loading.py @@ -290,14 +290,12 @@ def test_strategy_override_use_sell_signal(caplog, default_conf): assert strategy.use_sell_signal assert isinstance(strategy.use_sell_signal, bool) # must be inserted to configuration - assert 'use_sell_signal' in default_conf['ask_strategy'] - assert default_conf['ask_strategy']['use_sell_signal'] + assert 'use_sell_signal' in default_conf + assert default_conf['use_sell_signal'] default_conf.update({ 'strategy': 'DefaultStrategy', - 'ask_strategy': { - 'use_sell_signal': False, - }, + 'use_sell_signal': False, }) strategy = StrategyResolver.load_strategy(default_conf) @@ -315,14 +313,12 @@ def test_strategy_override_use_sell_profit_only(caplog, default_conf): assert not strategy.sell_profit_only assert isinstance(strategy.sell_profit_only, bool) # must be inserted to configuration - assert 'sell_profit_only' in default_conf['ask_strategy'] - assert not default_conf['ask_strategy']['sell_profit_only'] + assert 'sell_profit_only' in default_conf + assert not default_conf['sell_profit_only'] default_conf.update({ 'strategy': 'DefaultStrategy', - 'ask_strategy': { - 'sell_profit_only': True, - }, + 'sell_profit_only': True, }) strategy = StrategyResolver.load_strategy(default_conf) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index c5d0cd908..8edd09c5a 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -878,15 +878,15 @@ def test_validate_tsl(default_conf): def test_validate_edge2(edge_conf): - edge_conf.update({"ask_strategy": { + edge_conf.update({ "use_sell_signal": True, - }}) + }) # Passes test validate_config_consistency(edge_conf) - edge_conf.update({"ask_strategy": { + edge_conf.update({ "use_sell_signal": False, - }}) + }) with pytest.raises(OperationalException, match="Edge requires `use_sell_signal` to be True, " "otherwise no sells will happen."): validate_config_consistency(edge_conf) @@ -935,6 +935,23 @@ def test_validate_protections(default_conf, protconf, expected): validate_config_consistency(conf) +def test_validate_ask_orderbook(default_conf, caplog) -> None: + conf = deepcopy(default_conf) + conf['ask_strategy']['use_order_book'] = True + conf['ask_strategy']['order_book_min'] = 2 + conf['ask_strategy']['order_book_max'] = 2 + + validate_config_consistency(conf) + assert log_has_re(r"DEPRECATED: Please use `order_book_top` instead of.*", caplog) + assert conf['ask_strategy']['order_book_top'] == 2 + + conf['ask_strategy']['order_book_max'] = 5 + + with pytest.raises(OperationalException, + match=r"Using order_book_max != order_book_min in ask_strategy.*"): + validate_config_consistency(conf) + + def test_load_config_test_comments() -> None: """ Load config with comments @@ -1111,11 +1128,17 @@ def test_pairlist_resolving_fallback(mocker): assert config['datadir'] == Path.cwd() / "user_data/data/binance" -@pytest.mark.skip(reason='Currently no deprecated / moved sections') -# The below is kept as a sample for the future. @pytest.mark.parametrize("setting", [ ("ask_strategy", "use_sell_signal", True, - "experimental", "use_sell_signal", False), + None, "use_sell_signal", False), + ("ask_strategy", "sell_profit_only", True, + None, "sell_profit_only", False), + ("ask_strategy", "sell_profit_offset", 0.1, + None, "sell_profit_offset", 0.01), + ("ask_strategy", "ignore_roi_if_buy_signal", True, + None, "ignore_roi_if_buy_signal", False), + ("ask_strategy", "ignore_buying_expired_candle_after", 5, + None, "ignore_buying_expired_candle_after", 6), ]) def test_process_temporary_deprecated_settings(mocker, default_conf, setting, caplog): patched_configuration_load_config_file(mocker, default_conf) @@ -1124,10 +1147,14 @@ def test_process_temporary_deprecated_settings(mocker, default_conf, setting, ca # (they may not exist in the config) default_conf[setting[0]] = {} default_conf[setting[3]] = {} - # Assign new setting - default_conf[setting[0]][setting[1]] = setting[2] + # Assign deprecated setting - default_conf[setting[3]][setting[4]] = setting[5] + default_conf[setting[0]][setting[1]] = setting[2] + # Assign new setting + if setting[3]: + default_conf[setting[3]][setting[4]] = setting[5] + else: + default_conf[setting[4]] = setting[5] # New and deprecated settings are conflicting ones with pytest.raises(OperationalException, match=r'DEPRECATED'): @@ -1136,13 +1163,19 @@ def test_process_temporary_deprecated_settings(mocker, default_conf, setting, ca caplog.clear() # Delete new setting - del default_conf[setting[0]][setting[1]] + if setting[3]: + del default_conf[setting[3]][setting[4]] + else: + del default_conf[setting[4]] process_temporary_deprecated_settings(default_conf) assert log_has_re('DEPRECATED', caplog) # The value of the new setting shall have been set to the # value of the deprecated one - assert default_conf[setting[0]][setting[1]] == setting[5] + if setting[3]: + assert default_conf[setting[3]][setting[4]] == setting[2] + else: + assert default_conf[setting[4]] == setting[2] @pytest.mark.parametrize("setting", [ @@ -1192,16 +1225,16 @@ def test_check_conflicting_settings(mocker, default_conf, caplog): # New and deprecated settings are conflicting ones with pytest.raises(OperationalException, match=r'DEPRECATED'): check_conflicting_settings(default_conf, - 'sectionA', 'new_setting', - 'sectionB', 'deprecated_setting') + 'sectionB', 'deprecated_setting', + 'sectionA', 'new_setting') caplog.clear() # Delete new setting (deprecated exists) del default_conf['sectionA']['new_setting'] check_conflicting_settings(default_conf, - 'sectionA', 'new_setting', - 'sectionB', 'deprecated_setting') + 'sectionB', 'deprecated_setting', + 'sectionA', 'new_setting') assert not log_has_re('DEPRECATED', caplog) assert 'new_setting' not in default_conf['sectionA'] @@ -1212,8 +1245,8 @@ def test_check_conflicting_settings(mocker, default_conf, caplog): # Delete deprecated setting del default_conf['sectionB']['deprecated_setting'] check_conflicting_settings(default_conf, - 'sectionA', 'new_setting', - 'sectionB', 'deprecated_setting') + 'sectionB', 'deprecated_setting', + 'sectionA', 'new_setting') assert not log_has_re('DEPRECATED', caplog) assert default_conf['sectionA']['new_setting'] == 'valA' @@ -1225,15 +1258,13 @@ def test_process_deprecated_setting(mocker, default_conf, caplog): # (they may not exist in the config) default_conf['sectionA'] = {} default_conf['sectionB'] = {} - # Assign new setting - default_conf['sectionA']['new_setting'] = 'valA' # Assign deprecated setting default_conf['sectionB']['deprecated_setting'] = 'valB' # Both new and deprecated settings exists process_deprecated_setting(default_conf, - 'sectionA', 'new_setting', - 'sectionB', 'deprecated_setting') + 'sectionB', 'deprecated_setting', + 'sectionA', 'new_setting') assert log_has_re('DEPRECATED', caplog) # The value of the new setting shall have been set to the # value of the deprecated one @@ -1244,8 +1275,8 @@ def test_process_deprecated_setting(mocker, default_conf, caplog): # Delete new setting (deprecated exists) del default_conf['sectionA']['new_setting'] process_deprecated_setting(default_conf, - 'sectionA', 'new_setting', - 'sectionB', 'deprecated_setting') + 'sectionB', 'deprecated_setting', + 'sectionA', 'new_setting') assert log_has_re('DEPRECATED', caplog) # The value of the new setting shall have been set to the # value of the deprecated one @@ -1258,11 +1289,21 @@ def test_process_deprecated_setting(mocker, default_conf, caplog): # Delete deprecated setting del default_conf['sectionB']['deprecated_setting'] process_deprecated_setting(default_conf, - 'sectionA', 'new_setting', - 'sectionB', 'deprecated_setting') + 'sectionB', 'deprecated_setting', + 'sectionA', 'new_setting') assert not log_has_re('DEPRECATED', caplog) assert default_conf['sectionA']['new_setting'] == 'valA' + caplog.clear() + # Test moving to root + default_conf['sectionB']['deprecated_setting2'] = "DeadBeef" + process_deprecated_setting(default_conf, + 'sectionB', 'deprecated_setting2', + None, 'new_setting') + + assert log_has_re('DEPRECATED', caplog) + assert default_conf['new_setting'] + def test_process_removed_setting(mocker, default_conf, caplog): patched_configuration_load_config_file(mocker, default_conf) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 66f87f7c9..99e11e893 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -304,6 +304,7 @@ def test_total_open_trades_stakes(mocker, default_conf, ticker, fee) -> None: 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=False), ) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) @@ -333,6 +334,7 @@ def test_create_trade(default_conf, ticker, limit_buy_order, fee, mocker) -> Non 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=False), ) # Save state of current whitelist @@ -2532,6 +2534,7 @@ def test_execute_sell_up(default_conf, ticker, fee, ticker_sell_up, mocker) -> N 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=False), ) patch_whitelist(mocker, default_conf) freqtrade = FreqtradeBot(default_conf) @@ -2595,6 +2598,7 @@ def test_execute_sell_down(default_conf, ticker, fee, ticker_sell_down, mocker) 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=False), ) patch_whitelist(mocker, default_conf) freqtrade = FreqtradeBot(default_conf) @@ -2647,6 +2651,7 @@ def test_execute_sell_down_stoploss_on_exchange_dry_run(default_conf, ticker, fe 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=False), ) patch_whitelist(mocker, default_conf) freqtrade = FreqtradeBot(default_conf) @@ -2749,6 +2754,7 @@ def test_execute_sell_with_stoploss_on_exchange(default_conf, ticker, fee, ticke price_to_precision=lambda s, x, y: y, stoploss=stoploss, cancel_stoploss_order=cancel_order, + _is_dry_limit_order_filled=MagicMock(side_effect=[True, False]), ) freqtrade = FreqtradeBot(default_conf) @@ -2791,6 +2797,7 @@ def test_may_execute_sell_after_stoploss_on_exchange_hit(default_conf, ticker, f get_fee=fee, amount_to_precision=lambda s, x, y: y, price_to_precision=lambda s, x, y: y, + _is_dry_limit_order_filled=MagicMock(side_effect=[False, True]), ) stoploss = MagicMock(return_value={ @@ -2859,6 +2866,7 @@ def test_execute_sell_market_order(default_conf, ticker, fee, 'freqtrade.exchange.Exchange', fetch_ticker=ticker, get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=False), ) patch_whitelist(mocker, default_conf) freqtrade = FreqtradeBot(default_conf) @@ -2952,11 +2960,11 @@ def test_sell_profit_only_enable_profit(default_conf, limit_buy_order, limit_buy buy=MagicMock(return_value=limit_buy_order_open), get_fee=fee, ) - default_conf['ask_strategy'] = { + default_conf.update({ 'use_sell_signal': True, 'sell_profit_only': True, 'sell_profit_offset': 0.1, - } + }) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) freqtrade.strategy.min_roi_reached = MagicMock(return_value=False) @@ -2969,7 +2977,7 @@ def test_sell_profit_only_enable_profit(default_conf, limit_buy_order, limit_buy patch_get_signal(freqtrade, value=(False, True)) assert freqtrade.handle_trade(trade) is False - freqtrade.config['ask_strategy']['sell_profit_offset'] = 0.0 + freqtrade.strategy.sell_profit_offset = 0.0 assert freqtrade.handle_trade(trade) is True assert trade.sell_reason == SellType.SELL_SIGNAL.value @@ -2989,10 +2997,10 @@ def test_sell_profit_only_disable_profit(default_conf, limit_buy_order, limit_bu buy=MagicMock(return_value=limit_buy_order_open), get_fee=fee, ) - default_conf['ask_strategy'] = { + default_conf.update({ 'use_sell_signal': True, 'sell_profit_only': False, - } + }) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) freqtrade.strategy.min_roi_reached = MagicMock(return_value=False) @@ -3020,10 +3028,10 @@ def test_sell_profit_only_enable_loss(default_conf, limit_buy_order, limit_buy_o buy=MagicMock(return_value=limit_buy_order_open), get_fee=fee, ) - default_conf['ask_strategy'] = { + default_conf.update({ 'use_sell_signal': True, 'sell_profit_only': True, - } + }) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) freqtrade.strategy.stop_loss_reached = MagicMock(return_value=SellCheckTuple( @@ -3050,10 +3058,10 @@ def test_sell_profit_only_disable_loss(default_conf, limit_buy_order, limit_buy_ buy=MagicMock(return_value=limit_buy_order_open), get_fee=fee, ) - default_conf['ask_strategy'] = { + default_conf.update({ 'use_sell_signal': True, 'sell_profit_only': False, - } + }) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) @@ -3201,9 +3209,8 @@ def test_ignore_roi_if_buy_signal(default_conf, limit_buy_order, limit_buy_order buy=MagicMock(return_value=limit_buy_order_open), get_fee=fee, ) - default_conf['ask_strategy'] = { - 'ignore_roi_if_buy_signal': True - } + default_conf['ignore_roi_if_buy_signal'] = True + freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) freqtrade.strategy.min_roi_reached = MagicMock(return_value=True) @@ -3464,6 +3471,7 @@ def test_disable_ignore_roi_if_buy_signal(default_conf, limit_buy_order, limit_b }), buy=MagicMock(return_value=limit_buy_order_open), get_fee=fee, + _is_dry_limit_order_filled=MagicMock(return_value=False), ) default_conf['ask_strategy'] = { 'ignore_roi_if_buy_signal': False @@ -3981,8 +3989,7 @@ def test_order_book_ask_strategy(default_conf, limit_buy_order_open, limit_buy_o mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', order_book_l2) default_conf['exchange']['name'] = 'binance' default_conf['ask_strategy']['use_order_book'] = True - default_conf['ask_strategy']['order_book_min'] = 1 - default_conf['ask_strategy']['order_book_max'] = 2 + default_conf['ask_strategy']['order_book_top'] = 1 default_conf['telegram']['enabled'] = False patch_RPCManager(mocker) patch_exchange(mocker) @@ -4018,7 +4025,8 @@ def test_order_book_ask_strategy(default_conf, limit_buy_order_open, limit_buy_o return_value={'bids': [[]], 'asks': [[]]}) with pytest.raises(PricingError): freqtrade.handle_trade(trade) - assert log_has('Sell Price at location 1 from orderbook could not be determined.', caplog) + assert log_has_re(r'Sell Price at location 1 from orderbook could not be determined\..*', + caplog) def test_startup_state(default_conf, mocker):