diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d187e650b..f1c11c58b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: strategy: matrix: os: [ ubuntu-20.04, ubuntu-22.04 ] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 @@ -127,7 +127,7 @@ jobs: strategy: matrix: os: [ macos-latest ] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 @@ -237,7 +237,7 @@ jobs: strategy: matrix: os: [ windows-latest ] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 @@ -448,7 +448,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.9" + python-version: "3.11" - name: Extract branch name shell: bash diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5bbf75965..4a05f7876 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: # stages: [push] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.5.0" + rev: "v1.5.1" hooks: - id: mypy exclude: build_helpers diff --git a/Dockerfile b/Dockerfile index b5f6f5d5e..7a4e85812 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.4-slim-bullseye as base +FROM python:3.11.5-slim-bullseye as base # Setup env ENV LANG C.UTF-8 diff --git a/README.md b/README.md index 57c4e3a52..0cacfe703 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Please find the complete documentation on the [freqtrade website](https://www.fr ## Features -- [x] **Based on Python 3.8+**: For botting on any operating system - Windows, macOS and Linux. +- [x] **Based on Python 3.9+**: For botting on any operating system - Windows, macOS and Linux. - [x] **Persistence**: Persistence is achieved through sqlite. - [x] **Dry-run**: Run the bot without paying money. - [x] **Backtesting**: Run a simulation of your buy/sell strategy. @@ -207,7 +207,7 @@ To run this bot we recommend you a cloud instance with a minimum of: ### Software requirements -- [Python >= 3.8](http://docs.python-guide.org/en/latest/starting/installation/) +- [Python >= 3.9](http://docs.python-guide.org/en/latest/starting/installation/) - [pip](https://pip.pypa.io/en/stable/installing/) - [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) - [TA-Lib](https://ta-lib.github.io/ta-lib-python/) diff --git a/build_helpers/TA_Lib-0.4.28-cp38-cp38-win_amd64.whl b/build_helpers/TA_Lib-0.4.28-cp38-cp38-win_amd64.whl deleted file mode 100644 index 34fecd677..000000000 Binary files a/build_helpers/TA_Lib-0.4.28-cp38-cp38-win_amd64.whl and /dev/null differ diff --git a/build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl b/build_helpers/pyarrow-13.0.0-cp39-cp39-linux_armv7l.whl similarity index 63% rename from build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl rename to build_helpers/pyarrow-13.0.0-cp39-cp39-linux_armv7l.whl index 55211ca01..6dcca7db8 100644 Binary files a/build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl and b/build_helpers/pyarrow-13.0.0-cp39-cp39-linux_armv7l.whl differ diff --git a/docs/configuration.md b/docs/configuration.md index 6c0795c67..d2519d58a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -613,6 +613,7 @@ Once you will be happy with your bot performance running in the Dry-run mode, yo * Orders are simulated, and will not be posted to the exchange. * Market orders fill based on orderbook volume the moment the order is placed. * Limit orders fill once the price reaches the defined level - or time out based on `unfilledtimeout` settings. +* Limit orders will be converted to market orders if they cross the price by more than 1%. * 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 kept open after bot restarts, with the assumption that they were not filled while being offline. diff --git a/docs/edge.md b/docs/edge.md index e92abf40f..bb702f202 100644 --- a/docs/edge.md +++ b/docs/edge.md @@ -2,6 +2,10 @@ The `Edge Positioning` module uses probability to calculate your win rate and risk reward ratio. It will use these statistics to control your strategy trade entry points, position size and, stoploss. +!!! Danger "Deprecated functionality" + `Edge positioning` (or short Edge) is currently in maintenance mode only (we keep existing functionality alive) and should be considered as deprecated. + It will currently not receive new features until either someone stepped forward to take up ownership of that module - or we'll decide to remove edge from freqtrade. + !!! Warning When using `Edge positioning` with a dynamic whitelist (VolumePairList), make sure to also use `AgeFilter` and set it to at least `calculate_since_number_of_days` to avoid problems with missing data. diff --git a/docs/freqai-reinforcement-learning.md b/docs/freqai-reinforcement-learning.md index df4508c86..c5cda3bc3 100644 --- a/docs/freqai-reinforcement-learning.md +++ b/docs/freqai-reinforcement-learning.md @@ -237,11 +237,10 @@ class MyCoolRLModel(ReinforcementLearner): Reinforcement Learning models benefit from tracking training metrics. FreqAI has integrated Tensorboard to allow users to track training and evaluation performance across all coins and across all retrainings. Tensorboard is activated via the following command: ```bash -cd freqtrade tensorboard --logdir user_data/models/unique-id ``` -where `unique-id` is the `identifier` set in the `freqai` configuration file. This command must be run in a separate shell to view the output in their browser at 127.0.0.1:6006 (6006 is the default port used by Tensorboard). +where `unique-id` is the `identifier` set in the `freqai` configuration file. This command must be run in a separate shell to view the output in the browser at 127.0.0.1:6006 (6006 is the default port used by Tensorboard). ![tensorboard](assets/tensorboard.jpg) diff --git a/docs/index.md b/docs/index.md index 77542ae78..190e7e3c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -83,7 +83,7 @@ To run this bot we recommend you a linux cloud instance with a minimum of: Alternatively -- Python 3.8+ +- Python 3.9+ - pip (pip3) - git - TA-Lib diff --git a/docs/installation.md b/docs/installation.md index a6bfaccf0..a87a3ff4e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -24,7 +24,7 @@ The easiest way to install and run Freqtrade is to clone the bot Github reposito The `stable` branch contains the code of the last release (done usually once per month on an approximately one week old snapshot of the `develop` branch to prevent packaging bugs, so potentially it's more stable). !!! Note - Python3.8 or higher and the corresponding `pip` are assumed to be available. The install-script will warn you and stop if that's not the case. `git` is also needed to clone the Freqtrade repository. + Python3.9 or higher and the corresponding `pip` are assumed to be available. The install-script will warn you and stop if that's not the case. `git` is also needed to clone the Freqtrade repository. Also, python headers (`python-dev` / `python-devel`) must be available for the installation to complete successfully. !!! Warning "Up-to-date clock" @@ -42,7 +42,7 @@ These requirements apply to both [Script Installation](#script-installation) and ### Install guide -* [Python >= 3.8.x](http://docs.python-guide.org/en/latest/starting/installation/) +* [Python >= 3.9](http://docs.python-guide.org/en/latest/starting/installation/) * [pip](https://pip.pypa.io/en/stable/installing/) * [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) * [virtualenv](https://virtualenv.pypa.io/en/stable/installation.html) (Recommended) @@ -54,7 +54,7 @@ We've included/collected install instructions for Ubuntu, MacOS, and Windows. Th OS Specific steps are listed first, the [Common](#common) section below is necessary for all systems. !!! Note - Python3.8 or higher and the corresponding pip are assumed to be available. + Python3.9 or higher and the corresponding pip are assumed to be available. === "Debian/Ubuntu" #### Install necessary dependencies @@ -169,7 +169,7 @@ You can as well update, configure and reset the codebase of your bot with `./scr ** --install ** With this option, the script will install the bot and most dependencies: -You will need to have git and python3.8+ installed beforehand for this to work. +You will need to have git and python3.9+ installed beforehand for this to work. * Mandatory software as: `ta-lib` * Setup your virtualenv under `.venv/` @@ -286,7 +286,7 @@ cd freqtrade #### Freqtrade install: Conda Environment ```bash -conda create --name freqtrade python=3.10 +conda create --name freqtrade python=3.11 ``` !!! Note "Creating Conda Environment" diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 96e3dbf1d..f596b6517 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,6 +1,6 @@ markdown==3.4.4 mkdocs==1.5.2 -mkdocs-material==9.2.1 +mkdocs-material==9.2.7 mdx_truly_sane_lists==1.3 -pymdown-extensions==10.1 +pymdown-extensions==10.3 jinja2==3.1.2 diff --git a/docs/rest-api.md b/docs/rest-api.md index 5b33bfa6f..666056a65 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -151,6 +151,8 @@ python3 scripts/rest_client.py --config rest_config.json [optional par | `performance` | Show performance of each finished trade grouped by pair. | `balance` | Show account balance per currency. | `daily ` | Shows profit or loss per day, over the last n days (n defaults to 7). +| `weekly ` | Shows profit or loss per week, over the last n days (n defaults to 4). +| `monthly ` | Shows profit or loss per month, over the last n days (n defaults to 3). | `stats` | Display a summary of profit / loss reasons as well as average holding times. | `whitelist` | Show the current whitelist. | `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index ab8eb9f98..5ce898dbb 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -164,6 +164,31 @@ E.g. If the `current_rate` is 200 USD, then returning `0.02` will set the stoplo During backtesting, `current_rate` (and `current_profit`) are provided against the candle's high (or low for short trades) - while the resulting stoploss is evaluated against the candle's low (or high for short trades). The absolute value of the return value is used (the sign is ignored), so returning `0.05` or `-0.05` have the same result, a stoploss 5% below the current price. +Returning None will be interpreted as "no desire to change", and is the only safe way to return when you'd like to not modify the stoploss. + +Stoploss on exchange works similar to `trailing_stop`, and the stoploss on exchange is updated as configured in `stoploss_on_exchange_interval` ([More details about stoploss on exchange](stoploss.md#stop-loss-on-exchange-freqtrade)). + +!!! Note "Use of dates" + All time-based calculations should be done based on `current_time` - using `datetime.now()` or `datetime.utcnow()` is discouraged, as this will break backtesting support. + +!!! Tip "Trailing stoploss" + It's recommended to disable `trailing_stop` when using custom stoploss values. Both can work in tandem, but you might encounter the trailing stop to move the price higher while your custom function would not want this, causing conflicting behavior. + +### Adjust stoploss after position adjustments + +Depending on your strategy, you may encounter the need to adjust the stoploss in both directions after a [position adjustment](#adjust-trade-position). +For this, freqtrade will make an additional call with `after_fill=True` after an order fills, which will allow the strategy to move the stoploss in any direction (also widening the gap between stoploss and current price, which is otherwise forbidden). + +!!! Note "backwards compatibility" + This call will only be made if the `after_fill` parameter is part of the function definition of your `custom_stoploss` function. + As such, this will not impact (and with that, surprise) existing, running strategies. + +### Custom stoploss examples + +The next section will show some examples on what's possible with the custom stoploss function. +Of course, many more things are possible, and all examples can be combined at will. + +#### Trailing stop via custom stoploss To simulate a regular trailing stoploss of 4% (trailing 4% behind the maximum reached price) you would use the following very simple method: @@ -179,7 +204,8 @@ class AwesomeStrategy(IStrategy): use_custom_stoploss = True def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: """ Custom stoploss logic, returning the new distance relative to current_rate (as ratio). e.g. returning -0.05 would create a stoploss 5% below current_rate. @@ -187,7 +213,7 @@ class AwesomeStrategy(IStrategy): For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ - When not implemented by a strategy, returns the initial stoploss value + When not implemented by a strategy, returns the initial stoploss value. Only called when use_custom_stoploss is set to True. :param pair: Pair that's currently analyzed @@ -195,25 +221,13 @@ class AwesomeStrategy(IStrategy): :param current_time: datetime object, containing the current datetime :param current_rate: Rate, calculated based on pricing settings in exit_pricing. :param current_profit: Current profit (as ratio), calculated based on current_rate. + :param after_fill: True if the stoploss is called after the order was filled. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. - :return float: New stoploss value, relative to the current rate + :return float: New stoploss value, relative to the current_rate """ return -0.04 ``` -Stoploss on exchange works similar to `trailing_stop`, and the stoploss on exchange is updated as configured in `stoploss_on_exchange_interval` ([More details about stoploss on exchange](stoploss.md#stop-loss-on-exchange-freqtrade)). - -!!! Note "Use of dates" - All time-based calculations should be done based on `current_time` - using `datetime.now()` or `datetime.utcnow()` is discouraged, as this will break backtesting support. - -!!! Tip "Trailing stoploss" - It's recommended to disable `trailing_stop` when using custom stoploss values. Both can work in tandem, but you might encounter the trailing stop to move the price higher while your custom function would not want this, causing conflicting behavior. - -### Custom stoploss examples - -The next section will show some examples on what's possible with the custom stoploss function. -Of course, many more things are possible, and all examples can be combined at will. - #### Time based trailing stop Use the initial stoploss for the first 60 minutes, after this change to 10% trailing stoploss, and after 2 hours (120 minutes) we use a 5% trailing stoploss. @@ -229,14 +243,45 @@ class AwesomeStrategy(IStrategy): use_custom_stoploss = True def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: # Make sure you have the longest interval first - these conditions are evaluated from top to bottom. if current_time - timedelta(minutes=120) > trade.open_date_utc: return -0.05 elif current_time - timedelta(minutes=60) > trade.open_date_utc: return -0.10 - return 1 + return None +``` + +#### Time based trailing stop with after-fill adjustments + +Use the initial stoploss for the first 60 minutes, after this change to 10% trailing stoploss, and after 2 hours (120 minutes) we use a 5% trailing stoploss. +If an additional order fills, set stoploss to -10% below the new `open_rate` ([Averaged across all entries](#position-adjust-calculations)). + +``` python +from datetime import datetime, timedelta +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: + + if after_fill: + # After an additional order, start with a stoploss of 10% below the new open rate + return stoploss_from_open(0.10, current_profit, is_short=trade.is_short, leverage=trade.leverage) + # Make sure you have the longest interval first - these conditions are evaluated from top to bottom. + if current_time - timedelta(minutes=120) > trade.open_date_utc: + return -0.05 + elif current_time - timedelta(minutes=60) > trade.open_date_utc: + return -0.10 + return None ``` #### Different stoploss per pair @@ -255,7 +300,8 @@ class AwesomeStrategy(IStrategy): use_custom_stoploss = True def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: if pair in ('ETH/BTC', 'XRP/BTC'): return -0.10 @@ -281,7 +327,8 @@ class AwesomeStrategy(IStrategy): use_custom_stoploss = True def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: if current_profit < 0.04: return -1 # return a value bigger than the initial stoploss to keep using the initial stoploss @@ -314,7 +361,8 @@ class AwesomeStrategy(IStrategy): use_custom_stoploss = True def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: # evaluate highest to lowest, so that highest possible stop is used if current_profit > 0.40: @@ -325,7 +373,7 @@ class AwesomeStrategy(IStrategy): return stoploss_from_open(0.07, current_profit, is_short=trade.is_short, leverage=trade.leverage) # return maximum stoploss value, keeping current stoploss price unchanged - return 1 + return None ``` #### Custom stoploss using an indicator from dataframe example @@ -342,7 +390,8 @@ class AwesomeStrategy(IStrategy): use_custom_stoploss = True def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) last_candle = dataframe.iloc[-1].squeeze() @@ -355,7 +404,7 @@ class AwesomeStrategy(IStrategy): return stoploss_from_absolute(stoploss_price, current_rate, is_short=trade.is_short) # return maximum stoploss value, keeping current stoploss price unchanged - return 1 + return None ``` See [Dataframe access](strategy-advanced.md#dataframe-access) for more information about dataframe use in strategy callbacks. @@ -364,15 +413,89 @@ See [Dataframe access](strategy-advanced.md#dataframe-access) for more informati #### Stoploss relative to open price -Stoploss values returned from `custom_stoploss()` always specify a percentage relative to `current_rate`. In order to set a stoploss relative to the *open* price, we need to use `current_profit` to calculate what percentage relative to the `current_rate` will give you the same result as if the percentage was specified from the open price. +Stoploss values returned from `custom_stoploss()` must specify a percentage relative to `current_rate`, but sometimes you may want to specify a stoploss relative to the _entry_ price instead. +`stoploss_from_open()` is a helper function to calculate a stoploss value that can be returned from `custom_stoploss` which will be equivalent to the desired trade profit above the entry point. -The helper function [`stoploss_from_open()`](strategy-customization.md#stoploss_from_open) can be used to convert from an open price relative stop, to a current price relative stop which can be returned from `custom_stoploss()`. +??? Example "Returning a stoploss relative to the open price from the custom stoploss function" + + Say the open price was $100, and `current_price` is $121 (`current_profit` will be `0.21`). + + If we want a stop price at 7% above the open price we can call `stoploss_from_open(0.07, current_profit, False)` which will return `0.1157024793`. 11.57% below $121 is $107, which is the same as 7% above $100. + + This function will consider leverage - so at 10x leverage, the actual stoploss would be 0.7% above $100 (0.7% * 10x = 7%). + + + ``` python + + from datetime import datetime + from freqtrade.persistence import Trade + from freqtrade.strategy import IStrategy, stoploss_from_open + + class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: + + # once the profit has risen above 10%, keep the stoploss at 7% above the open price + if current_profit > 0.10: + return stoploss_from_open(0.07, current_profit, is_short=trade.is_short, leverage=trade.leverage) + + return 1 + + ``` + + Full examples can be found in the [Custom stoploss](strategy-advanced.md#custom-stoploss) section of the Documentation. + +!!! Note + Providing invalid input to `stoploss_from_open()` may produce "CustomStoploss function did not return valid stoploss" warnings. + This may happen if `current_profit` parameter is below specified `open_relative_stop`. Such situations may arise when closing trade + is blocked by `confirm_trade_exit()` method. Warnings can be solved by never blocking stop loss sells by checking `exit_reason` in + `confirm_trade_exit()`, or by using `return stoploss_from_open(...) or 1` idiom, which will request to not change stop loss when + `current_profit < open_relative_stop`. #### Stoploss percentage from absolute price Stoploss values returned from `custom_stoploss()` always specify a percentage relative to `current_rate`. In order to set a stoploss at specified absolute price level, we need to use `stop_rate` to calculate what percentage relative to the `current_rate` will give you the same result as if the percentage was specified from the open price. -The helper function [`stoploss_from_absolute()`](strategy-customization.md#stoploss_from_absolute) can be used to convert from an absolute price, to a current price relative stop which can be returned from `custom_stoploss()`. +The helper function `stoploss_from_absolute()` can be used to convert from an absolute price, to a current price relative stop which can be returned from `custom_stoploss()`. + +??? Example "Returning a stoploss using absolute price from the custom stoploss function" + + If we want to trail a stop price at 2xATR below current price we can call `stoploss_from_absolute(current_rate + (side * candle['atr'] * 2), current_rate, is_short=trade.is_short, leverage=trade.leverage)`. + For futures, we need to adjust the direction (up or down), as well as adjust for leverage, since the [`custom_stoploss`](strategy-callbacks.md#custom-stoploss) callback returns the ["risk for this trade"](stoploss.md#stoploss-and-leverage) - not the relative price movement. + + ``` python + + from datetime import datetime + from freqtrade.persistence import Trade + from freqtrade.strategy import IStrategy, stoploss_from_absolute, timeframe_to_prev_date + + class AwesomeStrategy(IStrategy): + + use_custom_stoploss = True + + def populate_indicators_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe['atr'] = ta.ATR(dataframe, timeperiod=14) + return dataframe + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + trade_date = timeframe_to_prev_date(self.timeframe, trade.open_date_utc) + candle = dataframe.iloc[-1].squeeze() + sign = 1 if trade.is_short else -1 + return stoploss_from_absolute(current_rate + (side * candle['atr'] * 2), + current_rate, is_short=trade.is_short, + leverage=trade.leverage) + + ``` + --- diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 8913d787b..b8ce22843 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -586,6 +586,67 @@ for more information. will overwrite previously defined method and not produce any errors due to limitations of Python programming language. In such cases you will find that indicators created in earlier-defined methods are not available in the dataframe. Carefully review method names and make sure they are unique! +### *merge_informative_pair()* + +This method helps you merge an informative pair to a regular dataframe without lookahead bias. +It's there to help you merge the dataframe in a safe and consistent way. + +Options: + +- Rename the columns for you to create unique columns +- Merge the dataframe without lookahead bias +- Forward-fill (optional) + +For a full sample, please refer to the [complete data provider example](#complete-data-provider-sample) below. + +All columns of the informative dataframe will be available on the returning dataframe in a renamed fashion: + +!!! Example "Column renaming" + Assuming `inf_tf = '1d'` the resulting columns will be: + + ``` python + 'date', 'open', 'high', 'low', 'close', 'rsi' # from the original dataframe + 'date_1d', 'open_1d', 'high_1d', 'low_1d', 'close_1d', 'rsi_1d' # from the informative dataframe + ``` + +??? Example "Column renaming - 1h" + Assuming `inf_tf = '1h'` the resulting columns will be: + + ``` python + 'date', 'open', 'high', 'low', 'close', 'rsi' # from the original dataframe + 'date_1h', 'open_1h', 'high_1h', 'low_1h', 'close_1h', 'rsi_1h' # from the informative dataframe + ``` + +??? Example "Custom implementation" + A custom implementation for this is possible, and can be done as follows: + + ``` python + + # Shift date by 1 candle + # This is necessary since the data is always the "open date" + # and a 15m candle starting at 12:15 should not know the close of the 1h candle from 12:00 to 13:00 + minutes = timeframe_to_minutes(inf_tf) + # Only do this if the timeframes are different: + informative['date_merge'] = informative["date"] + pd.to_timedelta(minutes, 'm') + + # Rename columns to be unique + informative.columns = [f"{col}_{inf_tf}" for col in informative.columns] + # Assuming inf_tf = '1d' - then the columns will now be: + # date_1d, open_1d, high_1d, low_1d, close_1d, rsi_1d + + # Combine the 2 dataframes + # all indicators on the informative sample MUST be calculated before this point + dataframe = pd.merge(dataframe, informative, left_on='date', right_on=f'date_merge_{inf_tf}', how='left') + # FFill to have the 1d value available in every row throughout the day. + # Without this, comparisons would only work once per day. + dataframe = dataframe.ffill() + + ``` + +!!! Warning "Informative timeframe < timeframe" + Using informative timeframes smaller than the dataframe timeframe is not recommended with this method, as it will not use any of the additional information this would provide. + To use the more detailed information properly, more advanced methods should be applied (which are out of scope for freqtrade documentation, as it'll depend on the respective need). + ## Additional data (DataProvider) The strategy provides access to the `DataProvider`. This allows you to get additional data to use in your strategy. @@ -810,146 +871,6 @@ class SampleStrategy(IStrategy): *** -## Helper functions - -### *merge_informative_pair()* - -This method helps you merge an informative pair to a regular dataframe without lookahead bias. -It's there to help you merge the dataframe in a safe and consistent way. - -Options: - -- Rename the columns for you to create unique columns -- Merge the dataframe without lookahead bias -- Forward-fill (optional) - -For a full sample, please refer to the [complete data provider example](#complete-data-provider-sample) below. - -All columns of the informative dataframe will be available on the returning dataframe in a renamed fashion: - -!!! Example "Column renaming" - Assuming `inf_tf = '1d'` the resulting columns will be: - - ``` python - 'date', 'open', 'high', 'low', 'close', 'rsi' # from the original dataframe - 'date_1d', 'open_1d', 'high_1d', 'low_1d', 'close_1d', 'rsi_1d' # from the informative dataframe - ``` - -??? Example "Column renaming - 1h" - Assuming `inf_tf = '1h'` the resulting columns will be: - - ``` python - 'date', 'open', 'high', 'low', 'close', 'rsi' # from the original dataframe - 'date_1h', 'open_1h', 'high_1h', 'low_1h', 'close_1h', 'rsi_1h' # from the informative dataframe - ``` - -??? Example "Custom implementation" - A custom implementation for this is possible, and can be done as follows: - - ``` python - - # Shift date by 1 candle - # This is necessary since the data is always the "open date" - # and a 15m candle starting at 12:15 should not know the close of the 1h candle from 12:00 to 13:00 - minutes = timeframe_to_minutes(inf_tf) - # Only do this if the timeframes are different: - informative['date_merge'] = informative["date"] + pd.to_timedelta(minutes, 'm') - - # Rename columns to be unique - informative.columns = [f"{col}_{inf_tf}" for col in informative.columns] - # Assuming inf_tf = '1d' - then the columns will now be: - # date_1d, open_1d, high_1d, low_1d, close_1d, rsi_1d - - # Combine the 2 dataframes - # all indicators on the informative sample MUST be calculated before this point - dataframe = pd.merge(dataframe, informative, left_on='date', right_on=f'date_merge_{inf_tf}', how='left') - # FFill to have the 1d value available in every row throughout the day. - # Without this, comparisons would only work once per day. - dataframe = dataframe.ffill() - - ``` - -!!! Warning "Informative timeframe < timeframe" - Using informative timeframes smaller than the dataframe timeframe is not recommended with this method, as it will not use any of the additional information this would provide. - To use the more detailed information properly, more advanced methods should be applied (which are out of scope for freqtrade documentation, as it'll depend on the respective need). - -*** - -### *stoploss_from_open()* - -Stoploss values returned from `custom_stoploss` must specify a percentage relative to `current_rate`, but sometimes you may want to specify a stoploss relative to the entry point instead. `stoploss_from_open()` is a helper function to calculate a stoploss value that can be returned from `custom_stoploss` which will be equivalent to the desired trade profit above the entry point. - -??? Example "Returning a stoploss relative to the open price from the custom stoploss function" - - Say the open price was $100, and `current_price` is $121 (`current_profit` will be `0.21`). - - If we want a stop price at 7% above the open price we can call `stoploss_from_open(0.07, current_profit, False)` which will return `0.1157024793`. 11.57% below $121 is $107, which is the same as 7% above $100. - - This function will consider leverage - so at 10x leverage, the actual stoploss would be 0.7% above $100 (0.7% * 10x = 7%). - - - ``` python - - from datetime import datetime - from freqtrade.persistence import Trade - from freqtrade.strategy import IStrategy, stoploss_from_open - - class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - use_custom_stoploss = True - - def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: - - # once the profit has risen above 10%, keep the stoploss at 7% above the open price - if current_profit > 0.10: - return stoploss_from_open(0.07, current_profit, is_short=trade.is_short, leverage=trade.leverage) - - return 1 - - ``` - - Full examples can be found in the [Custom stoploss](strategy-advanced.md#custom-stoploss) section of the Documentation. - -!!! Note - Providing invalid input to `stoploss_from_open()` may produce "CustomStoploss function did not return valid stoploss" warnings. - This may happen if `current_profit` parameter is below specified `open_relative_stop`. Such situations may arise when closing trade - is blocked by `confirm_trade_exit()` method. Warnings can be solved by never blocking stop loss sells by checking `exit_reason` in - `confirm_trade_exit()`, or by using `return stoploss_from_open(...) or 1` idiom, which will request to not change stop loss when - `current_profit < open_relative_stop`. - -### *stoploss_from_absolute()* - -In some situations it may be confusing to deal with stops relative to current rate. Instead, you may define a stoploss level using an absolute price. - -??? Example "Returning a stoploss using absolute price from the custom stoploss function" - - If we want to trail a stop price at 2xATR below current price we can call `stoploss_from_absolute(current_rate - (candle['atr'] * 2), current_rate, is_short=trade.is_short)`. - - ``` python - - from datetime import datetime - from freqtrade.persistence import Trade - from freqtrade.strategy import IStrategy, stoploss_from_absolute - - class AwesomeStrategy(IStrategy): - - use_custom_stoploss = True - - def populate_indicators_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - dataframe['atr'] = ta.ATR(dataframe, timeperiod=14) - return dataframe - - def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: - dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) - candle = dataframe.iloc[-1].squeeze() - return stoploss_from_absolute(current_rate - (candle['atr'] * 2), current_rate, is_short=trade.is_short) - - ``` - ## Additional data (Wallets) The strategy provides access to the `wallets` object. This contains the current balances on the exchange. diff --git a/docs/strategy_migration.md b/docs/strategy_migration.md index d00349d1d..d4d5f0068 100644 --- a/docs/strategy_migration.md +++ b/docs/strategy_migration.md @@ -311,12 +311,13 @@ After: ``` python hl_lines="5 7" def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: + current_rate: float, current_profit: float, after_fill: bool, + **kwargs) -> Optional[float]: # once the profit has risen above 10%, keep the stoploss at 7% above the open price if current_profit > 0.10: return stoploss_from_open(0.07, current_profit, is_short=trade.is_short) - return stoploss_from_absolute(current_rate - (candle['atr'] * 2), current_rate, is_short=trade.is_short) + return stoploss_from_absolute(current_rate - (candle['atr'] * 2), current_rate, is_short=trade.is_short, leverage=trade.leverage) ``` diff --git a/docs/windows_installation.md b/docs/windows_installation.md index db785a1fc..5ac3d5e3d 100644 --- a/docs/windows_installation.md +++ b/docs/windows_installation.md @@ -24,7 +24,7 @@ git clone https://github.com/freqtrade/freqtrade.git Install ta-lib according to the [ta-lib documentation](https://github.com/mrjbq7/ta-lib#windows). -As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), Freqtrade provides these dependencies (in the binary wheel format) for the latest 3 Python versions (3.8, 3.9, 3.10 and 3.11) and for 64bit Windows. +As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), Freqtrade provides these dependencies (in the binary wheel format) for the latest 3 Python versions (3.9, 3.10 and 3.11) and for 64bit Windows. These Wheels are also used by CI running on windows, and are therefore tested together with freqtrade. Other versions must be downloaded from the above link. diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 42ff08414..4dfcfea21 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2023.8.dev' +__version__ = '2023.9-dev' if 'dev' in __version__: from pathlib import Path diff --git a/freqtrade/__main__.py b/freqtrade/__main__.py index fc45bdf61..ed950fa01 100755 --- a/freqtrade/__main__.py +++ b/freqtrade/__main__.py @@ -3,7 +3,7 @@ __main__.py for Freqtrade To launch Freqtrade as a module -> python -m freqtrade (with Python >= 3.8) +> python -m freqtrade (with Python >= 3.9) """ from freqtrade import main diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 733fa11b3..318c414e1 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -688,6 +688,7 @@ CANCEL_REASON = { "CANCELLED_ON_EXCHANGE": "cancelled on exchange", "FORCE_EXIT": "forcesold", "REPLACE": "cancelled to be replaced by new limit order", + "REPLACE_FAILED": "failed to replace order, deleting Trade", "USER_CANCEL": "user requested order cancel" } diff --git a/freqtrade/data/entryexitanalysis.py b/freqtrade/data/entryexitanalysis.py index db3a7d3a4..ac5893585 100644 --- a/freqtrade/data/entryexitanalysis.py +++ b/freqtrade/data/entryexitanalysis.py @@ -119,8 +119,15 @@ def _do_group_table_output(bigdf, glist, csv_path: Path, to_csv=False, ): new['avg_win'] = (new['profit_abs_wins'] / new.iloc[:, 1]).fillna(0) new['avg_loss'] = (new['profit_abs_loss'] / new.iloc[:, 2]).fillna(0) - new.columns = ['total_num_buys', 'wins', 'losses', 'profit_abs_wins', 'profit_abs_loss', - 'profit_tot', 'wl_ratio_pct', 'avg_win', 'avg_loss'] + new['exp_ratio'] = ( + ( + (1 + (new['avg_win'] / abs(new['avg_loss']))) * (new['wl_ratio_pct'] / 100) + ) - 1).fillna(0) + + new.columns = ['total_num_buys', 'wins', 'losses', + 'profit_abs_wins', 'profit_abs_loss', + 'profit_tot', 'wl_ratio_pct', + 'avg_win', 'avg_loss', 'exp_ratio'] sortcols = ['total_num_buys'] @@ -204,6 +211,7 @@ def prepare_results(analysed_trades, stratname, timerange=None): res_df = pd.DataFrame() for pair, trades in analysed_trades[stratname].items(): + trades.dropna(subset=['close_date'], inplace=True) res_df = pd.concat([res_df, trades], ignore_index=True) res_df = _select_rows_within_dates(res_df, timerange) diff --git a/freqtrade/exchange/binance_leverage_tiers.json b/freqtrade/exchange/binance_leverage_tiers.json index a4a81abb3..f191cdd31 100644 --- a/freqtrade/exchange/binance_leverage_tiers.json +++ b/freqtrade/exchange/binance_leverage_tiers.json @@ -6399,6 +6399,120 @@ } } ], + "BTC/USDT:USDT-231229": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 375000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 25.0, + "info": { + "bracket": "1", + "initialLeverage": "25", + "notionalCap": "375000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 375000.0, + "maxNotional": 2000000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "2", + "initialLeverage": "10", + "notionalCap": "2000000", + "notionalFloor": "375000", + "maintMarginRatio": "0.05", + "cum": "11250.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 2000000.0, + "maxNotional": 4000000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "3", + "initialLeverage": "5", + "notionalCap": "4000000", + "notionalFloor": "2000000", + "maintMarginRatio": "0.1", + "cum": "111250.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 4000000.0, + "maxNotional": 10000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "4", + "initialLeverage": "4", + "notionalCap": "10000000", + "notionalFloor": "4000000", + "maintMarginRatio": "0.125", + "cum": "211250.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 10000000.0, + "maxNotional": 20000000.0, + "maintenanceMarginRate": 0.15, + "maxLeverage": 3.0, + "info": { + "bracket": "5", + "initialLeverage": "3", + "notionalCap": "20000000", + "notionalFloor": "10000000", + "maintMarginRatio": "0.15", + "cum": "461250.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 20000000.0, + "maxNotional": 40000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "40000000", + "notionalFloor": "20000000", + "maintMarginRatio": "0.25", + "cum": "2461250.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 40000000.0, + "maxNotional": 120000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "120000000", + "notionalFloor": "40000000", + "maintMarginRatio": "0.5", + "cum": "1.246125E7" + } + } + ], "BTCDOM/USDT:USDT": [ { "tier": 1.0, @@ -8487,6 +8601,120 @@ } } ], + "CYBER/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "DAR/USDT:USDT": [ { "tier": 1.0, @@ -9212,10 +9440,10 @@ "minNotional": 0.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 20.0, + "maxLeverage": 10.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "10", "notionalCap": "100000", "notionalFloor": "0", "maintMarginRatio": "0.025", @@ -9228,10 +9456,10 @@ "minNotional": 100000.0, "maxNotional": 500000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "8", "notionalCap": "500000", "notionalFloor": "100000", "maintMarginRatio": "0.05", @@ -9290,13 +9518,13 @@ "tier": 6.0, "currency": "BUSD", "minNotional": 5000000.0, - "maxNotional": 8000000.0, + "maxNotional": 5200000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "6", "initialLeverage": "1", - "notionalCap": "8000000", + "notionalCap": "5200000", "notionalFloor": "5000000", "maintMarginRatio": "0.5", "cum": "1527500.0" @@ -11447,6 +11675,120 @@ } } ], + "ETH/USDT:USDT-231229": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 375000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 25.0, + "info": { + "bracket": "1", + "initialLeverage": "25", + "notionalCap": "375000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 375000.0, + "maxNotional": 2000000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "2", + "initialLeverage": "10", + "notionalCap": "2000000", + "notionalFloor": "375000", + "maintMarginRatio": "0.05", + "cum": "11250.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 2000000.0, + "maxNotional": 4000000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "3", + "initialLeverage": "5", + "notionalCap": "4000000", + "notionalFloor": "2000000", + "maintMarginRatio": "0.1", + "cum": "111250.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 4000000.0, + "maxNotional": 10000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "4", + "initialLeverage": "4", + "notionalCap": "10000000", + "notionalFloor": "4000000", + "maintMarginRatio": "0.125", + "cum": "211250.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 10000000.0, + "maxNotional": 20000000.0, + "maintenanceMarginRate": 0.15, + "maxLeverage": 3.0, + "info": { + "bracket": "5", + "initialLeverage": "3", + "notionalCap": "20000000", + "notionalFloor": "10000000", + "maintMarginRatio": "0.15", + "cum": "461250.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 20000000.0, + "maxNotional": 40000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "40000000", + "notionalFloor": "20000000", + "maintMarginRatio": "0.25", + "cum": "2461250.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 40000000.0, + "maxNotional": 120000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "120000000", + "notionalFloor": "40000000", + "maintMarginRatio": "0.5", + "cum": "1.246125E7" + } + } + ], "FET/USDT:USDT": [ { "tier": 1.0, @@ -17160,10 +17502,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 25.0, + "maxLeverage": 10.0, "info": { "bracket": "1", - "initialLeverage": "25", + "initialLeverage": "10", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -17176,10 +17518,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 15.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "15", + "initialLeverage": "8", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -17192,10 +17534,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "10", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -17254,13 +17596,13 @@ "tier": 7.0, "currency": "BUSD", "minNotional": 3000000.0, - "maxNotional": 8000000.0, + "maxNotional": 3200000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "8000000", + "notionalCap": "3200000", "notionalFloor": "3000000", "maintMarginRatio": "0.5", "cum": "949400.0" @@ -22333,6 +22675,120 @@ } } ], + "SEI/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "SFP/USDT:USDT": [ { "tier": 1.0, @@ -22650,10 +23106,10 @@ "minNotional": 0.0, "maxNotional": 50000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 25.0, + "maxLeverage": 10.0, "info": { "bracket": "1", - "initialLeverage": "25", + "initialLeverage": "10", "notionalCap": "50000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -22666,10 +23122,10 @@ "minNotional": 50000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 20.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "20", + "initialLeverage": "8", "notionalCap": "100000", "notionalFloor": "50000", "maintMarginRatio": "0.025", @@ -22682,10 +23138,10 @@ "minNotional": 100000.0, "maxNotional": 500000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "10", + "initialLeverage": "6", "notionalCap": "500000", "notionalFloor": "100000", "maintMarginRatio": "0.05", @@ -22744,13 +23200,13 @@ "tier": 7.0, "currency": "BUSD", "minNotional": 5000000.0, - "maxNotional": 8000000.0, + "maxNotional": 5500000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "8000000", + "notionalCap": "5500000", "notionalFloor": "5000000", "maintMarginRatio": "0.5", "cum": "1527750.0" diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index 626643d06..41e667fb5 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -1,6 +1,6 @@ """ Bybit exchange subclass """ import logging -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple import ccxt @@ -11,6 +11,7 @@ from freqtrade.enums.candletype import CandleType from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exchange import Exchange from freqtrade.exchange.common import retrier +from freqtrade.util.datetime_helpers import dt_now, dt_ts logger = logging.getLogger(__name__) @@ -203,3 +204,31 @@ class Bybit(Exchange): return self._fetch_and_calculate_funding_fees( pair, amount, is_short, open_date) return 0.0 + + def fetch_orders(self, pair: str, since: datetime, params: Optional[Dict] = None) -> List[Dict]: + """ + Fetch all orders for a pair "since" + :param pair: Pair for the query + :param since: Starting time for the query + """ + # On bybit, the distance between since and "until" can't exceed 7 days. + # we therefore need to split the query into multiple queries. + orders = [] + + while since < dt_now(): + until = since + timedelta(days=7, minutes=-1) + orders += super().fetch_orders(pair, since, params={'until': dt_ts(until)}) + since = until + + return orders + + def fetch_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict: + order = super().fetch_order(order_id, pair, params) + if ( + order.get('status') == 'canceled' + and order.get('filled') == 0.0 + and order.get('remaining') == 0.0 + ): + # Canceled orders will have "remaining=0" on bybit. + order['remaining'] = None + return order diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 83a60b906..163e9a2a2 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -832,7 +832,7 @@ class Exchange: rate: float, leverage: float, params: Dict = {}, stop_loss: bool = False) -> Dict[str, Any]: now = dt_now() - order_id = f'dry_run_{side}_{now.timestamp()}' + order_id = f'dry_run_{side}_{pair}_{now.timestamp()}' # Rounding here must respect to contract sizes _amount = self._contracts_to_amount( pair, self.amount_to_precision(pair, self._amount_to_contracts(pair, amount))) @@ -863,8 +863,8 @@ class Exchange: if self.exchange_has('fetchL2OrderBook'): orderbook = self.fetch_l2_order_book(pair, 20) if ordertype == "limit" and orderbook: - # Allow a 3% price difference - allowed_diff = 0.03 + # Allow a 1% price difference + allowed_diff = 0.01 if self._dry_is_price_crossed(pair, side, rate, orderbook, allowed_diff): logger.info( f"Converted order {pair} to market order due to price {rate} crossing spread " @@ -920,7 +920,7 @@ class Exchange: max_slippage_val = rate * ((1 + slippage) if side == 'buy' else (1 - slippage)) remaining_amount = amount - filled_amount = 0.0 + filled_value = 0.0 book_entry_price = 0.0 for book_entry in orderbook[ob_type]: book_entry_price = book_entry[0] @@ -928,17 +928,17 @@ class Exchange: if remaining_amount > 0: if remaining_amount < book_entry_coin_volume: # Orderbook at this slot bigger than remaining amount - filled_amount += remaining_amount * book_entry_price + filled_value += remaining_amount * book_entry_price break else: - filled_amount += book_entry_coin_volume * book_entry_price + filled_value += 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 = max(filled_amount, 0) / amount + filled_value += remaining_amount * book_entry_price + forecast_avg_filled_price = max(filled_value, 0) / amount # Limit max. slippage to specified value if side == 'buy': forecast_avg_filled_price = min(forecast_avg_filled_price, max_slippage_val) @@ -1421,8 +1421,17 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e + def __fetch_orders_emulate(self, pair: str, since_ms: int) -> List[Dict]: + orders = [] + if self.exchange_has('fetchClosedOrders'): + orders = self._api.fetch_closed_orders(pair, since=since_ms) + if self.exchange_has('fetchOpenOrders'): + orders_open = self._api.fetch_open_orders(pair, since=since_ms) + orders.extend(orders_open) + return orders + @retrier(retries=0) - def fetch_orders(self, pair: str, since: datetime) -> List[Dict]: + def fetch_orders(self, pair: str, since: datetime, params: Optional[Dict] = None) -> List[Dict]: """ Fetch all orders for a pair "since" :param pair: Pair for the query @@ -1431,26 +1440,20 @@ class Exchange: if self._config['dry_run']: return [] - def fetch_orders_emulate() -> List[Dict]: - orders = [] - if self.exchange_has('fetchClosedOrders'): - orders = self._api.fetch_closed_orders(pair, since=since_ms) - if self.exchange_has('fetchOpenOrders'): - orders_open = self._api.fetch_open_orders(pair, since=since_ms) - orders.extend(orders_open) - return orders - try: since_ms = int((since.timestamp() - 10) * 1000) + if self.exchange_has('fetchOrders'): + if not params: + params = {} try: - orders: List[Dict] = self._api.fetch_orders(pair, since=since_ms) + orders: List[Dict] = self._api.fetch_orders(pair, since=since_ms, params=params) except ccxt.NotSupported: # Some exchanges don't support fetchOrders # attempt to fetch open and closed orders separately - orders = fetch_orders_emulate() + orders = self.__fetch_orders_emulate(pair, since_ms) else: - orders = fetch_orders_emulate() + orders = self.__fetch_orders_emulate(pair, since_ms) self._log_exchange_response('fetch_orders', orders) orders = [self._order_contracts_to_amount(o) for o in orders] return orders diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index fe7264dd9..4dfa97b28 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -248,6 +248,39 @@ def amount_to_contract_precision( return amount +def __price_to_precision_significant_digits( + price: float, + price_precision: float, + *, + rounding_mode: int = ROUND, +) -> float: + """ + Implementation of ROUND_UP/Round_down for significant digits mode. + """ + from decimal import ROUND_DOWN as dec_ROUND_DOWN + from decimal import ROUND_UP as dec_ROUND_UP + from decimal import Decimal + dec = Decimal(str(price)) + string = f'{dec:f}' + precision = round(price_precision) + + q = precision - dec.adjusted() - 1 + sigfig = Decimal('10') ** -q + if q < 0: + string_to_precision = string[:precision] + # string_to_precision is '' when we have zero precision + below = sigfig * Decimal(string_to_precision if string_to_precision else '0') + above = below + sigfig + res = above if rounding_mode == ROUND_UP else below + precise = f'{res:f}' + else: + precise = '{:f}'.format(dec.quantize( + sigfig, + rounding=dec_ROUND_DOWN if rounding_mode == ROUND_DOWN else dec_ROUND_UP) + ) + return float(precise) + + def price_to_precision( price: float, price_precision: Optional[float], @@ -271,28 +304,39 @@ def price_to_precision( :return: price rounded up to the precision the Exchange accepts """ if price_precision is not None and precisionMode is not None: + if rounding_mode not in (ROUND_UP, ROUND_DOWN): + # Use CCXT code where possible. + return float(decimal_to_precision(price, rounding_mode=rounding_mode, + precision=price_precision, + counting_mode=precisionMode + )) + if precisionMode == TICK_SIZE: - if rounding_mode == ROUND: - ticks = price / price_precision - rounded_ticks = round(ticks) - return rounded_ticks * price_precision precision = FtPrecise(price_precision) price_str = FtPrecise(price) missing = price_str % precision if not missing == FtPrecise("0"): - return round(float(str(price_str - missing + precision)), 14) + if rounding_mode == ROUND_UP: + res = price_str - missing + precision + elif rounding_mode == ROUND_DOWN: + res = price_str - missing + return round(float(str(res)), 14) return price - elif precisionMode in (SIGNIFICANT_DIGITS, DECIMAL_PLACES): + elif precisionMode == DECIMAL_PLACES: + ndigits = round(price_precision) - if rounding_mode == ROUND: - return round(price, ndigits) ticks = price * (10**ndigits) if rounding_mode == ROUND_UP: return ceil(ticks) / (10**ndigits) - if rounding_mode == TRUNCATE: - return int(ticks) / (10**ndigits) if rounding_mode == ROUND_DOWN: return floor(ticks) / (10**ndigits) + raise ValueError(f"Unknown rounding_mode {rounding_mode}") + elif precisionMode == SIGNIFICANT_DIGITS: + if rounding_mode in (ROUND_UP, ROUND_DOWN): + return __price_to_precision_significant_digits( + price, price_precision, rounding_mode=rounding_mode + ) + raise ValueError(f"Unknown precisionMode {precisionMode}") return price diff --git a/freqtrade/freqai/RL/BaseEnvironment.py b/freqtrade/freqai/RL/BaseEnvironment.py index 91c7501c6..54502c869 100644 --- a/freqtrade/freqai/RL/BaseEnvironment.py +++ b/freqtrade/freqai/RL/BaseEnvironment.py @@ -11,6 +11,8 @@ from gymnasium import spaces from gymnasium.utils import seeding from pandas import DataFrame +from freqtrade.exceptions import OperationalException + logger = logging.getLogger(__name__) @@ -80,8 +82,9 @@ class BaseEnvironment(gym.Env): self.can_short: bool = can_short self.live: bool = live if not self.live and self.add_state_info: - self.add_state_info = False - logger.warning("add_state_info is not available in backtesting. Deactivating.") + raise OperationalException("`add_state_info` is not available in backtesting. Change " + "parameter to false in your rl_config. See `add_state_info` " + "docs for more info.") self.seed(seed) self.reset_env(df, prices, window_size, reward_kwargs, starting_point) diff --git a/freqtrade/freqai/RL/BaseReinforcementLearningModel.py b/freqtrade/freqai/RL/BaseReinforcementLearningModel.py index 06ce772a8..5b98efb49 100644 --- a/freqtrade/freqai/RL/BaseReinforcementLearningModel.py +++ b/freqtrade/freqai/RL/BaseReinforcementLearningModel.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) torch.multiprocessing.set_sharing_strategy('file_system') SB3_MODELS = ['PPO', 'A2C', 'DQN'] -SB3_CONTRIB_MODELS = ['TRPO', 'ARS', 'RecurrentPPO', 'MaskablePPO'] +SB3_CONTRIB_MODELS = ['TRPO', 'ARS', 'RecurrentPPO', 'MaskablePPO', 'QRDQN'] class BaseReinforcementLearningModel(IFreqaiModel): diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index b9dfb6772..2d206eab3 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -781,7 +781,7 @@ class FreqtradeBot(LoggingMixin): order_obj = Order.parse_from_ccxt_object(order, pair, side, amount, enter_limit_requested) order_id = order['id'] order_status = order.get('status') - logger.info(f"Order #{order_id} was created for {pair} and status is {order_status}.") + logger.info(f"Order {order_id} was created for {pair} and status is {order_status}.") # we assume the order is executed at the price requested enter_limit_filled_price = enter_limit_requested @@ -1410,7 +1410,7 @@ class FreqtradeBot(LoggingMixin): replacing=replacing) if adjusted_entry_price: # place new order only if new price is supplied - self.execute_entry( + if not self.execute_entry( pair=trade.pair, stake_amount=( order_obj.safe_remaining * order_obj.safe_price / trade.leverage), @@ -1418,7 +1418,15 @@ class FreqtradeBot(LoggingMixin): trade=trade, is_short=trade.is_short, order_adjust=True, - ) + ): + logger.warning(f"Could not replace order for {trade}.") + if trade.nr_of_successful_entries == 0: + # this is the first entry and we didn't get filled yet, delete trade + logger.warning(f"Removing {trade} from database.") + self._notify_enter_cancel( + trade, order_type=self.strategy.order_types['entry'], + reason=constants.CANCEL_REASON['REPLACE_FAILED']) + trade.delete() def cancel_all_open_orders(self) -> None: """ @@ -1524,17 +1532,18 @@ class FreqtradeBot(LoggingMixin): cancelled = False # Cancelled orders may have the status of 'canceled' or 'closed' if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES: - filled_val: float = order.get('filled', 0.0) or 0.0 - filled_rem_stake = trade.stake_amount - filled_val * trade.open_rate + filled_amt: float = order.get('filled', 0.0) or 0.0 + # Filled val is in quote currency (after leverage) + filled_rem_stake = trade.stake_amount - (filled_amt * trade.open_rate / trade.leverage) minstake = self.exchange.get_min_pair_stake_amount( trade.pair, trade.open_rate, self.strategy.stoploss) # Double-check remaining amount - if filled_val > 0: + if filled_amt > 0: reason = constants.CANCEL_REASON['PARTIALLY_FILLED'] if minstake and filled_rem_stake < minstake: logger.warning( f"Order {order_id} for {trade.pair} not cancelled, as " - f"the filled amount of {filled_val} would result in an unexitable trade.") + f"the filled amount of {filled_amt} would result in an unexitable trade.") reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN'] self._notify_exit_cancel( @@ -1724,14 +1733,12 @@ class FreqtradeBot(LoggingMixin): amount = order.safe_filled if fill else order.safe_amount order_rate: float = order.safe_price - profit = trade.calc_profit(rate=order_rate, amount=amount, open_rate=trade.open_rate) - profit_ratio = trade.calc_profit_ratio(order_rate, amount, trade.open_rate) + profit = trade.calculate_profit(order_rate, amount, trade.open_rate) else: order_rate = trade.safe_close_rate - profit = trade.calc_profit(rate=order_rate) + (0.0 if fill else trade.realized_profit) - profit_ratio = trade.calc_profit_ratio(order_rate) + profit = trade.calculate_profit(rate=order_rate) amount = trade.amount - gain = "profit" if profit_ratio > 0 else "loss" + gain = "profit" if profit.profit_ratio > 0 else "loss" msg: RPCSellMsg = { 'type': (RPCMessageType.EXIT_FILL if fill @@ -1749,8 +1756,8 @@ class FreqtradeBot(LoggingMixin): 'open_rate': trade.open_rate, 'close_rate': order_rate, 'current_rate': current_rate, - 'profit_amount': profit, - 'profit_ratio': profit_ratio, + 'profit_amount': profit.profit_abs if fill else profit.total_profit, + 'profit_ratio': profit.profit_ratio, 'buy_tag': trade.enter_tag, 'enter_tag': trade.enter_tag, 'sell_reason': trade.exit_reason, # Deprecated @@ -1782,11 +1789,10 @@ class FreqtradeBot(LoggingMixin): order = self.order_obj_or_raise(order_id, order_or_none) profit_rate: float = trade.safe_close_rate - profit_trade = trade.calc_profit(rate=profit_rate) + profit = trade.calculate_profit(rate=profit_rate) current_rate = self.exchange.get_rate( trade.pair, side='exit', is_short=trade.is_short, refresh=False) - profit_ratio = trade.calc_profit_ratio(profit_rate) - gain = "profit" if profit_ratio > 0 else "loss" + gain = "profit" if profit.profit_ratio > 0 else "loss" msg: RPCSellCancelMsg = { 'type': RPCMessageType.EXIT_CANCEL, @@ -1801,8 +1807,8 @@ class FreqtradeBot(LoggingMixin): 'amount': order.safe_amount_after_fee, 'open_rate': trade.open_rate, 'current_rate': current_rate, - 'profit_amount': profit_trade, - 'profit_ratio': profit_ratio, + 'profit_amount': profit.profit_abs, + 'profit_ratio': profit.profit_ratio, 'buy_tag': trade.enter_tag, 'enter_tag': trade.enter_tag, 'sell_reason': trade.exit_reason, # Deprecated @@ -1871,15 +1877,23 @@ class FreqtradeBot(LoggingMixin): trade.update_trade(order_obj) - if order.get('status') in constants.NON_OPEN_EXCHANGE_STATES: + trade = self._update_trade_after_fill(trade, order_obj) + Trade.commit() + + self.order_close_notify(trade, order_obj, stoploss_order, send_msg) + + return False + + def _update_trade_after_fill(self, trade: Trade, order: Order) -> Trade: + if order.status in constants.NON_OPEN_EXCHANGE_STATES: # If a entry order was closed, force update on stoploss on exchange - if order.get('side') == trade.entry_side: + if order.ft_order_side == trade.entry_side: trade = self.cancel_stoploss_on_exchange(trade) if not self.edge: # TODO: should shorting/leverage be supported by Edge, # then this will need to be fixed. trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) - if order.get('side') == trade.entry_side or (trade.amount > 0 and trade.is_open): + if order.ft_order_side == trade.entry_side or (trade.amount > 0 and trade.is_open): # Must also run for partial exits # TODO: Margin will need to use interest_rate as well. # interest_rate = self.exchange.get_interest_rate() @@ -1895,13 +1909,16 @@ class FreqtradeBot(LoggingMixin): )) except DependencyException: logger.warning('Unable to calculate liquidation price') + if self.strategy.use_custom_stoploss: + current_rate = self.exchange.get_rate( + trade.pair, side='exit', is_short=trade.is_short, refresh=True) + profit = trade.calc_profit_ratio(current_rate) + self.strategy.ft_stoploss_adjust(current_rate, trade, + datetime.now(timezone.utc), profit, 0, + after_fill=True) # Updating wallets when order is closed self.wallets.update() - Trade.commit() - - self.order_close_notify(trade, order_obj, stoploss_order, send_msg) - - return False + return trade def order_close_notify( self, trade: Trade, order: Order, stoploss_order: bool, send_msg: bool): diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index f592cd211..4b267b315 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -579,6 +579,11 @@ class Backtesting: """ Rate is within candle, therefore filled""" return row[LOW_IDX] <= rate <= row[HIGH_IDX] + def _call_adjust_stop(self, current_date: datetime, trade: LocalTrade, current_rate: float): + profit = trade.calc_profit_ratio(current_rate) + self.strategy.ft_stoploss_adjust(current_rate, trade, # type: ignore + current_date, profit, 0, after_fill=True) + def _try_close_open_order( self, order: Optional[Order], trade: LocalTrade, current_date: datetime, row: Tuple) -> bool: @@ -588,6 +593,9 @@ class Backtesting: """ if order and self._get_order_filled(order.ft_price, row): order.close_bt_order(current_date, trade) + if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount): + self._call_adjust_stop(current_date, trade, order.ft_price) + # pass return True return False diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index 6129797e8..69d37530f 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -88,6 +88,9 @@ def migrate_trades_and_orders_table( stop_loss_pct = get_column_def(cols, 'stop_loss_pct', 'null') initial_stop_loss = get_column_def(cols, 'initial_stop_loss', '0.0') initial_stop_loss_pct = get_column_def(cols, 'initial_stop_loss_pct', 'null') + is_stop_loss_trailing = get_column_def( + cols, 'is_stop_loss_trailing', + f'coalesce({stop_loss_pct}, 0.0) <> coalesce({initial_stop_loss_pct}, 0.0)') stoploss_order_id = get_column_def(cols, 'stoploss_order_id', 'null') stoploss_last_update = get_column_def(cols, 'stoploss_last_update', 'null') max_rate = get_column_def(cols, 'max_rate', '0.0') @@ -156,7 +159,7 @@ def migrate_trades_and_orders_table( open_rate_requested, close_rate, close_rate_requested, close_profit, stake_amount, amount, amount_requested, open_date, close_date, stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct, - stoploss_order_id, stoploss_last_update, + is_stop_loss_trailing, stoploss_order_id, stoploss_last_update, max_rate, min_rate, exit_reason, exit_order_status, strategy, enter_tag, timeframe, open_trade_value, close_profit_abs, trading_mode, leverage, liquidation_price, is_short, @@ -175,6 +178,7 @@ def migrate_trades_and_orders_table( {stop_loss} stop_loss, {stop_loss_pct} stop_loss_pct, {initial_stop_loss} initial_stop_loss, {initial_stop_loss_pct} initial_stop_loss_pct, + {is_stop_loss_trailing} is_stop_loss_trailing, {stoploss_order_id} stoploss_order_id, {stoploss_last_update} stoploss_last_update, {max_rate} max_rate, {min_rate} min_rate, case when {exit_reason} = 'sell_signal' then 'exit_signal' @@ -324,8 +328,8 @@ def check_migrate(engine, decl_base, previous_tables) -> None: # if ('orders' not in previous_tables # or not has_column(cols_orders, 'funding_fee')): migrating = False - # if not has_column(cols_trades, 'max_stake_amount'): - if not has_column(cols_orders, 'ft_price'): + # if not has_column(cols_orders, 'ft_price'): + if not has_column(cols_trades, 'is_stop_loss_trailing'): migrating = True logger.info(f"Running database migration for trades - " f"backup: {table_back_name}, {order_table_bak_name}") diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index aeba99a5e..2f9588cd8 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -3,6 +3,7 @@ This module contains the class to persist trades into SQLite """ import logging from collections import defaultdict +from dataclasses import dataclass from datetime import datetime, timedelta, timezone from math import isclose from typing import Any, ClassVar, Dict, List, Optional, Sequence, cast @@ -26,6 +27,14 @@ from freqtrade.util import FtPrecise, dt_now logger = logging.getLogger(__name__) +@dataclass +class ProfitStruct: + profit_abs: float + profit_ratio: float + total_profit: float + total_profit_ratio: float + + class Order(ModelBase): """ Order database model @@ -240,7 +249,10 @@ class Order(ModelBase): if (self.ft_order_side == trade.entry_side and self.price): trade.open_rate = self.price trade.recalc_trade_from_orders() - trade.adjust_stop_loss(trade.open_rate, trade.stop_loss_pct, refresh=True) + if trade.nr_of_successful_entries == 1: + trade.initial_stop_loss_pct = None + trade.is_stop_loss_trailing = False + trade.adjust_stop_loss(trade.open_rate, trade.stop_loss_pct) @staticmethod def update_orders(orders: List['Order'], order: Dict[str, Any]): @@ -348,6 +360,7 @@ class LocalTrade: initial_stop_loss: Optional[float] = 0.0 # percentage value of the initial stop loss initial_stop_loss_pct: Optional[float] = None + is_stop_loss_trailing: bool = False # stoploss order id which is on exchange stoploss_order_id: Optional[str] = None # last update time of the stoploss order on exchange @@ -655,18 +668,18 @@ class LocalTrade: self.stop_loss_pct = -1 * abs(percent) def adjust_stop_loss(self, current_price: float, stoploss: Optional[float], - initial: bool = False, refresh: bool = False) -> None: + initial: bool = False, allow_refresh: bool = False) -> None: """ This adjusts the stop loss to it's most recently observed setting :param current_price: Current rate the asset is traded :param stoploss: Stoploss as factor (sample -0.05 -> -5% below current price). :param initial: Called to initiate stop_loss. Skips everything if self.stop_loss is already set. + :param refresh: Called to refresh stop_loss, allows adjustment in both directions """ if stoploss is None or (initial and not (self.stop_loss is None or self.stop_loss == 0)): # Don't modify if called with initial and nothing to do return - refresh = True if refresh and self.nr_of_successful_entries == 1 else False leverage = self.leverage or 1.0 if self.is_short: @@ -677,7 +690,7 @@ class LocalTrade: stop_loss_norm = price_to_precision(new_loss, self.price_precision, self.precision_mode, rounding_mode=ROUND_DOWN if self.is_short else ROUND_UP) # no stop loss assigned yet - if self.initial_stop_loss_pct is None or refresh: + if self.initial_stop_loss_pct is None: self.__set_stop_loss(stop_loss_norm, stoploss) self.initial_stop_loss = price_to_precision( stop_loss_norm, self.price_precision, self.precision_mode, @@ -692,8 +705,14 @@ class LocalTrade: # stop losses only walk up, never down!, # ? But adding more to a leveraged trade 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): + if ( + allow_refresh + or (higher_stop and not self.is_short) + or (lower_stop and self.is_short) + ): logger.debug(f"{self.pair} - Adjusting stoploss...") + if not allow_refresh: + self.is_stop_loss_trailing = True self.__set_stop_loss(stop_loss_norm, stoploss) else: logger.debug(f"{self.pair} - Keeping current stoploss...") @@ -900,11 +919,26 @@ class LocalTrade: open_rate: Optional[float] = None) -> float: """ Calculate the absolute profit in stake currency between Close and Open trade + Deprecated - only available for backwards compatibility :param rate: close rate to compare with. :param amount: Amount to use for the calculation. Falls back to trade.amount if not set. :param open_rate: open_rate to use. Defaults to self.open_rate if not provided. :return: profit in stake currency as float """ + prof = self.calculate_profit(rate, amount, open_rate) + return prof.profit_abs + + def calculate_profit(self, rate: float, amount: Optional[float] = None, + open_rate: Optional[float] = None) -> ProfitStruct: + """ + Calculate profit metrics (absolute, ratio, total, total ratio). + All calculations include fees. + :param rate: close rate to compare with. + :param amount: Amount to use for the calculation. Falls back to trade.amount if not set. + :param open_rate: open_rate to use. Defaults to self.open_rate if not provided. + :return: Profit structure, containing absolute and relative profits. + """ + close_trade_value = self.calc_close_trade_value(rate, amount) if amount is None or open_rate is None: open_trade_value = self.open_trade_value @@ -912,10 +946,33 @@ class LocalTrade: open_trade_value = self._calc_open_trade_value(amount, open_rate) if self.is_short: - profit = open_trade_value - close_trade_value + profit_abs = open_trade_value - close_trade_value else: - profit = close_trade_value - open_trade_value - return float(f"{profit:.8f}") + profit_abs = close_trade_value - open_trade_value + + try: + if self.is_short: + profit_ratio = (1 - (close_trade_value / open_trade_value)) * self.leverage + else: + profit_ratio = ((close_trade_value / open_trade_value) - 1) * self.leverage + profit_ratio = float(f"{profit_ratio:.8f}") + except ZeroDivisionError: + profit_ratio = 0.0 + + total_profit_abs = profit_abs + self.realized_profit + total_profit_ratio = ( + (total_profit_abs / self.max_stake_amount) * self.leverage + if self.max_stake_amount else 0.0 + ) + total_profit_ratio = float(f"{total_profit_ratio:.8f}") + profit_abs = float(f"{profit_abs:.8f}") + + return ProfitStruct( + profit_abs=profit_abs, + profit_ratio=profit_ratio, + total_profit=profit_abs + self.realized_profit, + total_profit_ratio=total_profit_ratio, + ) def calc_profit_ratio( self, rate: float, amount: Optional[float] = None, @@ -936,15 +993,14 @@ class LocalTrade: short_close_zero = (self.is_short and close_trade_value == 0.0) long_close_zero = (not self.is_short and 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.is_short: - profit_ratio = (1 - (close_trade_value / open_trade_value)) * leverage + profit_ratio = (1 - (close_trade_value / open_trade_value)) * self.leverage else: - profit_ratio = ((close_trade_value / open_trade_value) - 1) * leverage + profit_ratio = ((close_trade_value / open_trade_value) - 1) * self.leverage return float(f"{profit_ratio:.8f}") @@ -957,7 +1013,6 @@ class LocalTrade: avg_price = FtPrecise(0.0) close_profit = 0.0 close_profit_abs = 0.0 - profit = None # Reset funding fees self.funding_fees = 0.0 funding_fees = 0.0 @@ -987,11 +1042,9 @@ class LocalTrade: exit_rate = o.safe_price exit_amount = o.safe_amount_after_fee - profit = self.calc_profit(rate=exit_rate, amount=exit_amount, - open_rate=float(avg_price)) - close_profit_abs += profit - close_profit = self.calc_profit_ratio( - exit_rate, amount=exit_amount, open_rate=avg_price) + prof = self.calculate_profit(exit_rate, exit_amount, float(avg_price)) + close_profit_abs += prof.profit_abs + close_profit = prof.profit_ratio else: total_stake = total_stake + self._calc_open_trade_value(tmp_amount, price) max_stake_amount += (tmp_amount * price) @@ -1001,7 +1054,7 @@ class LocalTrade: if close_profit: self.close_profit = close_profit self.realized_profit = close_profit_abs - self.close_profit_abs = profit + self.close_profit_abs = prof.profit_abs current_amount_tr = amount_to_contract_precision( float(current_amount), self.amount_precision, self.precision_mode, self.contract_size) @@ -1226,7 +1279,7 @@ class LocalTrade: logger.info(f"Found open trade: {trade}") # skip case if trailing-stop changed the stoploss already. - if (trade.stop_loss == trade.initial_stop_loss + if (not trade.is_stop_loss_trailing and trade.initial_stop_loss_pct != desired_stoploss): # Stoploss value got changed @@ -1298,6 +1351,8 @@ class Trade(ModelBase, LocalTrade): # percentage value of the initial stop loss initial_stop_loss_pct: Mapped[Optional[float]] = mapped_column( Float(), nullable=True) # type: ignore + is_stop_loss_trailing: Mapped[bool] = mapped_column( + nullable=False, default=False) # type: ignore # stoploss order id which is on exchange stoploss_order_id: Mapped[Optional[str]] = mapped_column( String(255), nullable=True, index=True) # type: ignore diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 9e4a4fca9..c2f102777 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -260,6 +260,7 @@ class VolumePairList(IPairList): quoteVolume = (pair_candles['quoteVolume'] .rolling(self._lookback_period) .sum() + .fillna(0) .iloc[-1]) # replace quoteVolume with range quoteVolume sum calculated above diff --git a/freqtrade/plugins/pairlist/pairlist_helpers.py b/freqtrade/plugins/pairlist/pairlist_helpers.py index 93d4fc308..ca8fdc200 100644 --- a/freqtrade/plugins/pairlist/pairlist_helpers.py +++ b/freqtrade/plugins/pairlist/pairlist_helpers.py @@ -29,9 +29,8 @@ def expand_pairlist(wildcardpl: List[str], available_pairs: List[str], except re.error as err: raise ValueError(f"Wildcard error in {pair_wc}, {err}") - for element in result: - if not re.fullmatch(r'^[A-Za-z0-9/-]+$', element): - result.remove(element) + result = [element for element in result if re.fullmatch(r'^[A-Za-z0-9:/-]+$', element)] + else: for pair_wc in wildcardpl: try: diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index 6f5b6655d..7e0204c0e 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -218,6 +218,12 @@ class StrategyResolver(IResolver): "Please update your strategy to implement " "`populate_indicators`, `populate_entry_trend` and `populate_exit_trend` " "with the metadata argument. ") + + has_after_fill = ('after_fill' in getfullargspec(strategy.custom_stoploss).args + and check_override(strategy, IStrategy, 'custom_stoploss')) + if has_after_fill: + strategy._ft_stop_uses_after_fill = True + return strategy @staticmethod diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index e5d76a62e..38dd92831 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -141,6 +141,10 @@ class Profit(BaseModel): expectancy_ratio: float max_drawdown: float max_drawdown_abs: float + max_drawdown_start: str + max_drawdown_start_timestamp: int + max_drawdown_end: str + max_drawdown_end_timestamp: int trading_volume: Optional[float] = None bot_start_timestamp: int bot_start_date: str @@ -157,7 +161,7 @@ class Stats(BaseModel): durations: Dict[str, Optional[float]] -class DailyRecord(BaseModel): +class DailyWeeklyMonthlyRecord(BaseModel): date: date abs_profit: float rel_profit: float @@ -166,8 +170,8 @@ class DailyRecord(BaseModel): trade_count: int -class Daily(BaseModel): - data: List[DailyRecord] +class DailyWeeklyMonthly(BaseModel): + data: List[DailyWeeklyMonthlyRecord] fiat_display_currency: str stake_currency: str diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 7299364a8..8b1bb2a48 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -11,7 +11,7 @@ from freqtrade.enums import CandleType, TradingMode from freqtrade.exceptions import OperationalException from freqtrade.rpc import RPC from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, BlacklistPayload, - BlacklistResponse, Count, Daily, + BlacklistResponse, Count, DailyWeeklyMonthly, DeleteLockRequest, DeleteTrade, ExchangeListResponse, ForceEnterPayload, ForceEnterResponse, ForceExitPayload, @@ -51,7 +51,8 @@ logger = logging.getLogger(__name__) # 2.30: new /pairlists endpoint # 2.31: new /backtest/history/ delete endpoint # 2.32: new /backtest/history/ patch endpoint -API_VERSION = 2.32 +# 2.33: Additional weekly/monthly metrics +API_VERSION = 2.33 # Public API, requires no auth. router_public = APIRouter() @@ -99,12 +100,24 @@ def stats(rpc: RPC = Depends(get_rpc)): return rpc._rpc_stats() -@router.get('/daily', response_model=Daily, tags=['info']) +@router.get('/daily', response_model=DailyWeeklyMonthly, tags=['info']) def daily(timescale: int = 7, rpc: RPC = Depends(get_rpc), config=Depends(get_config)): return rpc._rpc_timeunit_profit(timescale, config['stake_currency'], config.get('fiat_display_currency', '')) +@router.get('/weekly', response_model=DailyWeeklyMonthly, tags=['info']) +def weekly(timescale: int = 4, rpc: RPC = Depends(get_rpc), config=Depends(get_config)): + return rpc._rpc_timeunit_profit(timescale, config['stake_currency'], + config.get('fiat_display_currency', ''), 'weeks') + + +@router.get('/monthly', response_model=DailyWeeklyMonthly, tags=['info']) +def monthly(timescale: int = 3, rpc: RPC = Depends(get_rpc), config=Depends(get_config)): + return rpc._rpc_timeunit_profit(timescale, config['stake_currency'], + config.get('fiat_display_currency', ''), 'months') + + @router.get('/status', response_model=List[OpenTradeSchema], tags=['info']) def status(rpc: RPC = Depends(get_rpc)): try: diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index e46236131..a5d511827 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -16,7 +16,7 @@ from sqlalchemy import func, select from freqtrade import __version__ from freqtrade.configuration.timerange import TimeRange -from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT, Config +from freqtrade.constants import CANCEL_REASON, Config from freqtrade.data.history import load_data from freqtrade.data.metrics import calculate_expectancy, calculate_max_drawdown from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, MarketDirection, SignalDirection, @@ -31,7 +31,7 @@ from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.rpc.rpc_types import RPCSendMsg -from freqtrade.util import dt_humanize, dt_now, shorten_date +from freqtrade.util import dt_humanize, dt_now, dt_ts_def, format_date, shorten_date from freqtrade.wallets import PositionWallet, Wallet @@ -183,6 +183,8 @@ class RPC: ] oo_details = ''.join(map(str, oo_details_lst)) + total_profit_abs = 0.0 + total_profit_ratio: Optional[float] = None # calculate profit and send message to user if trade.is_open: try: @@ -191,23 +193,22 @@ class RPC: except (ExchangeError, PricingError): current_rate = NAN if len(trade.select_filled_orders(trade.entry_side)) > 0: - current_profit = trade.calc_profit_ratio( - current_rate) if not isnan(current_rate) else NAN - current_profit_abs = trade.calc_profit( - current_rate) if not isnan(current_rate) else NAN + + current_profit = current_profit_abs = current_profit_fiat = NAN + if not isnan(current_rate): + prof = trade.calculate_profit(current_rate) + current_profit = prof.profit_ratio + current_profit_abs = prof.profit_abs + total_profit_abs = prof.total_profit + total_profit_ratio = prof.total_profit_ratio else: current_profit = current_profit_abs = current_profit_fiat = 0.0 + else: # Closed trade ... current_rate = trade.close_rate current_profit = trade.close_profit or 0.0 current_profit_abs = trade.close_profit_abs or 0.0 - total_profit_abs = trade.realized_profit + current_profit_abs - total_profit_ratio: Optional[float] = None - if trade.max_stake_amount: - total_profit_ratio = ( - (total_profit_abs / trade.max_stake_amount) * trade.leverage - ) # Calculate fiat profit if not isnan(current_profit_abs) and self._fiat_converter: @@ -223,8 +224,11 @@ class RPC: ) # Calculate guaranteed profit (in case of trailing stop) - stoploss_entry_dist = trade.calc_profit(trade.stop_loss) - stoploss_entry_dist_ratio = trade.calc_profit_ratio(trade.stop_loss) + stop_entry = trade.calculate_profit(trade.stop_loss) + + stoploss_entry_dist = stop_entry.profit_abs + stoploss_entry_dist_ratio = stop_entry.profit_ratio + # calculate distance to stoploss stoploss_current_dist = trade.stop_loss - current_rate stoploss_current_dist_ratio = stoploss_current_dist / current_rate @@ -270,8 +274,9 @@ class RPC: profit_str = f'{NAN:.2%}' else: if trade.nr_of_successful_entries > 0: - trade_profit = trade.calc_profit(current_rate) - profit_str = f'{trade.calc_profit_ratio(current_rate):.2%}' + profit = trade.calculate_profit(current_rate) + trade_profit = profit.profit_abs + profit_str = f'{profit.profit_ratio:.2%}' else: trade_profit = 0.0 profit_str = f'{0.0:.2f}' @@ -371,7 +376,7 @@ class RPC: data = [ { - 'date': f"{key.year}-{key.month:02d}" if timeunit == 'months' else key, + 'date': key, 'abs_profit': value["amount"], 'starting_balance': value["daily_stake"], 'rel_profit': value["rel_profit"], @@ -494,9 +499,10 @@ class RPC: profit_ratio = NAN profit_abs = NAN else: - profit_ratio = trade.calc_profit_ratio(rate=current_rate) - profit_abs = trade.calc_profit( - rate=trade.close_rate or current_rate) + trade.realized_profit + profit = trade.calculate_profit(trade.close_rate or current_rate) + + profit_ratio = profit.profit_ratio + profit_abs = profit.total_profit profit_all_coin.append(profit_abs) profit_all_ratio.append(profit_ratio) @@ -532,7 +538,8 @@ class RPC: winrate = (winning_trades / closed_trade_count) if closed_trade_count > 0 else 0 - trades_df = DataFrame([{'close_date': trade.close_date.strftime(DATETIME_PRINT_FORMAT), + trades_df = DataFrame([{'close_date': format_date(trade.close_date), + 'close_date_dt': trade.close_date, 'profit_abs': trade.close_profit_abs} for trade in trades if not trade.is_open and trade.close_date]) @@ -540,10 +547,15 @@ class RPC: max_drawdown_abs = 0.0 max_drawdown = 0.0 + drawdown_start: Optional[datetime] = None + drawdown_end: Optional[datetime] = None + dd_high_val = dd_low_val = 0.0 if len(trades_df) > 0: try: - (max_drawdown_abs, _, _, _, _, max_drawdown) = calculate_max_drawdown( - trades_df, value_col='profit_abs', starting_balance=starting_balance) + (max_drawdown_abs, drawdown_start, drawdown_end, dd_high_val, dd_low_val, + max_drawdown) = calculate_max_drawdown( + trades_df, value_col='profit_abs', date_col='close_date_dt', + starting_balance=starting_balance) except ValueError: # ValueError if no losing trade. pass @@ -577,12 +589,12 @@ class RPC: 'profit_all_fiat': profit_all_fiat, 'trade_count': len(trades), 'closed_trade_count': closed_trade_count, - 'first_trade_date': first_date.strftime(DATETIME_PRINT_FORMAT) if first_date else '', + 'first_trade_date': format_date(first_date), 'first_trade_humanized': dt_humanize(first_date) if first_date else '', - 'first_trade_timestamp': int(first_date.timestamp() * 1000) if first_date else 0, - 'latest_trade_date': last_date.strftime(DATETIME_PRINT_FORMAT) if last_date else '', + 'first_trade_timestamp': dt_ts_def(first_date, 0), + 'latest_trade_date': format_date(last_date), 'latest_trade_humanized': dt_humanize(last_date) if last_date else '', - 'latest_trade_timestamp': int(last_date.timestamp() * 1000) if last_date else 0, + 'latest_trade_timestamp': dt_ts_def(last_date, 0), 'avg_duration': str(timedelta(seconds=sum(durations) / num)).split('.')[0], 'best_pair': best_pair[0] if best_pair else '', 'best_rate': round(best_pair[1] * 100, 2) if best_pair else 0, # Deprecated @@ -595,9 +607,15 @@ class RPC: 'expectancy_ratio': expectancy_ratio, 'max_drawdown': max_drawdown, 'max_drawdown_abs': max_drawdown_abs, + 'max_drawdown_start': format_date(drawdown_start), + 'max_drawdown_start_timestamp': dt_ts_def(drawdown_start), + 'max_drawdown_end': format_date(drawdown_end), + 'max_drawdown_end_timestamp': dt_ts_def(drawdown_end), + 'drawdown_high': dd_high_val, + 'drawdown_low': dd_low_val, 'trading_volume': trading_volume, - 'bot_start_timestamp': int(bot_start.timestamp() * 1000) if bot_start else 0, - 'bot_start_date': bot_start.strftime(DATETIME_PRINT_FORMAT) if bot_start else '', + 'bot_start_timestamp': dt_ts_def(bot_start, 0), + 'bot_start_date': format_date(bot_start), } def __balance_get_est_stake( @@ -1106,7 +1124,7 @@ class RPC: buffer = bufferHandler.buffer[-limit:] else: buffer = bufferHandler.buffer - records = [[datetime.fromtimestamp(r.created).strftime(DATETIME_PRINT_FORMAT), + records = [[format_date(datetime.fromtimestamp(r.created)), r.created * 1000, r.name, r.levelname, r.message + ('\n' + r.exc_text if r.exc_text else '')] for r in buffer] @@ -1323,7 +1341,7 @@ class RPC: return { "last_process": str(last_p), - "last_process_loc": last_p.astimezone(tzlocal()).strftime(DATETIME_PRINT_FORMAT), + "last_process_loc": format_date(last_p.astimezone(tzlocal())), "last_process_ts": int(last_p.timestamp()), } diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index dc4ec45b5..05ad99fb0 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -51,6 +51,7 @@ class TimeunitMappings: message2: str callback: str default: int + dateformat: str def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]): @@ -736,10 +737,10 @@ class Telegram(RPCHandler): """ vals = { - 'days': TimeunitMappings('Day', 'Daily', 'days', 'update_daily', 7), + 'days': TimeunitMappings('Day', 'Daily', 'days', 'update_daily', 7, '%Y-%m-%d'), 'weeks': TimeunitMappings('Monday', 'Weekly', 'weeks (starting from Monday)', - 'update_weekly', 8), - 'months': TimeunitMappings('Month', 'Monthly', 'months', 'update_monthly', 6), + 'update_weekly', 8, '%Y-%m-%d'), + 'months': TimeunitMappings('Month', 'Monthly', 'months', 'update_monthly', 6, '%Y-%m'), } val = vals[unit] @@ -756,7 +757,7 @@ class Telegram(RPCHandler): unit ) stats_tab = tabulate( - [[f"{period['date']} ({period['trade_count']})", + [[f"{period['date']:{val.dateformat}} ({period['trade_count']})", f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}", f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}", f"{period['rel_profit']:.2%}", @@ -888,7 +889,11 @@ class Telegram(RPCHandler): f"*Trading volume:* `{round_coin_value(stats['trading_volume'], stake_cur)}`\n" f"*Profit factor:* `{stats['profit_factor']:.2f}`\n" f"*Max Drawdown:* `{stats['max_drawdown']:.2%} " - f"({round_coin_value(stats['max_drawdown_abs'], stake_cur)})`" + f"({round_coin_value(stats['max_drawdown_abs'], stake_cur)})`\n" + f" from `{stats['max_drawdown_start']} " + f"({round_coin_value(stats['drawdown_high'], stake_cur)})`\n" + f" to `{stats['max_drawdown_end']} " + f"({round_coin_value(stats['drawdown_low'], stake_cur)})`\n" ) await self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit", query=update.callback_query) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index eca3e3ede..43d2a0baf 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -373,7 +373,7 @@ class IStrategy(ABC, HyperStrategyMixin): return True def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, - current_profit: float, **kwargs) -> float: + current_profit: float, after_fill: bool, **kwargs) -> Optional[float]: """ Custom stoploss logic, returning the new distance relative to current_rate (as ratio). e.g. returning -0.05 would create a stoploss 5% below current_rate. @@ -389,6 +389,7 @@ class IStrategy(ABC, HyperStrategyMixin): :param current_time: datetime object, containing the current datetime :param current_rate: Rate, calculated based on pricing settings in exit_pricing. :param current_profit: Current profit (as ratio), calculated based on current_rate. + :param after_fill: True if the stoploss is called after the order was filled. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. :return float: New stoploss value, relative to the current_rate """ @@ -719,6 +720,8 @@ class IStrategy(ABC, HyperStrategyMixin): # END - Intended to be overridden by strategy ### + _ft_stop_uses_after_fill = False + def __informative_pairs_freqai(self) -> ListPairsWithTimeframes: """ Create informative-pairs needed for FreqAI @@ -1160,13 +1163,17 @@ class IStrategy(ABC, HyperStrategyMixin): def ft_stoploss_adjust(self, current_rate: float, trade: Trade, current_time: datetime, current_profit: float, force_stoploss: float, low: Optional[float] = None, - high: Optional[float] = None) -> None: + high: Optional[float] = None, after_fill: bool = False) -> None: """ Adjust stop-loss dynamically if configured to do so. :param current_profit: current profit as ratio :param low: Low value of this candle, only set in backtesting :param high: High value of this candle, only set in backtesting """ + if after_fill and not self._ft_stop_uses_after_fill: + # Skip if the strategy doesn't support after fill. + return + stop_loss_value = force_stoploss if force_stoploss else self.stoploss # Initiate stoploss with open_rate. Does nothing if stoploss is already set. @@ -1181,18 +1188,20 @@ class IStrategy(ABC, HyperStrategyMixin): bound = (low if trade.is_short else high) bound_profit = current_profit if not bound else trade.calc_profit_ratio(bound) if self.use_custom_stoploss and dir_correct: - stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None, - supress_error=True - )(pair=trade.pair, trade=trade, - current_time=current_time, - current_rate=(bound or current_rate), - current_profit=bound_profit) + stop_loss_value_custom = strategy_safe_wrapper( + self.custom_stoploss, default_retval=None, supress_error=True + )(pair=trade.pair, trade=trade, + current_time=current_time, + current_rate=(bound or current_rate), + current_profit=bound_profit, + after_fill=after_fill) # Sanity check - error cases will return None - if stop_loss_value: - # logger.info(f"{trade.pair} {stop_loss_value=} {bound_profit=}") - trade.adjust_stop_loss(bound or current_rate, stop_loss_value) + if stop_loss_value_custom: + stop_loss_value = stop_loss_value_custom + trade.adjust_stop_loss(bound or current_rate, stop_loss_value, + allow_refresh=after_fill) else: - logger.warning("CustomStoploss function did not return valid stoploss") + logger.debug("CustomStoploss function did not return valid stoploss") if self.trailing_stop and dir_correct: # trailing stoploss handling @@ -1245,7 +1254,7 @@ class IStrategy(ABC, HyperStrategyMixin): exit_type = ExitType.STOP_LOSS # If initial stoploss is not the same as current one then it is trailing. - if trade.initial_stop_loss != trade.stop_loss: + if trade.is_stop_loss_trailing: exit_type = ExitType.TRAILING_STOP_LOSS logger.debug( f"{trade.pair} - HIT STOP: current price at " diff --git a/freqtrade/strategy/strategy_helper.py b/freqtrade/strategy/strategy_helper.py index 27ebe7e69..f47e1db45 100644 --- a/freqtrade/strategy/strategy_helper.py +++ b/freqtrade/strategy/strategy_helper.py @@ -123,7 +123,8 @@ def stoploss_from_open( return max(stoploss * leverage, 0.0) -def stoploss_from_absolute(stop_rate: float, current_rate: float, is_short: bool = False) -> float: +def stoploss_from_absolute(stop_rate: float, current_rate: float, is_short: bool = False, + leverage: float = 1.0) -> float: """ Given current price and desired stop price, return a stop loss value that is relative to current price. @@ -136,6 +137,7 @@ def stoploss_from_absolute(stop_rate: float, current_rate: float, is_short: bool :param stop_rate: Stop loss price. :param current_rate: Current asset price. :param is_short: When true, perform the calculation for short instead of long + :param leverage: Leverage to use for the calculation :return: Positive stop loss value relative to current price """ @@ -150,4 +152,4 @@ def stoploss_from_absolute(stop_rate: float, current_rate: float, is_short: bool # negative stoploss values indicate the requested stop price is higher/lower # (long/short) than the current price # shorts can yield stoploss values higher than 1, so limit that as well - return max(min(stoploss, 1.0), 0.0) + return max(min(stoploss, 1.0), 0.0) * leverage diff --git a/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 index bfbb20ec1..95c6df2ea 100644 --- a/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 +++ b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 @@ -102,8 +102,8 @@ def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: f use_custom_stoploss = True -def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', - current_rate: float, current_profit: float, **kwargs) -> float: +def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, + current_profit: float, after_fill: bool, **kwargs) -> float: """ Custom stoploss logic, returning the new distance relative to current_rate (as ratio). e.g. returning -0.05 would create a stoploss 5% below current_rate. @@ -111,7 +111,7 @@ def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ - When not implemented by a strategy, returns the initial stoploss value + When not implemented by a strategy, returns the initial stoploss value. Only called when use_custom_stoploss is set to True. :param pair: Pair that's currently analyzed @@ -119,10 +119,10 @@ def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', :param current_time: datetime object, containing the current datetime :param current_rate: Rate, calculated based on pricing settings in exit_pricing. :param current_profit: Current profit (as ratio), calculated based on current_rate. + :param after_fill: True if the stoploss is called after the order was filled. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. :return float: New stoploss value, relative to the current_rate """ - return self.stoploss def custom_exit(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, current_profit: float, **kwargs) -> 'Optional[Union[str, bool]]': diff --git a/freqtrade/util/__init__.py b/freqtrade/util/__init__.py index af09624ac..fc7cf5f6a 100644 --- a/freqtrade/util/__init__.py +++ b/freqtrade/util/__init__.py @@ -1,5 +1,6 @@ from freqtrade.util.datetime_helpers import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, - dt_utc, format_ms_time, shorten_date) + dt_ts_def, dt_utc, format_date, format_ms_time, + shorten_date) from freqtrade.util.ft_precise import FtPrecise from freqtrade.util.periodic_cache import PeriodicCache from freqtrade.util.template_renderer import render_template, render_template_with_fallback # noqa @@ -11,7 +12,9 @@ __all__ = [ 'dt_humanize', 'dt_now', 'dt_ts', + 'dt_ts_def', 'dt_utc', + 'format_date', 'format_ms_time', 'FtPrecise', 'PeriodicCache', diff --git a/freqtrade/util/datetime_helpers.py b/freqtrade/util/datetime_helpers.py index 7f44cbdb0..102c83143 100644 --- a/freqtrade/util/datetime_helpers.py +++ b/freqtrade/util/datetime_helpers.py @@ -4,6 +4,8 @@ from typing import Optional import arrow +from freqtrade.constants import DATETIME_PRINT_FORMAT + def dt_now() -> datetime: """Return the current datetime in UTC.""" @@ -26,6 +28,16 @@ def dt_ts(dt: Optional[datetime] = None) -> int: return int(dt_now().timestamp() * 1000) +def dt_ts_def(dt: Optional[datetime], default: int = 0) -> int: + """ + Return dt in ms as a timestamp in UTC. + If dt is None, return the current datetime in UTC. + """ + if dt: + return int(dt.timestamp() * 1000) + return default + + def dt_floor_day(dt: datetime) -> datetime: """Return the floor of the day for the given datetime.""" return dt.replace(hour=0, minute=0, second=0, microsecond=0) @@ -63,6 +75,17 @@ def dt_humanize(dt: datetime, **kwargs) -> str: return arrow.get(dt).humanize(**kwargs) +def format_date(date: Optional[datetime]) -> str: + """ + Return a formatted date string. + Returns an empty string if date is None. + :param date: datetime to format + """ + if date: + return date.strftime(DATETIME_PRINT_FORMAT) + return '' + + def format_ms_time(date: int) -> str: """ convert MS date to readable format. diff --git a/requirements-dev.txt b/requirements-dev.txt index cac9d5cd7..4230bee4b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,10 +7,10 @@ -r docs/requirements-docs.txt coveralls==3.3.1 -ruff==0.0.285 +ruff==0.0.287 mypy==1.5.1 -pre-commit==3.3.3 -pytest==7.4.0 +pre-commit==3.4.0 +pytest==7.4.1 pytest-asyncio==0.21.1 pytest-cov==4.1.0 pytest-mock==3.11.1 @@ -20,7 +20,7 @@ isort==5.12.0 time-machine==2.12.0 # Convert jupyter notebooks to markdown documents -nbconvert==7.7.4 +nbconvert==7.8.0 # mypy types types-cachetools==5.3.0.6 diff --git a/requirements-freqai.txt b/requirements-freqai.txt index d8421e968..8f690f957 100644 --- a/requirements-freqai.txt +++ b/requirements-freqai.txt @@ -5,7 +5,7 @@ # Required for freqai scikit-learn==1.1.3 joblib==1.3.2 -catboost==1.2; 'arm' not in platform_machine +catboost==1.2.1; 'arm' not in platform_machine lightgbm==4.0.0 xgboost==1.7.6 tensorboard==2.14.0 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 80b62e2f3..4cc7cc4c7 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,8 +2,7 @@ -r requirements.txt # Required for hyperopt -scipy==1.11.2; python_version >= '3.9' -scipy==1.10.1; python_version < '3.9' +scipy==1.11.2 scikit-learn==1.1.3 scikit-optimize==0.9.0 -filelock==3.12.2 +filelock==3.12.3 diff --git a/requirements.txt b/requirements.txt index bf346382f..763399d0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,13 @@ -numpy==1.25.2; python_version > '3.8' -numpy==1.24.3; python_version <= '3.8' -pandas==2.0.3 +numpy==1.25.2 +pandas==2.1.0 pandas-ta==0.3.14b -ccxt==4.0.71 +ccxt==4.0.81 cryptography==41.0.3; platform_machine != 'armv7l' cryptography==40.0.1; platform_machine == 'armv7l' aiohttp==3.8.5 SQLAlchemy==2.0.20 -python-telegram-bot==20.4 +python-telegram-bot==20.5 # can't be hard-pinned due to telegram-bot pinning httpx with ~ httpx>=0.24.1 arrow==1.2.3 @@ -25,7 +24,7 @@ tables==3.8.0 blosc==1.11.1 joblib==1.3.2 rich==13.5.2 -pyarrow==12.0.1; platform_machine != 'armv7l' +pyarrow==13.0.0; platform_machine != 'armv7l' # find first, C search in arrays py_find_1st==1.1.5 @@ -39,8 +38,8 @@ orjson==3.9.5 sdnotify==0.3.2 # API Server -fastapi==0.101.1 -pydantic==2.2.1 +fastapi==0.103.1 +pydantic==2.3.0 uvicorn==0.23.2 pyjwt==2.8.0 aiofiles==23.2.1 diff --git a/scripts/rest_client.py b/scripts/rest_client.py index 2b4690287..dfe50cc2c 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -134,6 +134,20 @@ class FtRestClient: """ return self._get("daily", params={"timescale": days} if days else None) + def weekly(self, weeks=None): + """Return the profits for each week, and amount of trades. + + :return: json object + """ + return self._get("weekly", params={"timescale": weeks} if weeks else None) + + def monthly(self, months=None): + """Return the profits for each month, and amount of trades. + + :return: json object + """ + return self._get("monthly", params={"timescale": months} if months else None) + def edge(self): """Return information about edge. diff --git a/setup.cfg b/setup.cfg index b54b62619..d4d70bc34 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,7 +14,6 @@ classifiers = Environment :: Console Intended Audience :: Science/Research License :: OSI Approved :: GNU General Public License v3 (GPLv3) - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 @@ -33,7 +32,7 @@ tests_require = pytest-mock packages = find: -python_requires = >=3.8 +python_requires = >=3.9 [options.entry_points] console_scripts = @@ -50,3 +49,5 @@ exclude = __pycache__, .eggs, user_data, + .venv + .env diff --git a/setup.sh b/setup.sh index cd9b85d2a..6bf85edab 100755 --- a/setup.sh +++ b/setup.sh @@ -25,7 +25,7 @@ function check_installed_python() { exit 2 fi - for v in 11 10 9 8 + for v in 11 10 9 do PYTHON="python3.${v}" which $PYTHON @@ -36,7 +36,7 @@ function check_installed_python() { fi done - echo "No usable python found. Please make sure to have python3.8 or newer installed." + echo "No usable python found. Please make sure to have python3.9 or newer installed." exit 1 } @@ -192,7 +192,7 @@ function update() { fi updateenv echo "Update completed." - echo_block "Don't forget to activate your virtual enviorment with 'source .venv/bin/activate'!" + echo_block "Don't forget to activate your virtual environment with 'source .venv/bin/activate'!" } @@ -277,7 +277,7 @@ function install() { install_redhat else echo "This script does not support your OS." - echo "If you have Python version 3.8 - 3.11, pip, virtualenv, ta-lib you can continue." + echo "If you have Python version 3.9 - 3.11, pip, virtualenv, ta-lib you can continue." echo "Wait 10 seconds to continue the next install steps or use ctrl+c to interrupt this shell." sleep 10 fi @@ -304,7 +304,7 @@ function help() { echo " -p,--plot Install dependencies for Plotting scripts." } -# Verify if 3.8+ is installed +# Verify if 3.9+ is installed check_installed_python case $* in diff --git a/tests/exchange/test_bybit.py b/tests/exchange/test_bybit.py index 7495f543b..f7383934b 100644 --- a/tests/exchange/test_bybit.py +++ b/tests/exchange/test_bybit.py @@ -1,9 +1,9 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.tradingmode import TradingMode -from tests.conftest import get_mock_coro, get_patched_exchange +from tests.conftest import EXMS, get_mock_coro, get_patched_exchange from tests.exchange.test_exchange import ccxt_exceptionhandlers @@ -68,3 +68,83 @@ def test_bybit_get_funding_fees(default_conf, mocker): exchange.get_funding_fees('BTC/USDT:USDT', 1, False, now) assert exchange._fetch_and_calculate_funding_fees.call_count == 1 + + +def test_bybit_fetch_orders(default_conf, mocker, limit_order): + + api_mock = MagicMock() + api_mock.fetch_orders = MagicMock(return_value=[ + limit_order['buy'], + limit_order['sell'], + ]) + api_mock.fetch_open_orders = MagicMock(return_value=[limit_order['buy']]) + api_mock.fetch_closed_orders = MagicMock(return_value=[limit_order['buy']]) + + mocker.patch(f'{EXMS}.exchange_has', return_value=True) + start_time = datetime.now(timezone.utc) - timedelta(days=20) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, id='bybit') + # Not available in dry-run + assert exchange.fetch_orders('mocked', start_time) == [] + assert api_mock.fetch_orders.call_count == 0 + default_conf['dry_run'] = False + + exchange = get_patched_exchange(mocker, default_conf, api_mock, id='bybit') + res = exchange.fetch_orders('mocked', start_time) + # Bybit will call the endpoint 3 times, as it has a limit of 7 days per call + assert api_mock.fetch_orders.call_count == 3 + assert api_mock.fetch_open_orders.call_count == 0 + assert api_mock.fetch_closed_orders.call_count == 0 + assert len(res) == 2 * 3 + + +def test_bybit_fetch_order_canceled_empty(default_conf_usdt, mocker): + default_conf_usdt['dry_run'] = False + + api_mock = MagicMock() + api_mock.fetch_order = MagicMock(return_value={ + 'id': '123', + 'symbol': 'BTC/USDT', + 'status': 'canceled', + 'filled': 0.0, + 'remaining': 0.0, + 'amount': 20.0, + }) + + exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, id='bybit') + + res = exchange.fetch_order('123', 'BTC/USDT') + assert res['remaining'] is None + assert res['filled'] == 0.0 + assert res['amount'] == 20.0 + assert res['status'] == 'canceled' + + api_mock.fetch_order = MagicMock(return_value={ + 'id': '123', + 'symbol': 'BTC/USDT', + 'status': 'canceled', + 'filled': 0.0, + 'remaining': 20.0, + 'amount': 20.0, + }) + # Don't touch orders which return correctly. + res1 = exchange.fetch_order('123', 'BTC/USDT') + assert res1['remaining'] == 20.0 + assert res1['filled'] == 0.0 + assert res1['amount'] == 20.0 + assert res1['status'] == 'canceled' + + # Reverse test - remaining is not touched + api_mock.fetch_order = MagicMock(return_value={ + 'id': '124', + 'symbol': 'BTC/USDT', + 'status': 'open', + 'filled': 0.0, + 'remaining': 20.0, + 'amount': 20.0, + }) + res2 = exchange.fetch_order('123', 'BTC/USDT') + assert res2['remaining'] == 20.0 + assert res2['filled'] == 0.0 + assert res2['amount'] == 20.0 + assert res2['status'] == 'open' diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index adb8b7497..73baa15f2 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -7,20 +7,16 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch import ccxt import pytest -from ccxt import DECIMAL_PLACES, ROUND, ROUND_UP, TICK_SIZE, TRUNCATE from pandas import DataFrame from freqtrade.enums import CandleType, MarginMode, TradingMode from freqtrade.exceptions import (DDosProtection, DependencyException, ExchangeError, InsufficientFundsError, InvalidOrderException, OperationalException, PricingError, TemporaryError) -from freqtrade.exchange import (Binance, Bittrex, Exchange, Kraken, amount_to_precision, - date_minus_candles, market_is_active, price_to_precision, - timeframe_to_minutes, timeframe_to_msecs, timeframe_to_next_date, - timeframe_to_prev_date, timeframe_to_seconds) +from freqtrade.exchange import (Binance, Bittrex, Exchange, Kraken, market_is_active, + timeframe_to_prev_date) from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, API_RETRY_COUNT, calculate_backoff, remove_exchange_credentials) -from freqtrade.exchange.exchange import amount_to_contract_precision from freqtrade.resolvers.exchange_resolver import ExchangeResolver from freqtrade.util import dt_now, dt_ts from tests.conftest import (EXMS, generate_test_data_raw, get_mock_coro, get_patched_exchange, @@ -287,87 +283,6 @@ def test_validate_order_time_in_force(default_conf, mocker, caplog): ex.validate_order_time_in_force(tif2) -@pytest.mark.parametrize("amount,precision_mode,precision,expected", [ - (2.34559, 2, 4, 2.3455), - (2.34559, 2, 5, 2.34559), - (2.34559, 2, 3, 2.345), - (2.9999, 2, 3, 2.999), - (2.9909, 2, 3, 2.990), - (2.9909, 2, 0, 2), - (29991.5555, 2, 0, 29991), - (29991.5555, 2, -1, 29990), - (29991.5555, 2, -2, 29900), - # Tests for Tick-size - (2.34559, 4, 0.0001, 2.3455), - (2.34559, 4, 0.00001, 2.34559), - (2.34559, 4, 0.001, 2.345), - (2.9999, 4, 0.001, 2.999), - (2.9909, 4, 0.001, 2.990), - (2.9909, 4, 0.005, 2.99), - (2.9999, 4, 0.005, 2.995), -]) -def test_amount_to_precision(amount, precision_mode, precision, expected,): - """ - Test rounds down - """ - # digits counting mode - # DECIMAL_PLACES = 2 - # SIGNIFICANT_DIGITS = 3 - # TICK_SIZE = 4 - - assert amount_to_precision(amount, precision, precision_mode) == expected - - -@pytest.mark.parametrize("price,precision_mode,precision,expected,rounding_mode", [ - # Tests for DECIMAL_PLACES, ROUND_UP - (2.34559, 2, 4, 2.3456, ROUND_UP), - (2.34559, 2, 5, 2.34559, ROUND_UP), - (2.34559, 2, 3, 2.346, ROUND_UP), - (2.9999, 2, 3, 3.000, ROUND_UP), - (2.9909, 2, 3, 2.991, ROUND_UP), - # Tests for DECIMAL_PLACES, ROUND - (2.345600000000001, DECIMAL_PLACES, 4, 2.3456, ROUND), - (2.345551, DECIMAL_PLACES, 4, 2.3456, ROUND), - (2.49, DECIMAL_PLACES, 0, 2., ROUND), - (2.51, DECIMAL_PLACES, 0, 3., ROUND), - (5.1, DECIMAL_PLACES, -1, 10., ROUND), - (4.9, DECIMAL_PLACES, -1, 0., ROUND), - # Tests for TICK_SIZE, ROUND_UP - (2.34559, TICK_SIZE, 0.0001, 2.3456, ROUND_UP), - (2.34559, TICK_SIZE, 0.00001, 2.34559, ROUND_UP), - (2.34559, TICK_SIZE, 0.001, 2.346, ROUND_UP), - (2.9999, TICK_SIZE, 0.001, 3.000, ROUND_UP), - (2.9909, TICK_SIZE, 0.001, 2.991, ROUND_UP), - (2.9909, TICK_SIZE, 0.005, 2.995, ROUND_UP), - (2.9973, TICK_SIZE, 0.005, 3.0, ROUND_UP), - (2.9977, TICK_SIZE, 0.005, 3.0, ROUND_UP), - (234.43, TICK_SIZE, 0.5, 234.5, ROUND_UP), - (234.53, TICK_SIZE, 0.5, 235.0, ROUND_UP), - (0.891534, TICK_SIZE, 0.0001, 0.8916, ROUND_UP), - (64968.89, TICK_SIZE, 0.01, 64968.89, ROUND_UP), - (0.000000003483, TICK_SIZE, 1e-12, 0.000000003483, ROUND_UP), - # Tests for TICK_SIZE, ROUND - (2.49, TICK_SIZE, 1., 2., ROUND), - (2.51, TICK_SIZE, 1., 3., ROUND), - (2.000000051, TICK_SIZE, 0.0000001, 2.0000001, ROUND), - (2.000000049, TICK_SIZE, 0.0000001, 2., ROUND), - (2.9909, TICK_SIZE, 0.005, 2.990, ROUND), - (2.9973, TICK_SIZE, 0.005, 2.995, ROUND), - (2.9977, TICK_SIZE, 0.005, 3.0, ROUND), - (234.24, TICK_SIZE, 0.5, 234., ROUND), - (234.26, TICK_SIZE, 0.5, 234.5, ROUND), - # Tests for TRUNCATTE - (2.34559, 2, 4, 2.3455, TRUNCATE), - (2.34559, 2, 5, 2.34559, TRUNCATE), - (2.34559, 2, 3, 2.345, TRUNCATE), - (2.9999, 2, 3, 2.999, TRUNCATE), - (2.9909, 2, 3, 2.990, TRUNCATE), -]) -def test_price_to_precision(price, precision_mode, precision, expected, rounding_mode): - assert price_to_precision( - price, precision, precision_mode, rounding_mode=rounding_mode) == expected - - @pytest.mark.parametrize("price,precision_mode,precision,expected", [ (2.34559, 2, 4, 0.0001), (2.34559, 2, 5, 0.00001), @@ -3640,96 +3555,6 @@ def test_ohlcv_candle_limit(default_conf, mocker, exchange_name): assert exchange.ohlcv_candle_limit(timeframe, CandleType.SPOT) == expected -def test_timeframe_to_minutes(): - assert timeframe_to_minutes("5m") == 5 - assert timeframe_to_minutes("10m") == 10 - assert timeframe_to_minutes("1h") == 60 - assert timeframe_to_minutes("1d") == 1440 - - -def test_timeframe_to_seconds(): - assert timeframe_to_seconds("5m") == 300 - assert timeframe_to_seconds("10m") == 600 - assert timeframe_to_seconds("1h") == 3600 - assert timeframe_to_seconds("1d") == 86400 - - -def test_timeframe_to_msecs(): - assert timeframe_to_msecs("5m") == 300000 - assert timeframe_to_msecs("10m") == 600000 - assert timeframe_to_msecs("1h") == 3600000 - assert timeframe_to_msecs("1d") == 86400000 - - -def test_timeframe_to_prev_date(): - # 2019-08-12 13:22:08 - date = datetime.fromtimestamp(1565616128, tz=timezone.utc) - - tf_list = [ - # 5m -> 2019-08-12 13:20:00 - ("5m", datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc)), - # 10m -> 2019-08-12 13:20:00 - ("10m", datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc)), - # 1h -> 2019-08-12 13:00:00 - ("1h", datetime(2019, 8, 12, 13, 00, 0, tzinfo=timezone.utc)), - # 2h -> 2019-08-12 12:00:00 - ("2h", datetime(2019, 8, 12, 12, 00, 0, tzinfo=timezone.utc)), - # 4h -> 2019-08-12 12:00:00 - ("4h", datetime(2019, 8, 12, 12, 00, 0, tzinfo=timezone.utc)), - # 1d -> 2019-08-12 00:00:00 - ("1d", datetime(2019, 8, 12, 00, 00, 0, tzinfo=timezone.utc)), - ] - for interval, result in tf_list: - assert timeframe_to_prev_date(interval, date) == result - - date = datetime.now(tz=timezone.utc) - assert timeframe_to_prev_date("5m") < date - # Does not round - time = datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc) - assert timeframe_to_prev_date('5m', time) == time - time = datetime(2019, 8, 12, 13, 0, 0, tzinfo=timezone.utc) - assert timeframe_to_prev_date('1h', time) == time - - -def test_timeframe_to_next_date(): - # 2019-08-12 13:22:08 - date = datetime.fromtimestamp(1565616128, tz=timezone.utc) - tf_list = [ - # 5m -> 2019-08-12 13:25:00 - ("5m", datetime(2019, 8, 12, 13, 25, 0, tzinfo=timezone.utc)), - # 10m -> 2019-08-12 13:30:00 - ("10m", datetime(2019, 8, 12, 13, 30, 0, tzinfo=timezone.utc)), - # 1h -> 2019-08-12 14:00:00 - ("1h", datetime(2019, 8, 12, 14, 00, 0, tzinfo=timezone.utc)), - # 2h -> 2019-08-12 14:00:00 - ("2h", datetime(2019, 8, 12, 14, 00, 0, tzinfo=timezone.utc)), - # 4h -> 2019-08-12 14:00:00 - ("4h", datetime(2019, 8, 12, 16, 00, 0, tzinfo=timezone.utc)), - # 1d -> 2019-08-13 00:00:00 - ("1d", datetime(2019, 8, 13, 0, 0, 0, tzinfo=timezone.utc)), - ] - - for interval, result in tf_list: - assert timeframe_to_next_date(interval, date) == result - - date = datetime.now(tz=timezone.utc) - assert timeframe_to_next_date("5m") > date - - date = datetime(2019, 8, 12, 13, 30, 0, tzinfo=timezone.utc) - assert timeframe_to_next_date("5m", date) == date + timedelta(minutes=5) - - -def test_date_minus_candles(): - - date = datetime(2019, 8, 12, 13, 25, 0, tzinfo=timezone.utc) - - assert date_minus_candles("5m", 3, date) == date - timedelta(minutes=15) - assert date_minus_candles("5m", 5, date) == date - timedelta(minutes=25) - assert date_minus_candles("1m", 6, date) == date - timedelta(minutes=6) - assert date_minus_candles("1h", 3, date) == date - timedelta(hours=3, minutes=25) - assert date_minus_candles("1h", 3) == timeframe_to_prev_date('1h') - timedelta(hours=3) - - @pytest.mark.parametrize( "market_symbol,base,quote,exchange,spot,margin,futures,trademode,add_dict,expected_result", [ @@ -4623,20 +4448,6 @@ def test_amount_to_contract_precision( assert result_size == expected_fut -@pytest.mark.parametrize('amount,precision,precision_mode,contract_size,expected', [ - (1.17, 1.0, 4, 0.01, 1.17), # Tick size - (1.17, 1.0, 2, 0.01, 1.17), # - (1.16, 1.0, 4, 0.01, 1.16), # - (1.16, 1.0, 2, 0.01, 1.16), # - (1.13, 1.0, 2, 0.01, 1.13), # - (10.988, 1.0, 2, 10, 10), - (10.988, 1.0, 4, 10, 10), -]) -def test_amount_to_contract_precision2(amount, precision, precision_mode, contract_size, expected): - res = amount_to_contract_precision(amount, precision, precision_mode, contract_size) - assert pytest.approx(res) == expected - - @pytest.mark.parametrize('exchange_name,open_rate,is_short,trading_mode,margin_mode', [ # Bittrex ('bittrex', 2.0, False, 'spot', None), diff --git a/tests/exchange/test_exchange_utils.py b/tests/exchange/test_exchange_utils.py index db206ab98..377514468 100644 --- a/tests/exchange/test_exchange_utils.py +++ b/tests/exchange/test_exchange_utils.py @@ -1,9 +1,16 @@ # pragma pylint: disable=missing-docstring, protected-access, invalid-name +from datetime import datetime, timedelta, timezone import pytest +from ccxt import (DECIMAL_PLACES, ROUND, ROUND_DOWN, ROUND_UP, SIGNIFICANT_DIGITS, TICK_SIZE, + TRUNCATE) from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException +from freqtrade.exchange import (amount_to_contract_precision, amount_to_precision, + date_minus_candles, price_to_precision, timeframe_to_minutes, + timeframe_to_msecs, timeframe_to_next_date, timeframe_to_prev_date, + timeframe_to_seconds) from freqtrade.exchange.check_exchange import check_exchange from tests.conftest import log_has_re @@ -83,3 +90,239 @@ def test_check_exchange(default_conf, caplog) -> None: with pytest.raises(OperationalException, match=r'This command requires a configured exchange.*'): check_exchange(default_conf) + + +def test_date_minus_candles(): + + date = datetime(2019, 8, 12, 13, 25, 0, tzinfo=timezone.utc) + + assert date_minus_candles("5m", 3, date) == date - timedelta(minutes=15) + assert date_minus_candles("5m", 5, date) == date - timedelta(minutes=25) + assert date_minus_candles("1m", 6, date) == date - timedelta(minutes=6) + assert date_minus_candles("1h", 3, date) == date - timedelta(hours=3, minutes=25) + assert date_minus_candles("1h", 3) == timeframe_to_prev_date('1h') - timedelta(hours=3) + + +def test_timeframe_to_minutes(): + assert timeframe_to_minutes("5m") == 5 + assert timeframe_to_minutes("10m") == 10 + assert timeframe_to_minutes("1h") == 60 + assert timeframe_to_minutes("1d") == 1440 + + +def test_timeframe_to_seconds(): + assert timeframe_to_seconds("5m") == 300 + assert timeframe_to_seconds("10m") == 600 + assert timeframe_to_seconds("1h") == 3600 + assert timeframe_to_seconds("1d") == 86400 + + +def test_timeframe_to_msecs(): + assert timeframe_to_msecs("5m") == 300000 + assert timeframe_to_msecs("10m") == 600000 + assert timeframe_to_msecs("1h") == 3600000 + assert timeframe_to_msecs("1d") == 86400000 + + +def test_timeframe_to_prev_date(): + # 2019-08-12 13:22:08 + date = datetime.fromtimestamp(1565616128, tz=timezone.utc) + + tf_list = [ + # 5m -> 2019-08-12 13:20:00 + ("5m", datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc)), + # 10m -> 2019-08-12 13:20:00 + ("10m", datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc)), + # 1h -> 2019-08-12 13:00:00 + ("1h", datetime(2019, 8, 12, 13, 00, 0, tzinfo=timezone.utc)), + # 2h -> 2019-08-12 12:00:00 + ("2h", datetime(2019, 8, 12, 12, 00, 0, tzinfo=timezone.utc)), + # 4h -> 2019-08-12 12:00:00 + ("4h", datetime(2019, 8, 12, 12, 00, 0, tzinfo=timezone.utc)), + # 1d -> 2019-08-12 00:00:00 + ("1d", datetime(2019, 8, 12, 00, 00, 0, tzinfo=timezone.utc)), + ] + for interval, result in tf_list: + assert timeframe_to_prev_date(interval, date) == result + + date = datetime.now(tz=timezone.utc) + assert timeframe_to_prev_date("5m") < date + # Does not round + time = datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc) + assert timeframe_to_prev_date('5m', time) == time + time = datetime(2019, 8, 12, 13, 0, 0, tzinfo=timezone.utc) + assert timeframe_to_prev_date('1h', time) == time + + +def test_timeframe_to_next_date(): + # 2019-08-12 13:22:08 + date = datetime.fromtimestamp(1565616128, tz=timezone.utc) + tf_list = [ + # 5m -> 2019-08-12 13:25:00 + ("5m", datetime(2019, 8, 12, 13, 25, 0, tzinfo=timezone.utc)), + # 10m -> 2019-08-12 13:30:00 + ("10m", datetime(2019, 8, 12, 13, 30, 0, tzinfo=timezone.utc)), + # 1h -> 2019-08-12 14:00:00 + ("1h", datetime(2019, 8, 12, 14, 00, 0, tzinfo=timezone.utc)), + # 2h -> 2019-08-12 14:00:00 + ("2h", datetime(2019, 8, 12, 14, 00, 0, tzinfo=timezone.utc)), + # 4h -> 2019-08-12 14:00:00 + ("4h", datetime(2019, 8, 12, 16, 00, 0, tzinfo=timezone.utc)), + # 1d -> 2019-08-13 00:00:00 + ("1d", datetime(2019, 8, 13, 0, 0, 0, tzinfo=timezone.utc)), + ] + + for interval, result in tf_list: + assert timeframe_to_next_date(interval, date) == result + + date = datetime.now(tz=timezone.utc) + assert timeframe_to_next_date("5m") > date + + date = datetime(2019, 8, 12, 13, 30, 0, tzinfo=timezone.utc) + assert timeframe_to_next_date("5m", date) == date + timedelta(minutes=5) + + +@pytest.mark.parametrize("amount,precision_mode,precision,expected", [ + (2.34559, DECIMAL_PLACES, 4, 2.3455), + (2.34559, DECIMAL_PLACES, 5, 2.34559), + (2.34559, DECIMAL_PLACES, 3, 2.345), + (2.9999, DECIMAL_PLACES, 3, 2.999), + (2.9909, DECIMAL_PLACES, 3, 2.990), + (2.9909, DECIMAL_PLACES, 0, 2), + (29991.5555, DECIMAL_PLACES, 0, 29991), + (29991.5555, DECIMAL_PLACES, -1, 29990), + (29991.5555, DECIMAL_PLACES, -2, 29900), + # Tests for + (2.34559, SIGNIFICANT_DIGITS, 4, 2.345), + (2.34559, SIGNIFICANT_DIGITS, 5, 2.3455), + (2.34559, SIGNIFICANT_DIGITS, 3, 2.34), + (2.9999, SIGNIFICANT_DIGITS, 3, 2.99), + (2.9909, SIGNIFICANT_DIGITS, 3, 2.99), + (0.0000077723, SIGNIFICANT_DIGITS, 5, 0.0000077723), + (0.0000077723, SIGNIFICANT_DIGITS, 3, 0.00000777), + (0.0000077723, SIGNIFICANT_DIGITS, 1, 0.000007), + # Tests for Tick-size + (2.34559, TICK_SIZE, 0.0001, 2.3455), + (2.34559, TICK_SIZE, 0.00001, 2.34559), + (2.34559, TICK_SIZE, 0.001, 2.345), + (2.9999, TICK_SIZE, 0.001, 2.999), + (2.9909, TICK_SIZE, 0.001, 2.990), + (2.9909, TICK_SIZE, 0.005, 2.99), + (2.9999, TICK_SIZE, 0.005, 2.995), +]) +def test_amount_to_precision(amount, precision_mode, precision, expected,): + """ + Test rounds down + """ + # digits counting mode + # DECIMAL_PLACES = 2 + # SIGNIFICANT_DIGITS = 3 + # TICK_SIZE = 4 + + assert amount_to_precision(amount, precision, precision_mode) == expected + + +@pytest.mark.parametrize("price,precision_mode,precision,expected,rounding_mode", [ + # Tests for DECIMAL_PLACES, ROUND_UP + (2.34559, DECIMAL_PLACES, 4, 2.3456, ROUND_UP), + (2.34559, DECIMAL_PLACES, 5, 2.34559, ROUND_UP), + (2.34559, DECIMAL_PLACES, 3, 2.346, ROUND_UP), + (2.9999, DECIMAL_PLACES, 3, 3.000, ROUND_UP), + (2.9909, DECIMAL_PLACES, 3, 2.991, ROUND_UP), + (2.9901, DECIMAL_PLACES, 3, 2.991, ROUND_UP), + (2.34559, DECIMAL_PLACES, 5, 2.34559, ROUND_DOWN), + (2.34559, DECIMAL_PLACES, 4, 2.3455, ROUND_DOWN), + (2.9901, DECIMAL_PLACES, 3, 2.990, ROUND_DOWN), + (0.00299, DECIMAL_PLACES, 3, 0.002, ROUND_DOWN), + # Tests for DECIMAL_PLACES, ROUND + (2.345600000000001, DECIMAL_PLACES, 4, 2.3456, ROUND), + (2.345551, DECIMAL_PLACES, 4, 2.3456, ROUND), + (2.49, DECIMAL_PLACES, 0, 2., ROUND), + (2.51, DECIMAL_PLACES, 0, 3., ROUND), + (5.1, DECIMAL_PLACES, -1, 10., ROUND), + (4.9, DECIMAL_PLACES, -1, 0., ROUND), + (0.000007222, SIGNIFICANT_DIGITS, 1, 0.000007, ROUND), + (0.000007222, SIGNIFICANT_DIGITS, 2, 0.0000072, ROUND), + (0.000007777, SIGNIFICANT_DIGITS, 2, 0.0000078, ROUND), + # Tests for TICK_SIZE, ROUND_UP + (2.34559, TICK_SIZE, 0.0001, 2.3456, ROUND_UP), + (2.34559, TICK_SIZE, 0.00001, 2.34559, ROUND_UP), + (2.34559, TICK_SIZE, 0.001, 2.346, ROUND_UP), + (2.9999, TICK_SIZE, 0.001, 3.000, ROUND_UP), + (2.9909, TICK_SIZE, 0.001, 2.991, ROUND_UP), + (2.9909, TICK_SIZE, 0.001, 2.990, ROUND_DOWN), + (2.9909, TICK_SIZE, 0.005, 2.995, ROUND_UP), + (2.9973, TICK_SIZE, 0.005, 3.0, ROUND_UP), + (2.9977, TICK_SIZE, 0.005, 3.0, ROUND_UP), + (234.43, TICK_SIZE, 0.5, 234.5, ROUND_UP), + (234.43, TICK_SIZE, 0.5, 234.0, ROUND_DOWN), + (234.53, TICK_SIZE, 0.5, 235.0, ROUND_UP), + (234.53, TICK_SIZE, 0.5, 234.5, ROUND_DOWN), + (0.891534, TICK_SIZE, 0.0001, 0.8916, ROUND_UP), + (64968.89, TICK_SIZE, 0.01, 64968.89, ROUND_UP), + (0.000000003483, TICK_SIZE, 1e-12, 0.000000003483, ROUND_UP), + # Tests for TICK_SIZE, ROUND + (2.49, TICK_SIZE, 1., 2., ROUND), + (2.51, TICK_SIZE, 1., 3., ROUND), + (2.000000051, TICK_SIZE, 0.0000001, 2.0000001, ROUND), + (2.000000049, TICK_SIZE, 0.0000001, 2., ROUND), + (2.9909, TICK_SIZE, 0.005, 2.990, ROUND), + (2.9973, TICK_SIZE, 0.005, 2.995, ROUND), + (2.9977, TICK_SIZE, 0.005, 3.0, ROUND), + (234.24, TICK_SIZE, 0.5, 234., ROUND), + (234.26, TICK_SIZE, 0.5, 234.5, ROUND), + # Tests for TRUNCATTE + (2.34559, DECIMAL_PLACES, 4, 2.3455, TRUNCATE), + (2.34559, DECIMAL_PLACES, 5, 2.34559, TRUNCATE), + (2.34559, DECIMAL_PLACES, 3, 2.345, TRUNCATE), + (2.9999, DECIMAL_PLACES, 3, 2.999, TRUNCATE), + (2.9909, DECIMAL_PLACES, 3, 2.990, TRUNCATE), + (2.9909, TICK_SIZE, 0.001, 2.990, TRUNCATE), + (2.9909, TICK_SIZE, 0.01, 2.99, TRUNCATE), + (2.9909, TICK_SIZE, 0.1, 2.9, TRUNCATE), + # Tests for Significant + (2.34559, SIGNIFICANT_DIGITS, 4, 2.345, TRUNCATE), + (2.34559, SIGNIFICANT_DIGITS, 5, 2.3455, TRUNCATE), + (2.34559, SIGNIFICANT_DIGITS, 3, 2.34, TRUNCATE), + (2.9999, SIGNIFICANT_DIGITS, 3, 2.99, TRUNCATE), + (2.9909, SIGNIFICANT_DIGITS, 2, 2.9, TRUNCATE), + (0.00000777, SIGNIFICANT_DIGITS, 2, 0.0000077, TRUNCATE), + (0.00000729, SIGNIFICANT_DIGITS, 2, 0.0000072, TRUNCATE), + # ROUND + (722.2, SIGNIFICANT_DIGITS, 1, 700.0, ROUND), + (790.2, SIGNIFICANT_DIGITS, 1, 800.0, ROUND), + (722.2, SIGNIFICANT_DIGITS, 2, 720.0, ROUND), + (722.2, SIGNIFICANT_DIGITS, 1, 800.0, ROUND_UP), + (722.2, SIGNIFICANT_DIGITS, 2, 730.0, ROUND_UP), + (777.7, SIGNIFICANT_DIGITS, 2, 780.0, ROUND_UP), + (777.7, SIGNIFICANT_DIGITS, 3, 778.0, ROUND_UP), + (722.2, SIGNIFICANT_DIGITS, 1, 700.0, ROUND_DOWN), + (722.2, SIGNIFICANT_DIGITS, 2, 720.0, ROUND_DOWN), + (777.7, SIGNIFICANT_DIGITS, 2, 770.0, ROUND_DOWN), + (777.7, SIGNIFICANT_DIGITS, 3, 777.0, ROUND_DOWN), + + (0.000007222, SIGNIFICANT_DIGITS, 1, 0.000008, ROUND_UP), + (0.000007222, SIGNIFICANT_DIGITS, 2, 0.0000073, ROUND_UP), + (0.000007777, SIGNIFICANT_DIGITS, 2, 0.0000078, ROUND_UP), + (0.000007222, SIGNIFICANT_DIGITS, 1, 0.000007, ROUND_DOWN), + (0.000007222, SIGNIFICANT_DIGITS, 2, 0.0000072, ROUND_DOWN), + (0.000007777, SIGNIFICANT_DIGITS, 2, 0.0000077, ROUND_DOWN), +]) +def test_price_to_precision(price, precision_mode, precision, expected, rounding_mode): + assert price_to_precision( + price, precision, precision_mode, rounding_mode=rounding_mode) == expected + + +@pytest.mark.parametrize('amount,precision,precision_mode,contract_size,expected', [ + (1.17, 1.0, 4, 0.01, 1.17), # Tick size + (1.17, 1.0, 2, 0.01, 1.17), # + (1.16, 1.0, 4, 0.01, 1.16), # + (1.16, 1.0, 2, 0.01, 1.16), # + (1.13, 1.0, 2, 0.01, 1.13), # + (10.988, 1.0, 2, 10, 10), + (10.988, 1.0, 4, 10, 10), +]) +def test_amount_to_contract_precision_standalone(amount, precision, precision_mode, contract_size, + expected): + res = amount_to_contract_precision(amount, precision, precision_mode, contract_size) + assert pytest.approx(res) == expected diff --git a/tests/persistence/test_persistence.py b/tests/persistence/test_persistence.py index 6e1b75045..8032602cc 100644 --- a/tests/persistence/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -78,18 +78,28 @@ def test_set_stop_loss_liquidation(fee): assert trade.liquidation_price == 0.11 # Stoploss does not change from liquidation price assert trade.stop_loss == 1.8 + assert trade.stop_loss_pct == -0.2 assert trade.initial_stop_loss == 1.8 # lower stop doesn't move stoploss trade.adjust_stop_loss(1.8, 0.2) assert trade.liquidation_price == 0.11 assert trade.stop_loss == 1.8 + assert trade.stop_loss_pct == -0.2 + assert trade.initial_stop_loss == 1.8 + + # Lower stop with "allow_refresh" does move stoploss + trade.adjust_stop_loss(1.8, 0.22, allow_refresh=True) + assert trade.liquidation_price == 0.11 + assert trade.stop_loss == 1.602 + assert trade.stop_loss_pct == -0.22 assert trade.initial_stop_loss == 1.8 # higher stop does move stoploss trade.adjust_stop_loss(2.1, 0.1) assert trade.liquidation_price == 0.11 assert pytest.approx(trade.stop_loss) == 1.994999 + assert trade.stop_loss_pct == -0.1 assert trade.initial_stop_loss == 1.8 assert trade.stoploss_or_liquidation == trade.stop_loss @@ -131,12 +141,21 @@ def test_set_stop_loss_liquidation(fee): assert trade.liquidation_price == 3.8 # Stoploss does not change from liquidation price assert trade.stop_loss == 2.2 + assert trade.stop_loss_pct == -0.2 assert trade.initial_stop_loss == 2.2 # Stop doesn't move stop higher trade.adjust_stop_loss(2.0, 0.3) assert trade.liquidation_price == 3.8 assert trade.stop_loss == 2.2 + assert trade.stop_loss_pct == -0.2 + assert trade.initial_stop_loss == 2.2 + + # Stop does move stop higher with "allow_refresh" + trade.adjust_stop_loss(2.0, 0.3, allow_refresh=True) + assert trade.liquidation_price == 3.8 + assert trade.stop_loss == 2.3 + assert trade.stop_loss_pct == -0.3 assert trade.initial_stop_loss == 2.2 # Stoploss does move lower @@ -144,6 +163,7 @@ def test_set_stop_loss_liquidation(fee): trade.adjust_stop_loss(1.8, 0.1) assert trade.liquidation_price == 1.5 assert pytest.approx(trade.stop_loss) == 1.89 + assert trade.stop_loss_pct == -0.1 assert trade.initial_stop_loss == 2.2 assert trade.stoploss_or_liquidation == 1.5 @@ -1125,13 +1145,30 @@ def test_calc_profit( leverage=lev, fee_open=0.0025, fee_close=fee_close, + max_stake_amount=60.0, trading_mode=trading_mode, funding_fees=funding_fees ) + profit_res = trade.calculate_profit(close_rate) + assert pytest.approx(profit_res.profit_abs) == round(profit, 8) + assert pytest.approx(profit_res.profit_ratio) == round(profit_ratio, 8) + val = trade.open_trade_value * (profit_res.profit_ratio) / lev + assert pytest.approx(val) == profit_res.profit_abs + + assert pytest.approx(profit_res.total_profit) == round(profit, 8) + # assert pytest.approx(profit_res.total_profit_ratio) == round(profit_ratio, 8) + assert pytest.approx(trade.calc_profit(rate=close_rate)) == round(profit, 8) assert pytest.approx(trade.calc_profit_ratio(rate=close_rate)) == round(profit_ratio, 8) + profit_res2 = trade.calculate_profit(close_rate, trade.amount, trade.open_rate) + assert pytest.approx(profit_res2.profit_abs) == round(profit, 8) + assert pytest.approx(profit_res2.profit_ratio) == round(profit_ratio, 8) + + assert pytest.approx(profit_res2.total_profit) == round(profit, 8) + # assert pytest.approx(profit_res2.total_profit_ratio) == round(profit_ratio, 8) + assert pytest.approx(trade.calc_profit(close_rate, trade.amount, trade.open_rate)) == round(profit, 8) assert pytest.approx(trade.calc_profit_ratio(close_rate, trade.amount, diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 369a7f223..8319ef4c4 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -616,6 +616,10 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", "lookback_timeframe": "1h", "lookback_period": 2, "refresh_period": 3600}], "BTC", "binance", ['ETH/BTC', 'LTC/BTC', 'NEO/BTC', 'TKN/BTC', 'XRP/BTC']), + # TKN/BTC is removed because it doesn't have enough candles + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", + "lookback_timeframe": "1d", "lookback_period": 6, "refresh_period": 86400}], + "BTC", "binance", ['LTC/BTC', 'XRP/BTC', 'ETH/BTC', 'HOT/BTC', 'NEO/BTC']), # ftx data is already in Quote currency, therefore won't require conversion # ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", # "lookback_timeframe": "1d", "lookback_period": 1, "refresh_period": 86400}], @@ -626,23 +630,25 @@ def test_VolumePairList_range(mocker, whitelist_conf, shitcoinmarkets, tickers, whitelist_conf['pairlists'] = pairlists whitelist_conf['stake_currency'] = base_currency whitelist_conf['exchange']['name'] = exchange + # Ensure we have 6 candles + ohlcv_history_long = pd.concat([ohlcv_history, ohlcv_history]) - ohlcv_history_high_vola = ohlcv_history.copy() + ohlcv_history_high_vola = ohlcv_history_long.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 = ohlcv_history_long.copy() ohlcv_history_medium_volume.loc[ohlcv_history_medium_volume.index == 2, 'volume'] = 5 # create candles for high volume with all candles high volume, but very low price. - ohlcv_history_high_volume = ohlcv_history.copy() + ohlcv_history_high_volume = ohlcv_history_long.copy() ohlcv_history_high_volume['volume'] = 10 ohlcv_history_high_volume['low'] = ohlcv_history_high_volume.loc[:, 'low'] * 0.01 ohlcv_history_high_volume['high'] = ohlcv_history_high_volume.loc[:, 'high'] * 0.01 ohlcv_history_high_volume['close'] = ohlcv_history_high_volume.loc[:, 'close'] * 0.01 ohlcv_data = { - ('ETH/BTC', '1d', CandleType.SPOT): ohlcv_history, + ('ETH/BTC', '1d', CandleType.SPOT): ohlcv_history_long, ('TKN/BTC', '1d', CandleType.SPOT): ohlcv_history, ('LTC/BTC', '1d', CandleType.SPOT): ohlcv_history_medium_volume, ('XRP/BTC', '1d', CandleType.SPOT): ohlcv_history_high_vola, @@ -1370,7 +1376,12 @@ def test_expand_pairlist(wildcardlist, pairs, expected): (['BTC/USD'], ['BTC/USD', 'BTC/USDT'], ['BTC/USD']), - + (['BTC/USDT:USDT'], + ['BTC/USDT:USDT', 'BTC/USDT'], + ['BTC/USDT:USDT']), + (['BB_BTC/USDT', 'CC_BTC/USDT', 'AA_ETH/USDT', 'XRP/USDT', 'ETH/USDT', 'XX_BTC/USDT'], + ['BTC/USDT', 'ETH/USDT'], + ['XRP/USDT', 'ETH/USDT']), ]) def test_expand_pairlist_keep_invalid(wildcardlist, pairs, expected): if expected is None: diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 03d0f97e2..57489d51e 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -163,7 +163,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: response = deepcopy(gen_response) response.update({ 'max_stake_amount': 0.001, - 'total_profit_ratio': pytest.approx(-0.00409), + 'total_profit_ratio': pytest.approx(-0.00409153), }) assert results[0] == response diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 20fc87ae9..f50490e32 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -617,6 +617,47 @@ def test_api_daily(botclient, mocker, ticker, fee, markets): assert rc.json()['data'][0]['date'] == str(datetime.now(timezone.utc).date()) +def test_api_weekly(botclient, mocker, ticker, fee, markets, time_machine): + ftbot, client = botclient + patch_get_signal(ftbot) + mocker.patch.multiple( + EXMS, + get_balances=MagicMock(return_value=ticker), + fetch_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + time_machine.move_to("2023-03-31 21:45:05 +00:00") + rc = client_get(client, f"{BASE_URI}/weekly") + assert_response(rc) + assert len(rc.json()['data']) == 4 + assert rc.json()['stake_currency'] == 'BTC' + assert rc.json()['fiat_display_currency'] == 'USD' + # Moved to monday + assert rc.json()['data'][0]['date'] == '2023-03-27' + assert rc.json()['data'][1]['date'] == '2023-03-20' + + +def test_api_monthly(botclient, mocker, ticker, fee, markets, time_machine): + ftbot, client = botclient + patch_get_signal(ftbot) + mocker.patch.multiple( + EXMS, + get_balances=MagicMock(return_value=ticker), + fetch_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + time_machine.move_to("2023-03-31 21:45:05 +00:00") + rc = client_get(client, f"{BASE_URI}/monthly") + assert_response(rc) + assert len(rc.json()['data']) == 3 + assert rc.json()['stake_currency'] == 'BTC' + assert rc.json()['fiat_display_currency'] == 'USD' + assert rc.json()['data'][0]['date'] == '2023-03-01' + assert rc.json()['data'][1]['date'] == '2023-02-01' + + @pytest.mark.parametrize('is_short', [True, False]) def test_api_trades(botclient, mocker, fee, markets, is_short): ftbot, client = botclient @@ -936,6 +977,10 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, is_short, expected) 'expectancy_ratio': expected['expectancy_ratio'], 'max_drawdown': ANY, 'max_drawdown_abs': ANY, + 'max_drawdown_start': ANY, + 'max_drawdown_start_timestamp': ANY, + 'max_drawdown_end': ANY, + 'max_drawdown_end_timestamp': ANY, 'trading_volume': expected['trading_volume'], 'bot_start_timestamp': 0, 'bot_start_date': '', @@ -985,7 +1030,7 @@ def test_api_performance(botclient, fee): fee_close=fee.return_value, fee_open=fee.return_value, close_rate=0.265441, - + leverage=1.0, ) trade.close_profit = trade.calc_profit_ratio(trade.close_rate) trade.close_profit_abs = trade.calc_profit(trade.close_rate) @@ -1000,7 +1045,8 @@ def test_api_performance(botclient, fee): is_open=False, fee_close=fee.return_value, fee_open=fee.return_value, - close_rate=0.391 + close_rate=0.391, + leverage=1.0, ) trade.close_profit = trade.calc_profit_ratio(trade.close_rate) trade.close_profit_abs = trade.calc_profit(trade.close_rate) diff --git a/tests/strategy/test_default_strategy.py b/tests/strategy/test_default_strategy.py index b5b07e0cd..afe7fc97a 100644 --- a/tests/strategy/test_default_strategy.py +++ b/tests/strategy/test_default_strategy.py @@ -52,4 +52,5 @@ def test_strategy_test_v3(dataframe_1m, fee, is_short, side): side=side) is True assert strategy.custom_stoploss(pair='ETH/BTC', trade=trade, current_time=datetime.now(), - current_rate=20_000, current_profit=0.05) == strategy.stoploss + current_rate=20_000, current_profit=0.05, after_fill=False + ) == strategy.stoploss diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index 8a609cf30..e8abcb362 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -503,6 +503,7 @@ def test_custom_exit(default_conf, fee, caplog) -> None: fee_close=fee.return_value, exchange='binance', open_rate=1, + leverage=1.0, ) now = dt_now() @@ -552,6 +553,7 @@ def test_should_sell(default_conf, fee) -> None: fee_close=fee.return_value, exchange='binance', open_rate=1, + leverage=1.0, ) now = dt_now() res = strategy.should_exit(trade, 1, now, diff --git a/tests/strategy/test_strategy_helpers.py b/tests/strategy/test_strategy_helpers.py index a55580780..d6aebd982 100644 --- a/tests/strategy/test_strategy_helpers.py +++ b/tests/strategy/test_strategy_helpers.py @@ -211,15 +211,18 @@ def test_stoploss_from_absolute(): assert pytest.approx(stoploss_from_absolute(110, 100)) == 0 assert pytest.approx(stoploss_from_absolute(100, 0)) == 1 assert pytest.approx(stoploss_from_absolute(0, 100)) == 1 + assert pytest.approx(stoploss_from_absolute(0, 100, False, leverage=5)) == 5 assert pytest.approx(stoploss_from_absolute(90, 100, True)) == 0 assert pytest.approx(stoploss_from_absolute(100, 100, True)) == 0 assert pytest.approx(stoploss_from_absolute(110, 100, True)) == -(1 - (110 / 100)) assert pytest.approx(stoploss_from_absolute(110, 100, True)) == 0.1 assert pytest.approx(stoploss_from_absolute(105, 100, True)) == 0.05 + assert pytest.approx(stoploss_from_absolute(105, 100, True, 5)) == 0.05 * 5 assert pytest.approx(stoploss_from_absolute(100, 0, True)) == 1 assert pytest.approx(stoploss_from_absolute(0, 100, True)) == 0 - assert pytest.approx(stoploss_from_absolute(100, 1, True)) == 1 + assert pytest.approx(stoploss_from_absolute(100, 1, is_short=True)) == 1 + assert pytest.approx(stoploss_from_absolute(100, 1, is_short=True, leverage=5)) == 5 @pytest.mark.parametrize('trading_mode', ['futures', 'spot']) diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index a3c65a68e..3d6aaae31 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -3450,7 +3450,11 @@ def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order assert cancel_order_mock.call_count == 1 -def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: +@pytest.mark.parametrize('is_short', [True, False]) +@pytest.mark.parametrize('leverage', [1, 5]) +@pytest.mark.parametrize('amount', [2, 50]) +def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee, is_short, + leverage, amount) -> None: send_msg_mock = patch_RPCManager(mocker) patch_exchange(mocker) cancel_order_mock = MagicMock() @@ -3458,7 +3462,9 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: EXMS, cancel_order=cancel_order_mock, ) - mocker.patch(f'{EXMS}.get_rate', return_value=0.245441) + entry_price = 0.245441 + + mocker.patch(f'{EXMS}.get_rate', return_value=entry_price) mocker.patch(f'{EXMS}.get_min_pair_stake_amount', return_value=0.2) mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_order_fee') @@ -3466,28 +3472,30 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: freqtrade = FreqtradeBot(default_conf_usdt) trade = Trade( - pair='LTC/ETH', - amount=2, + pair='LTC/USDT', + amount=amount * leverage, exchange='binance', - open_rate=0.245441, + open_rate=entry_price, open_date=dt_now() - timedelta(days=2), fee_open=fee.return_value, fee_close=fee.return_value, close_rate=0.555, close_date=dt_now(), exit_reason="sell_reason_whatever", - stake_amount=0.245441 * 2, + stake_amount=entry_price * amount, + leverage=leverage, + is_short=is_short, ) trade.orders = [ Order( - ft_order_side='buy', + ft_order_side=entry_side(is_short), ft_pair=trade.pair, ft_is_open=False, order_id='buy_123456', status="closed", symbol=trade.pair, order_type="market", - side="buy", + side=entry_side(is_short), price=trade.open_rate, average=trade.open_rate, filled=trade.amount, @@ -3497,14 +3505,14 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: order_filled_date=trade.open_date, ), Order( - ft_order_side='sell', + ft_order_side=exit_side(is_short), ft_pair=trade.pair, ft_is_open=True, order_id='sell_123456', status="open", symbol=trade.pair, order_type="limit", - side="sell", + side=exit_side(is_short), price=trade.open_rate, average=trade.open_rate, filled=0.0, @@ -3530,8 +3538,8 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: send_msg_mock.reset_mock() # Partial exit - below exit threshold - order['amount'] = 2 - order['filled'] = 1.9 + order['amount'] = amount * leverage + order['filled'] = amount * 0.99 * leverage assert not freqtrade.handle_cancel_exit(trade, order, order['id'], reason) # Assert cancel_order was not called (callcount remains unchanged) assert cancel_order_mock.call_count == 1 @@ -3550,7 +3558,7 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: send_msg_mock.reset_mock() - order['filled'] = 1 + order['filled'] = amount * 0.5 * leverage assert freqtrade.handle_cancel_exit(trade, order, order['id'], reason) assert send_msg_mock.call_count == 1 assert (send_msg_mock.call_args_list[0][0][0]['reason'] diff --git a/tests/test_integration.py b/tests/test_integration.py index 2d79fbaac..21ebfc3e0 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -495,6 +495,76 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, leverage, fee, mocker) assert freqtrade.strategy.adjust_entry_price.call_count == 0 +@pytest.mark.parametrize('leverage', [1, 2]) +@pytest.mark.parametrize("is_short", [False, True]) +def test_dca_order_adjust_entry_replace_fails( + default_conf_usdt, ticker_usdt, fee, mocker, caplog, is_short, leverage +) -> None: + spot = leverage == 1 + if not spot: + default_conf_usdt['trading_mode'] = 'futures' + default_conf_usdt['margin_mode'] = 'isolated' + default_conf_usdt['position_adjustment_enable'] = True + default_conf_usdt['max_open_trades'] = 2 + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + mocker.patch.multiple( + EXMS, + fetch_ticker=ticker_usdt, + get_fee=fee, + get_funding_fees=MagicMock(return_value=0), + ) + + # no order fills. + mocker.patch(f'{EXMS}._dry_is_price_crossed', side_effect=[False, True]) + patch_get_signal(freqtrade, enter_short=is_short, enter_long=not is_short) + freqtrade.enter_positions() + + trades = Trade.session.scalars( + select(Trade).filter(Trade.open_order_id.is_not(None))).all() + assert len(trades) == 1 + + mocker.patch(f'{EXMS}._dry_is_price_crossed', return_value=False) + + # Timeout to not interfere + freqtrade.strategy.ft_check_timed_out = MagicMock(return_value=False) + + # Create DCA order for 2nd trade (so we have 2 open orders on 2 trades) + # this 2nd order won't fill. + + freqtrade.strategy.adjust_trade_position = MagicMock(return_value=20) + + freqtrade.process() + + assert freqtrade.strategy.adjust_trade_position.call_count == 1 + trades = Trade.session.scalars( + select(Trade).filter(Trade.open_order_id.is_not(None))).all() + assert len(trades) == 2 + + # We now have 2 orders open + freqtrade.strategy.adjust_entry_price = MagicMock(return_value=2.05) + freqtrade.manage_open_orders() + trades = Trade.session.scalars( + select(Trade).filter(Trade.open_order_id.is_not(None))).all() + assert len(trades) == 2 + assert len(Order.get_open_orders()) == 2 + # Entry adjustment is called + assert freqtrade.strategy.adjust_entry_price.call_count == 2 + + # Attempt order replacement - fails. + freqtrade.strategy.adjust_entry_price = MagicMock(return_value=1234) + + entry_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_entry', + return_value=False) + msg = r"Could not replace order for.*" + assert not log_has_re(msg, caplog) + freqtrade.manage_open_orders() + + assert log_has_re(msg, caplog) + assert entry_mock.call_count == 2 + assert len(Trade.get_trades().all()) == 1 + assert len(Order.get_open_orders()) == 0 + + @pytest.mark.parametrize('leverage', [1, 2]) def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, leverage) -> None: default_conf_usdt['position_adjustment_enable'] = True diff --git a/tests/utils/test_datetime_helpers.py b/tests/utils/test_datetime_helpers.py index 6ce975732..b70065645 100644 --- a/tests/utils/test_datetime_helpers.py +++ b/tests/utils/test_datetime_helpers.py @@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone import pytest import time_machine -from freqtrade.util import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, dt_utc, - format_ms_time, shorten_date) +from freqtrade.util import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, dt_ts_def, dt_utc, + format_date, format_ms_time, shorten_date) def test_dt_now(): @@ -22,6 +22,13 @@ def test_dt_now(): assert dt_ts(now) == int(now.timestamp() * 1000) +def test_dt_ts_def(): + assert dt_ts_def(None) == 0 + assert dt_ts_def(None, 123) == 123 + assert dt_ts_def(datetime(2023, 5, 5, tzinfo=timezone.utc)) == 1683244800000 + assert dt_ts_def(datetime(2023, 5, 5, tzinfo=timezone.utc), 123) == 1683244800000 + + def test_dt_utc(): assert dt_utc(2023, 5, 5) == datetime(2023, 5, 5, tzinfo=timezone.utc) assert dt_utc(2023, 5, 5, 0, 0, 0, 555500) == datetime(2023, 5, 5, 0, 0, 0, 555500, @@ -70,3 +77,14 @@ def test_format_ms_time() -> None: # Date 2017-12-13 08:02:01 date_in_epoch_ms = 1513152121000 assert format_ms_time(date_in_epoch_ms) == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S') + + +def test_format_date() -> None: + + date = datetime(2023, 9, 1, 5, 2, 3, 455555, tzinfo=timezone.utc) + assert format_date(date) == '2023-09-01 05:02:03' + assert format_date(None) == '' + + date = datetime(2021, 9, 30, 22, 59, 3, 455555, tzinfo=timezone.utc) + assert format_date(date) == '2021-09-30 22:59:03' + assert format_date(None) == ''