summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-17 13:36:57 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-17 13:36:57 -0700
commitc3f19f79f66054dc3b3a98999ea38b0f05248e06 (patch)
tree2d3b551838bfc8abeb52be20350b729377bceea5
parent6b8e9470d5b40030172b0413f0c5875fcbe65595 (diff)
Refine overview ratios and shell
-rw-r--r--backend/app/schemas.py18
-rw-r--r--backend/app/services/data_service.py313
-rw-r--r--backend/tests/test_api.py205
-rw-r--r--frontend/app/page.tsx48
-rw-r--r--frontend/app/prism-shell.css170
-rw-r--r--frontend/components/prism/ChartCard.tsx7
-rw-r--r--frontend/components/prism/Sidebar.tsx6
-rw-r--r--frontend/components/prism/TickerHeader.tsx14
-rw-r--r--frontend/lib/overview.ts13
-rw-r--r--frontend/types/api.ts16
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 }) {
<h2 className="psm-card-title">Readthrough</h2>
</div>
</div>
- <div className="psm-signal-grid">
+ <div className="psm-signal-list">
{overview.signals.map((signal) => (
- <article key={signal.key} className={`psm-signal ${signalTone(signal.state)}`}>
+ <article key={signal.key} className={`psm-signal-row ${signalTone(signal.state)}`}>
<span className="psm-signal-key">{signal.key}</span>
- <span className="psm-signal-value">{signal.value}</span>
<span className="psm-signal-copy">{signal.description}</span>
+ <span className="psm-signal-value">{signal.value}</span>
</article>
))}
</div>
@@ -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 (
<section className="psm-card">
<div className="psm-card-head">
<div>
- <div className="psm-eyebrow">Data Quality</div>
+ <div className="psm-eyebrow">Coverage</div>
<h2 className="psm-card-title">Coverage</h2>
</div>
<span className={`psm-status-chip${overview.meta.is_partial ? " partial" : ""}`}>{overview.meta.status}</span>
@@ -405,7 +407,8 @@ function DataStatusCard({ overview, missingFields }: { overview: TickerOverview;
<p className="psm-quality-copy">{availableFieldSummary(overview)}</p>
{overview.meta.is_partial ? (
<div className="psm-stack">
- {missingFields.length ? missingFields.slice(0, 8).map((field) => <span key={field} className="psm-field-tag missing">{field}</span>) : null}
+ {missingFields.length ? visibleMissing.map((field) => <span key={field} className="psm-field-tag missing">{field}</span>) : null}
+ {hiddenMissingCount ? <span className="psm-field-tag missing">+{hiddenMissingCount} more</span> : null}
</div>
) : null}
<div className="psm-source-list">
@@ -433,7 +436,7 @@ function ProfileCard({ overview }: { overview: TickerOverview }) {
<div className="psm-card-head">
<div>
<div className="psm-eyebrow">Company Profile</div>
- <h2 className="psm-card-title">Context</h2>
+ <h2 className="psm-card-title">Company Profile</h2>
</div>
</div>
<div className="psm-profile-list">
@@ -474,7 +477,7 @@ function ShortInterestCard({ overview }: { overview: TickerOverview }) {
<div className="psm-card-head">
<div>
<div className="psm-eyebrow">Short Interest</div>
- <h2 className="psm-card-title">Pressure</h2>
+ <h2 className="psm-card-title">Short Interest</h2>
</div>
</div>
<div className="psm-detail-grid">
@@ -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 (
<section className="psm-card">
<div className="psm-card-head">
<div>
- <div className="psm-eyebrow">Overview Stats</div>
+ <div className="psm-eyebrow">Reference</div>
<h2 className="psm-card-title">Reference</h2>
</div>
</div>
<div className="psm-stat-list">
- <StatRow label="Market Cap" value={fmtLarge(overview.stats.market_cap)} missing={overview.stats.market_cap == null} />
- <StatRow label="P/E TTM" value={fmtNumber(overview.stats.trailing_pe)} missing={overview.stats.trailing_pe == null} />
- <StatRow label="EPS TTM" value={fmtCurrency(overview.stats.trailing_eps)} missing={overview.stats.trailing_eps == null} />
- <StatRow label="Volume" value={fmtNumber(overview.stats.volume, 0)} missing={overview.stats.volume == null} />
- <StatRow label="Avg Volume" value={fmtNumber(overview.stats.average_volume, 0)} missing={overview.stats.average_volume == null} />
- <StatRow label="Beta" value={fmtNumber(overview.stats.beta)} missing={overview.stats.beta == null} />
+ {referenceRows.map((row) => (
+ <StatRow key={row.label} label={row.label} value={row.value} missing={row.missing} />
+ ))}
</div>
</section>
);
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
<section className="psm-card">
<div className="psm-card-head">
<div>
- <div className="psm-eyebrow">Price History</div>
- <h2 className="psm-card-title">{symbol}</h2>
+ <div className="psm-eyebrow">Price Action</div>
+ <h2 className="psm-card-title">{symbol} Price History</h2>
</div>
<div className="psm-tabs" role="tablist" aria-label="Chart range">
{PERIODS.map((option) => (
@@ -42,7 +42,7 @@ export function ChartCard({ symbol, period, points, chartState, chartError, onCh
</div>
</div>
- <p className="psm-chart-meta">Chart loading is isolated from the rest of Overview. A history miss only affects this card.</p>
+ <p className="psm-chart-meta">Interactive history for the selected window. If history fails, the rest of Overview stays intact.</p>
<div className="psm-chart-frame">
{chartState === "loading" ? <div className="psm-card-empty">Loading {period.toUpperCase()} history…</div> : null}
@@ -52,4 +52,3 @@ export function ChartCard({ symbol, period, points, chartState, chartError, onCh
</section>
);
}
-
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 (
<div key={item.symbol} className={`psm-watch-row${active ? " active" : ""}`}>
<button type="button" className="psm-watch-select" onClick={() => onSelectTicker(item.symbol)}>
- <span className="psm-watch-main">
+ <span className="psm-watch-cell psm-watch-main">
<span className="psm-watch-symbol">{item.symbol}</span>
<span className="psm-watch-date">{watchlistSubtitle(item)}</span>
</span>
- <span className="psm-watch-price">{fmtCurrency(item.quote?.price)}</span>
- <span className={`psm-watch-change ${deltaClass(item.quote?.change_pct)}`}>{fmtPct(item.quote?.change_pct, 2, true)}</span>
+ <span className="psm-watch-cell psm-watch-price">{fmtCurrency(item.quote?.price)}</span>
+ <span className={`psm-watch-cell psm-watch-change ${deltaClass(item.quote?.change_pct)}`}>{fmtPct(item.quote?.change_pct, 2, true)}</span>
</button>
<button
type="button"
diff --git a/frontend/components/prism/TickerHeader.tsx b/frontend/components/prism/TickerHeader.tsx
index 23254f8..369d06c 100644
--- a/frontend/components/prism/TickerHeader.tsx
+++ b/frontend/components/prism/TickerHeader.tsx
@@ -10,15 +10,18 @@ type Props = {
export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) {
const pct = rangePercent(overview);
+ const lastSource = overview.meta.sources["quote.price"];
return (
<header className="psm-ticker-head">
<div className="psm-header-left">
- <span className="psm-identity-line psm-eyebrow">{overview.profile.symbol}</span>
+ <div className="psm-head-meta">
+ <span className="psm-identity-line psm-eyebrow">{overview.profile.symbol}</span>
+ {overview.profile.exchange ? <span className="psm-tag">{overview.profile.exchange}</span> : null}
+ {overview.meta.is_partial ? <span className="psm-partial-chip">Partial Data</span> : null}
+ </div>
<div className="psm-heading-row">
- <span className="psm-symbol">{overview.profile.symbol}</span>
<span className="psm-company-name">{overview.profile.name || "Name unavailable"}</span>
- {overview.meta.is_partial ? <span className="psm-partial-chip">Partial Data</span> : null}
</div>
<p className="psm-subline">{buildIdentityLine(overview)}</p>
</div>
@@ -27,12 +30,13 @@ export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) {
<div className="psm-eyebrow">52 Week Range</div>
<div className="psm-range-values">
<span>{fmtCurrency(overview.range_52w.low)}</span>
- <span>{fmtCurrency(overview.range_52w.price ?? overview.quote.price)}</span>
+ <span className="psm-range-spot">{fmtCurrency(overview.range_52w.price ?? overview.quote.price)}</span>
<span>{fmtCurrency(overview.range_52w.high)}</span>
</div>
<div className="psm-range-rail" aria-hidden>
{pct != null ? <span className="psm-range-indicator" style={{ left: `${pct}%` }} /> : null}
</div>
+ <div className="psm-range-caption">{pct == null ? "Range unavailable" : `${pct.toFixed(0)}% through the annual range`}</div>
</div>
<div className="psm-price-stack">
@@ -41,6 +45,7 @@ export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) {
{fmtCurrency(overview.quote.change)} · {fmtPct(overview.quote.change_pct, 2, true)}
</span>
<span className="psm-quote-line">Prev close {fmtCurrency(overview.quote.prev_close)}</span>
+ {lastSource ? <span className="psm-quote-line">Source {lastSource.replaceAll("_", " ")}</span> : null}
<button type="button" className={`psm-primary-action${isSaved ? " subtle" : ""}`} onClick={onToggleWatchlist}>
{isSaved ? "Remove From Watchlist" : "Save To Watchlist"}
</button>
@@ -48,4 +53,3 @@ export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) {
</header>
);
}
-
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;