Merge pull request #11158 from freqtrade/feat/plot_annotations

add support for plot_annotations
This commit is contained in:
Matthias
2025-05-03 08:25:57 +02:00
committed by GitHub
12 changed files with 241 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -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
```

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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]:

View File

@@ -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",
]

View File

@@ -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 []

View File

@@ -28,6 +28,7 @@ from freqtrade.strategy import (
merge_informative_pair,
stoploss_from_absolute,
stoploss_from_open,
AnnotationType,
)
# --------------------------------

View File

@@ -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 []

View File

@@ -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