Skip to content

Commit

Permalink
Add ETF holdings and holding report dates from FMP (OpenBB-finance#5629)
Browse files Browse the repository at this point in the history
* Add ETF info from FMP

* Add integration tests

* Add unit tests

* Add etf sectors endpoint

* Add etf sectors fmp endpoint

* Omit nested sector list from fmp info data

* Update tests and test data

* Update test data

* Specify provider explicitly in integration tests

* Update tests

* Skip yfinance etf historical test

* Add etf holdings and holdings date routes and standard models

* Add fmp holdings and holding dates fetchers

* Update holdings fetcher to properly handle date object inputs

* Add unit tests

* Add integration tests

* Black after merge

* Fix test input data format
  • Loading branch information
piiq authored Oct 31, 2023
1 parent 0a99b55 commit 4b4cd9a
Show file tree
Hide file tree
Showing 11 changed files with 671 additions and 4 deletions.
58 changes: 58 additions & 0 deletions openbb_platform/extensions/etf/integration/test_etf_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,64 @@ def test_etf_sectors(params, headers):
assert result.status_code == 200


@pytest.mark.parametrize(
"params",
[
(
{
"symbol": "IOO",
"date": "2023-01-01",
"cik": None,
"provider": "fmp",
}
),
(
{
"symbol": "SPY",
"date": "2023-04-20",
"cik": None,
"provider": "fmp",
}
),
(
{
"symbol": "MISL",
"date": "2023-04-20",
"cik": "0001329377",
"provider": "fmp",
}
),
],
)
@pytest.mark.integration
def test_etf_holdings(params, headers):
params = {p: v for p, v in params.items() if v}

query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/etf/holdings?{query_str}"
result = requests.get(url, headers=headers, timeout=10)
assert isinstance(result, requests.Response)
assert result.status_code == 200


@pytest.mark.parametrize(
"params",
[
({"symbol": "IOO"}),
({"symbol": "MISL", "cik": None, "provider": "fmp"}),
],
)
@pytest.mark.integration
def test_etf_holdings_date(params, headers):
params = {p: v for p, v in params.items() if v}

query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/etf/holdings_date?{query_str}"
result = requests.get(url, headers=headers, timeout=10)
assert isinstance(result, requests.Response)
assert result.status_code == 200


@pytest.mark.parametrize(
"params",
[({"symbol": "SPY,VOO,QQQ,IWM,IWN,GOVT,JNK", "provider": "fmp"})],
Expand Down
56 changes: 56 additions & 0 deletions openbb_platform/extensions/etf/integration/test_etf_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,62 @@ def test_etf_sectors(params, obb):
assert len(result.results) > 0


@pytest.mark.parametrize(
"params",
[
(
{
"symbol": "IOO",
"date": "2023-01-01",
"cik": None,
"provider": "fmp",
}
),
(
{
"symbol": "SPY",
"date": "2023-04-20",
"cik": None,
"provider": "fmp",
}
),
(
{
"symbol": "MISL",
"date": "2023-04-20",
"cik": "0001329377",
"provider": "fmp",
}
),
],
)
@pytest.mark.integration
def test_etf_holdings(params, obb):
params = {p: v for p, v in params.items() if v}

result = obb.etf.holdings(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0


@pytest.mark.parametrize(
"params",
[
({"symbol": "IOO"}),
({"symbol": "MISL", "cik": None, "provider": "fmp"}),
],
)
@pytest.mark.integration
def test_etf_holdings_date(params, obb):
params = {p: v for p, v in params.items() if v}

result = obb.etf.holdings_date(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0


@pytest.mark.parametrize(
"params",
[({"symbol": "SPY,VOO,QQQ,IWM,IWN,GOVT,JNK", "provider": "fmp"})],
Expand Down
22 changes: 22 additions & 0 deletions openbb_platform/extensions/etf/openbb_etf/etf_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,25 @@ def price_performance(
) -> OBBject[BaseModel]:
"""Price performance as a return, over different periods."""
return OBBject(results=Query(**locals()).execute())


@router.command(model="EtfHoldings")
def holdings(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject[BaseModel]:
"""Get the holdings for an individual ETF."""
return OBBject(results=Query(**locals()).execute())


@router.command(model="EtfHoldingsDate")
def holdings_date(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject[BaseModel]:
"""Get the holdings filing date for an individual ETF."""
return OBBject(results=Query(**locals()).execute())
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@


class EtfHoldingsQueryParams(QueryParams):
"""ETF Holdings Query Params"""
"""ETF Holdings Query Params."""

symbol: str = Field(description=QUERY_DESCRIPTIONS.get("symbol", ""))
symbol: str = Field(description=QUERY_DESCRIPTIONS.get("symbol", "") + " (ETF)")


class EtfHoldingsData(Data):
"""ETF Holdings Data."""

symbol: Optional[str] = Field(description=DATA_DESCRIPTIONS.get("symbol", ""))
symbol: Optional[str] = Field(
description=DATA_DESCRIPTIONS.get("symbol", "") + " (ETF)"
)
name: Optional[str] = Field(description="Name of the ETF holding.")
weight: Optional[float] = Field(description="Weight of the ETF holding.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""ETF Holdings Date data model."""

from datetime import date as dateType

from pydantic import Field

from openbb_provider.abstract.data import Data
from openbb_provider.abstract.query_params import QueryParams
from openbb_provider.utils.descriptions import DATA_DESCRIPTIONS, QUERY_DESCRIPTIONS


class EtfHoldingsDateQueryParams(QueryParams):
"""ETF Holdings Query Params."""

symbol: str = Field(description=QUERY_DESCRIPTIONS.get("symbol", "") + " (ETF)")


class EtfHoldingsDateData(Data):
"""ETF Holdings Data."""

date: dateType = Field(description=DATA_DESCRIPTIONS.get("date"))
4 changes: 4 additions & 0 deletions openbb_platform/providers/fmp/openbb_fmp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from openbb_fmp.models.earnings_calendar import FMPEarningsCalendarFetcher
from openbb_fmp.models.earnings_call_transcript import FMPEarningsCallTranscriptFetcher
from openbb_fmp.models.economic_calendar import FMPEconomicCalendarFetcher
from openbb_fmp.models.etf_holdings import FMPEtfHoldingsFetcher
from openbb_fmp.models.etf_holdings_date import FMPEtfHoldingsDateFetcher
from openbb_fmp.models.etf_info import FMPEtfInfoFetcher
from openbb_fmp.models.etf_search import FMPEtfSearchFetcher
from openbb_fmp.models.etf_sectors import FMPEtfSectorsFetcher
Expand Down Expand Up @@ -90,6 +92,8 @@
"EtfSearch": FMPEtfSearchFetcher,
"EtfSectors": FMPEtfSectorsFetcher,
"EtfInfo": FMPEtfInfoFetcher,
"EtfHoldings": FMPEtfHoldingsFetcher,
"EtfHoldingsDate": FMPEtfHoldingsDateFetcher,
"CryptoHistorical": FMPCryptoHistoricalFetcher,
"ForexHistorical": FMPForexHistoricalFetcher,
"ForexPairs": FMPForexPairsFetcher,
Expand Down
162 changes: 162 additions & 0 deletions openbb_platform/providers/fmp/openbb_fmp/models/etf_holdings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""FMP ETF Holdings fetcher."""

from datetime import (
date as dateType,
datetime,
timedelta,
)
from typing import Any, Dict, List, Optional, Union

from openbb_fmp.utils.helpers import create_url, get_data_many
from openbb_provider.abstract.fetcher import Fetcher
from openbb_provider.standard_models.etf_holdings import (
EtfHoldingsData,
EtfHoldingsQueryParams,
)
from openbb_provider.utils.descriptions import QUERY_DESCRIPTIONS
from pydantic import Field


class FMPEtfHoldingsQueryParams(EtfHoldingsQueryParams):
"""FMP ETF Holdings query.
Source: https://site.financialmodelingprep.com/developer/docs#Historical-ETF-Holdings
"""

date: Optional[Union[str, dateType]] = Field(
description=QUERY_DESCRIPTIONS.get("date", "")
+ " The input date is adjusted to the nearest previous quarter-end date."
+ " Holdings are returned as of the adjusted date if available, with no data from the subsequent quarter.",
default=None,
)

cik: Optional[str] = Field(
description=QUERY_DESCRIPTIONS.get("cik", "")
+ "The CIK of the filing entity. Overrides symbol.",
default=None,
)


class FMPEtfHoldingsData(EtfHoldingsData):
"""FMP ETF Holdings Data."""

lei: Optional[str] = Field(description="The LEI of the holding.", default=None)
title: Optional[str] = Field(description="The title of the holding.", default=None)
cusip: Optional[str] = Field(description="The CUSIP of the holding.", default=None)
isin: Optional[str] = Field(description="The ISIN of the holding.", default=None)
balance: Optional[float] = Field(
description="The balance of the holding.", default=None
)
units: Optional[Union[float, str]] = Field(
description="The units of the holding.", default=None
)
currency: Optional[str] = Field(
description="The currency of the holding.", alias="cur_cd", default=None
)
value: Optional[float] = Field(
description="The value of the holding in USD.", alias="valUsd", default=None
)
weight: Optional[float] = Field(
description="The weight of the holding in ETF in %.",
alias="pctVal",
default=None,
)
payoff_profile: Optional[str] = Field(
description="The payoff profile of the holding.",
alias="payoffProfile",
default=None,
)
asset_category: Optional[str] = Field(
description="The asset category of the holding.", alias="assetCat", default=None
)
issuer_category: Optional[str] = Field(
description="The issuer category of the holding.",
alias="issuerCat",
default=None,
)
country: Optional[str] = Field(
description="The country of the holding.", alias="invCountry", default=None
)
is_restricted: Optional[str] = Field(
description="Whether the holding is restricted.",
alias="isRestrictedSec",
default=None,
)
fair_value_level: Optional[int] = Field(
description="The fair value level of the holding.",
alias="fairValLevel",
default=None,
)
is_cash_collateral: Optional[str] = Field(
description="Whether the holding is cash collateral.",
alias="isCashCollateral",
default=None,
)
is_non_cash_collateral: Optional[str] = Field(
description="Whether the holding is non-cash collateral.",
alias="isNonCashCollateral",
default=None,
)
is_loan_by_fund: Optional[str] = Field(
description="Whether the holding is loan by fund.",
alias="isLoanByFund",
default=None,
)
cik: Optional[str] = Field(description="The CIK of the filing.", default=None)
acceptance_datetime: Optional[str] = Field(
description="The acceptance datetime of the filing.",
alias="acceptanceTime",
default=None,
)


class FMPEtfHoldingsFetcher(
Fetcher[
FMPEtfHoldingsQueryParams,
List[FMPEtfHoldingsData],
]
):
"""Transform the query, extract and transform the data from the FMP endpoints."""

@staticmethod
def transform_query(params: Dict[str, Any]) -> FMPEtfHoldingsQueryParams:
"""Transform the query.
Adjust input date to the nearest previous quarter-end date.
"""
date_str = params.get("date", datetime.today().strftime("%Y-%m-%d"))
date_str = (
date_str.strftime("%Y-%m-%d")
if isinstance(date_str, dateType)
else date_str
)
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
quarter_month = ((date_obj.month - 1) // 3) * 3 + 1
quarter_start = date_obj.replace(month=quarter_month, day=1)
previous_quarter_end = quarter_start - timedelta(days=1)
params["date"] = previous_quarter_end.strftime("%Y-%m-%d")
return FMPEtfHoldingsQueryParams(**params)

@staticmethod
def extract_data(
query: FMPEtfHoldingsQueryParams,
credentials: Optional[Dict[str, str]],
**kwargs: Any,
) -> List[Dict]:
"""Return the raw data from the FMP endpoint."""
api_key = credentials.get("fmp_api_key") if credentials else ""

url = create_url(
version=4, endpoint="etf-holdings", api_key=api_key, query=query
)

return get_data_many(url, **kwargs)

@staticmethod
def transform_data(
query: FMPEtfHoldingsQueryParams,
data: List[Dict],
**kwargs: Any,
) -> List[FMPEtfHoldingsData]:
"""Return the transformed data."""
return [FMPEtfHoldingsData.model_validate(d) for d in data]
Loading

0 comments on commit 4b4cd9a

Please sign in to comment.