diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 638a394..daa31e3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,14 @@ Change Log ========== +2.4.2 +----- +## Add +- Added a new function and minor improvements (see PR/commit for details) + +## Fix +- Minor bug and doc updates related to the new function + 2.4.1 ----- ## Fix diff --git a/docs/docs/guide/ticker/financials.md b/docs/docs/guide/ticker/financials.md index fdf6617..6c503ee 100644 --- a/docs/docs/guide/ticker/financials.md +++ b/docs/docs/guide/ticker/financials.md @@ -125,6 +125,20 @@ | aapl | 2020-07-29 00:00:00 | TTM | 1.6632e+12 | nan | nan | 25.1256 | 1.64774e+12 | 21.0104 | 29.7232 | 2.0905 | 6.37702 | | aapl | 2020-07-30 00:00:00 | TTM | nan | 20.2772 | 6.2064 | nan | nan | nan | nan | nan | nan | +### **current_valuation_measures** + +=== "Details" + + - *Description*: Retrieves a JSON-serializable mapping of symbol to the most recent TTM valuation metrics. + - *Return*: `dict` + +=== "Example" + + ```python + aapl = Ticker('aapl') + aapl.current_valuation_measures() + ``` + ## Multiple diff --git a/docs/docs/release_notes.md b/docs/docs/release_notes.md index a98c4de..b87f4d6 100644 --- a/docs/docs/release_notes.md +++ b/docs/docs/release_notes.md @@ -1,5 +1,13 @@ # Release Notes +2.4.2 +----- +## Add +- Added a new function and minor improvements (see PR/commit for details) + +## Fix +- Minor bug and doc updates related to the new function + 2.4.0 ----- ## Update diff --git a/pyproject.toml b/pyproject.toml index 6ccdd70..827e79f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yahooquery" -version = "2.4.1" +version = "2.4.2" description = "Python wrapper for an unofficial Yahoo Finance API" authors = [ {name = "Doug Guthrie", email = "douglas.p.guthrie@gmail.com"} diff --git a/tests/test_ticker.py b/tests/test_ticker.py index 775452a..42ccda2 100644 --- a/tests/test_ticker.py +++ b/tests/test_ticker.py @@ -29,6 +29,7 @@ "all_financial_data", "p_all_financial_data", "p_valuation_measures", + "current_valuation_measures", ] SEPERATE_ENDPOINTS = [ @@ -185,9 +186,61 @@ def test_history_bad_args(ticker, period, interval): def test_adj_ohlc(ticker): + + def test_current_valuation_measures(ticker): + res = ticker.current_valuation_measures() + assert res is not None + if isinstance(res, str): + assert "unavailable" in res.lower() + else: + assert isinstance(res, dict) + for sym, metrics in res.items(): + # Ensure there is an asOfDate for the current selected entry + assert "asOfDate" in metrics assert ticker.history(period="max", adj_ohlc=True) is not None +def test_current_valuation_measures_batch_ttm(monkeypatch): + """Ensure batch current_valuation_measures returns most recent TTM per symbol""" + # Create a Ticker with two symbols + ticker = Ticker("AAPL MSFT") + # Craft a MultiIndex DataFrame to mimic the API response + symbols = ["AAPL", "MSFT"] + dates = pd.to_datetime(["2020-01-01", "2020-02-01", "2020-03-01"]) + idx = pd.MultiIndex.from_product([symbols, dates], names=["symbol", "date"]) + df = pd.DataFrame(index=idx) + # Make TTM present for the middle date for each symbol + df["periodType"] = "TTM" + df["asOfDate"] = df.index.get_level_values(1) + df["value"] = range(len(df)) + + # Monkeypatch valuation_measures to return our DataFrame + monkeypatch.setattr(ticker, "valuation_measures", lambda frequency, trailing=True: df) + res = ticker.current_valuation_measures() + # Expect keys to be the two symbols and values have asOfDate equal to most recent TTM per symbol + assert set(res.keys()) == set(symbols) + for symbol in symbols: + assert res[symbol]["asOfDate"] == pd.Timestamp("2020-03-01").isoformat() + + +def test_current_valuation_measures_batch_no_ttm(monkeypatch): + """Ensure fallback to most recent asOfDate per symbol when TTM is absent""" + ticker = Ticker("AAPL MSFT") + symbols = ["AAPL", "MSFT"] + dates = pd.to_datetime(["2020-01-01", "2020-02-01", "2020-03-01"]) + idx = pd.MultiIndex.from_product([symbols, dates], names=["symbol", "date"]) + df = pd.DataFrame(index=idx) + # No TTM rows + df["periodType"] = "MRQ" + df["asOfDate"] = df.index.get_level_values(1) + df["value"] = range(len(df)) + monkeypatch.setattr(ticker, "valuation_measures", lambda frequency, trailing=True: df) + res = ticker.current_valuation_measures() + assert set(res.keys()) == set(symbols) + for symbol in symbols: + assert res[symbol]["asOfDate"] == pd.Timestamp("2020-03-01").isoformat() + + class TestHistoryDataframe: """Tests for `utils.history_dataframe` and dependencies.""" diff --git a/yahooquery/__init__.py b/yahooquery/__init__.py index 2115fcb..02e8f27 100644 --- a/yahooquery/__init__.py +++ b/yahooquery/__init__.py @@ -1,7 +1,7 @@ """Python interface to unofficial Yahoo Finance API endpoints""" name = "yahooquery" -__version__ = "2.4.1" +__version__ = "2.4.2" from .misc import ( # noqa diff --git a/yahooquery/base.py b/yahooquery/base.py index de66d30..6cc4da6 100644 --- a/yahooquery/base.py +++ b/yahooquery/base.py @@ -328,3 +328,59 @@ def _construct_data(self, json, response_field, **kwargs): except TypeError: data = json return data + + def _normalize_quote_summary_response(self, data, module_name="quoteSummary"): + """ + Normalize quoteSummary response to ensure consistent structure across all modules. + + Many quoteSummary endpoints can return either: + 1. A dictionary with nested module data (normal case) + 2. A string error message when no data is found (error case) + + This method ensures that error cases return a consistent structure + instead of a plain string, making it easier for consumers to handle + the response predictably across all quoteSummary-based properties. + + Parameters + ---------- + data : dict + Raw response from _quote_summary + module_name : str, optional + Name of the module being normalized (for logging/debugging) + + Returns + ------- + dict + Normalized response where each symbol maps to either: + - A dict with module data (success case) + - A dict with error information (error case): + { + "error": { + "code": 404, + "type": "NotFoundError", + "message": "No fundamentals data found for symbol: EAI", + "symbol": "EAI" + } + } + """ + if not isinstance(data, dict): + return data + + normalized_data = {} + for symbol, module_data in data.items(): + + if isinstance(module_data, str): + # Convert string error messages to a consistent error structure + normalized_data[symbol] = { + "error": { + "code": 404, + "type": "NotFoundError", + "message": module_data, + "symbol": symbol + } + } + else: + # Keep successful responses as-is + normalized_data[symbol] = module_data + + return normalized_data diff --git a/yahooquery/constants.py b/yahooquery/constants.py index d99eb5a..0a51776 100644 --- a/yahooquery/constants.py +++ b/yahooquery/constants.py @@ -587,15 +587,15 @@ "UnrealizedGainLossOnInvestmentSecurities", ], "valuation": [ + "MarketCap", + "EnterpriseValue", + "PeRatio", "ForwardPeRatio", + "PegRatio", "PsRatio", "PbRatio", - "EnterprisesValueEBITDARatio", "EnterprisesValueRevenueRatio", - "PeRatio", - "MarketCap", - "EnterpriseValue", - "PegRatio", + "EnterprisesValueEBITDARatio", ], } @@ -714,17 +714,14 @@ }, }, "news": { - "path": "https://query2.finance.yahoo.com/v2/finance/news", - "response_field": "Content", + "path": "https://finance.yahoo.com/xhr/ncp", + "response_field": "data", + "method": "post", "query": { - "start": {"required": False, "default": None}, - "count": {"required": False, "default": None}, - "symbols": {"required": True, "default": None}, - "sizeLabels": {"required": False, "default": None}, - "widths": {"required": False, "default": None}, - "tags": {"required": False, "default": None}, - "filterOldVideos": {"required": False, "default": None}, - "category": {"required": False, "default": None}, + "symbol": {"required": True, "default": None}, + "location": {"required": False, "default": "US"}, + "queryRef": {"required": False, "default": "newsAll"}, + "serviceKey": {"required": False, "default": "ncp_fin"}, }, }, "quoteSummary": { diff --git a/yahooquery/ticker.py b/yahooquery/ticker.py index 1c45246..7dfbfdf 100644 --- a/yahooquery/ticker.py +++ b/yahooquery/ticker.py @@ -195,7 +195,8 @@ def get_modules(self, modules): One of {} is not a valid value. Valid values are {}. """.format(", ".join(modules), ", ".join(all_modules)) ) - return self._quote_summary(modules) + data = self._quote_summary(modules) + return self._normalize_quote_summary_response(data, "quoteSummary") @property def asset_profile(self): @@ -208,7 +209,8 @@ def asset_profile(self): dict assetProfile module data """ - return self._quote_summary(["assetProfile"]) + data = self._quote_summary(["assetProfile"]) + return self._normalize_quote_summary_response(data, "asset_profile") @property def calendar_events(self): @@ -222,7 +224,8 @@ def calendar_events(self): dict calendarEvents module data """ - return self._quote_summary(["calendarEvents"]) + data = self._quote_summary(["calendarEvents"]) + return self._normalize_quote_summary_response(data, "calendar_events") @property def earnings(self): @@ -235,7 +238,8 @@ def earnings(self): dict earnings module data """ - return self._quote_summary(["earnings"]) + data = self._quote_summary(["earnings"]) + return self._normalize_quote_summary_response(data, "earnings") @property def earnings_trend(self): @@ -249,7 +253,8 @@ def earnings_trend(self): dict earningsTrend module data """ - return self._quote_summary(["earningsTrend"]) + data = self._quote_summary(["earningsTrend"]) + return self._normalize_quote_summary_response(data, "earnings_trend") @property def esg_scores(self): @@ -263,7 +268,8 @@ def esg_scores(self): dict esgScores module data """ - return self._quote_summary(["esgScores"]) + data = self._quote_summary(["esgScores"]) + return self._normalize_quote_summary_response(data, "esg_scores") @property def financial_data(self): @@ -276,7 +282,8 @@ def financial_data(self): dict financialData module data """ - return self._quote_summary(["financialData"]) + data = self._quote_summary(["financialData"]) + return self._normalize_quote_summary_response(data, "financial_data") def news(self, count=25, start=None): """News articles related to given symbol(s) @@ -296,17 +303,66 @@ def news(self, count=25, start=None): ----- It's recommended to use only one symbol for this property as the data returned does not distinguish between what symbol the news stories - belong to + belong to. + + The start parameter is no longer supported by the Yahoo Finance API. Returns ------- dict """ - if start: - start = convert_to_timestamp(start) - return self._chunk_symbols( - "news", params={"count": count, "start": start}, list_result=True - ) + config = CONFIG["news"] + results = {} + + for symbol in self._symbols: + # Build query parameters with defaults from config + params = { + "location": self._country_params.get("region", "US"), + "queryRef": "newsAll", + "serviceKey": "ncp_fin", + "listName": f"{symbol}-news", + "lang": self._country_params.get("lang", "en-US"), + "region": self._country_params.get("region", "US"), + } + + # Add default query params (crumb, etc.) + params.update(self.default_query_params) + + # POST body + payload = { + "serviceConfig": { + "count": count or 25, + "s": [symbol] + }, + "session": { + "lang": self._country_params.get("lang", "en-US"), + "region": self._country_params.get("region", "US"), + } + } + + # Make POST request using session + response = self.session.post( + url=config["path"], + params=params, + json=payload + ) + + try: + json_data = response.json() + # Extract news items from the API response + if "data" in json_data and "tickerStream" in json_data["data"]: + stream = json_data["data"]["tickerStream"].get("stream", []) + results[symbol] = [item["content"] for item in stream if "content" in item] + else: + results[symbol] = json_data + except Exception: + try: + results[symbol] = f"Error: {response.status_code} - {response.text[:100]}" + except Exception: + results[symbol] = "Error: Unable to retrieve news" + + return results + @property def index_trend(self): @@ -320,7 +376,8 @@ def index_trend(self): dict indexTrend module data """ - return self._quote_summary(["indexTrend"]) + data = self._quote_summary(["indexTrend"]) + return self._normalize_quote_summary_response(data, "index_trend") @property def industry_trend(self): @@ -333,7 +390,8 @@ def industry_trend(self): dict industryTrend module data """ - return self._quote_summary(["industryTrend"]) + data = self._quote_summary(["industryTrend"]) + return self._normalize_quote_summary_response(data, "industry_trend") @property def key_stats(self): @@ -346,7 +404,8 @@ def key_stats(self): dict defaultKeyStatistics module data """ - return self._quote_summary(["defaultKeyStatistics"]) + data = self._quote_summary(["defaultKeyStatistics"]) + return self._normalize_quote_summary_response(data, "key_stats") @property def major_holders(self): @@ -360,7 +419,8 @@ def major_holders(self): dict majorHoldersBreakdown module data """ - return self._quote_summary(["majorHoldersBreakdown"]) + data = self._quote_summary(["majorHoldersBreakdown"]) + return self._normalize_quote_summary_response(data, "major_holders") @property def page_views(self): @@ -373,7 +433,8 @@ def page_views(self): dict pageViews module data """ - return self._quote_summary(["pageViews"]) + data = self._quote_summary(["pageViews"]) + return self._normalize_quote_summary_response(data, "page_views") @property def price(self): @@ -387,7 +448,8 @@ def price(self): dict price module data """ - return self._quote_summary(["price"]) + data = self._quote_summary(["price"]) + return self._normalize_quote_summary_response(data, "price") @property def quote_type(self): @@ -446,7 +508,8 @@ def share_purchase_activity(self): dict netSharePurchaseActivity module data """ - return self._quote_summary(["netSharePurchaseActivity"]) + data = self._quote_summary(["netSharePurchaseActivity"]) + return self._normalize_quote_summary_response(data, "share_purchase_activity") @property def summary_detail(self): @@ -459,7 +522,8 @@ def summary_detail(self): dict summaryDetail module data """ - return self._quote_summary(["summaryDetail"]) + data = self._quote_summary(["summaryDetail"]) + return self._normalize_quote_summary_response(data, "summary_detail") @property def summary_profile(self): @@ -472,7 +536,8 @@ def summary_profile(self): dict summaryProfile module data """ - return self._quote_summary(["summaryProfile"]) + data = self._quote_summary(["summaryProfile"]) + return self._normalize_quote_summary_response(data, "summary_profile") @property def technical_insights(self): @@ -557,7 +622,7 @@ def _financials_dataframes(self, data, period_type): df = pd.DataFrame.from_records(data[data_type]) if period_type: df["reportedValue"] = df["reportedValue"].apply( - lambda x: x.get("raw") if isinstance(x, dict) else x + lambda x: self._extract_reported_value(x) ) df["dataType"] = data_type df["symbol"] = symbol @@ -571,6 +636,22 @@ def _financials_dataframes(self, data, period_type): # No data is available for that type pass + def _extract_reported_value(self, reported_value): + """Extract the raw value from reportedValue, handling nested structures""" + if isinstance(reported_value, dict): + raw_value = reported_value.get("raw") + if isinstance(raw_value, dict): + # Handle nested raw structure (e.g., for MarketCap, EnterpriseValue) + raw_value = raw_value.get("parsedValue", raw_value.get("source")) + if isinstance(raw_value, str): + try: + return float(raw_value) + except (ValueError, TypeError): + pass + return raw_value + return reported_value + + def all_financial_data(self, frequency="a"): """ Retrieve all financial data, including income statement, @@ -635,17 +716,94 @@ def corporate_guidance(self): trailing=False, ) - @property - def valuation_measures(self): + def valuation_measures(self, frequency="q", trailing=True): """Valuation Measures - Retrieves valuation measures for most recent four quarters as well - as the most recent date + + Retrieves valuation measures for most recent quarters or years Notes ----- Only quarterly data is available for non-premium subscribers + + Parameters + ---------- + frequency: str, default 'q', optional + Specify either annual or quarterly. Value should be 'a' or 'q'. + trailing: bool, default True, optional + Specify whether or not you'd like trailing twelve month (TTM) + data returned + + Returns + ------- + pandas.DataFrame + """ + return self._financials("valuation", frequency, trailing=trailing) + + + def current_valuation_measures(self, frequency="q"): + """Current Valuation Measures + + Returns a JSON-serializable dict of the most recent Trailing Twelve Months + (TTM) valuation metrics for each symbol. If TTM is not available for a + symbol, falls back to the most recent date. + + Parameters + ---------- + frequency: str, default 'q' + Specify either annual or quarterly. Value should be 'a' or 'q'. + + Returns + ------- + dict | str + JSON-serializable dict keyed by symbol mapping to valuation measure + fields for the most recent TTM period. If the underlying API returns + an error string or dict, that value is returned directly. """ - return self._financials("valuation", "q") + # Retrieve valuation measures with trailing enabled to ensure TTM rows + df = self.valuation_measures(frequency, trailing=True) + # If _financials returned an error message or nested dict, just return it + if isinstance(df, (str, dict)): + return df + try: + # Ensure `periodType` and `asOfDate` exist + if "periodType" not in df.columns or "asOfDate" not in df.columns: + return {} + # Normalize periodType to upper case and filter for TTM rows + ttm_df = df[df["periodType"].astype(str).str.upper() == "TTM"] + + # Determine grouping key — for batch calls the index is a MultiIndex + # where the first level is the symbol. We want to group by symbol + # to select the most recent record per symbol. For single-ticker + # calls it will be a single-level index (dates) and grouping by + # the index itself is sufficient. + def _group_key(dfobj): + if isinstance(dfobj.index, pd.MultiIndex): + # groupby with the first level (symbol) + return dfobj.index.get_level_values(0) + # fallback to group by the index (single-ticker case) + return dfobj.index + + if ttm_df.empty: + # Fallback to most recent 'asOfDate' per symbol + sorted_df = df.sort_values("asOfDate") + latest = sorted_df.groupby(_group_key(sorted_df)).last() + else: + sorted_ttm = ttm_df.sort_values("asOfDate") + latest = sorted_ttm.groupby(_group_key(sorted_ttm)).last() + + # Convert to JSON-serializable dict and format asOfDate + out = {} + records = latest.to_dict(orient="index") + for symbol, rec in records.items(): + if isinstance(rec.get("asOfDate"), (pd.Timestamp,)): + rec["asOfDate"] = rec["asOfDate"].isoformat() + out[symbol] = rec + return out + except Exception: + # If anything unexpected happens, return an empty dict rather than + # raising, to keep API behavior consistent with other endpoints. + return {} + def balance_sheet(self, frequency="a"): """Balance Sheet @@ -1170,12 +1328,26 @@ def p_ideas(self, idea_id): def p_technical_events(self): return self._get_data("technical_events") - def p_valuation_measures(self, frequency="q"): + def p_valuation_measures(self, frequency="q", trailing=True): """Valuation Measures + Retrieves valuation measures for all available dates for given symbol(s) + + Parameters + ---------- + frequency: str, default 'q', optional + Specify either annual or quarterly. Value should be 'a' or 'q'. + trailing: bool, default True, optional + Specify whether or not you'd like trailing twelve month (TTM) + data returned + + Returns + ------- + pandas.DataFrame """ - return self._financials("valuation", frequency, premium=True) + return self._financials("valuation", frequency, premium=True, trailing=trailing) + @property def p_value_analyzer(self):