Skip to content

Commit

Permalink
Add ETF info and sector exposure from FMP (OpenBB-finance#5620)
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

* Fix linter errors after the merge

* Fix more linter errors

* Fix failing and add missing tests

* Fix integration tests

* Fix calendar ipo tests
  • Loading branch information
piiq authored Oct 31, 2023
1 parent fc97948 commit 0a99b55
Show file tree
Hide file tree
Showing 22 changed files with 4,482 additions and 4,162 deletions.
40 changes: 39 additions & 1 deletion openbb_platform/extensions/etf/integration/test_etf_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ def headers():

@pytest.mark.parametrize(
"params",
[({"query": None, "provider": "fmp"})],
[
({"query": "", "provider": "fmp"}),
],
)
@pytest.mark.integration
def test_etf_search(params, headers):
Expand Down Expand Up @@ -65,6 +67,42 @@ def test_etf_historical(params, headers):
assert result.status_code == 200


@pytest.mark.parametrize(
"params",
[
({"symbol": "IOO", "provider": "fmp"}),
({"symbol": "MISL", "provider": "fmp"}),
],
)
@pytest.mark.integration
def test_etf_info(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/info?{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", "provider": "fmp"}),
({"symbol": "MISL", "provider": "fmp"}),
],
)
@pytest.mark.integration
def test_etf_sectors(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/sectors?{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
34 changes: 34 additions & 0 deletions openbb_platform/extensions/etf/integration/test_etf_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,40 @@ def test_etf_historical(params, obb):
assert len(result.results) > 0


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

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


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

result = obb.etf.sectors(**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
25 changes: 24 additions & 1 deletion openbb_platform/extensions/etf/openbb_etf/etf_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
router = Router(prefix="")
router.include_router(disc_router)


# pylint: disable=unused-argument


@router.command(model="EtfSearch")
def search(
cc: CommandContext,
Expand All @@ -43,6 +44,28 @@ def historical(
return OBBject(results=Query(**locals()).execute())


@router.command(model="EtfInfo")
def info(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject[BaseModel]:
"""ETF Information Overview."""
return OBBject(results=Query(**locals()).execute())


@router.command(model="EtfSectors")
def sectors(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject[BaseModel]:
"""ETF Sector weighting."""
return OBBject(results=Query(**locals()).execute())


@router.command(model="PricePerformance")
def price_performance(
cc: CommandContext,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""API integration tests for stocks extension."""
from datetime import time

import pytest
import requests
from openbb_provider.utils.helpers import get_querystring

# pylint: disable=too-many-lines,redefined-outer-name


@pytest.fixture(scope="session")
def headers():
Expand Down Expand Up @@ -1040,13 +1043,13 @@ def test_stocks_price_performance(params, headers):
[
(
{
"symbol": "AAPL",
"start_date": "2023-01-01",
"symbol": "UBER",
"start_date": "2018-01-01",
"end_date": "2023-06-06",
"limit": 300,
"provider": "intrinio",
}
)
),
],
)
@pytest.mark.integration
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""Test stocks extension."""
"""Python interface integration tests for the stocks extension."""
from datetime import time

import pytest
from openbb_core.app.model.obbject import OBBject

# pylint: disable=too-many-lines,redefined-outer-name


# pylint: disable=import-outside-toplevel,inconsistent-return-statements
@pytest.fixture(scope="session")
def obb(pytestconfig):
"""Fixture to setup obb."""

if pytestconfig.getoption("markexpr") != "not integration":
import openbb

Expand Down Expand Up @@ -985,12 +987,13 @@ def test_stocks_price_performance(params, obb):
[
(
{
"symbol": "AAPL",
"start_date": "2023-01-01",
"symbol": "UBER",
"start_date": "2018-01-01",
"end_date": "2023-06-06",
"limit": 300,
"provider": "intrinio",
}
)
),
],
)
@pytest.mark.integration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def ftd(
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject[BaseModel]:
"""Gets reported Fail-to-deliver (FTD) data."""
"""Get reported Fail-to-deliver (FTD) data."""
return OBBject(results=Query(**locals()).execute())


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
class EtfInfoQueryParams(QueryParams):
"""ETF Info Query Params."""

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

@field_validator("symbol")
@classmethod
Expand All @@ -26,6 +26,6 @@ def upper_symbol(cls, v: Union[str, List[str], Set[str]]):
class EtfInfoData(Data):
"""ETF Info Data."""

symbol: str = Field(description=DATA_DESCRIPTIONS.get("symbol", ""))
inception_date: Optional[str] = Field(description="Inception date of the ETF.")
symbol: str = Field(description=DATA_DESCRIPTIONS.get("symbol", "") + " (ETF)")
name: Optional[str] = Field(description="Name of the ETF.")
inception_date: Optional[str] = Field(description="Inception date of the ETF.")
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
class EtfSectorsQueryParams(QueryParams):
"""ETF Sectors Query Params."""

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

@field_validator("symbol")
@classmethod
Expand All @@ -24,38 +24,9 @@ def upper_symbol(cls, v: Union[str, List[str], Set[str]]):


class EtfSectorsData(Data):
"""ETF Sectors Data."""
"""FMP ETF Sector Info."""

energy: Optional[float] = Field(description="Energy Sector Weight", default=None)
materials: Optional[float] = Field(
description="Materials Sector Weight.", default=None
)
industrials: Optional[float] = Field(
description="Industrials Sector Weight.", default=None
)
consumer_cyclical: Optional[float] = Field(
description="Consumer Cyclical Sector Weight.", default=None
)
consumer_defensive: Optional[float] = Field(
description="Consumer Defensive Sector Weight.", default=None
)
financial_services: Optional[float] = Field(
description="Financial Services Sector Weight.", default=None
)
technology: Optional[float] = Field(
description="Technology Sector Weight.", default=None
)
health_care: Optional[float] = Field(
description="Health Care Sector Weight.", default=None
)
communication_services: Optional[float] = Field(
description="Communication Services Sector Weight.",
alias="Communication Services",
default=None,
)
utilities: Optional[float] = Field(
description="Utilities Sector Weight.", default=None
)
real_estate: Optional[float] = Field(
description="Real Estate Sector Weight.", default=None
sector: str = Field(description="Sector of exposure.")
weight: Optional[float] = Field(
description="Exposure of the ETF to the sector in normalized percentage points."
)
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,7 +12,9 @@
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_info import FMPEtfInfoFetcher
from openbb_fmp.models.etf_search import FMPEtfSearchFetcher
from openbb_fmp.models.etf_sectors import FMPEtfSectorsFetcher
from openbb_fmp.models.executive_compensation import FMPExecutiveCompensationFetcher
from openbb_fmp.models.financial_ratios import FMPFinancialRatiosFetcher
from openbb_fmp.models.forex_historical import FMPForexHistoricalFetcher
Expand Down Expand Up @@ -86,6 +88,8 @@
"TreasuryRates": FMPTreasuryRatesFetcher,
"ExecutiveCompensation": FMPExecutiveCompensationFetcher,
"EtfSearch": FMPEtfSearchFetcher,
"EtfSectors": FMPEtfSectorsFetcher,
"EtfInfo": FMPEtfInfoFetcher,
"CryptoHistorical": FMPCryptoHistoricalFetcher,
"ForexHistorical": FMPForexHistoricalFetcher,
"ForexPairs": FMPForexPairsFetcher,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""FMP Data Integration."""
79 changes: 79 additions & 0 deletions openbb_platform/providers/fmp/openbb_fmp/models/etf_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""FMP ETF Info fetcher."""

from typing import Any, Dict, List, Optional

from openbb_fmp.utils.helpers import create_url, get_data_many
from openbb_provider.abstract.fetcher import Fetcher
from openbb_provider.standard_models.etf_info import EtfInfoData, EtfInfoQueryParams
from pydantic import Field


class FMPEtfInfoQueryParams(EtfInfoQueryParams):
"""FMP ETF Info Query Params."""


class FMPEtfInfoData(EtfInfoData):
"""FMP ETF Info Data."""

asset_class: Optional[str] = Field(
alias="assetClass", description="Asset class of the ETF."
)
aum: Optional[float] = Field(description="Assets under management.")
avg_volume: Optional[float] = Field(
alias="avgVolume", description="Average trading volume of the ETF."
)
cusip: Optional[str] = Field(description="CUSIP of the ETF.")
description: Optional[str] = Field(description="Description of the ETF.")
domicile: Optional[str] = Field(description="Domicile of the ETF.")
etf_company: Optional[str] = Field(
alias="etfCompany", description="Company of the ETF."
)
expense_ratio: Optional[float] = Field(
alias="expenseRatio", description="Expense ratio of the ETF."
)
isin: Optional[str] = Field(description="ISIN of the ETF.")
nav: Optional[float] = Field(description="Net asset value of the ETF.")
nav_currency: Optional[str] = Field(
alias="navCurrency", description="Currency of the ETF's net asset value."
)
website: Optional[str] = Field(description="Website link of the ETF.")
holdings_count: Optional[int] = Field(
alias="holdingsCount", description="Number of holdings in the ETF."
)


class FMPEtfInfoFetcher(
Fetcher[
FMPEtfInfoQueryParams,
List[FMPEtfInfoData],
]
):
"""Transform the query, extract and transform the data from the FMP endpoints."""

@staticmethod
def transform_query(params: Dict[str, Any]) -> FMPEtfInfoQueryParams:
"""Transform the query."""
return FMPEtfInfoQueryParams(**params)

@staticmethod
def extract_data(
query: FMPEtfInfoQueryParams,
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-info", api_key=api_key, query=query)

return get_data_many(url, **kwargs)

@staticmethod
def transform_data(
query: FMPEtfInfoQueryParams, data: List[Dict], **kwargs: Any
) -> List[FMPEtfInfoData]:
"""Return the transformed data."""
# remove "sectorList" key from data. it's handled by the sectors
for d in data:
d.pop("sectorList", None)
return [FMPEtfInfoData.model_validate(d) for d in data]
8 changes: 4 additions & 4 deletions openbb_platform/providers/fmp/openbb_fmp/models/etf_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ def extract_data(
api_key = credentials.get("fmp_api_key") if credentials else ""

url = create_url(
3,
"stock-screener",
api_key,
{"isEtf": True, "limit": 10000},
version=3,
endpoint="stock-screener",
api_key=api_key,
query={"isEtf": True, "limit": 10000},
exclude=["symbol"],
)

Expand Down
Loading

0 comments on commit 0a99b55

Please sign in to comment.