Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] - Add Futures Curve Chart #6547

Merged
merged 20 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions openbb_platform/core/openbb_core/app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@ def basemodel_to_df(
) -> pd.DataFrame:
"""Convert list of BaseModel to a Pandas DataFrame."""
if isinstance(data, list):
df = pd.DataFrame([d.model_dump() for d in data])
df = pd.DataFrame(
[d.model_dump(exclude_none=True, exclude_unset=True) for d in data]
)
else:
try:
df = pd.DataFrame(data.model_dump())
df = pd.DataFrame(data.model_dump(exclude_none=True, exclude_unset=True))
except ValueError:
df = pd.DataFrame(data.model_dump(), index=["values"])
df = pd.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])
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -17,22 +17,27 @@ 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()


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"},
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be a bad idea to have such a small timeout on an integration test, will probably cause a false positive.

assert isinstance(result, requests.Response)
assert result.status_code == 200

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Views for the Derivatives Extension."""

from typing import Any, Dict, Tuple
from typing import TYPE_CHECKING, Any, Dict, Tuple

from openbb_charting.charts.price_historical import price_historical
from openbb_charting.core.openbb_figure import OpenBBFigure
if TYPE_CHECKING:
from openbb_charting.core.openbb_figure import OpenBBFigure


class DerivativesViews:
Expand All @@ -12,6 +12,215 @@ class DerivativesViews:
@staticmethod
def derivatives_futures_historical( # noqa: PLR0912
**kwargs,
) -> Tuple[OpenBBFigure, Dict[str, Any]]:
"""Get Derivatives Price Historical Chart."""
) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Get Derivatives Futures Historical Chart."""
# pylint: disable=import-outside-toplevel
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
Comment on lines +72 to +77
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is due to import time reduction?


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
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading