diff options
author | Danglewood <85772166+deeleeramone@users.noreply.github.com> | 2024-04-24 01:35:27 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-24 08:35:27 +0000 |
commit | 38727389b12cac684ff597f98c4344f26f09a722 (patch) | |
tree | c80dd8abb1350efb7806d83c08c5d74841941d5f | |
parent | 9bfa378022673fff014cb8ab2af2e8de1b2ee78d (diff) |
[BugFix] Intrinio News (#6336)
* add default=None
* patch intrinio news
* pylint
* mypy
* no need to assign articles var before len
* more pylint
* other assignment
7 files changed, 939 insertions, 7732 deletions
diff --git a/openbb_platform/extensions/news/integration/test_news_api.py b/openbb_platform/extensions/news/integration/test_news_api.py index fe8fc4dac53..b54a1f38320 100644 --- a/openbb_platform/extensions/news/integration/test_news_api.py +++ b/openbb_platform/extensions/news/integration/test_news_api.py @@ -59,6 +59,15 @@ def headers(): "limit": 20, "start_date": None, "end_date": None, + "source": "yahoo", + "topic": None, + "is_spam": False, + "sentiment": None, + "language": None, + "word_count_greater_than": None, + "word_count_less_than": None, + "business_relevance_greater_than": None, + "business_relevance_less_than": None, } ), ( @@ -164,8 +173,17 @@ def test_news_world(params, headers): "provider": "intrinio", "symbol": "AAPL", "limit": 20, - "start_date": None, - "end_date": None, + "start_date": "2024-01-02", + "end_date": "2024-01-03", + "source": "yahoo", + "topic": None, + "is_spam": False, + "sentiment": None, + "language": None, + "word_count_greater_than": None, + "word_count_less_than": None, + "business_relevance_greater_than": None, + "business_relevance_less_than": None, } ), ( diff --git a/openbb_platform/extensions/news/integration/test_news_python.py b/openbb_platform/extensions/news/integration/test_news_python.py index 2b876d19391..2e9b49b1eeb 100644 --- a/openbb_platform/extensions/news/integration/test_news_python.py +++ b/openbb_platform/extensions/news/integration/test_news_python.py @@ -53,8 +53,17 @@ def obb(pytestconfig): # pylint: disable=inconsistent-return-statements { "provider": "intrinio", "limit": 20, - "start_date": None, - "end_date": None, + "start_date": "2024-01-02", + "end_date": "2024-01-03", + "source": "yahoo", + "topic": None, + "is_spam": False, + "sentiment": None, + "language": None, + "word_count_greater_than": None, + "word_count_less_than": None, + "business_relevance_greater_than": None, + "business_relevance_less_than": None, } ), ( @@ -146,8 +155,17 @@ def test_news_world(params, obb): "provider": "intrinio", "symbol": "AAPL", "limit": 20, - "start_date": None, - "end_date": None, + "start_date": "2024-01-02", + "end_date": "2024-01-03", + "source": "yahoo", + "topic": None, + "is_spam": False, + "sentiment": None, + "language": None, + "word_count_greater_than": None, + "word_count_less_than": None, + "business_relevance_greater_than": None, + "business_relevance_less_than": None, } ), ( diff --git a/openbb_platform/providers/intrinio/openbb_intrinio/models/company_news.py b/openbb_platform/providers/intrinio/openbb_intrinio/models/company_news.py index d28d7f6e968..d728cd3384d 100644 --- a/openbb_platform/providers/intrinio/openbb_intrinio/models/company_news.py +++ b/openbb_platform/providers/intrinio/openbb_intrinio/models/company_news.py @@ -1,19 +1,21 @@ """Intrinio Company News Model.""" +import asyncio from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional, Union from openbb_core.provider.abstract.fetcher import Fetcher from openbb_core.provider.standard_models.company_news import ( CompanyNewsData, CompanyNewsQueryParams, ) +from openbb_core.provider.utils.errors import EmptyDataError from openbb_core.provider.utils.helpers import ( - ClientResponse, - amake_requests, - filter_by_dates, + amake_request, get_querystring, ) +from openbb_intrinio.utils.helpers import get_data +from openbb_intrinio.utils.references import IntrinioSecurity from pydantic import Field, field_validator @@ -23,26 +25,133 @@ class IntrinioCompanyNewsQueryParams(CompanyNewsQueryParams): Source: https://docs.intrinio.com/documentation/web_api/get_company_news_v2 """ - __alias_dict__ = {"symbol": "symbols", "limit": "page_size"} + __alias_dict__ = { + "limit": "page_size", + "source": "specific_source", + } __json_schema_extra__ = {"symbol": ["multiple_items_allowed"]} + source: Optional[ + Literal["yahoo", "moody", "moody_us_news", "moody_us_press_releases"] + ] = Field( + default=None, + description="The source of the news article.", + ) + sentiment: Union[None, Literal["positive", "neutral", "negative"]] = Field( + default=None, + description="Return news only from this source.", + ) + language: Optional[str] = Field( + default=None, + description="Filter by language. Unsupported for yahoo source.", + ) + topic: Optional[str] = Field( + default=None, + description="Filter by topic. Unsupported for yahoo source.", + ) + word_count_greater_than: Optional[int] = Field( + default=None, + description="News stories will have a word count greater than this value." + + " Unsupported for yahoo source.", + ) + word_count_less_than: Optional[int] = Field( + default=None, + description="News stories will have a word count less than this value." + + " Unsupported for yahoo source.", + ) + is_spam: Optional[bool] = Field( + default=None, + description="Filter whether it is marked as spam or not." + + " Unsupported for yahoo source.", + ) + business_relevance_greater_than: Optional[float] = Field( + default=None, + description="News stories will have a business relevance score more than this value." + + " Unsupported for yahoo source.", + ) + business_relevance_less_than: Optional[float] = Field( + default=None, + description="News stories will have a business relevance score less than this value." + + " Unsupported for yahoo source.", + ) + class IntrinioCompanyNewsData(CompanyNewsData): """Intrinio Company News Data.""" __alias_dict__ = { - "symbols": "symbol", "date": "publication_date", - "text": "summary", + "sentiment": "article_sentiment", + "sentiment_confidence": "article_sentiment_confidence", + "symbols": "symbol", } - + source: Optional[str] = Field( + default=None, + description="The source of the news article.", + ) + summary: Optional[str] = Field( + default=None, + description="The summary of the news article.", + ) + topics: Optional[str] = Field( + default=None, + description="The topics related to the news article.", + ) + word_count: Optional[int] = Field( + default=None, + description="The word count of the news article.", + ) + business_relevance: Optional[float] = Field( + default=None, + description=" How strongly correlated the news article is to the business", + ) + sentiment: Optional[str] = Field( + default=None, + description="The sentiment of the news article - i.e, negative, positive.", + ) + sentiment_confidence: Optional[float] = Field( + default=None, + description="The confidence score of the sentiment rating.", + ) + language: Optional[str] = Field( + default=None, + description="The language of the news article.", + ) + spam: Optional[bool] = Field( + default=None, + description="Whether the news article is spam.", + ) + copyright: Optional[str] = Field( + default=None, + description="The copyright notice of the news article.", + ) id: str = Field(description="Article ID.") + security: Optional[IntrinioSecurity] = Field( + default=None, + description="The Intrinio Security object. Contains the security details related to the news article.", + ) @field_validator("publication_date", mode="before", check_fields=False) - def date_validate(cls, v): # pylint: disable=E0213 + @classmethod + def date_validate(cls, v): """Return the date as a datetime object.""" return datetime.strptime(v, "%Y-%m-%dT%H:%M:%S.000Z") + @field_validator("topics", mode="before", check_fields=False) + @classmethod + def topics_validate(cls, v): + """ "Parse the topics as a string.""" + if v: + topics = [t.get("name") for t in v if t and t not in ["", " "]] + return ", ".join(topics) + return None + + @field_validator("copyright", mode="before", check_fields=False) + @classmethod + def copyright_validate(cls, v): + """Clean empty strings""" + return None if v in ["", " "] else v + class IntrinioCompanyNewsFetcher( Fetcher[ @@ -67,28 +176,60 @@ class IntrinioCompanyNewsFetcher( api_key = credentials.get("intrinio_api_key") if credentials else "" base_url = "https://api-v2.intrinio.com/companies" - query_str = get_querystring( - query.model_dump(by_alias=True), ["symbols", "page"] + ignore = ( + ["symbol", "page_size", "is_spam"] + if not query.source or query.source == "yahoo" + else ["symbol", "page_size"] ) - - async def callback(response: ClientResponse, _: Any) -> List[Dict]: - """Return the response.""" - if response.status != 200: - return [] - + query_str = get_querystring(query.model_dump(by_alias=True), ignore) + symbols = query.symbol.split(",") + news: List = [] + + async def callback(response, session): + """Response callback.""" + result = await response.json() + if "error" in result: + raise RuntimeError(f"Intrinio Error Message -> {result['error']}") symbol = response.url.parts[-2] - data = await response.json() - - if isinstance(data, dict): - return [{**d, "symbol": symbol} for d in data.get("news", [])] - return [] - - urls = [ - f"{base_url}/{symbol}/news?{query_str}&api_key={api_key}" - for symbol in [s.strip() for s in getattr(query, "symbol", "").split(",")] - ] - - return await amake_requests(urls, callback, **kwargs) + _data = result.get("news", []) + data = [] + data.extend([{"symbol": symbol, **d} for d in _data]) + articles = len(data) + next_page = result.get("next_page") + while next_page and query.limit > articles: + url = f"{base_url}/{symbol}/news?{query_str}&api_key={api_key}&next_page={next_page}" + result = await get_data(url, session=session, **kwargs) + _data = result.get("news", []) + if _data: + data.extend([{"symbol": symbol, **d} for d in _data]) + articles = len(data) + next_page = result.get("next_page") + # Remove duplicates based on URL + return data + + seen = set() + + async def get_one(symbol): + """Get the data for one symbol.""" + # TODO: Change page_size to a more appropriate value when Intrinio fixes the bug in this param. + url = f"{base_url}/{symbol}/news?{query_str}&page_size=99&api_key={api_key}" + data = await amake_request(url, response_callback=callback, **kwargs) + if data: + data = [x for x in data if not (x["url"] in seen or seen.add(x["url"]))] # type: ignore + news.extend( + sorted(data, key=lambda x: x["publication_date"], reverse=True)[ + : query.limit + ] + ) + + tasks = [get_one(symbol) for symbol in symbols] + + await asyncio.gather(*tasks) + + if not news: + raise EmptyDataError("Error: The request was returned as empty.") + + return news # pylint: disable=unused-argument @staticmethod @@ -96,5 +237,14 @@ class IntrinioCompanyNewsFetcher( query: IntrinioCompanyNewsQueryParams, data: List[Dict], **kwargs: Any ) -> List[IntrinioCompanyNewsData]: """Return the transformed data.""" - modeled_data = [IntrinioCompanyNewsData.model_validate(d) for d in data] - return filter_by_dates(modeled_data, query.start_date, query.end_date) + results: List[IntrinioCompanyNewsData] = [] + for item in data: + body = item.get("body", {}) + if not body: + item["text"] = item.pop("summary") + if body: + _ = item.pop("body") + item["publication_date"] = body.get("publication_date", None) + item["text"] = body.get("body", None) + results.append(IntrinioCompanyNewsData.model_validate(item)) + return results diff --git a/openbb_platform/providers/intrinio/openbb_intrinio/models/world_news.py b/openbb_platform/providers/intrinio/openbb_intrinio/models/world_news.py index f08161e6f61..9be9ca3b5d5 100644 --- a/openbb_platform/providers/intrinio/openbb_intrinio/models/world_news.py +++ b/openbb_platform/providers/intrinio/openbb_intrinio/models/world_news.py @@ -1,19 +1,19 @@ """Intrinio World News Model.""" -import warnings from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional, Union from openbb_core.provider.abstract.fetcher import Fetcher from openbb_core.provider.standard_models.world_news import ( WorldNewsData, WorldNewsQueryParams, ) -from openbb_intrinio.utils.helpers import get_data_many +from openbb_core.provider.utils.errors import EmptyDataError +from openbb_core.provider.utils.helpers import amake_request, get_querystring +from openbb_intrinio.utils.helpers import get_data +from openbb_intrinio.utils.references import IntrinioCompany, IntrinioSecurity from pydantic import Field, field_validator -_warn = warnings.warn - class IntrinioWorldNewsQueryParams(WorldNewsQueryParams): """Intrinio World News Query. @@ -21,22 +21,134 @@ class IntrinioWorldNewsQueryParams(WorldNewsQueryParams): Source: https://docs.intrinio.com/documentation/web_api/get_all_company_news_v2 """ + __alias_dict__ = { + "source": "specific_source", + "limit": "page_size", + } + source: Optional[ + Literal["yahoo", "moody", "moody_us_news", "moody_us_press_releases"] + ] = Field( + default=None, + description="The source of the news article.", + ) + sentiment: Union[None, Literal["positive", "neutral", "negative"]] = Field( + default=None, + description="Return news only from this source.", + ) + language: Optional[str] = Field( + default=None, + description="Filter by language. Unsupported for yahoo source.", + ) + topic: Optional[str] = Field( + default=None, + description="Filter by topic. Unsupported for yahoo source.", + ) + word_count_greater_than: Optional[int] = Field( + default=None, + description="News stories will have a word count greater than this value." + + " Unsupported for yahoo source.", + ) + word_count_less_than: Optional[int] = Field( + default=None, + description="News stories will have a word count less than this value." + + " Unsupported for yahoo source.", + ) + is_spam: Optional[bool] = Field( + default=None, + description="Filter whether it is marked as spam or not." + + " Unsupported for yahoo source.", + ) + business_relevance_greater_than: Optional[float] = Field( + default=None, + description="News stories will have a business relevance score more than this value." + + " Unsupported for yahoo source.", + ) + business_relevance_less_than: Optional[float] = Field( + default=None, + description="News stories will have a business relevance score less than this value." + + " Unsupported for yahoo source.", + ) + class IntrinioWorldNewsData(WorldNewsData): """Intrinio World News Data.""" - __alias_dict__ = {"date": "publication_date", "text": "summary"} - + __alias_dict__ = { + "date": "publication_date", + "sentiment": "article_sentiment", + "sentiment_confidence": "article_sentiment_confidence", + } + source: Optional[str] = Field( + default=None, + description="The source of the news article.", + ) + summary: Optional[str] = Field( + default=None, + description="The summary of the news article.", + ) + topics: Optional[str] = Field( + default=None, + description="The topics related to the news article.", + ) + word_count: Optional[int] = Field( + default=None, + description="The word count of the news article.", + ) + business_relevance: Optional[float] = Field( + default=None, + description=" How strongly correlated the news article is to the business", + ) + sentiment: Optional[str] = Field( + default=None, + description="The sentiment of the news article - i.e, negative, positive.", + ) + sentiment_confidence: Optional[float] = Field( + default=None, + description="The confidence score of the sentiment rating.", + ) + language: Optional[str] = Field( + default=None, + description="The language of the news article.", + ) + spam: Optional[bool] = Field( + default=None, + description="Whether the news article is spam.", + ) + copyright: Optional[str] = Field( + default=None, + description="The copyright notice of the news article.", + ) id: str = Field(description="Article ID.") - company: Optional[Dict[str, Any]] = Field( - description="Company details related to the news article." + company: Optional[IntrinioCompany] = Field( + default=None, + description="The Intrinio Company object. Contains details company reference data.", + ) + security: Optional[IntrinioSecurity] = Field( + default=None, + description="The Intrinio Security object. Contains the security details related to the news article.", ) @field_validator("publication_date", mode="before", check_fields=False) - def date_validate(cls, v): # pylint: disable=E0213 + @classmethod + def date_validate(cls, v): """Return the date as a datetime object.""" return datetime.strptime(v, "%Y-%m-%dT%H:%M:%S.000Z") + @field_validator("topics", mode="before", check_fields=False) + @classmethod + def topics_validate(cls, v): + """ "Parse the topics as a string.""" + if v: + topics = [t.get("name") for t in v if t and t not in ["", " "]] + return ", ".join(topics) + return None + + @field_validator("copyright", mode="before", check_fields=False) + @classmethod + def copyright_validate(cls, v): + """Clean empty strings""" + return None if v in ["", " "] else v + class IntrinioWorldNewsFetcher( Fetcher[ @@ -44,13 +156,11 @@ class IntrinioWorldNewsFetcher( List[IntrinioWorldNewsData], ] ): - """Transform the query, extract and transform the data from the Intrinio endpoints.""" + """Intrinio World News Fetcher.""" @staticmethod def transform_query(params: Dict[str, Any]) -> IntrinioWorldNewsQueryParams: """Transform the query params.""" - if params.get("start_date") or params.get("end_date"): - _warn("start_date and end_date are not supported for this endpoint.") return IntrinioWorldNewsQueryParams(**params) @staticmethod @@ -62,10 +172,48 @@ class IntrinioWorldNewsFetcher( """Return the raw data from the Intrinio endpoint.""" api_key = credentials.get("intrinio_api_key") if credentials else "" - base_url = "https://api-v2.intrinio.com" - url = f"{base_url}/companies/news?page_size={query.limit}&api_key={api_key}" - - return await get_data_many(url, "news", **kwargs) + base_url = "https://api-v2.intrinio.com/companies" + ignore = ( + ["symbol", "page_size", "is_spam"] + if not query.source or query.source == "yahoo" + else ["symbol", "page_size"] + ) + query_str = get_querystring(query.model_dump(by_alias=True), ignore) + # TODO: Change page_size to a more appropriate value when Intrinio fixes the bug in this param. + url = f"{base_url}/news?{query_str}&page_size=99&api_key={api_key}" + + seen = set() + + async def callback(response, session): + """Response callback.""" + result = await response.json() + _data = result.get("news", []) + data = [] + data.extend( + [x for x in _data if not (x["url"] in seen or seen.add(x["url"]))] # type: ignore + ) + articles = len(data) + next_page = result.get("next_page") + while next_page and articles < query.limit: + url = f"{base_url}/news?{query_str}&page_size=99&api_key={api_key}&next_page={next_page}" + result = await get_data(url, session=session, **kwargs) + _data = result.get("news", []) + if _data: + # Remove duplicates based on URL + data.extend( + [ + x + for x in _data + if not (x["url"] in seen or seen.add(x["url"])) # type: ignore + ] + ) + articles = len(data) + next_page = result.get("next_page") + return sorted(data, key=lambda x: x["publication_date"], reverse=True)[ + : query.limit + ] + + return await amake_request(url, response_callback=callback, **kwargs) # pylint: disable=unused-argument @staticmethod @@ -73,4 +221,16 @@ class IntrinioWorldNewsFetcher( query: IntrinioWorldNewsQueryParams, data: List[Dict], **kwargs: Any ) -> List[IntrinioWorldNewsData]: """Return the transformed data.""" - return [IntrinioWorldNewsData.model_validate(d) for d in data] + if not data: + raise EmptyDataError("Error: The request was returned as empty.") + results: List[IntrinioWorldNewsData] = [] + for item in data: + body = item.get("body", {}) + if not body: + item["text"] = item.pop("summary") + if body: + _ = item.pop("body") + item["publication_date"] = body.get("publication_date", None) + item["text"] = body.get("body", None) + results.append(IntrinioWorldNewsData.model_validate(item)) + return results diff --git a/openbb_platform/providers/intrinio/tests/record/http/test_intrinio_fetchers/test_intrinio_company_news_fetcher.yaml b/openbb_platform/providers/intrinio/tests/record/http/test_intrinio_fetchers/test_intrinio_company_news_fetcher.yaml index 446815e13ec..b015caf0aa3 100644 --- a/openbb_platform/providers/intrinio/tests/record/http/test_intrinio_fetchers/test_intrinio_company_news_fetcher.yaml +++ b/openbb_platform/providers/intrinio/tests/record/http/test_intrinio_fetchers/test_intrinio_company_news_fetcher.yaml @@ -9,7564 +9,95 @@ interactions: Connection: - keep-alive method: GET - uri: https://api-v2.intrinio.com/companies/AAPL/news?api_key=MOCK_API_KEY&page_size=2500 + uri: https://api-v2.intrinio.com/companies/AAPL/news?api_key=MOCK_API_KEY&end_date=2024-01-03&page_size=99&specific_source=yahoo&start_date=2024-01-02 response: body: string: !!binary | - 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 |