diff options
author | Danglewood <85772166+deeleeramone@users.noreply.github.com> | 2024-04-19 09:23:14 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-19 16:23:14 +0000 |
commit | e1af0a7dc19d3ec720f8ab8594d7c9cd4c07d9b2 (patch) | |
tree | 94f7cdec60a8e57aba656330cec1f610131aa55c | |
parent | 04d3af5c5062bbbfdc49cebea9eff2436b4e82d3 (diff) |
[Feature] Add Relative Rotation To `openbb-technical` and `openbb-charting` (#6277)
* add relative rotation
* router examples
* docstring
* test params
* black
* name of test functions
* router example description
* fix NoneType is not iterable
* clear print
* linting
* black
* revert that one place, it can stay None
* forgot that file in the commit
* some cleanup
* improving the get_type_name funciton a bit
* query_params types
* pylint
---------
Co-authored-by: Igor Radovanovic <74266147+IgorWounds@users.noreply.github.com>
Co-authored-by: Henrique Joaquim <henriquecjoaquim@gmail.com>
Co-authored-by: hjoaquim <h.joaquim@campus.fct.unl.pt>
13 files changed, 1596 insertions, 36 deletions
diff --git a/openbb_platform/core/openbb_core/app/command_runner.py b/openbb_platform/core/openbb_core/app/command_runner.py index a8c368839f8..4d9985a2172 100644 --- a/openbb_platform/core/openbb_core/app/command_runner.py +++ b/openbb_platform/core/openbb_core/app/command_runner.py @@ -316,15 +316,18 @@ class StaticCommandRunner: elif isinstance(extra_params, dict) and "chart_params" in extra_params: chart_params = kwargs["extra_params"].get("chart_params", {}) - if "chart_params" in kwargs: + if "chart_params" in kwargs and kwargs["chart_params"] is not None: chart_params.update(kwargs.pop("chart_params", {})) - if "kwargs" in kwargs: + if ( + "kwargs" in kwargs + and "chart_params" in kwargs["kwargs"] + and kwargs["kwargs"].get("chart_params") is not None + ): chart_params.update(kwargs.pop("kwargs", {}).get("chart_params", {})) if chart_params: kwargs.update(chart_params) - obbject.charting.show(render=False, **kwargs) # pylint: disable=R0913, R0914 @@ -374,7 +377,6 @@ class StaticCommandRunner: # pylint: disable=protected-access obbject._route = route obbject._standard_params = kwargs.get("standard_params", None) - if chart and obbject.results: cls._chart(obbject, **kwargs) diff --git a/openbb_platform/extensions/technical/integration/test_technical_api.py b/openbb_platform/extensions/technical/integration/test_technical_api.py index 1ed903f85c4..481fce9f356 100644 --- a/openbb_platform/extensions/technical/integration/test_technical_api.py +++ b/openbb_platform/extensions/technical/integration/test_technical_api.py @@ -959,3 +959,42 @@ def test_technical_ema(params, data_type): result = requests.post(url, headers=get_headers(), timeout=10, data=body) assert isinstance(result, requests.Response) assert result.status_code == 200 + + +@parametrize( + "params", + [ + ( + { + "data": "", + "study": "price", + "benchmark": "SPY", + "long_period": 252, + "short_period": 21, + "window": 21, + "trading_periods": 252, + "chart_params": {"show_tails": False}, + } + ), + ], +) +@pytest.mark.integration +def test_technical_relative_rotation(params): + params = {p: v for p, v in params.items() if v} + data_params = dict( + symbol="AAPL,MSFT,GOOGL,AMZN,SPY", + provider="yfinance", + start_date="2022-01-01", + end_date="2024-01-01", + ) + data_query_str = get_querystring(data_params, []) + data_url = f"http://0.0.0.0:8000/api/v1/equity/price/historical?{data_query_str}" + data_result = requests.get(data_url, headers=get_headers(), timeout=10).json()[ + "results" + ] + body = json.dumps({"data": data_result}) + query_str = get_querystring(params, ["data"]) + url = f"http://0.0.0.0:8000/api/v1/technical/relative_rotation?{query_str}" + result = requests.post(url, headers=get_headers(), timeout=10, data=body) + assert isinstance(result, requests.Response) + assert result.status_code == 200 diff --git a/openbb_platform/extensions/technical/integration/test_technical_python.py b/openbb_platform/extensions/technical/integration/test_technical_python.py index afc0d60b909..d866c3623c8 100644 --- a/openbb_platform/extensions/technical/integration/test_technical_python.py +++ b/openbb_platform/extensions/technical/integration/test_technical_python.py @@ -926,3 +926,45 @@ def test_technical_ema(params, data_type, obb): assert result assert isinstance(result, OBBject) assert len(result.results) > 0 + + +@parametrize( + "params", + [ + ( + { + "data": "", + "study": "price", + "benchmark": "SPY", + "long_period": 252, + "short_period": 21, + "window": 21, + "trading_periods": 252, + "chart_params": {"show_tails": False}, + } + ), + ], +) +@pytest.mark.integration +def test_technical_relative_rotation(params, obb): + params["data"] = obb.equity.price.historical( + "AAPL,MSFT,GOOGL,AMZN,SPY", + provider="yfinance", + start_date="2022-01-01", + end_date="2024-01-01", + ).results + result = obb.technical.relative_rotation( + data=params["data"], + benchmark=params["benchmark"], + study=params["study"], + long_period=params["long_period"], + short_period=params["short_period"], + window=params["window"], + trading_periods=params["trading_periods"], + ) + assert result + assert isinstance(result, OBBject) + assert hasattr(result.results, "rs_ratios") + assert len(result.results.rs_ratios) > 0 # type: ignore + assert hasattr(result.results, "rs_momentum") + assert len(result.results.rs_momentum) > 0 # type: ignore diff --git a/openbb_platform/extensions/technical/openbb_technical/helpers.py b/openbb_platform/extensions/technical/openbb_technical/helpers.py index d4eca5d6201..7c7fec91dc7 100644 --- a/openbb_platform/extensions/technical/openbb_technical/helpers.py +++ b/openbb_platform/extensions/technical/openbb_technical/helpers.py @@ -1,13 +1,11 @@ """Technical Analysis Helpers.""" -import warnings from typing import Any, List, Literal, Optional, Tuple, Union +from warnings import warn import numpy as np import pandas as pd -_warn = warnings.warn - def validate_data(data: list, length: Union[int, List[int]]) -> None: """Validate data.""" @@ -51,11 +49,11 @@ def parkinson( Dataframe with results. """ if window < 1: - _warn("Error: Window must be at least 1, defaulting to 30.") + warn("Error: Window must be at least 1, defaulting to 30.") window = 30 if trading_periods and is_crypto: - _warn("is_crypto is overridden by trading_periods.") + warn("is_crypto is overridden by trading_periods.") if not trading_periods: trading_periods = 365 if is_crypto else 252 @@ -106,11 +104,11 @@ def standard_deviation( Dataframe with results. """ if window < 2: - _warn("Error: Window must be at least 2, defaulting to 30.") + warn("Error: Window must be at least 2, defaulting to 30.") window = 30 if trading_periods and is_crypto: - _warn("is_crypto is overridden by trading_periods.") + warn("is_crypto is overridden by trading_periods.") if not trading_periods: trading_periods = 365 if is_crypto else 252 @@ -159,11 +157,11 @@ def garman_klass( Dataframe with results. """ if window < 1: - _warn("Error: Window must be at least 1, defaulting to 30.") + warn("Error: Window must be at least 1, defaulting to 30.") window = 30 if trading_periods and is_crypto: - _warn("is_crypto is overridden by trading_periods.") + warn("is_crypto is overridden by trading_periods.") if not trading_periods: trading_periods = 365 if is_crypto else 252 @@ -220,11 +218,11 @@ def hodges_tompkins( >>> df = obb.technical.hodges_tompkins(data, is_crypto = True) """ if window < 2: - _warn("Error: Window must be at least 2, defaulting to 30.") + warn("Error: Window must be at least 2, defaulting to 30.") window = 30 if trading_periods and is_crypto: - _warn("is_crypto is overridden by trading_periods.") + warn("is_crypto is overridden by trading_periods.") if not trading_periods: trading_periods = 365 if is_crypto else 252 @@ -280,11 +278,11 @@ def rogers_satchell( Pandas Series with results. """ if window < 1: - _warn("Error: Window must be at least 1, defaulting to 30.") + warn("Error: Window must be at least 1, defaulting to 30.") window = 30 if trading_periods and is_crypto: - _warn("is_crypto is overridden by trading_periods.") + warn("is_crypto is overridden by trading_periods.") if not trading_periods: trading_periods = 365 if is_crypto else 252 @@ -337,11 +335,11 @@ def yang_zhang( Dataframe with results. """ if window < 2: - _warn("Error: Window must be at least 2, defaulting to 30.") + warn("Error: Window must be at least 2, defaulting to 30.") window = 30 if trading_periods and is_crypto: - _warn("is_crypto is overridden by trading_periods.") + warn("is_crypto is overridden by trading_periods.") if not trading_periods: trading_periods = 365 if is_crypto else 252 @@ -547,12 +545,12 @@ def calculate_fib_levels( if start_date and end_date: if start_date not in data.index: date0 = data.index[data.index.get_indexer([end_date], method="nearest")[0]] - _warn(f"Start date not in data. Using nearest: {date0}") + warn(f"Start date not in data. Using nearest: {date0}") else: date0 = start_date if end_date not in data.index: date1 = data.index[data.index.get_indexer([end_date], method="nearest")[0]] - _warn(f"End date not in data. Using nearest: {date1}") + warn(f"End date not in data. Using nearest: {date1}") else: date1 = end_date diff --git a/openbb_platform/extensions/technical/openbb_technical/relative_rotation.py b/openbb_platform/extensions/technical/openbb_technical/relative_rotation.py new file mode 100644 index 00000000000..5bd3f7a295f --- /dev/null +++ b/openbb_platform/extensions/technical/openbb_technical/relative_rotation.py @@ -0,0 +1,554 @@ +"""Relative Rotation Model.""" + +# pylint: disable=too-many-arguments, too-many-instance-attributes, protected-access +# pylint: disable=too-many-locals, too-few-public-methods, unused-argument + +import contextlib +from typing import Any, Dict, List, Literal, Optional, Tuple, Union + +import numpy as np +from openbb_core.app.model.obbject import OBBject +from openbb_core.app.utils import basemodel_to_df, convert_to_basemodel, df_to_basemodel +from openbb_core.provider.abstract.data import Data +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.abstract.query_params import QueryParams +from pandas import DataFrame, Series, to_datetime +from pydantic import Field, field_validator + + +def absolute_maximum_scale(data: Series) -> Series: + """Absolute Maximum Scale Normaliztion Method.""" + return data / data.abs().max() + + +def min_max_scaling(data: Series) -> Series: + """Min/Max ScalingNormalization Method.""" + return (data - data.min()) / (data.max() - data.min()) + + +def z_score_standardization(data: Series) -> Series: + """Z-Score Standardization Method.""" + return (data - data.mean()) / data.std() + + +def normalize(data: DataFrame, method: Literal["z", "m", "a"] = "z") -> DataFrame: + """ + Normalize a Pandas DataFrame based on method. + + Parameters + ---------- + data: DataFrame + Pandas DataFrame with any number of columns to be normalized. + method: Literal["z", "m", "a"] + Normalization method. + z: Z-Score Standardization + m: Min/Max Scaling + a: Absolute Maximum Scale + + Returns + ------- + DataFrame + Normalized DataFrame. + """ + methods = { + "z": z_score_standardization, + "m": min_max_scaling, + "a": absolute_maximum_scale, + } + + df = data.copy() + + for col in df.columns: + df.loc[:, col] = methods[f"{method}"](df.loc[:, col]) + + return df + + +def standard_deviation( + data: DataFrame, + window: int = 21, + trading_periods: int = 252, +) -> DataFrame: + """ + Measures how widely returns are dispersed from the average return. + + It is the most common (and biased) estimator of volatility. + + Parameters + ---------- + data : pd.DataFrame + Dataframe of OHLC prices. + window : int [default: 21] + Length of window to calculate over. + trading_periods : Optional[int] [default: 252] + Number of trading periods in a year. + + Returns + ------- + pd.DataFrame : results + Dataframe with results. + """ + data = data.copy() + results = DataFrame() + if window < 2: + window = 21 + + for col in data.columns.tolist(): + log_return = (data[col] / data[col].shift(1)).apply(np.log) + + result = log_return.rolling(window=window, center=False).std() * np.sqrt( + trading_periods + ) + results[col] = result + + return results.dropna() + + +def calculate_momentum( + data: Series, long_period: int = 252, short_period: int = 21 +) -> Series: + """ + Momentum is calculated as the log trailing 12-month return minus trailing one-month return. + + Higher values indicate larger, positive momentum exposure. + + Momentum = ln(1 + r12) - ln(1 + r1) + + Parameters + ---------- + data: Series + Time series data to calculate the momentum for. + long_period: Optional[int] + Long period to base the calculation on. Default is one standard trading year. + short_period: Optional[int] + Short period to subtract from the long period. Default is one trading month. + + Returns + ------- + Series + Pandas Series with the calculated momentum. + """ + df = data.copy() + epsilon = 1e-10 + momentum_long = np.log(1 + df.pct_change(long_period) + epsilon) + momentum_short = np.log(1 + df.pct_change(short_period) + epsilon) + data = momentum_long - momentum_short # type: ignore + + return data + + +def get_momentum( + data: DataFrame, long_period: int = 252, short_period: int = 21 +) -> DataFrame: + """ + Calculate the Relative-Strength Momentum Indicator. + + Takes the Relative Strength Ratio as the input. + + Parameters + ---------- + data: DataFrame + Indexed time series data formatted with each column representing a ticker. + long_period: Optional[int] + Long period to base the calculation on. Default is one standard trading year. + short_period: Optional[int] + Short period to subtract from the long period. Default is one trading month. + + Returns + ------- + DataFrame + Pandas DataFrame with the calculated historical momentum factor exposure score. + """ + df = data.copy() + rs_momentum = DataFrame() + for ticker in df.columns.to_list(): + rs_momentum.loc[:, ticker] = calculate_momentum( + df.loc[:, ticker], long_period, short_period + ) # type: ignore + + return rs_momentum + + +def calculate_relative_strength_ratio( + symbols_data: DataFrame, + benchmark_data: DataFrame, +) -> DataFrame: + """Calculate the Relative Strength Ratio for each ticker (column) in a DataFrame against the benchmark. + + Symbols data and benchmark data should have the same index, + and each column should represent a ticker. + + Parameters + ---------- + symbols_data: DataFrame + Pandas DataFrame with the symbols data to compare against the benchmark. + benchmark_data: DataFrame + Pandas DataFrame with the benchmark data. + + Returns + ------- + DataFrame + Pandas DataFrame with the calculated relative strength + ratio for each ticker joined with the benchmark values. + """ + return ( + symbols_data.div(benchmark_data.iloc[:, 0], axis=0) + .multiply(100) + .join(benchmark_data.iloc[:, 0]) + .dropna() + ) + + +def process_data( + symbols_data: DataFrame, + benchmark_data: DataFrame, + long_period: int = 252, + short_period: int = 21, + normalize_method: Literal["z", "m", "a"] = "z", +) -> Tuple[DataFrame, DataFrame]: + """Process the raw data into normalized indicator values. + + Parameters + ---------- + symbols_data: DataFrame + Indexed time series data formatted with each column representing a ticker. + benchmark_data: DataFrame + Indexed time series data of the benchmark symbol. + long_period: Optional[int] + Long period to base the calculation on. Default is one standard trading year. + short_period: Optional[int] + Short period to subtract from the long period. Default is one trading month. + normalize_method: Literal["z", "m", "a"] + + Returns + ------- + Tuple[DataFrame, DataFrame] + Tuple of Pandas DataFrames with the normalized ratio and momentum indicator values. + """ + ratio_data = calculate_relative_strength_ratio(symbols_data, benchmark_data) + momentum_data = get_momentum(ratio_data, long_period, short_period) + normalized_ratio = normalize(ratio_data, normalize_method) + normalized_momentum = normalize(momentum_data, normalize_method) + + return normalized_ratio, normalized_momentum + + +class RelativeRotation: + """Relative Rotation Class.""" + + def __init__( + self, + data: Union[List[Data], DataFrame], + benchmark: str, + study: Optional[Literal["price", "volume", "volatility"]] = "price", + long_period: Optional[int] = 252, + short_period: Optional[int] = 21, + window: Optional[int] = 21, + trading_periods: Optional[int] = 252, + ): + """Initialize the class.""" + benchmark = benchmark.upper() + df = DataFrame() + + target_col = "volume" if study == "volume" else "close" + + if isinstance(data, OBBject): + data = data.results # type: ignore + + if isinstance(data, List) and ( + all(isinstance(d, Data) for d in data) + or all(isinstance(d, dict) for d in data) + ): + with contextlib.suppress(Exception): + df = basemodel_to_df(convert_to_basemodel(data), index="date") + + if isinstance(data, DataFrame) and not df.empty: + df = data.copy() + if "date" in df.columns: + df.set_index("date", inplace=True) + + if df.empty: + raise ValueError( + "Data must be a list of Data objects or a DataFrame with a 'date' column." + ) + + if "symbol" in df.columns: + df = df.pivot(columns="symbol", values=target_col) + + if benchmark not in df.columns: + raise RuntimeError("The benchmark symbol was not found in the data.") + + benchmark_data = df.pop(benchmark).to_frame() + symbols_data = df + + if len(symbols_data) <= 252 and study in ["price", "volume"]: # type: ignore + raise ValueError( + "Supplied data must be daily intervals and have more than one year of back data to calculate" + " the most recent day in the time series." + ) + + if study == "volatility" and len(symbols_data) <= 504: # type: ignore + raise ValueError( + "Supplied data must be daily intervals and have more than two years of back data to calculate" + " the most recent day in the time series as a volatility study." + ) + self.symbols = df.columns.to_list() + self.benchmark = benchmark + self.study = study + self.long_period = long_period + self.short_period = short_period + self.window = window + self.trading_periods = trading_periods + self.symbols_data = symbols_data # type: ignore + self.benchmark_data = benchmark_data # type: ignore + self._process_data() # type: ignore + self.symbols_data = df_to_basemodel(self.symbols_data.reset_index()) # type: ignore + self.benchmark_data = df_to_basemodel(self.benchmark_data.reset_index()) # type: ignore + + def _process_data(self): + """Process the data.""" + if self.study == "volatility": + self.symbols_data = standard_deviation( + self.symbols_data, # type: ignore + window=self.window, # type: ignore + trading_periods=self.trading_periods, # type: ignore + ) + self.benchmark_data = standard_deviation( + self.benchmark_data, # type: ignore + window=self.window, # type: ignore + trading_periods=self.trading_periods, # type: ignore + ) + ratios, momentum = process_data( + self.symbols_data, # type: ignore + self.benchmark_data, # type: ignore + long_period=self.long_period, # type: ignore + short_period=self.short_period, # type: ignore + ) + # Re-index rs_ratios using the new index + index_after_dropping_nans = momentum.dropna().index + ratios = ratios.reindex(index_after_dropping_nans) + self.rs_ratios = df_to_basemodel(ratios.reset_index()) + self.rs_momentum = df_to_basemodel(momentum.dropna().reset_index()) + self.end_date = to_datetime(ratios.index[-1]).strftime("%Y-%m-%d") + self.start_date = to_datetime(ratios.index[0]).strftime("%Y-%m-%d") + return self + + +def _get_type_name(t): + """Get the type name of a type hint.""" + if hasattr(t, "__origin__"): + if hasattr(t.__origin__, "__name__"): + return f"{t.__origin__.__name__}[{', '.join([_get_type_name(arg) for arg in t.__args__])}]" + if hasattr(t.__origin__, "_name"): + return f"{t.__origin__._name}[{', '.join([_get_type_name(arg) for arg in t.__args__])}]" + if isinstance(t, str): + return t + if hasattr(t, "__name__"): + return t.__name__ + if hasattr(t, "_name"): + return t._name + return str(t) + + +class RelativeRotationQueryParams(QueryParams): + """Relative Rotation Query Parameters.""" + + data: List[Data] = Field( + description="The data to be used for the relative rotation calculations." + + " This should be the multi-symbol output from the" + + " 'equity.price.historical' endpoint, or similar, at a daily interval." + + " Or a pivot table with the 'date' column as the index, the symbols as the columns," + + " and the 'study' as the values." + + " It is recommended to use the 'equity.price.historical' endpoint to get the data," + + " and feed the results as-is." + ) + benchmark: str = Field(description="The symbol to be used as the benchmark.") + study: Literal["price", "volume", "volatility"] = Field( + default="price", + description="The data point for the calculations." + + " If 'price', the closing price will be used." + + " If 'volatility', the standard deviation of the closing price will be used." + + " If 'data' is supplied as a pivot table," + + " the 'study' will assume the values are the closing price and 'volume' will be ignored.", + ) + long_period: Optional[int] = Field( + default=252, + description="The length of the long period for momentum calculation, by default is 252." + + " Adjust this value, to 365, when supplying assets such as crypto.", + ) + short_period: Optional[int] = Field( + default=21, + description="The length of the short period for momentum calculation, by default is 21." + + " Adjust this value, to 30, when supplying assets such as crypto.", + ) + window: Optional[int] = Field( + default=21, + description="The length of window for the standard deviation calculation, by default is 21." + + " Adjust this value, to 30, when supplying assets such as crypto.", + ) + trading_periods: Optional[int] = Field( + default=252, + description="The number of trading periods per year," + + " for the standard deviation calculation, by default is 252." + + " Adjust this value, to 365, when supplying assets such as crypto.", + ) + chart_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Additional parameters to pass when `chart=True` and the `openbb-charting` extension is installed." + + " Parameters can be passed again to redraw the chart using the charting.to_chart() method of the response." + + "\n" + + "\n ChartParams" + + "\n -----------" + + "\n date: Optional[str]" + + "\n A target end date within the data, by default is the last date in the data." + + "\n show_tails: bool" + + "\n Show the tails on the chart, by default is True." + + "\n tail_periods: Optional[int]" + + "\n Number of periods to show in the tails, by default is 16." + + "\n tail_interval: Literal['day', 'week', 'month']" + + "\n Interval to show the tails, by default is 'week'." + + "\n title: Optional[str]" + + "\n Title of the chart.", |