mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-12-03 18:43:04 +00:00
Merge pull request #11158 from freqtrade/feat/plot_annotations
add support for plot_annotations
This commit is contained in:
BIN
docs/assets/freqUI-chart-annotations-dark.png
Normal file
BIN
docs/assets/freqUI-chart-annotations-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
docs/assets/freqUI-chart-annotations-light.png
Normal file
BIN
docs/assets/freqUI-chart-annotations-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -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
|
||||
|
||||

|
||||

|
||||
|
||||
??? 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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
18
freqtrade/ft_types/plot_annotation_type.py
Normal file
18
freqtrade/ft_types/plot_annotation_type.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -28,6 +28,7 @@ from freqtrade.strategy import (
|
||||
merge_informative_pair,
|
||||
stoploss_from_absolute,
|
||||
stoploss_from_open,
|
||||
AnnotationType,
|
||||
)
|
||||
|
||||
# --------------------------------
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user