From c3f19f79f66054dc3b3a98999ea38b0f05248e06 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Sun, 17 May 2026 13:36:57 -0700 Subject: Refine overview ratios and shell --- backend/app/schemas.py | 18 ++ backend/app/services/data_service.py | 313 ++++++++++++++++++++++++++++++++--- backend/tests/test_api.py | 205 +++++++++++++++++++++++ 3 files changed, 515 insertions(+), 21 deletions(-) (limited to 'backend') diff --git a/backend/app/schemas.py b/backend/app/schemas.py index db0076d..2ee6dac 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -39,6 +39,23 @@ class OverviewStats(BaseModel): beta: float | None = None +class OverviewRatios(BaseModel): + price_to_book: float | None = None + price_to_sales: float | None = None + ev_to_sales: float | None = None + ev_to_ebitda: float | None = None + gross_margin_ttm: float | None = None + operating_margin_ttm: float | None = None + net_margin_ttm: float | None = None + roe_ttm: float | None = None + roa_ttm: float | None = None + roic_ttm: float | None = None + debt_to_equity: float | None = None + current_ratio: float | None = None + dividend_yield_ttm: float | None = None + dividend_payout_ratio_ttm: float | None = None + + class Range52Week(BaseModel): low: float | None = None high: float | None = None @@ -75,6 +92,7 @@ class TickerOverview(BaseModel): quote: Quote signals: list[Signal] = Field(default_factory=list) stats: OverviewStats + ratios: OverviewRatios range_52w: Range52Week short_interest: ShortInterest meta: OverviewMeta diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index ae078cd..6f8587f 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -18,10 +18,18 @@ PRICE_CACHE = TTLCache(maxsize=256, ttl=300) HISTORY_CACHE = TTLCache(maxsize=256, ttl=300) INTRADAY_CACHE = TTLCache(maxsize=128, ttl=60) MARKET_CACHE = TTLCache(maxsize=8, ttl=300) +STATEMENT_CACHE = TTLCache(maxsize=256, ttl=3600) +SHARES_CACHE = TTLCache(maxsize=256, ttl=3600) +RATIO_CACHE = TTLCache(maxsize=256, ttl=3600) PERIODS = {"1m", "3m", "6m", "1y", "5y"} YF_PERIOD_MAP = {"1m": "1mo", "3m": "3mo", "6m": "6mo", "1y": "1y", "5y": "5y"} _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} +_SHARE_LABELS = ( + "Ordinary Shares Number", + "Share Issued", + "Common Stock Shares Outstanding", +) def normalize_symbol(symbol: str) -> str: @@ -55,6 +63,46 @@ def _json_value(value: Any) -> Any: return value +def _cap_ratio(value: float | None, lower: float, upper: float) -> float | None: + if value is None or value <= lower or value >= upper: + return None + return value + + +def _balance_value(frame: pd.DataFrame, *labels: str) -> float | None: + if frame is None or frame.empty: + return None + for label in labels: + if label not in frame.index: + continue + series = pd.to_numeric(frame.loc[label], errors="coerce").dropna() + if series.empty: + continue + value = _safe_float(series.iloc[0]) + if value is not None: + return value + return None + + +def _statement_ttm(frame: pd.DataFrame, *labels: str) -> float | None: + if frame is None or frame.empty: + return None + for label in labels: + if label not in frame.index: + continue + series = pd.to_numeric(frame.loc[label].iloc[:4], errors="coerce").dropna() + if len(series) == 4: + value = _safe_float(series.sum()) + if value is not None: + return value + return None + + +def _latest_share_count(balance_sheet: pd.DataFrame) -> float | None: + shares = _balance_value(balance_sheet, *_SHARE_LABELS) + return shares if shares is not None and shares > 0 else None + + def _pick_search_match(symbol: str) -> dict[str, Any]: sym = normalize_symbol(symbol) results = search_tickers(sym) @@ -97,8 +145,7 @@ def get_company_info(symbol: str) -> dict[str, Any]: info = yf.Ticker(sym).info or {} if not isinstance(info, dict): return {} - cleaned = {str(k): _json_value(v) for k, v in info.items()} - return cleaned + return {str(k): _json_value(v) for k, v in info.items()} except Exception: return {} @@ -187,6 +234,76 @@ def get_intraday_history(symbol: str, period: str, interval: str) -> list[dict[s return [] +@cached(STATEMENT_CACHE) +def get_income_statement(symbol: str, quarterly: bool = False) -> pd.DataFrame: + try: + ticker = yf.Ticker(normalize_symbol(symbol)) + frame = ticker.quarterly_income_stmt if quarterly else ticker.income_stmt + return frame if isinstance(frame, pd.DataFrame) else pd.DataFrame() + except Exception: + return pd.DataFrame() + + +@cached(STATEMENT_CACHE) +def get_balance_sheet(symbol: str, quarterly: bool = False) -> pd.DataFrame: + try: + ticker = yf.Ticker(normalize_symbol(symbol)) + frame = ticker.quarterly_balance_sheet if quarterly else ticker.balance_sheet + return frame if isinstance(frame, pd.DataFrame) else pd.DataFrame() + except Exception: + return pd.DataFrame() + + +@cached(STATEMENT_CACHE) +def get_cash_flow(symbol: str, quarterly: bool = False) -> pd.DataFrame: + try: + ticker = yf.Ticker(normalize_symbol(symbol)) + frame = ticker.quarterly_cashflow if quarterly else ticker.cashflow + return frame if isinstance(frame, pd.DataFrame) else pd.DataFrame() + except Exception: + return pd.DataFrame() + + +@cached(SHARES_CACHE) +def get_shares_outstanding(symbol: str) -> float | None: + sym = normalize_symbol(symbol) + info = get_company_info(sym) + for key in ("sharesOutstanding", "impliedSharesOutstanding"): + shares = _safe_float(info.get(key)) + if shares is not None and shares > 0: + return shares + + fast_info = get_fast_info(sym) + shares = _safe_float(fast_info.get("shares")) + if shares is not None and shares > 0: + return shares + + balance_sheet = get_balance_sheet(sym, quarterly=True) + shares = _latest_share_count(balance_sheet) + if shares is not None: + return shares + + try: + history = yf.Ticker(sym).get_shares_full(start="2000-01-01") + if isinstance(history, pd.Series): + values = pd.to_numeric(history, errors="coerce").dropna() + if not values.empty: + latest = _safe_float(values.iloc[-1]) + if latest is not None and latest > 0: + return latest + except Exception: + pass + return None + + +def get_market_cap_computed(symbol: str, price: float | None = None, shares: float | None = None) -> float | None: + latest_price = price if price is not None else get_latest_price(symbol) + share_count = shares if shares is not None else get_shares_outstanding(symbol) + if latest_price is not None and latest_price > 0 and share_count is not None and share_count > 0: + return latest_price * share_count + return None + + def _history_rows(df: pd.DataFrame, include_time: bool) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for idx, row in df.iterrows(): @@ -246,9 +363,11 @@ def build_quote(info: dict[str, Any], symbol: str) -> dict[str, Any]: return {"price": price, "prev_close": prev_close, "change": change, "change_pct": change_pct} -def build_signals(info: dict[str, Any]) -> list[dict[str, str]]: +def build_signals(info: dict[str, Any], ratios: dict[str, Any]) -> list[dict[str, str]]: signals: list[dict[str, str]] = [] pe = _safe_float(info.get("trailingPE")) + if pe is None: + pe = _safe_float(ratios.get("trailing_pe")) if pe is not None and pe > 0: if pe < 15: signals.append({"key": "Valuation", "state": "pos", "value": f"P/E {pe:.1f}x", "description": "Attractive multiple"}) @@ -260,18 +379,25 @@ def build_signals(info: dict[str, Any]) -> list[dict[str, str]]: signals.append({"key": "Valuation", "state": "neu", "value": "P/E unavailable", "description": "No trailing earnings"}) _ratio_signal(signals, "Growth", info.get("revenueGrowth"), 0.10, 0.0, "Strong top-line growth", "Low but positive growth", "Contracting revenue") - _ratio_signal(signals, "Profit", info.get("profitMargins"), 0.15, 0.05, "High net margin", "Moderate net margin", "Thin or negative margin") + + profit = _safe_float(info.get("profitMargins")) + if profit is None: + profit = _safe_float(ratios.get("net_margin_ttm")) + _ratio_signal(signals, "Profit", profit, 0.15, 0.05, "High net margin", "Moderate net margin", "Thin or negative margin") debt_to_equity = _safe_float(info.get("debtToEquity")) if debt_to_equity is not None: - de_x = debt_to_equity / 100.0 - if de_x < 0.5: + debt_to_equity = debt_to_equity / 100.0 + else: + debt_to_equity = _safe_float(ratios.get("debt_to_equity")) + if debt_to_equity is not None: + if debt_to_equity < 0.5: state, desc = "pos", "Low leverage" - elif de_x < 2.0: + elif debt_to_equity < 2.0: state, desc = "warn", "Moderate leverage" else: state, desc = "neg", "High leverage" - signals.append({"key": "Leverage", "state": state, "value": f"D/E {de_x:.2f}x", "description": desc}) + signals.append({"key": "Leverage", "state": state, "value": f"D/E {debt_to_equity:.2f}x", "description": desc}) return signals @@ -295,7 +421,8 @@ def _ratio_signal( state, desc = "warn", warn_desc else: state, desc = "neg", negative_desc - signals.append({"key": key, "state": state, "value": f"{ratio * 100:+.0f}%" if key == "Growth" else f"{ratio * 100:.0f}%", "description": desc}) + formatted = f"{ratio * 100:+.0f}%" if key == "Growth" else f"{ratio * 100:.0f}%" + signals.append({"key": key, "state": state, "value": formatted, "description": desc}) def _field(source_map: dict[str, dict[str, Any]], field_sources: dict[str, str], name: str, *candidates: tuple[str, str]) -> Any: @@ -412,11 +539,111 @@ def _build_profile(sym: str, info: dict[str, Any], fast_info: dict[str, Any], se } +@cached(RATIO_CACHE) +def compute_ttm_ratios(symbol: str) -> dict[str, Any]: + sym = normalize_symbol(symbol) + info = get_company_info(sym) + price = _safe_float(info.get("currentPrice") or info.get("regularMarketPrice")) or get_latest_price(sym) + shares = get_shares_outstanding(sym) + income = get_income_statement(sym, quarterly=True) + balance = get_balance_sheet(sym, quarterly=True) + cash_flow = get_cash_flow(sym, quarterly=True) + + if income is None or income.empty: + return {} + + revenue = _statement_ttm(income, "Total Revenue") + gross_profit = _statement_ttm(income, "Gross Profit") + operating_income = _statement_ttm(income, "Operating Income") + net_income = _statement_ttm(income, "Net Income") + ebit = _statement_ttm(income, "EBIT") + ebitda = _statement_ttm(income, "EBITDA", "Normalized EBITDA") + tax_provision = _statement_ttm(income, "Tax Provision") + pretax_income = _statement_ttm(income, "Pretax Income") + + equity = _balance_value(balance, "Stockholders Equity", "Common Stock Equity") + total_assets = _balance_value(balance, "Total Assets") + total_debt = _balance_value(balance, "Total Debt", "Long Term Debt And Capital Lease Obligation") + current_assets = _balance_value(balance, "Current Assets") + current_liabilities = _balance_value(balance, "Current Liabilities") + cash = _balance_value(balance, "Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments") or 0.0 + + market_cap = get_market_cap_computed(sym, price=price, shares=shares) + trailing_eps = None + if shares is not None and shares > 0 and net_income is not None: + trailing_eps = net_income / shares + + ratios: dict[str, Any] = {} + ratios["market_cap"] = market_cap + ratios["trailing_eps"] = trailing_eps + + if revenue and revenue > 0: + if gross_profit is not None: + ratios["gross_margin_ttm"] = gross_profit / revenue + if operating_income is not None: + ratios["operating_margin_ttm"] = operating_income / revenue + if net_income is not None: + ratios["net_margin_ttm"] = net_income / revenue + + if equity and equity > 0 and net_income is not None: + roe = net_income / equity + if abs(roe) < 10: + ratios["roe_ttm"] = roe + + if total_assets and total_assets > 0 and net_income is not None: + roa = net_income / total_assets + if abs(roa) < 10: + ratios["roa_ttm"] = roa + + if ebit is not None and pretax_income not in (None, 0): + effective_tax_rate = max(0.0, (tax_provision or 0.0) / pretax_income) + invested_capital = (equity or 0.0) + (total_debt or 0.0) - cash + if invested_capital > 0: + roic = (ebit * (1 - effective_tax_rate)) / invested_capital + if abs(roic) < 10: + ratios["roic_ttm"] = roic + + if market_cap and market_cap > 0: + if net_income and net_income > 0: + ratios["trailing_pe"] = market_cap / net_income + if revenue and revenue > 0: + ratios["price_to_sales"] = _cap_ratio(market_cap / revenue, 0, 100) + if equity and equity > 0: + ratios["price_to_book"] = _cap_ratio(market_cap / equity, 0, 100) + + enterprise_value = market_cap + (total_debt or 0.0) - cash + if revenue and revenue > 0: + ratios["ev_to_sales"] = _cap_ratio(enterprise_value / revenue, 0, 100) + if ebitda and ebitda > 1e6: + ratios["ev_to_ebitda"] = _cap_ratio(enterprise_value / ebitda, 0, 500) + + if equity and equity > 0 and total_debt is not None: + ratios["debt_to_equity"] = _cap_ratio(total_debt / equity, -1, 100) + + if current_liabilities and current_liabilities > 0 and current_assets is not None: + ratios["current_ratio"] = current_assets / current_liabilities + + dividends_paid = _statement_ttm(cash_flow, "Cash Dividends Paid", "Common Stock Dividend Paid") + if dividends_paid is not None: + dividends_paid = abs(dividends_paid) + if market_cap and market_cap > 0: + div_yield = dividends_paid / market_cap + if 0 <= div_yield < 1: + ratios["dividend_yield_ttm"] = div_yield + if net_income and net_income > 0: + payout = dividends_paid / net_income + if 0 <= payout < 10: + ratios["dividend_payout_ratio_ttm"] = payout + + return {key: value for key, value in ratios.items() if value is not None} + + def _build_quote_and_stats( info: dict[str, Any], fast_info: dict[str, Any], month_history: list[dict[str, Any]], year_history: list[dict[str, Any]], + computed: dict[str, Any], field_sources: dict[str, str], ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: month_snapshot = _history_snapshot(month_history) @@ -426,6 +653,7 @@ def _build_quote_and_stats( "fast_info": fast_info, "history_recent": month_snapshot, "history_year": year_snapshot, + "computed": computed, } price = _safe_float( @@ -478,9 +706,11 @@ def _build_quote_and_stats( ("history_recent", "averageVolume"), ) ) - market_cap = _safe_float(_field(source_map, field_sources, "stats.market_cap", ("info", "marketCap"), ("fast_info", "marketCap"))) - trailing_pe = _safe_float(_field(source_map, field_sources, "stats.trailing_pe", ("info", "trailingPE"))) - trailing_eps = _safe_float(_field(source_map, field_sources, "stats.trailing_eps", ("info", "trailingEps"))) + market_cap = _safe_float( + _field(source_map, field_sources, "stats.market_cap", ("info", "marketCap"), ("fast_info", "marketCap"), ("computed", "market_cap")) + ) + trailing_pe = _safe_float(_field(source_map, field_sources, "stats.trailing_pe", ("info", "trailingPE"), ("computed", "trailing_pe"))) + trailing_eps = _safe_float(_field(source_map, field_sources, "stats.trailing_eps", ("info", "trailingEps"), ("computed", "trailing_eps"))) beta = _safe_float(_field(source_map, field_sources, "stats.beta", ("info", "beta"))) range_low = _safe_float( _field( @@ -513,23 +743,46 @@ def _build_quote_and_stats( "average_volume": average_volume, "beta": beta, }, - { - "low": range_low, - "high": range_high, - "price": price, - }, + {"low": range_low, "high": range_high, "price": price}, ) +def _build_ratios(computed: dict[str, Any], field_sources: dict[str, str]) -> dict[str, Any]: + ratios: dict[str, Any] = {} + keys = ( + "price_to_book", + "price_to_sales", + "ev_to_sales", + "ev_to_ebitda", + "gross_margin_ttm", + "operating_margin_ttm", + "net_margin_ttm", + "roe_ttm", + "roa_ttm", + "roic_ttm", + "debt_to_equity", + "current_ratio", + "dividend_yield_ttm", + "dividend_payout_ratio_ttm", + ) + for key in keys: + value = _safe_float(computed.get(key)) + ratios[key] = value + if value is not None: + field_sources[f"ratios.{key}"] = "computed" + return ratios + + def _has_any_overview_data( profile: dict[str, Any], quote: dict[str, Any], stats: dict[str, Any], + ratios: dict[str, Any], range_52w: dict[str, Any], short_interest: dict[str, Any], field_sources: dict[str, str], ) -> bool: - for bucket in (profile, quote, stats, range_52w, short_interest): + for bucket in (profile, quote, stats, ratios, range_52w, short_interest): for key, value in bucket.items(): if key == "symbol": continue @@ -549,10 +802,13 @@ def get_ticker_overview(symbol: str) -> dict[str, Any] | None: fast_info = get_fast_info(sym) month_history = get_price_history(sym, period="1m") year_history = get_price_history(sym, period="1y") + computed = compute_ttm_ratios(sym) field_sources: dict[str, str] = {} profile = _build_profile(sym, info, fast_info, search_match, field_sources) - quote, stats, range_52w = _build_quote_and_stats(info, fast_info, month_history, year_history, field_sources) + quote, stats, range_52w = _build_quote_and_stats(info, fast_info, month_history, year_history, computed, field_sources) + ratios = _build_ratios(computed, field_sources) + short = _safe_int(info.get("sharesShort")) short_prior = _safe_int(info.get("sharesShortPriorMonth")) short_delta = None @@ -566,7 +822,7 @@ def get_ticker_overview(symbol: str) -> dict[str, Any] | None: "shares_short_delta_pct": short_delta, } - if not _has_any_overview_data(profile, quote, stats, range_52w, short_interest, field_sources): + if not _has_any_overview_data(profile, quote, stats, ratios, range_52w, short_interest, field_sources): return None field_availability = { @@ -584,6 +840,20 @@ def get_ticker_overview(symbol: str) -> dict[str, Any] | None: "stats.volume": stats.get("volume") is not None, "stats.average_volume": stats.get("average_volume") is not None, "stats.beta": stats.get("beta") is not None, + "ratios.price_to_book": ratios.get("price_to_book") is not None, + "ratios.price_to_sales": ratios.get("price_to_sales") is not None, + "ratios.ev_to_sales": ratios.get("ev_to_sales") is not None, + "ratios.ev_to_ebitda": ratios.get("ev_to_ebitda") is not None, + "ratios.gross_margin_ttm": ratios.get("gross_margin_ttm") is not None, + "ratios.operating_margin_ttm": ratios.get("operating_margin_ttm") is not None, + "ratios.net_margin_ttm": ratios.get("net_margin_ttm") is not None, + "ratios.roe_ttm": ratios.get("roe_ttm") is not None, + "ratios.roa_ttm": ratios.get("roa_ttm") is not None, + "ratios.roic_ttm": ratios.get("roic_ttm") is not None, + "ratios.debt_to_equity": ratios.get("debt_to_equity") is not None, + "ratios.current_ratio": ratios.get("current_ratio") is not None, + "ratios.dividend_yield_ttm": ratios.get("dividend_yield_ttm") is not None, + "ratios.dividend_payout_ratio_ttm": ratios.get("dividend_payout_ratio_ttm") is not None, "range_52w.low": range_52w.get("low") is not None, "range_52w.high": range_52w.get("high") is not None, } @@ -592,8 +862,9 @@ def get_ticker_overview(symbol: str) -> dict[str, Any] | None: return { "profile": profile, "quote": quote, - "signals": build_signals(info), + "signals": build_signals(info, computed), "stats": stats, + "ratios": ratios, "range_52w": range_52w, "short_interest": short_interest, "meta": { diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 70b67a7..1c99cf9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -6,6 +6,21 @@ from app import main from app.services import data_service +def clear_service_caches() -> None: + data_service.INFO_CACHE.clear() + data_service.FAST_INFO_CACHE.clear() + data_service.PRICE_CACHE.clear() + data_service.HISTORY_CACHE.clear() + data_service.STATEMENT_CACHE.clear() + data_service.SHARES_CACHE.clear() + data_service.RATIO_CACHE.clear() + + +def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame: + columns = pd.to_datetime(["2025-12-31", "2025-09-30", "2025-06-30", "2025-03-31"]) + return pd.DataFrame(rows, index=columns).T + + def test_health() -> None: assert main.health() == {"status": "ok"} @@ -31,6 +46,22 @@ def test_mocked_ticker_overview(monkeypatch) -> None: "quote": {"price": 100.0, "prev_close": 98.0, "change": 2.0, "change_pct": 0.0204}, "signals": [], "stats": {"market_cap": None, "trailing_pe": None, "trailing_eps": None, "volume": None, "average_volume": None, "beta": None}, + "ratios": { + "price_to_book": None, + "price_to_sales": None, + "ev_to_sales": None, + "ev_to_ebitda": None, + "gross_margin_ttm": None, + "operating_margin_ttm": None, + "net_margin_ttm": None, + "roe_ttm": None, + "roa_ttm": None, + "roic_ttm": None, + "debt_to_equity": None, + "current_ratio": None, + "dividend_yield_ttm": None, + "dividend_payout_ratio_ttm": None, + }, "range_52w": {"low": None, "high": None, "price": 100.0}, "short_interest": {"short_percent_of_float": None, "short_ratio": None, "shares_short": None, "shares_short_prior_month": None, "shares_short_delta_pct": None}, "meta": {"status": "partial", "is_partial": True, "field_availability": {}, "sources": {}}, @@ -40,6 +71,7 @@ def test_mocked_ticker_overview(monkeypatch) -> None: def test_service_overview_prefers_info_fields(monkeypatch) -> None: + clear_service_caches() monkeypatch.setattr( data_service, "get_company_info", @@ -74,6 +106,7 @@ def test_service_overview_prefers_info_fields(monkeypatch) -> None: def test_service_overview_falls_back_to_fast_info(monkeypatch) -> None: + clear_service_caches() monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) monkeypatch.setattr( data_service, @@ -103,6 +136,7 @@ def test_service_overview_falls_back_to_fast_info(monkeypatch) -> None: def test_service_overview_falls_back_to_search_and_history(monkeypatch) -> None: + clear_service_caches() month_history = [ {"date": "2026-01-01", "close": 98.0, "volume": 1000.0}, {"date": "2026-01-02", "close": 100.0, "volume": 1200.0}, @@ -124,6 +158,7 @@ def test_service_overview_falls_back_to_search_and_history(monkeypatch) -> None: def test_service_overview_invalid_symbol(monkeypatch) -> None: + clear_service_caches() monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {}) monkeypatch.setattr(data_service, "_pick_search_match", lambda symbol: {}) @@ -149,6 +184,22 @@ def test_ticker_overview_partial_response(monkeypatch) -> None: "quote": {"price": 100.0, "prev_close": 98.0, "change": 2.0, "change_pct": 0.0204}, "signals": [], "stats": {"market_cap": None, "trailing_pe": None, "trailing_eps": None, "volume": 1000.0, "average_volume": None, "beta": None}, + "ratios": { + "price_to_book": None, + "price_to_sales": None, + "ev_to_sales": None, + "ev_to_ebitda": None, + "gross_margin_ttm": None, + "operating_margin_ttm": None, + "net_margin_ttm": None, + "roe_ttm": None, + "roa_ttm": None, + "roic_ttm": None, + "debt_to_equity": None, + "current_ratio": None, + "dividend_yield_ttm": None, + "dividend_payout_ratio_ttm": None, + }, "range_52w": {"low": None, "high": None, "price": 100.0}, "short_interest": {"short_percent_of_float": None, "short_ratio": None, "shares_short": None, "shares_short_prior_month": None, "shares_short_delta_pct": None}, "meta": {"status": "partial", "is_partial": True, "field_availability": {"stats.market_cap": False}, "sources": {"profile.name": "search"}}, @@ -179,3 +230,157 @@ def test_ticker_history_period_mapping(monkeypatch) -> None: assert len(data_service.get_price_history("AAPL", period="3m")) == 1 assert len(data_service.get_price_history("AAPL", period="6m")) == 1 assert captured == ["1mo", "3mo", "6mo"] + + +def test_compute_ttm_ratios_populates_overlapping_stats(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_latest_price", lambda symbol: 50.0) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 100.0) + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: quarterly_frame( + { + "Total Revenue": [1_000.0, 1_000.0, 1_000.0, 1_000.0], + "Gross Profit": [500.0, 500.0, 500.0, 500.0], + "Operating Income": [250.0, 250.0, 250.0, 250.0], + "Net Income": [100.0, 100.0, 100.0, 100.0], + "EBIT": [150.0, 150.0, 150.0, 150.0], + "EBITDA": [200.0, 200.0, 200.0, 200.0], + "Tax Provision": [20.0, 20.0, 20.0, 20.0], + "Pretax Income": [120.0, 120.0, 120.0, 120.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: quarterly_frame( + { + "Stockholders Equity": [500.0, 0.0, 0.0, 0.0], + "Total Assets": [1_200.0, 0.0, 0.0, 0.0], + "Total Debt": [150.0, 0.0, 0.0, 0.0], + "Current Assets": [300.0, 0.0, 0.0, 0.0], + "Current Liabilities": [100.0, 0.0, 0.0, 0.0], + "Cash And Cash Equivalents": [50.0, 0.0, 0.0, 0.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_cash_flow", + lambda symbol, quarterly=False: quarterly_frame({"Cash Dividends Paid": [-10.0, -10.0, -10.0, -10.0]}), + ) + + ratios = data_service.compute_ttm_ratios("AAPL") + assert ratios["market_cap"] == 5_000.0 + assert ratios["trailing_eps"] == 4.0 + assert ratios["trailing_pe"] == 12.5 + assert ratios["price_to_book"] == 10.0 + assert ratios["price_to_sales"] == 1.25 + assert ratios["ev_to_sales"] == 1.275 + assert ratios["gross_margin_ttm"] == 0.5 + assert ratios["operating_margin_ttm"] == 0.25 + assert ratios["net_margin_ttm"] == 0.1 + assert ratios["roe_ttm"] == 0.8 + assert round(ratios["roic_ttm"], 6) == round((600.0 * (1 - (80.0 / 480.0))) / 600.0, 6) + assert ratios["debt_to_equity"] == 0.3 + assert ratios["current_ratio"] == 3.0 + assert ratios["dividend_yield_ttm"] == 0.008 + assert ratios["dividend_payout_ratio_ttm"] == 0.1 + + +def test_compute_ttm_ratios_guardrails_suppress_outliers(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_latest_price", lambda symbol: 1_000.0) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 100.0) + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: quarterly_frame( + { + "Total Revenue": [1.0, 1.0, 1.0, 1.0], + "Net Income": [1.0, 1.0, 1.0, 1.0], + "EBIT": [1.0, 1.0, 1.0, 1.0], + "EBITDA": [100_000.0, 100_000.0, 100_000.0, 100_000.0], + "Tax Provision": [0.0, 0.0, 0.0, 0.0], + "Pretax Income": [1.0, 1.0, 1.0, 1.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: quarterly_frame( + { + "Stockholders Equity": [1.0, 0.0, 0.0, 0.0], + "Total Assets": [10.0, 0.0, 0.0, 0.0], + "Total Debt": [1_000.0, 0.0, 0.0, 0.0], + "Cash And Cash Equivalents": [0.0, 0.0, 0.0, 0.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame()) + + ratios = data_service.compute_ttm_ratios("AAPL") + assert ratios["trailing_pe"] == 25_000.0 + assert "price_to_book" not in ratios + assert "price_to_sales" not in ratios + assert "ev_to_sales" not in ratios + assert "ev_to_ebitda" not in ratios + + +def test_overview_uses_computed_sources_and_ratios(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "get_company_info", + lambda symbol: { + "longName": "Apple Inc.", + "exchange": "NMS", + "currentPrice": 120.0, + "previousClose": 118.0, + }, + ) + monkeypatch.setattr(data_service, "get_fast_info", lambda symbol: {}) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="1m": []) + monkeypatch.setattr(data_service, "_pick_search_match", lambda symbol: {"symbol": "AAPL", "name": "Apple Inc.", "exchange": "NASDAQ"}) + monkeypatch.setattr(data_service, "get_profile_enrichment", lambda symbol: {}) + monkeypatch.setattr( + data_service, + "compute_ttm_ratios", + lambda symbol: { + "market_cap": 1_500_000_000.0, + "trailing_pe": 24.5, + "trailing_eps": 4.9, + "price_to_book": 8.0, + "price_to_sales": 6.2, + "ev_to_sales": 6.8, + "ev_to_ebitda": 22.1, + "gross_margin_ttm": 0.44, + "operating_margin_ttm": 0.27, + "net_margin_ttm": 0.18, + "roe_ttm": 0.31, + "roa_ttm": 0.12, + "roic_ttm": 0.19, + "debt_to_equity": 0.42, + "current_ratio": 1.8, + "dividend_yield_ttm": 0.005, + "dividend_payout_ratio_ttm": 0.12, + }, + ) + + overview = data_service.get_ticker_overview("AAPL") + assert overview is not None + assert overview["stats"]["trailing_pe"] == 24.5 + assert overview["stats"]["market_cap"] == 1_500_000_000.0 + assert overview["ratios"]["price_to_book"] == 8.0 + assert overview["meta"]["sources"]["stats.trailing_pe"] == "computed" + assert overview["meta"]["sources"]["stats.market_cap"] == "computed" + assert overview["meta"]["sources"]["ratios.price_to_book"] == "computed" + assert overview["meta"]["field_availability"]["ratios.ev_to_ebitda"] is True + assert any(signal["key"] == "Valuation" and "24.5x" in signal["value"] for signal in overview["signals"]) -- cgit v1.3-2-g0d8e