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 +++++++++++++++++++ frontend/app/page.tsx | 48 +++-- frontend/app/prism-shell.css | 170 ++++++++++++---- frontend/components/prism/ChartCard.tsx | 7 +- frontend/components/prism/Sidebar.tsx | 6 +- frontend/components/prism/TickerHeader.tsx | 14 +- frontend/lib/overview.ts | 13 +- frontend/types/api.ts | 16 ++ 10 files changed, 712 insertions(+), 98 deletions(-) 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"]) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 02bd706..41408b0 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -377,12 +377,12 @@ function SignalCard({ overview }: { overview: TickerOverview }) {

Readthrough

-
+
{overview.signals.map((signal) => ( -
+
{signal.key} - {signal.value} {signal.description} + {signal.value}
))}
@@ -392,12 +392,14 @@ function SignalCard({ overview }: { overview: TickerOverview }) { function DataStatusCard({ overview, missingFields }: { overview: TickerOverview; missingFields: string[] }) { const entries = Object.entries(overview.meta.sources).slice(0, 6); + const visibleMissing = missingFields.slice(0, 4); + const hiddenMissingCount = Math.max(0, missingFields.length - visibleMissing.length); return (
-
Data Quality
+
Coverage

Coverage

{overview.meta.status} @@ -405,7 +407,8 @@ function DataStatusCard({ overview, missingFields }: { overview: TickerOverview;

{availableFieldSummary(overview)}

{overview.meta.is_partial ? (
- {missingFields.length ? missingFields.slice(0, 8).map((field) => {field}) : null} + {missingFields.length ? visibleMissing.map((field) => {field}) : null} + {hiddenMissingCount ? +{hiddenMissingCount} more : null}
) : null}
@@ -433,7 +436,7 @@ function ProfileCard({ overview }: { overview: TickerOverview }) {
Company Profile
-

Context

+

Company Profile

@@ -474,7 +477,7 @@ function ShortInterestCard({ overview }: { overview: TickerOverview }) {
Short Interest
-

Pressure

+

Short Interest

@@ -488,21 +491,38 @@ function ShortInterestCard({ overview }: { overview: TickerOverview }) { } function StatsCard({ overview }: { overview: TickerOverview }) { + const referenceRows = [ + { label: "Market Cap", value: fmtLarge(overview.stats.market_cap), missing: overview.stats.market_cap == null }, + { label: "P/E TTM", value: overview.stats.trailing_pe == null ? "-" : `${fmtNumber(overview.stats.trailing_pe)}x`, missing: overview.stats.trailing_pe == null }, + { label: "EPS TTM", value: fmtCurrency(overview.stats.trailing_eps), missing: overview.stats.trailing_eps == null }, + { label: "P/B", value: overview.ratios.price_to_book == null ? "-" : `${fmtNumber(overview.ratios.price_to_book)}x`, missing: overview.ratios.price_to_book == null }, + { label: "P/S", value: overview.ratios.price_to_sales == null ? "-" : `${fmtNumber(overview.ratios.price_to_sales)}x`, missing: overview.ratios.price_to_sales == null }, + { label: "EV/Sales", value: overview.ratios.ev_to_sales == null ? "-" : `${fmtNumber(overview.ratios.ev_to_sales)}x`, missing: overview.ratios.ev_to_sales == null }, + { label: "EV/EBITDA", value: overview.ratios.ev_to_ebitda == null ? "-" : `${fmtNumber(overview.ratios.ev_to_ebitda)}x`, missing: overview.ratios.ev_to_ebitda == null }, + { label: "Gross Margin", value: fmtPct(overview.ratios.gross_margin_ttm), missing: overview.ratios.gross_margin_ttm == null }, + { label: "Op Margin", value: fmtPct(overview.ratios.operating_margin_ttm), missing: overview.ratios.operating_margin_ttm == null }, + { label: "Net Margin", value: fmtPct(overview.ratios.net_margin_ttm), missing: overview.ratios.net_margin_ttm == null }, + { label: "ROE", value: fmtPct(overview.ratios.roe_ttm), missing: overview.ratios.roe_ttm == null }, + { label: "ROA", value: fmtPct(overview.ratios.roa_ttm), missing: overview.ratios.roa_ttm == null }, + { label: "ROIC", value: fmtPct(overview.ratios.roic_ttm), missing: overview.ratios.roic_ttm == null }, + { label: "D/E", value: overview.ratios.debt_to_equity == null ? "-" : `${fmtNumber(overview.ratios.debt_to_equity)}x`, missing: overview.ratios.debt_to_equity == null }, + { label: "Current Ratio", value: overview.ratios.current_ratio == null ? "-" : `${fmtNumber(overview.ratios.current_ratio)}x`, missing: overview.ratios.current_ratio == null }, + { label: "Dividend Yield", value: fmtPct(overview.ratios.dividend_yield_ttm), missing: overview.ratios.dividend_yield_ttm == null }, + { label: "Payout Ratio", value: fmtPct(overview.ratios.dividend_payout_ratio_ttm), missing: overview.ratios.dividend_payout_ratio_ttm == null } + ]; + return (
-
Overview Stats
+
Reference

Reference

- - - - - - + {referenceRows.map((row) => ( + + ))}
); diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css index 9b0e1b5..b6372c2 100644 --- a/frontend/app/prism-shell.css +++ b/frontend/app/prism-shell.css @@ -1,6 +1,6 @@ .prism-app { display: grid; - grid-template-columns: 256px minmax(0, 1fr); + grid-template-columns: 284px minmax(0, 1fr); min-height: 100vh; } @@ -192,22 +192,22 @@ .psm-watch-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: var(--sp-2); + grid-template-columns: minmax(0, 1fr) 34px; + gap: var(--sp-3); align-items: center; border-bottom: 1px solid var(--line-1); } .psm-watch-select { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: var(--sp-2); + grid-template-columns: minmax(0, 1fr) minmax(82px, auto) minmax(52px, auto); + column-gap: var(--sp-5); align-items: center; width: 100%; border: 0; background: transparent; color: var(--fg-2); - padding: 10px 0; + padding: 12px 0; } .psm-watch-select:hover, @@ -221,13 +221,22 @@ .psm-watch-main { min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; text-align: left; } +.psm-watch-cell { + min-width: 0; +} + .psm-watch-symbol { color: var(--fg-1); + font-family: var(--font-mono); font-size: var(--fs-14); font-weight: 500; + letter-spacing: 0.04em; } .psm-watch-date, @@ -240,6 +249,11 @@ font-size: var(--fs-13); } +.psm-watch-date { + line-height: 1.2; + white-space: nowrap; +} + .psm-watch-price, .psm-watch-change, .psm-quote-line, @@ -261,13 +275,13 @@ } .psm-watch-remove { - width: 26px; - height: 26px; + width: 30px; + height: 30px; border: 1px solid var(--line-2); - border-radius: var(--r-full); + border-radius: var(--r-2); background: transparent; color: var(--fg-4); - margin-right: 2px; + justify-self: end; } .psm-watch-remove:hover { @@ -484,9 +498,9 @@ .psm-ticker-head { display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(220px, 0.75fr) auto; + grid-template-columns: minmax(0, 1.4fr) minmax(240px, 0.75fr) minmax(220px, auto); gap: var(--sp-5); - align-items: end; + align-items: start; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--line-1); } @@ -495,32 +509,32 @@ min-width: 0; } +.psm-head-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: var(--sp-3); +} + .psm-identity-line { color: var(--brass); - display: block; - margin-bottom: var(--sp-2); + display: inline-flex; } .psm-heading-row { display: flex; flex-wrap: wrap; - align-items: baseline; - gap: var(--sp-4); -} - -.psm-symbol { - color: var(--fg-1); - font-family: var(--font-display); - font-size: clamp(3rem, 6vw, var(--fs-64)); - line-height: 0.95; - letter-spacing: -0.03em; + align-items: flex-start; + gap: var(--sp-3); } .psm-company-name { - color: var(--fg-2); + color: var(--fg-1); font-family: var(--font-display); - font-size: var(--fs-24); + font-size: clamp(2.8rem, 5vw, var(--fs-56)); font-style: italic; + line-height: 0.95; } .psm-partial-chip, @@ -559,22 +573,30 @@ color: var(--negative); } +.psm-tag { + border: 1px solid var(--line-2); + background: var(--ink-2); + color: var(--fg-2); +} + .psm-subline { margin-top: var(--sp-2); color: var(--fg-3); font-size: var(--fs-14); + max-width: 52ch; } .psm-price-stack { display: flex; flex-direction: column; align-items: flex-end; - gap: 4px; + gap: 5px; + padding-top: 2px; } .psm-price { color: var(--fg-1); - font-size: clamp(2.4rem, 4vw, var(--fs-48)); + font-size: clamp(2.6rem, 4vw, var(--fs-48)); line-height: 1; } @@ -585,6 +607,8 @@ .psm-quote-line { color: var(--fg-3); font-size: var(--fs-12); + text-transform: uppercase; + letter-spacing: var(--tr-wider); } .psm-primary-action, @@ -601,6 +625,7 @@ border: 1px solid var(--brass); background: var(--brass); color: var(--brass-ink); + margin-top: var(--sp-2); } .psm-primary-action.subtle { @@ -618,6 +643,7 @@ display: flex; flex-direction: column; gap: var(--sp-2); + padding-top: 8px; } .psm-range-values { @@ -629,6 +655,10 @@ font-size: var(--fs-12); } +.psm-range-spot { + color: var(--fg-1); +} + .psm-range-rail { position: relative; height: 4px; @@ -644,6 +674,11 @@ background: var(--brass); } +.psm-range-caption { + color: var(--fg-4); + font-size: var(--fs-12); +} + .psm-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); @@ -769,14 +804,12 @@ line-height: 1.5; } -.psm-signal-grid, .psm-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); } -.psm-signal, .psm-detail-item { border: 1px solid var(--line-1); border-radius: var(--r-2); @@ -792,7 +825,6 @@ text-transform: uppercase; } -.psm-signal-value, .psm-detail-value, .psm-stat-value { display: block; @@ -801,7 +833,6 @@ font-size: var(--fs-18); } -.psm-signal-copy, .psm-detail-copy { display: block; margin-top: 6px; @@ -810,20 +841,50 @@ line-height: 1.45; } -.psm-signal.pos { - border-color: rgba(79, 140, 94, 0.35); +.psm-signal-list { + display: flex; + flex-direction: column; } -.psm-signal.warn { - border-color: rgba(196, 149, 69, 0.35); +.psm-signal-row { + display: grid; + grid-template-columns: 84px minmax(0, 1fr) auto; + gap: var(--sp-3); + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--line-1); +} + +.psm-signal-row:last-child { + border-bottom: 0; +} + +.psm-signal-row.pos .psm-signal-value { + color: var(--positive); +} + +.psm-signal-row.warn .psm-signal-value { + color: var(--warning); +} + +.psm-signal-row.neg .psm-signal-value { + color: var(--negative); } -.psm-signal.neg { - border-color: rgba(181, 73, 75, 0.35); +.psm-signal-row.neu .psm-signal-value { + color: var(--fg-3); +} + +.psm-signal-value { + color: var(--fg-1); + font-family: var(--font-mono); + font-size: var(--fs-14); + font-variant-numeric: tabular-nums; } -.psm-signal.neu { - border-color: var(--line-2); +.psm-signal-copy { + color: var(--fg-2); + font-size: var(--fs-14); } .psm-profile-list, @@ -841,7 +902,7 @@ grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-3); align-items: start; - padding-bottom: var(--sp-3); + padding-bottom: var(--sp-2); border-bottom: 1px solid var(--line-1); } @@ -868,6 +929,11 @@ word-break: break-word; } +.psm-stat-value { + text-align: right; + white-space: nowrap; +} + .psm-source-value { font-family: var(--font-mono); font-size: var(--fs-12); @@ -884,6 +950,7 @@ display: flex; flex-wrap: wrap; gap: var(--sp-2); + margin-bottom: var(--sp-2); } .psm-field-tag { @@ -988,18 +1055,33 @@ .psm-kpis, .psm-main-grid, .psm-detail-grid, - .psm-signal-grid, .psm-ticker-head { grid-template-columns: 1fr; } .psm-heading-row { align-items: start; - flex-direction: column; - gap: var(--sp-2); } .psm-price-stack { align-items: start; } + + .psm-signal-row { + grid-template-columns: 1fr; + gap: 4px; + } + + .psm-watch-select { + grid-template-columns: minmax(0, 1fr) auto auto; + row-gap: 4px; + } + + .psm-watch-price { + grid-column: 2; + } + + .psm-watch-change { + grid-column: 3; + } } diff --git a/frontend/components/prism/ChartCard.tsx b/frontend/components/prism/ChartCard.tsx index bc650d7..87bb399 100644 --- a/frontend/components/prism/ChartCard.tsx +++ b/frontend/components/prism/ChartCard.tsx @@ -23,8 +23,8 @@ export function ChartCard({ symbol, period, points, chartState, chartError, onCh
-
Price History
-

{symbol}

+
Price Action
+

{symbol} Price History

{PERIODS.map((option) => ( @@ -42,7 +42,7 @@ export function ChartCard({ symbol, period, points, chartState, chartError, onCh
-

Chart loading is isolated from the rest of Overview. A history miss only affects this card.

+

Interactive history for the selected window. If history fails, the rest of Overview stays intact.

{chartState === "loading" ?
Loading {period.toUpperCase()} history…
: null} @@ -52,4 +52,3 @@ export function ChartCard({ symbol, period, points, chartState, chartError, onCh
); } - diff --git a/frontend/components/prism/Sidebar.tsx b/frontend/components/prism/Sidebar.tsx index 80e13f3..7f106d8 100644 --- a/frontend/components/prism/Sidebar.tsx +++ b/frontend/components/prism/Sidebar.tsx @@ -75,12 +75,12 @@ export function Sidebar({ return (
@@ -41,6 +45,7 @@ export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) { {fmtCurrency(overview.quote.change)} · {fmtPct(overview.quote.change_pct, 2, true)} Prev close {fmtCurrency(overview.quote.prev_close)} + {lastSource ? Source {lastSource.replaceAll("_", " ")} : null} @@ -48,4 +53,3 @@ export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) { ); } - diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts index efbd9f9..d02c5d3 100644 --- a/frontend/lib/overview.ts +++ b/frontend/lib/overview.ts @@ -41,14 +41,14 @@ export function buildKpis(overview: TickerOverview): KpiItem[] { { key: "P / E", value: overview.stats.trailing_pe == null ? "Unavailable" : `${fmtNumber(overview.stats.trailing_pe)}x`, - sublabel: `EPS ${fmtCurrency(overview.stats.trailing_eps)}`, + sublabel: `P/B ${overview.ratios.price_to_book == null ? "-" : `${fmtNumber(overview.ratios.price_to_book)}x`}`, missing: overview.stats.trailing_pe == null }, { - key: "Prev Close", - value: fmtCurrency(overview.quote.prev_close), - sublabel: `Day chg ${fmtCurrency(overview.quote.change)}`, - missing: overview.quote.prev_close == null + key: "EPS · TTM", + value: fmtCurrency(overview.stats.trailing_eps), + sublabel: `Net margin ${fmtPct(overview.ratios.net_margin_ttm)}`, + missing: overview.stats.trailing_eps == null }, { key: "52W Position", @@ -95,7 +95,7 @@ export function availableFieldSummary(overview: TickerOverview): string { const fields = Object.values(overview.meta.field_availability); if (!fields.length) return "Availability metadata unavailable"; const available = fields.filter(Boolean).length; - return `${available}/${fields.length} tracked fields available`; + return `${available} of ${fields.length} tracked fields filled`; } export function watchlistSubtitle(item: WatchlistItem): string { @@ -131,4 +131,3 @@ export function marketClock(now = new Date()) { }).format(now) }; } - diff --git a/frontend/types/api.ts b/frontend/types/api.ts index 679ada9..84dfd19 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -44,6 +44,22 @@ export type TickerOverview = { average_volume?: number | null; beta?: number | null; }; + ratios: { + price_to_book?: number | null; + price_to_sales?: number | null; + ev_to_sales?: number | null; + ev_to_ebitda?: number | null; + gross_margin_ttm?: number | null; + operating_margin_ttm?: number | null; + net_margin_ttm?: number | null; + roe_ttm?: number | null; + roa_ttm?: number | null; + roic_ttm?: number | null; + debt_to_equity?: number | null; + current_ratio?: number | null; + dividend_yield_ttm?: number | null; + dividend_payout_ratio_ttm?: number | null; + }; range_52w: { low?: number | null; high?: number | null; -- cgit v1.3-2-g0d8e