feat(platform): extend Country type standardization to equity screeners (#7355)

* feat(platform): extend Country type standardization to equity screeners

Apply standardized Country type from PR #7333 to additional providers.

Changes:
- YFinance equity_screener: Accept ISO codes, country names; convert to lowercase
- Nasdaq equity_screener: Accept ISO codes, preserve multi-value functionality
- Nasdaq economic_calendar: Accept ISO codes, convert to snake_case
- Seeking Alpha calendar_earnings: Accept ISO codes for US/CA

All validators normalize inputs to provider-expected formats while maintaining
backward compatibility with existing inputs.

Part of extending #6969 coverage across providers.

---

Question for maintainers: Should this be extended to moderate (OECD, EconDB)
or full (all providers) coverage?

* refactor: apply review feedback - validate invalid country input instead of passing to API

* refactor(yfinance): validate country input against supported list

* dedupe nasdaq screener choices definitions

* fix(nasdaq): use get_args() instead of __args__ for mypy compatibility

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
This commit is contained in:
John Seong
2026-02-13 10:34:49 -08:00
committed by GitHub
parent 04064a1537
commit 6b12c47f0a
5 changed files with 199 additions and 159 deletions

View File

@@ -9,7 +9,9 @@ from openbb_core.provider.standard_models.etf_search import (
EtfSearchData,
EtfSearchQueryParams,
)
from pydantic import ConfigDict, Field
from openbb_core.provider.utils.country_utils import Country
from openbb_fmp.utils.definitions import Countries
from pydantic import ConfigDict, Field, field_validator
class FMPEtfSearchQueryParams(EtfSearchQueryParams):
@@ -26,13 +28,42 @@ class FMPEtfSearchQueryParams(EtfSearchQueryParams):
{"label": "Euronext", "value": "euronext"},
],
}
}
},
"country": {
"x-widget_config": {
"options": [
{"label": country.upper(), "value": country}
for country in Countries.__args__
],
},
},
}
exchange: Literal["amex", "nyse", "nasdaq", "tsx", "euronext"] | None = Field(
description="Exchange where the ETF is listed. If not provided, all exchanges are searched.",
default=None,
)
country: Country | None = Field(
default=None,
description="Filter by country. Accepts ISO 3166-1 alpha-2 codes (e.g., 'US', 'DE'), "
"alpha-3 codes (e.g., 'USA'), or country names (e.g., 'United States', 'united_states').",
)
@field_validator("country", mode="after")
@classmethod
def _validate_country(cls, v):
"""Validate country is supported by FMP."""
if v is None:
return v
# Country stores alpha_2 in uppercase, FMP expects lowercase
country_code = v.alpha_2.lower()
valid_countries = list(Countries.__args__)
if country_code not in valid_countries:
raise ValueError(
f"Country '{v.name}' ({v.alpha_2}) is not supported by FMP. "
f"Valid options: {', '.join(sorted(valid_countries)[:20])}..."
)
return v
class FMPEtfSearchData(EtfSearchData):
@@ -100,6 +131,8 @@ class FMPEtfSearchFetcher(
url = "https://financialmodelingprep.com/stable/company-screener?isEtf=true&isFund=false&isActivelyTrading=true"
if query.exchange:
url += f"&exchange={query.exchange.upper()}"
if query.country:
url += f"&country={query.country.upper()}"
url += f"&limit=10000&apikey={api_key}"

View File

@@ -10,6 +10,7 @@ from openbb_core.provider.standard_models.economic_calendar import (
EconomicCalendarData,
EconomicCalendarQueryParams,
)
from openbb_core.provider.utils.country_utils import Country
from pydantic import Field, field_validator
@@ -28,14 +29,27 @@ class NasdaqEconomicCalendarQueryParams(EconomicCalendarQueryParams):
country: str | None = Field(
default=None,
description="Country of the event",
description="Country of the event. Accepts country names, "
"ISO 3166-1 alpha-2/alpha-3 codes. Multiple comma-separated values allowed.",
)
@field_validator("country", mode="before", check_fields=False)
@classmethod
def validate_country(cls, c: str): # pylint: disable=E0213
"""Validate country."""
return ",".join([v.lower() for v in c.replace(" ", "_").split(",")])
"""Validate and normalize country input."""
if c is None:
return c
results = []
for item in c.split(","):
stripped = item.strip()
# Try to convert via Country type
try:
country = Country(stripped)
results.append(country.name.lower().replace(" ", "_"))
except ValueError:
# Keep original format (lowercase snake_case)
results.append(stripped.lower().replace(" ", "_"))
return ",".join(results)
class NasdaqEconomicCalendarData(EconomicCalendarData):

View File

@@ -2,7 +2,7 @@
# pylint: disable=unused-argument
from typing import Any, Literal
from typing import Any, Literal, get_args
from warnings import warn
from openbb_core.app.model.abstract.error import OpenBBError
@@ -11,14 +11,17 @@ from openbb_core.provider.standard_models.equity_screener import (
EquityScreenerData,
EquityScreenerQueryParams,
)
from openbb_core.provider.utils.country_utils import Country
from openbb_core.provider.utils.errors import EmptyDataError
from pydantic import Field, field_validator
EXCHANGE_CHOICES = ["all", "nasdaq", "nyse", "amex"]
EXSUBCATEGORY_CHOICES = ["all", "ngs", "ngm", "ncm", "adr"]
MKT_CAP_CHOICES = ["all", "mega", "large", "mid", "small", "micro"]
RECOMMENDATION_CHOICES = ["all", "strong_buy", "buy", "hold", "sell", "strong_sell"]
SECTOR_CHOICES = [
EXCHANGE_CHOICES = Literal["all", "nasdaq", "nyse", "amex"]
EXSUBCATEGORY_CHOICES = Literal["all", "ngs", "ngm", "ncm", "adr"]
MKT_CAP_CHOICES = Literal["all", "mega", "large", "mid", "small", "micro"]
RECOMMENDATION_CHOICES = Literal[
"all", "strong_buy", "buy", "hold", "sell", "strong_sell"
]
SECTOR_CHOICES = Literal[
"all",
"energy",
"basic_materials",
@@ -32,7 +35,7 @@ SECTOR_CHOICES = [
"utilities",
"real_estate",
]
REGION_CHOICES = [
REGION_CHOICES = Literal[
"all",
"africa",
"asia",
@@ -43,7 +46,7 @@ REGION_CHOICES = [
"north_america",
"south_america",
]
COUNTRY_CHOICES = [
COUNTRY_CHOICES = Literal[
"all",
"argentina",
"armenia",
@@ -106,145 +109,73 @@ class NasdaqEquityScreenerQueryParams(EquityScreenerQueryParams):
"mktcap": "marketcap",
}
__json_schema_extra__ = {
"exchange": {"multiple_items_allowed": True},
"exsubcategory": {"multiple_items_allowed": True},
"mktcap": {"multiple_items_allowed": True},
"recommendation": {"multiple_items_allowed": True},
"sector": {"multiple_items_allowed": True},
"region": {"multiple_items_allowed": True},
"country": {"multiple_items_allowed": True},
"exchange": {
"multiple_items_allowed": True,
"choices": list(get_args(EXCHANGE_CHOICES)),
},
"exsubcategory": {
"multiple_items_allowed": True,
"choices": list(get_args(EXSUBCATEGORY_CHOICES)),
},
"mktcap": {
"multiple_items_allowed": True,
"choices": list(get_args(MKT_CAP_CHOICES)),
},
"recommendation": {
"multiple_items_allowed": True,
"choices": list(get_args(RECOMMENDATION_CHOICES)),
},
"sector": {
"multiple_items_allowed": True,
"choices": list(get_args(SECTOR_CHOICES)),
},
"region": {
"multiple_items_allowed": True,
"choices": list(get_args(REGION_CHOICES)),
},
"country": {
"multiple_items_allowed": True,
"choices": list(get_args(COUNTRY_CHOICES)),
},
}
exchange: Literal["all", "nasdaq", "nyse", "amex"] | str = Field(
exchange: EXCHANGE_CHOICES | str = Field(
default="all",
description="Filter by exchange.",
json_schema_extra={"choices": EXCHANGE_CHOICES},
)
exsubcategory: Literal["all", "ngs", "ngm", "ncm", "adr"] | str = Field(
exsubcategory: EXSUBCATEGORY_CHOICES | str = Field(
default="all",
description="Filter by exchange subcategory."
"\n NGS - Nasdaq Global Select Market"
"\n NGM - Nasdaq Global Market"
"\n NCM - Nasdaq Capital Market"
"\n ADR - American Depository Receipt\n",
json_schema_extra={"choices": EXSUBCATEGORY_CHOICES},
"\n- NGS - Nasdaq Global Select Market"
"\n- NGM - Nasdaq Global Market"
"\n- NCM - Nasdaq Capital Market"
"\n- ADR - American Depository Receipt\n",
)
mktcap: Literal["all", "mega", "large", "mid", "small", "micro"] | str = Field(
mktcap: MKT_CAP_CHOICES | str = Field(
default="all",
description="Filter by market cap."
"\n Mega - > 200B"
"\n Large - 10B - 200B"
"\n Mid - 2B - 10B"
"\n Small - 300M - 2B"
"\n Micro - 50M - 300M\n",
json_schema_extra={"choices": MKT_CAP_CHOICES},
"\n- Mega - > 200B"
"\n- Large - 10B - 200B"
"\n- Mid - 2B - 10B"
"\n- Small - 300M - 2B"
"\n- Micro - 50M - 300M\n",
)
recommendation: (
Literal["all", "strong_buy", "buy", "hold", "sell", "strong_sell"] | str
) = Field(
recommendation: RECOMMENDATION_CHOICES | str = Field(
default="all",
description="Filter by consensus analyst action.",
json_schema_extra={"choices": RECOMMENDATION_CHOICES},
)
sector: (
Literal[
"all",
"energy",
"basic_materials",
"industrials",
"consumer_staples",
"consumer_discretionary",
"health_care",
"financial_services",
"technology",
"communication_services",
"utilities",
"real_estate",
]
| str
) = Field(
sector: SECTOR_CHOICES | str = Field(
default="all",
description="Filter by sector.",
json_schema_extra={"choices": SECTOR_CHOICES},
)
region: (
Literal[
"all",
"africa",
"asia",
"australia_and_south_pacific",
"caribbean",
"europe",
"middle_east",
"north_america",
"south_america",
]
| str
) = Field(
region: REGION_CHOICES | str = Field(
default="all",
description="Filter by region.",
json_schema_extra={"choices": REGION_CHOICES},
)
country: (
Literal[
"all",
"argentina",
"armenia",
"australia",
"austria",
"belgium",
"bermuda",
"brazil",
"canada",
"cayman_islands",
"chile",
"colombia",
"costa_rica",
"curacao",
"cyprus",
"denmark",
"finland",
"france",
"germany",
"greece",
"guernsey",
"hong_kong",
"india",
"indonesia",
"ireland",
"isle_of_man",
"israel",
"italy",
"japan",
"jersey",
"luxembourg",
"macau",
"mexico",
"monaco",
"netherlands",
"norway",
"panama",
"peru",
"philippines",
"puerto_rico",
"russia",
"singapore",
"south_africa",
"south_korea",
"spain",
"sweden",
"switzerland",
"taiwan",
"turkey",
"united_kingdom",
"united_states",
"usa",
]
| str
) = Field(
country: COUNTRY_CHOICES | str = Field(
default="all",
description="Filter by country.",
json_schema_extra={"choices": COUNTRY_CHOICES},
description="Filter by country. Accepts country names, ISO 3166-1 alpha-2/alpha-3 codes, "
"or 'all' for all countries. Multiple comma-separated values allowed.",
)
limit: int | None = Field(
default=None,
@@ -260,7 +191,7 @@ class NasdaqEquityScreenerQueryParams(EquityScreenerQueryParams):
for item in v:
if item == "all":
continue
if item in EXCHANGE_CHOICES:
if item in list(get_args(EXCHANGE_CHOICES)):
new_items.append(item)
else:
warn(f"Invalid exchange: {item}")
@@ -275,7 +206,7 @@ class NasdaqEquityScreenerQueryParams(EquityScreenerQueryParams):
for item in v:
if item == "all":
continue
if item in EXSUBCATEGORY_CHOICES:
if item in list(get_args(EXSUBCATEGORY_CHOICES)):
new_items.append(item)
else:
warn(f"Invalid exsubcategory: {item}")
@@ -290,7 +221,7 @@ class NasdaqEquityScreenerQueryParams(EquityScreenerQueryParams):
for item in v:
if item == "all":
continue
if item in MKT_CAP_CHOICES:
if item in list(get_args(MKT_CAP_CHOICES)):
new_items.append(item)
else:
warn(f"Invalid market cap: {item}")
@@ -305,7 +236,7 @@ class NasdaqEquityScreenerQueryParams(EquityScreenerQueryParams):
for item in v:
if item == "all":
continue
if item in RECOMMENDATION_CHOICES:
if item in list(get_args(RECOMMENDATION_CHOICES)):
new_items.append(item)
else:
warn(f"Invalid recommendation: {item}")
@@ -320,7 +251,7 @@ class NasdaqEquityScreenerQueryParams(EquityScreenerQueryParams):
for item in v:
if item == "all":
continue
if item in SECTOR_CHOICES:
if item in list(get_args(SECTOR_CHOICES)):
new_items.append(item)
else:
warn(f"Invalid sector: {item}")
@@ -335,7 +266,7 @@ class NasdaqEquityScreenerQueryParams(EquityScreenerQueryParams):
for item in v:
if item == "all":
continue
if item in REGION_CHOICES:
if item in list(get_args(REGION_CHOICES)):
new_items.append(item)
else:
warn(f"Invalid region: {item}")
@@ -344,14 +275,31 @@ class NasdaqEquityScreenerQueryParams(EquityScreenerQueryParams):
@field_validator("country", mode="before", check_fields=False)
@classmethod
def validate_country(cls, v):
"""Validate country."""
"""Validate country.
Accepts Country type, ISO codes, country names, or snake_case names.
Converts all inputs to Nasdaq's expected snake_case format.
"""
if isinstance(v, Country):
# Convert Country to snake_case name
v = v.name.lower().replace(" ", "_").replace("-", "_")
v = v.split(",")
new_items = []
for item in v:
if item == "all":
continue
if item in COUNTRY_CHOICES:
new_items.append(item)
# Try to convert via Country type if not already valid
normalized_item = item
if item not in list(get_args(COUNTRY_CHOICES)):
try:
country = Country(item)
normalized_item = (
country.name.lower().replace(" ", "_").replace("-", "_")
)
except ValueError:
pass # Keep original, will warn below
if normalized_item in list(get_args(COUNTRY_CHOICES)):
new_items.append(normalized_item)
else:
warn(f"Invalid country: {item}")
return ",".join(new_items) if new_items else "all"
@@ -408,14 +356,14 @@ class NasdaqEquityScreenerData(EquityScreenerData):
.replace("--", "")
.replace("NA", "")
)
return v if v else None
return v or None
class NasdaqEquityScreenerFetcher(
Fetcher[
NasdaqEquityScreenerQueryParams,
list[NasdaqEquityScreenerData],
]
],
):
"""Nasdaq Equity Screener Fetcher."""
@@ -440,7 +388,7 @@ class NasdaqEquityScreenerFetcher(
HEADERS = get_headers(accept_type="text")
base_url = (
"https://api.nasdaq.com/api/screener/stocks?tableonly=true&limit="
+ f"{query.limit if query.limit else 10000}&"
f"{query.limit or 10000}&"
)
exchange = query.exchange.split(",")
exsubcategory = query.exsubcategory.split(",")

View File

@@ -14,9 +14,12 @@ from openbb_core.provider.standard_models.calendar_earnings import (
CalendarEarningsData,
CalendarEarningsQueryParams,
)
from openbb_core.provider.utils.country_utils import Country
from openbb_seeking_alpha.utils.helpers import HEADERS, date_range
from pydantic import Field, field_validator
SA_COUNTRIES = {"us", "ca"}
class SACalendarEarningsQueryParams(CalendarEarningsQueryParams):
"""Seeking Alpha Calendar Earnings Query.
@@ -26,10 +29,29 @@ class SACalendarEarningsQueryParams(CalendarEarningsQueryParams):
country: Literal["us", "ca"] = Field(
default="us",
description="The country to get calendar data for.",
description="The country to get calendar data for. "
"Accepts 'us'/'ca', ISO codes ('US', 'USA', 'CA', 'CAN'), "
"or names ('United States', 'Canada').",
json_schema_extra={"choices": ["us", "ca"]},
)
@field_validator("country", mode="before")
@classmethod
def _validate_country(cls, v):
"""Normalize country input to Seeking Alpha format."""
if v in SA_COUNTRIES:
return v
try:
country = Country(v)
code = country.alpha_2.lower()
if code in SA_COUNTRIES:
return code
except ValueError:
pass
raise ValueError(
f"Country '{v}' not supported. Seeking Alpha only supports US and Canada."
)
class SACalendarEarningsData(CalendarEarningsData):
"""Seeking Alpha Calendar Earnings Data."""

View File

@@ -10,6 +10,7 @@ from openbb_core.provider.standard_models.equity_screener import (
EquityScreenerData,
EquityScreenerQueryParams,
)
from openbb_core.provider.utils.country_utils import Country
from openbb_core.provider.utils.errors import EmptyDataError
from openbb_yfinance.utils.references import (
COUNTRIES,
@@ -23,7 +24,7 @@ from openbb_yfinance.utils.references import (
YFPredefinedScreenerData,
get_industry_sector,
)
from pydantic import Field
from pydantic import Field, field_validator
class YFinanceEquityScreenerQueryParams(EquityScreenerQueryParams):
@@ -50,7 +51,8 @@ class YFinanceEquityScreenerQueryParams(EquityScreenerQueryParams):
country: str | None = Field(
default="us",
description="Filter by country, as a two-letter country code. Default is, 'us'. Use, 'all', for all countries.",
description="Filter by country. Accepts ISO 3166-1 alpha-2 codes (e.g., 'US', 'DE'), "
"alpha-3 codes (e.g., 'USA'), country names (e.g., 'United States'), or 'all' for all countries.",
)
exchange: Exchanges | None = Field(
default=None,
@@ -98,13 +100,34 @@ class YFinanceEquityScreenerQueryParams(EquityScreenerQueryParams):
description="Limit the number of results returned. Default is, 200. Set to, 0, for all results.",
)
@field_validator("country", mode="before")
@classmethod
def _validate_country(cls, v):
"""Validate and normalize country input."""
if v is None or v == "all":
return v
# Convert Country type or string to lowercase alpha-2 for YFinance
if isinstance(v, Country):
country_code = v.alpha_2.lower()
else:
try:
country_code = Country(v).alpha_2.lower()
except ValueError:
country_code = v.strip().lower()
if country_code not in COUNTRIES:
raise ValueError(
f"Country '{v}' ({country_code.upper()}) is not supported by YFinance. "
f"Valid options: {', '.join(sorted(COUNTRIES))}",
)
return country_code
class YFinanceEquityScreenerData(EquityScreenerData, YFPredefinedScreenerData):
"""YFinance Equity Screener Data."""
class YFinanceEquityScreenerFetcher(
Fetcher[YFinanceEquityScreenerQueryParams, list[YFinanceEquityScreenerData]]
Fetcher[YFinanceEquityScreenerQueryParams, list[YFinanceEquityScreenerData]],
):
"""YFinance Equity Screener Fetcher."""
@@ -121,8 +144,8 @@ class YFinanceEquityScreenerFetcher(
raise OpenBBError(
ValueError(
f"Industry {industry} does not belong to sector {sector}."
" Valid choices are:" + "\n\n " + f"{choices}"
)
" Valid choices are:" + "\n\n " + f"{choices}",
),
)
elif industry and not sector:
choices = "\n".join(INDUSTRIES)
@@ -131,8 +154,8 @@ class YFinanceEquityScreenerFetcher(
raise OpenBBError(
ValueError(
f"Industry {industry} not found. Valid choices are:\n"
+ f"{choices}"
)
f"{choices}",
),
)
_industry = INDUSTRY_MAP[sector][industry]
@@ -155,7 +178,7 @@ class YFinanceEquityScreenerFetcher(
if query.exchange is not None:
operands.append(
{"operator": "eq", "operands": ["exchange", query.exchange.upper()]}
{"operator": "eq", "operands": ["exchange", query.exchange.upper()]},
)
query.country = "all"
@@ -175,39 +198,39 @@ class YFinanceEquityScreenerFetcher(
industry = INDUSTRY_MAP[sector][query.industry]
if industry in PEER_GROUPS:
operands.append(
{"operator": "EQ", "operands": ["peer_group", industry]}
{"operator": "EQ", "operands": ["peer_group", industry]},
)
else:
operands.append({"operator": "EQ", "operands": ["industry", industry]})
if query.mktcap_min is not None:
operands.append(
{"operator": "gt", "operands": ["intradaymarketcap", query.mktcap_min]}
{"operator": "gt", "operands": ["intradaymarketcap", query.mktcap_min]},
)
if query.mktcap_max is not None:
operands.append(
{"operator": "lt", "operands": ["intradaymarketcap", query.mktcap_max]}
{"operator": "lt", "operands": ["intradaymarketcap", query.mktcap_max]},
)
if query.price_min is not None:
operands.append(
{"operator": "gt", "operands": ["intradayprice", query.price_min]}
{"operator": "gt", "operands": ["intradayprice", query.price_min]},
)
if query.price_max is not None:
operands.append(
{"operator": "lt", "operands": ["intradayprice", query.price_max]}
{"operator": "lt", "operands": ["intradayprice", query.price_max]},
)
if query.volume_min is not None:
operands.append(
{"operator": "gt", "operands": ["dayvolume", query.volume_min]}
{"operator": "gt", "operands": ["dayvolume", query.volume_min]},
)
if query.volume_max is not None:
operands.append(
{"operator": "lt", "operands": ["dayvolume", query.volume_max]}
{"operator": "lt", "operands": ["dayvolume", query.volume_max]},
)
if query.beta_min is not None: