diff options
author | Danglewood <85772166+deeleeramone@users.noreply.github.com> | 2024-07-02 09:06:59 -0700 |
---|---|---|
committer | Danglewood <85772166+deeleeramone@users.noreply.github.com> | 2024-07-02 09:06:59 -0700 |
commit | ec138ddd96803e7a2861b925fe82b8f6a0ed0fde (patch) | |
tree | 6ee641e94b60458ae9f02b7e2b0f036da585cce7 | |
parent | 6e267cae98c80c5bf6448de68f6b509ec6849ce5 (diff) | |
parent | 5c1a1efa9ae64b88e6f25504e9e0b6ca3128b220 (diff) |
merge branch develop
16 files changed, 15310 insertions, 213 deletions
diff --git a/openbb_platform/core/openbb_core/app/utils.py b/openbb_platform/core/openbb_core/app/utils.py index 21d2f79e07d..c9b5327f801 100644 --- a/openbb_platform/core/openbb_core/app/utils.py +++ b/openbb_platform/core/openbb_core/app/utils.py @@ -27,12 +27,16 @@ def basemodel_to_df( from pandas import DataFrame, to_datetime if isinstance(data, list): - df = DataFrame([d.model_dump() for d in data]) + df = DataFrame( + [d.model_dump(exclude_none=True, exclude_unset=True) for d in data] + ) else: try: - df = DataFrame(data.model_dump()) + df = DataFrame(data.model_dump(exclude_none=True, exclude_unset=True)) except ValueError: - df = DataFrame(data.model_dump(), index=["values"]) + df = DataFrame( + data.model_dump(exclude_none=True, exclude_unset=True), index=["values"] + ) if "is_multiindex" in df.columns: col_names = ast.literal_eval(df.multiindex_names.unique()[0]) diff --git a/openbb_platform/core/openbb_core/provider/standard_models/futures_curve.py b/openbb_platform/core/openbb_core/provider/standard_models/futures_curve.py index 266de2e79e2..2afd1cf8a60 100644 --- a/openbb_platform/core/openbb_core/provider/standard_models/futures_curve.py +++ b/openbb_platform/core/openbb_core/provider/standard_models/futures_curve.py @@ -1,7 +1,7 @@ """Futures Curve Standard Model.""" from datetime import date as dateType -from typing import Optional +from typing import Optional, Union from pydantic import Field, field_validator @@ -17,14 +17,14 @@ class FuturesCurveQueryParams(QueryParams): """Futures Curve Query.""" symbol: str = Field(description=QUERY_DESCRIPTIONS.get("symbol", "")) - date: Optional[dateType] = Field( + date: Optional[Union[dateType, str]] = Field( default=None, description=QUERY_DESCRIPTIONS.get("date", ""), ) @field_validator("symbol", mode="before", check_fields=False) @classmethod - def to_upper(cls, v: str) -> str: + def to_upper(cls, v): """Convert field to uppercase.""" return v.upper() @@ -32,7 +32,12 @@ class FuturesCurveQueryParams(QueryParams): class FuturesCurveData(Data): """Futures Curve Data.""" + date: Optional[dateType] = Field( + default=None, description=DATA_DESCRIPTIONS.get("date", "") + ) expiration: str = Field(description="Futures expiration month.") - price: Optional[float] = Field( - default=None, description=DATA_DESCRIPTIONS.get("close", "") + price: float = Field( + default=None, + description="The price of the futures contract.", + json_schema_extra={"x-unit_measurement": "currency"}, ) diff --git a/openbb_platform/extensions/derivatives/integration/test_derivatives_api.py b/openbb_platform/extensions/derivatives/integration/test_derivatives_api.py index 97e9a3551eb..8e52112ead0 100644 --- a/openbb_platform/extensions/derivatives/integration/test_derivatives_api.py +++ b/openbb_platform/extensions/derivatives/integration/test_derivatives_api.py @@ -128,8 +128,20 @@ def test_derivatives_futures_historical(params, headers): @parametrize( "params", [ - ({"provider": "cboe", "symbol": "VX", "date": None}), - ({"provider": "yfinance", "symbol": "ES", "date": "2023-08-01"}), + ( + { + "provider": "yfinance", + "symbol": "ES", + "date": None, + } + ), + ( + { + "provider": "cboe", + "symbol": "VX_EOD", + "date": None, + } + ), ], ) @pytest.mark.integration @@ -139,7 +151,7 @@ def test_derivatives_futures_curve(params, headers): query_str = get_querystring(params, []) url = f"http://0.0.0.0:8000/api/v1/derivatives/futures/curve?{query_str}" - result = requests.get(url, headers=headers, timeout=60) + result = requests.get(url, headers=headers, timeout=10) assert isinstance(result, requests.Response) assert result.status_code == 200 diff --git a/openbb_platform/extensions/derivatives/integration/test_derivatives_python.py b/openbb_platform/extensions/derivatives/integration/test_derivatives_python.py index 4e04039bc7a..0ba718e01fc 100644 --- a/openbb_platform/extensions/derivatives/integration/test_derivatives_python.py +++ b/openbb_platform/extensions/derivatives/integration/test_derivatives_python.py @@ -115,8 +115,8 @@ def test_derivatives_futures_historical(params, obb): @parametrize( "params", [ - ({"symbol": "VX", "provider": "cboe", "date": None}), - ({"provider": "yfinance", "symbol": "ES", "date": "2023-08-01"}), + ({"provider": "yfinance", "symbol": "ES", "date": None}), + ({"provider": "cboe", "symbol": "VX", "date": None}), ], ) @pytest.mark.integration diff --git a/openbb_platform/extensions/derivatives/openbb_derivatives/derivatives_views.py b/openbb_platform/extensions/derivatives/openbb_derivatives/derivatives_views.py index a13bbc861a7..e6af3d77ef9 100644 --- a/openbb_platform/extensions/derivatives/openbb_derivatives/derivatives_views.py +++ b/openbb_platform/extensions/derivatives/openbb_derivatives/derivatives_views.py @@ -18,3 +18,209 @@ class DerivativesViews: from openbb_charting.charts.price_historical import price_historical return price_historical(**kwargs) + + @staticmethod + def derivatives_futures_curve( # noqa: PLR0912 + **kwargs, + ) -> Tuple["OpenBBFigure", Dict[str, Any]]: + """Futures curve chart. All parameters are optional, and are kwargs. + Parameters can be directly accessed from the function end point by + entering as a nested dictionary to the 'chart_params' key. + + From the API, `chart_params` must be passed as a JSON in the request body with `extra_params`. + + If using the chart post-request, the parameters are passed directly + as `key=value` pairs in the `charting.to_chart` or `charting.show` methods. + + Parameters + ---------- + data : Optional[Union[List[Data], DataFrame]] + Data for the chart. Required fields are: 'expiration' and 'price'. + Multiple dates will be plotted on the same chart. + If not supplied, the original OBBject.results will be used. + If a DataFrame is supplied, flat data is expected, without a set index. + title: Optional[str] + Title for the chart. If not supplied, a default title will be used. + colors: Optional[List[str]] + List of colors to use for the chart. If not supplied, the default colorway will be used. + Colors should be in hex format, or named Plotly colors. Invalid colors will raise a Plotly error. + layout_kwargs: Optional[Dict[str, Any]] + Additional layout parameters for the chart, passed directly to `figure.update_layout` before output. + See Plotly documentation for available options. + + Returns + ------- + Tuple[OpenBBFigure, Dict[str, Any]] + Tuple with the OpenBBFigure object, and the JSON-serialized content. + If using the API, only the JSON content will be returned. + + Examples + -------- + ```python + from openbb import obb + data = obb.derivatives.futures.curve(symbol="vx", provider="cboe", date=["2020-03-31", "2024-06-28"], chart=True) + data.show() + ``` + + Redraw the chart, from the same data, with a custom colorway and title: + + ```python + data.charting.to_chart(colors=["green", "red"], title="VIX Futures Curve - 2020 vs. 2024") + ``` + """ + # pylint: disable=import-outside-toplevel + from openbb_charting.core.chart_style import ChartStyle + from openbb_charting.core.openbb_figure import OpenBBFigure + from openbb_charting.styles.colors import LARGE_CYCLER + from openbb_core.app.model.abstract.error import OpenBBError + from openbb_core.provider.abstract.data import Data + from pandas import DataFrame, to_datetime + + data = kwargs.get("data", None) + symbol = kwargs.get("standard_params", {}).get("symbol", "") + df: DataFrame = DataFrame() + if data: + if isinstance(data, DataFrame) and not data.empty: # noqa: SIM108 + df = data + elif isinstance(data, (list, Data)): + df = DataFrame([d.model_dump(exclude_none=True, exclude_unset=True) for d in data]) # type: ignore + else: + pass + else: + df = DataFrame( + [ + d.model_dump(exclude_none=True, exclude_unset=True) # type: ignore + for d in kwargs["obbject_item"] + ] + if isinstance(kwargs.get("obbject_item"), list) + else kwargs["obbject_item"].model_dump(exclude_none=True, exclude_unset=True) # type: ignore + ) + + if df.empty: + raise OpenBBError("Error: No data to plot.") + + if "expiration" not in df.columns: + raise OpenBBError("Expiration field not found in the data.") + + if "price" not in df.columns: + raise ValueError("Price field not found in the data.") + + provider = kwargs.get("provider", "") + + df["expiration"] = to_datetime(df["expiration"], errors="ignore").dt.strftime( + "%b-%Y" + ) + + if ( + provider == "cboe" + and "date" in df.columns + and len(df["date"].unique()) > 1 + and "symbol" in df.columns + ): + df["expiration"] = df.symbol + + # Use a complete list of expirations to categorize the x-axis across all dates. + expirations = df["expiration"].unique().tolist() + + # Use the supplied colors, if any. + colors = kwargs.get("colors", []) + if not colors: + colors = LARGE_CYCLER + color_count = 0 + + figure = OpenBBFigure().create_subplots(shared_xaxes=True) + figure.update_layout(ChartStyle().plotly_template.get("layout", {})) + + def create_fig(figure, df, dates, color_count): + """Create a scatter for each date in the data.""" + for date in dates: + color = colors[color_count % len(colors)] + plot_df = ( + df[df["date"].astype(str) == date].copy() + if "date" in df.columns + else df.copy() + ) + plot_df = plot_df.drop( + columns=["date"] if "date" in plot_df.columns else [] + ).rename(columns={"expiration": "Expiration", "price": "Price"}) + figure.add_scatter( + x=plot_df["Expiration"], + y=plot_df["Price"], + mode="lines+markers", + name=date, + line=dict(width=3, color=color), + marker=dict(size=10, color=color), + hovertemplate=( + "Expiration: %{x}<br>Price: $%{y}<extra></extra>" + if len(dates) == 1 + else "%{fullData.name}<br>Expiration: %{x}<br>Price: $%{y}<extra></extra>" + ), + ) + color_count += 1 + return figure, color_count + + dates = ( + df.date.astype(str).unique().tolist() + if "date" in df.columns + else ["Current"] + ) + figure, color_count = create_fig(figure, df, dates, color_count) + + # Set the title for the chart + title: str = "" + if provider == "cboe": + vx_eod_symbols = ["vx", "vix", "vx_eod", "^vix"] + title = ( + "VIX EOD Futures Curve" + if symbol.lower() in vx_eod_symbols + else "VIX Mid-Morning TWAP Futures Curve" + ) + if len(dates) == 1 and dates[0] != "Current": + title = f"{title} for {dates[0]}" + elif provider == "yfinance": + title = f"{symbol.upper()} Futures Curve" + + # Use the supplied title, if any. + title = kwargs.get("title", title) + + # Update the layout of the figure. + figure.update_layout( + title=dict(text=title, x=0.5, font=dict(size=20)), + plot_bgcolor="rgba(255,255,255,0)", + xaxis=dict( + title="", + ticklen=0, + showgrid=False, + type="category", + categoryorder="array", + categoryarray=expirations, + ), + yaxis=dict( + title="Price ($)", + ticklen=0, + showgrid=True, + gridcolor="rgba(128,128,128,0.3)", + ), + legend=dict( + orientation="v", + yanchor="top", + xanchor="right", + y=0.95, + x=0, + xref="paper", + font=dict(size=12), + bgcolor="rgba(0,0,0,0)", + ), + margin=dict( + b=10, + t=10, + ), + ) + + layout_kwargs = kwargs.get("layout_kwargs", {}) + if layout_kwargs: + figure.update_layout(layout_kwargs) + + content = figure.show(external=True).to_plotly_json() + + return figure, content diff --git a/openbb_platform/obbject_extensions/charting/integration/test_charting_api.py b/openbb_platform/obbject_extensions/charting/integration/test_charting_api.py index 76bcba65d7f..7dbd1497eb1 100644 --- a/openbb_platform/obbject_extensions/charting/integration/test_charting_api.py +++ b/openbb_platform/obbject_extensions/charting/integration/test_charting_api.py @@ -791,3 +791,39 @@ def test_charting_derivatives_futures_historical(params, headers): assert chart assert not fig assert list(chart.keys()) == ["content", "format"] + + +@parametrize( + "params", + [ + ( + { + "provider": "yfinance", + "symbol": "VX", + } + ), + ( + { + "provider": "cboe", + "symbol": "VX", + } + ), + ], +) +@pytest.mark.integration +def test_charting_derivatives_futures_curve(params, headers): + """Test chart derivatives futures curve.""" + params = {p: v for p, v in params.items() if v} + body = (json.dumps({"extra_params": {"chart_params": {"title": "test chart"}}}),) + query_str = get_querystring(params, []) + url = f"http://0.0.0.0:8000/api/v1/derivatives/futures/curve?{query_str}" + result = requests.get(url, headers=headers, timeout=10, json=body) + assert isinstance(result, requests.Response) + assert result.status_code == 200 + + chart = result.json()["chart"] + fig = chart.pop("fig", {}) + + assert chart + assert not fig + assert list(chart.keys()) == ["content", "format"] diff --git a/openbb_platform/obbject_extensions/charting/integration/test_charting_python.py b/openbb_platform/obbject_extensions/charting/integration/test_charting_python.py index 34705bb149f..6b95902bf2c 100644 --- a/openbb_platform/obbject_extensions/charting/integration/test_charting_python.py +++ b/openbb_platform/obbject_extensions/charting/integration/test_charting_python.py @@ -646,3 +646,31 @@ def test_charting_derivatives_futures_historical(params, obb): assert len(result.results) > 0 assert result.chart.content assert isinstance(result.chart.fig, OpenBBFigure) + + +@parametrize( + "params", + [ + ( + { + "provider": "yfinance", + "symbol": "VX", + } + ), + ( + { + "provider": "cboe", + "symbol": "VX", + } + ), + ], +) +@pytest.mark.integration +def test_charting_derivatives_futures_curve(params, obb): + """Test chart derivatives futures curve.""" + result = obb.derivatives.futures.curve(**params) + assert result + assert isinstance(result, OBBject) + assert len(result.results) > 0 + assert result.chart.content + assert isinstance(result.chart.fig, OpenBBFigure) diff --git a/openbb_platform/obbject_extensions/charting/openbb_charting/charting.py b/openbb_platform/obbject_extensions/charting/openbb_charting/charting.py index a0be6eb7788..ec43f0fac57 100644 --- a/openbb_platform/obbject_extensions/charting/openbb_charting/charting.py +++ b/openbb_platform/obbject_extensions/charting/openbb_charting/charting.py @@ -133,10 +133,11 @@ class Charting: ) return self._functions[adjusted_route] - def get_params(self) -> "ChartParams": + def get_params(self) -> Union["ChartParams", None]: """Return the ChartQueryParams class for the function the OBBject was created from. Without assigning to a variable, it will print the docstring to the console. + If the class is not defined, the help for the function will be returned. """ # pylint: disable=import-outside-toplevel from openbb_charting.query_params import ChartParams @@ -148,8 +149,13 @@ class Charting: ).replace("/", "_")[1:] if hasattr(ChartParams, charting_function): return getattr(ChartParams, charting_function)() - raise ValueError( - f"Error: No chart parameters are defined for the route: {charting_function}" + + return help( # type: ignore + self._get_chart_function( # pylint: disable=protected-access + self._obbject.extra[ # pylint: disable=protected-access + "metadata" + ].route + ) ) def _prepare_data_as_df( diff --git a/openbb_platform/obbject_extensions/charting/openbb_charting/query_params.py b/openbb_platform/obbject_extensions/charting/openbb_charting/query_params.py index 661ffda9356..82ada35af10 100644 --- a/openbb_platform/obbject_extensions/charting/openbb_charting/query_params.py +++ b/openbb_platform/obbject_extensions/charting/openbb_charting/query_params.py @@ -396,10 +396,10 @@ class ChartParams: """Chart Query Params.""" crypto_price_historical = EquityPriceHistoricalChartQueryParams + derivatives_futures_historical = EquityPriceHistoricalChartQueryParams equity_price_historical = EquityPriceHistoricalChartQueryParams economy_fred_series = EconomyFredSeriesChartQueryParams equity_price_historical = EquityPriceHistoricalChartQueryParams - derivatives_futures_historical = EquityPriceHistoricalChartQueryParams equity_price_performance = EquityPricePerformanceChartQueryParams etf_historical = EtfPricePerformanceChartQueryParams etf_holdings = EtfHoldingsChartQueryParams diff --git a/openbb_platform/providers/cboe/openbb_cboe/models/futures_curve.py b/openbb_platform/providers/cboe/openbb_cboe/models/futures_curve.py index eced0ba63b9..b10af1b536d 100644 --- a/openbb_platform/providers/cboe/openbb_cboe/models/futures_curve.py +++ b/openbb_platform/providers/cboe/openbb_cboe/models/futures_curve.py @@ -10,8 +10,14 @@ from openbb_core.provider.standard_models.futures_curve import ( FuturesCurveData, FuturesCurveQueryParams, ) +from openbb_core.provider.utils.descriptions import ( + DATA_DESCRIPTIONS, + QUERY_DESCRIPTIONS, +) from openbb_core.provider.utils.errors import EmptyDataError -from pydantic import Field +from pydantic import Field, field_validator + +SymbolChoices = Literal["VX_AM", "VX_EOD"] class CboeFuturesCurveQueryParams(FuturesCurveQueryParams): @@ -20,11 +26,32 @@ class CboeFuturesCurveQueryParams(FuturesCurveQueryParams): Source: https://www.cboe.com/ """ + __json_schema_extra__ = {"date": {"multiple_items_allowed": True}} + + symbol: SymbolChoices = Field( + default="VX_EOD", + description=QUERY_DESCRIPTIONS.get("symbol", "") + + "Default is 'VX_EOD'. Entered dates return the data nearest to the entered date." + + "\n 'VX_AM' = Mid-Morning TWAP Levels" + + "\n 'VX_EOD' = 4PM Eastern Time Levels", + json_schema_extra={"choices": ["VX_AM", "VX_EOD"]}, + ) + + @field_validator("symbol", mode="before", check_fields=False) + @classmethod + def validate_symbol(cls, v): + """Validate the symbol.""" + if not v or v.lower() in ["vx", "vix", "^vix", "vix_index"]: + return "VX_EOD" + return v.upper() + class CboeFuturesCurveData(FuturesCurveData): """CBOE Futures Curve Data.""" - symbol: str = Field(description="The trading symbol for the tenor of future.") + symbol: Optional[str] = Field( + default=None, description=DATA_DESCRIPTIONS.get("symbol", "") + ) class CboeFuturesCurveFetcher( @@ -48,21 +75,20 @@ class CboeFuturesCurveFetcher( ) -> List[Dict]: """Return the raw data from the CBOE endpoint.""" # pylint: disable=import-outside-toplevel - from openbb_cboe.utils.helpers import get_settlement_prices - - symbol = query.symbol.upper().split(",")[0] - FUTURES = await get_settlement_prices(**kwargs) - if len(FUTURES) == 0: - raise EmptyDataError() - - if symbol not in FUTURES["product"].unique().tolist(): - raise OpenBBError( - "The symbol, " - f"{symbol}" - ", is not valid. Chose from: " - f"{FUTURES['product'].unique().tolist()}" + from openbb_cboe.utils.vix import get_vx_by_date, get_vx_current + + symbol = "am" if query.symbol == "VX_AM" else "eod" + if query.date is not None: + data = await get_vx_by_date( + date=query.date, # type: ignore + vx_type=symbol, + use_cache=False, ) - data = FUTURES[FUTURES["product"] == symbol][["expiration", "symbol", "price"]] + else: + data = await get_vx_current(vx_type=symbol, use_cache=False) + + if data.empty: + raise EmptyDataError("The response was returned empty.") return data.to_dict("records") diff --git a/openbb_platform/providers/cboe/openbb_cboe/utils/vix.py b/openbb_platform/providers/cboe/openbb_cboe/utils/vix.py new file mode 100644 index 00000000000..150e017c90d --- /dev/null +++ b/openbb_platform/providers/cboe/openbb_cboe/utils/vix.py @@ -0,0 +1,326 @@ +"""VIX Utilities.""" + +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union + +if TYPE_CHECKING: + from pandas import DataFrame # pylint: disable=import-outside-toplevel + + +VX_AM_SYMBOLS = [ + "TWLV1", + "TWLV2", + "TWLV3", + "TWLV4", + "TWLV5", + "TWLV6", + "TWLV7", + "TWLV8", + "TWLV9", +] + +VX_EOD_SYMBOL_TO_MONTH = { + "UZF": 1, + "UZG": 2, + "UZH": 3, + "UZJ": 4, + "UZK": 5, + "UZM": 6, + "UZN": 7, + "UZQ": 8, + "UZU": 9, + "UZV": 10, + "UZX": 11, + "UZZ": 12, +} + + +def get_front_month(date: Optional[str] = None): + """Get the front month based on the th |