summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanglewood <85772166+deeleeramone@users.noreply.github.com>2024-04-24 01:35:27 -0700
committerGitHub <noreply@github.com>2024-04-24 08:35:27 +0000
commit38727389b12cac684ff597f98c4344f26f09a722 (patch)
treec80dd8abb1350efb7806d83c08c5d74841941d5f
parent9bfa378022673fff014cb8ab2af2e8de1b2ee78d (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
-rw-r--r--openbb_platform/extensions/news/integration/test_news_api.py22
-rw-r--r--openbb_platform/extensions/news/integration/test_news_python.py26
-rw-r--r--openbb_platform/providers/intrinio/openbb_intrinio/models/company_news.py212
-rw-r--r--openbb_platform/providers/intrinio/openbb_intrinio/models/world_news.py196
-rw-r--r--openbb_platform/providers/intrinio/tests/record/http/test_intrinio_fetchers/test_intrinio_company_news_fetcher.yaml7801
-rw-r--r--openbb_platform/providers/intrinio/tests/record/http/test_intrinio_fetchers/test_intrinio_world_news_fetcher.yaml399
-rw-r--r--openbb_platform/providers/intrinio/tests/test_intrinio_fetchers.py15
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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