diff --git a/docs/assets/freqUI-chart-annotations-dark.png b/docs/assets/freqUI-chart-annotations-dark.png new file mode 100644 index 000000000..9f7de269b Binary files /dev/null and b/docs/assets/freqUI-chart-annotations-dark.png differ diff --git a/docs/assets/freqUI-chart-annotations-light.png b/docs/assets/freqUI-chart-annotations-light.png new file mode 100644 index 000000000..ccc219647 Binary files /dev/null and b/docs/assets/freqUI-chart-annotations-light.png differ diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 4564a0f03..541b48c62 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -1107,3 +1107,119 @@ class AwesomeStrategy(IStrategy): return None ``` + +## Plot annotations callback + +The plot annotations callback is called whenever freqUI requests data to display a chart. +This callback has no meaning in the trade cycle context and is only used for charting purposes. + +The strategy can then return a list of `AnnotationType` objects to be displayed on the chart. +Depending on the content returned - the chart can display horizontal areas, vertical areas, or boxes. + +The full object looks like this: + +``` json +{ + "type": "area", // Type of the annotation, currently only "area" is supported + "start": "2024-01-01 15:00:00", // Start date of the area + "end": "2024-01-01 16:00:00", // End date of the area + "y_start": 94000.2, // Price / y axis value + "y_end": 98000, // Price / y axis value + "color": "", + "label": "some label" +} +``` + +The below example will mark the chart with areas for the hours 8 and 15, with a grey color, highlighting the market open and close hours. +This is obviously a very basic example. + +``` python +# Default imports + +class AwesomeStrategy(IStrategy): + def plot_annotations( + self, pair: str, start_date: datetime, end_date: datetime, dataframe: DataFrame, **kwargs + ) -> list[AnnotationType]: + """ + Retrieve area annotations for a chart. + Must be returned as array, with type, label, color, start, end, y_start, y_end. + All settings except for type are optional - though it usually makes sense to include either + "start and end" or "y_start and y_end" for either horizontal or vertical plots + (or all 4 for boxes). + :param pair: Pair that's currently analyzed + :param start_date: Start date of the chart data being requested + :param end_date: End date of the chart data being requested + :param dataframe: DataFrame with the analyzed data for the chart + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return: List of AnnotationType objects + """ + annotations = [] + while start_dt < end_date: + start_dt += timedelta(hours=1) + if start_dt.hour in (8, 15): + annotations.append( + { + "type": "area", + "label": "Trade open and close hours", + "start": start_dt, + "end": start_dt + timedelta(hours=1), + # Omitting y_start and y_end will result in a vertical area spanning the whole height of the main Chart + "color": "rgba(133, 133, 133, 0.4)", + } + ) + + return annotations + +``` + +Entries will be validated, and won't be passed to the UI if they don't correspond to the expected schema and will log an error if they don't. + +!!! Warning "Many annotations" + Using too many annotations can cause the UI to hang, especially when plotting large amounts of historic data. + Use the annotation feature with care. + +### Plot annotations example + +![FreqUI - plot Annotations](assets/freqUI-chart-annotations-dark.png#only-dark) +![FreqUI - plot Annotations](assets/freqUI-chart-annotations-light.png#only-light) + +??? Info "Code used for the plot above" + This is an example code and should be treated as such. + + ``` python + # Default imports + + class AwesomeStrategy(IStrategy): + def plot_annotations( + self, pair: str, start_date: datetime, end_date: datetime, dataframe: DataFrame, **kwargs + ) -> list[AnnotationType]: + annotations = [] + while start_dt < end_date: + start_dt += timedelta(hours=1) + if (start_dt.hour % 4) == 0: + mark_areas.append( + { + "type": "area", + "label": "4h", + "start": start_dt, + "end": start_dt + timedelta(hours=1), + "color": "rgba(133, 133, 133, 0.4)", + } + ) + elif (start_dt.hour % 2) == 0: + price = dataframe.loc[dataframe["date"] == start_dt, ["close"]].mean() + mark_areas.append( + { + "type": "area", + "label": "2h", + "start": start_dt, + "end": start_dt + timedelta(hours=1), + "y_end": price * 1.01, + "y_start": price * 0.99, + "color": "rgba(0, 255, 0, 0.4)", + } + ) + + return annotations + + ``` diff --git a/freqtrade/ft_types/__init__.py b/freqtrade/ft_types/__init__.py index 1915a8549..6cd74f7b2 100644 --- a/freqtrade/ft_types/__init__.py +++ b/freqtrade/ft_types/__init__.py @@ -7,4 +7,5 @@ from freqtrade.ft_types.backtest_result_type import ( BacktestResultType, get_BacktestResultType_default, ) +from freqtrade.ft_types.plot_annotation_type import AnnotationType from freqtrade.ft_types.valid_exchanges_type import ValidExchangesType diff --git a/freqtrade/ft_types/plot_annotation_type.py b/freqtrade/ft_types/plot_annotation_type.py new file mode 100644 index 000000000..b08db3c57 --- /dev/null +++ b/freqtrade/ft_types/plot_annotation_type.py @@ -0,0 +1,18 @@ +from datetime import datetime +from typing import Literal + +from pydantic import TypeAdapter +from typing_extensions import Required, TypedDict + + +class AnnotationType(TypedDict, total=False): + type: Required[Literal["area"]] + start: str | datetime + end: str | datetime + y_start: float + y_end: float + color: str + label: str + + +AnnotationTypeTA = TypeAdapter(AnnotationType) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 975166458..bb5dfafa7 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -5,7 +5,7 @@ from pydantic import AwareDatetime, BaseModel, RootModel, SerializeAsAny, model_ from freqtrade.constants import DL_DATA_TIMEFRAMES, IntOrInf from freqtrade.enums import MarginMode, OrderTypeValues, SignalDirection, TradingMode -from freqtrade.ft_types import ValidExchangesType +from freqtrade.ft_types import AnnotationType, ValidExchangesType from freqtrade.rpc.api_server.webserver_bgwork import ProgressTask @@ -539,6 +539,7 @@ class PairHistory(BaseModel): columns: list[str] all_columns: list[str] = [] data: SerializeAsAny[list[Any]] + annotations: list[AnnotationType] | None = None length: int buy_signals: int sell_signals: int diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index b1f3da529..696fec7b7 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -32,6 +32,7 @@ from freqtrade.enums import ( from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import Exchange, timeframe_to_minutes, timeframe_to_msecs from freqtrade.exchange.exchange_utils import price_to_precision +from freqtrade.ft_types import AnnotationType from freqtrade.loggers import bufferHandler from freqtrade.persistence import CustomDataWrapper, KeyValueStore, PairLocks, Trade from freqtrade.persistence.models import PairLock @@ -1356,6 +1357,7 @@ class RPC: dataframe: DataFrame, last_analyzed: datetime, selected_cols: list[str] | None, + annotations: list[AnnotationType], ) -> dict[str, Any]: has_content = len(dataframe) != 0 dataframe_columns = list(dataframe.columns) @@ -1411,6 +1413,7 @@ class RPC: "data_start_ts": 0, "data_stop": "", "data_stop_ts": 0, + "annotations": annotations, } if has_content: res.update( @@ -1429,8 +1432,16 @@ class RPC: """Analyzed dataframe in Dict form""" _data, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) + annotations = self._freqtrade.strategy.ft_plot_annotations(pair=pair, dataframe=_data) + return RPC._convert_dataframe_to_dict( - self._freqtrade.config["strategy"], pair, timeframe, _data, last_analyzed, selected_cols + self._freqtrade.config["strategy"], + pair, + timeframe, + _data, + last_analyzed, + selected_cols, + annotations, ) def __rpc_analysed_dataframe_raw( @@ -1531,6 +1542,7 @@ class RPC: ) data = _data[pair] + annotations = [] if config.get("strategy"): strategy.dp = DataProvider(config, exchange=exchange, pairlists=None) strategy.ft_bot_start() @@ -1539,6 +1551,8 @@ class RPC: df_analyzed = trim_dataframe( df_analyzed, timerange_parsed, startup_candles=startup_candles ) + annotations = strategy.ft_plot_annotations(pair=pair, dataframe=df_analyzed) + else: df_analyzed = data @@ -1549,6 +1563,7 @@ class RPC: df_analyzed.copy(), dt_now(), selected_cols, + annotations, ) def _rpc_plot_config(self) -> dict[str, Any]: diff --git a/freqtrade/strategy/__init__.py b/freqtrade/strategy/__init__.py index e99473b4e..f1a6c687c 100644 --- a/freqtrade/strategy/__init__.py +++ b/freqtrade/strategy/__init__.py @@ -6,6 +6,7 @@ from freqtrade.exchange import ( timeframe_to_prev_date, timeframe_to_seconds, ) +from freqtrade.ft_types import AnnotationType from freqtrade.persistence import Order, PairLocks, Trade from freqtrade.strategy.informative_decorator import informative from freqtrade.strategy.interface import IStrategy @@ -44,4 +45,5 @@ __all__ = [ "merge_informative_pair", "stoploss_from_absolute", "stoploss_from_open", + "AnnotationType", ] diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 28a6e2c78..bb4cd264a 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone from math import isinf, isnan from pandas import DataFrame +from pydantic import ValidationError from freqtrade.constants import CUSTOM_TAG_MAX_LENGTH, Config, IntOrInf, ListPairsWithTimeframes from freqtrade.data.converter import populate_dataframe_with_trades @@ -27,6 +28,7 @@ from freqtrade.enums import ( ) from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds +from freqtrade.ft_types import AnnotationType from freqtrade.misc import remove_entry_exit_signals from freqtrade.persistence import Order, PairLocks, Trade from freqtrade.strategy.hyper import HyperStrategyMixin @@ -834,6 +836,24 @@ class IStrategy(ABC, HyperStrategyMixin): """ return None + def plot_annotations( + self, pair: str, start_date: datetime, end_date: datetime, dataframe: DataFrame, **kwargs + ) -> list[AnnotationType]: + """ + Retrieve area annotations for a chart. + Must be returned as array, with type, label, color, start, end, y_start, y_end. + All settings except for type are optional - though it usually makes sense to include either + "start and end" or "y_start and y_end" for either horizontal or vertical plots + (or all 4 for boxes). + :param pair: Pair that's currently analyzed + :param start_date: Start date of the chart data being requested + :param end_date: End date of the chart data being requested + :param dataframe: DataFrame with the analyzed data for the chart + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return: List of AnnotationType objects + """ + return [] + def populate_any_indicators( self, pair: str, @@ -1780,3 +1800,33 @@ class IStrategy(ABC, HyperStrategyMixin): if "exit_long" not in df.columns: df = df.rename({"sell": "exit_long"}, axis="columns") return df + + def ft_plot_annotations(self, pair: str, dataframe: DataFrame) -> list[AnnotationType]: + """ + Internal wrapper around plot_dataframe + """ + if len(dataframe) > 0: + annotations = strategy_safe_wrapper(self.plot_annotations)( + pair=pair, + dataframe=dataframe, + start_date=dataframe.iloc[0]["date"].to_pydatetime(), + end_date=dataframe.iloc[-1]["date"].to_pydatetime(), + ) + + from freqtrade.ft_types.plot_annotation_type import AnnotationTypeTA + + annotations_new: list[AnnotationType] = [] + for annotation in annotations: + if isinstance(annotation, dict): + # Convert to AnnotationType + try: + AnnotationTypeTA.validate_python(annotation) + annotations_new.append(annotation) + except ValidationError as e: + logger.error(f"Invalid annotation data: {annotation}. Error: {e}") + else: + # Already an AnnotationType + annotations_new.append(annotation) + + return annotations_new + return [] diff --git a/freqtrade/templates/base_strategy.py.j2 b/freqtrade/templates/base_strategy.py.j2 index 16998ab52..e4c617ddf 100644 --- a/freqtrade/templates/base_strategy.py.j2 +++ b/freqtrade/templates/base_strategy.py.j2 @@ -28,6 +28,7 @@ from freqtrade.strategy import ( merge_informative_pair, stoploss_from_absolute, stoploss_from_open, + AnnotationType, ) # -------------------------------- diff --git a/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 index dc133c75a..306329742 100644 --- a/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 +++ b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 @@ -399,3 +399,21 @@ def order_filled( :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. """ pass + +def plot_annotations( + self, pair: str, start_date: datetime, end_date: datetime, dataframe: DataFrame, **kwargs +) -> list[AnnotationType]: + """ + Retrieve area annotations for a chart. + Must be returned as array, with type, label, color, start, end, y_start, y_end. + All settings except for type are optional - though it usually makes sense to include either + "start and end" or "y_start and y_end" for either horizontal or vertical plots + (or all 4 for boxes). + :param pair: Pair that's currently analyzed + :param start_date: Start date of the chart data being requested + :param end_date: End date of the chart data being requested + :param dataframe: DataFrame with the analyzed data for the chart + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return: List of AnnotationType objects + """ + return [] diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 9bb7e8318..c327a416b 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -1864,7 +1864,21 @@ def test_api_pair_candles(botclient, ohlcv_history): ohlcv_history["exit_short"] = 0 ftbot.dataprovider._set_cached_df("XRP/BTC", timeframe, ohlcv_history, CandleType.SPOT) + fake_plot_annotations = [ + { + "type": "area", + "start": "2024-01-01 15:00:00", + "end": "2024-01-01 16:00:00", + "y_start": 94000.2, + "y_end": 98000, + "color": "", + "label": "some label", + } + ] + plot_annotations_mock = MagicMock(return_value=fake_plot_annotations) + ftbot.strategy.plot_annotations = plot_annotations_mock for call in ("get", "post"): + plot_annotations_mock.reset_mock() if call == "get": rc = client_get( client, @@ -1894,6 +1908,8 @@ def test_api_pair_candles(botclient, ohlcv_history): assert resp["data_start_ts"] == 1511686200000 assert resp["data_stop"] == "2017-11-26 09:00:00+00:00" assert resp["data_stop_ts"] == 1511686800000 + assert resp["annotations"] == fake_plot_annotations + assert plot_annotations_mock.call_count == 1 assert isinstance(resp["columns"], list) base_cols = { "date", @@ -2235,6 +2251,7 @@ def test_api_pair_history(botclient, tmp_path, mocker): assert result["data_start_ts"] == 1515628800000 assert result["data_stop"] == "2018-01-12 00:00:00+00:00" assert result["data_stop_ts"] == 1515715200000 + assert result["annotations"] == [] lfm.reset_mock() # No data found