diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | backend/app/services/data_service.py | 88 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-05-17-beta-short-interest-fallbacks-design.md | 105 | ||||
| -rw-r--r-- | frontend/app/page.tsx | 20 | ||||
| -rw-r--r-- | frontend/next-env.d.ts | 2 |
5 files changed, 207 insertions, 9 deletions
@@ -16,3 +16,4 @@ out/ dist/ *.tsbuildinfo .DS_Store +.superpowers/ diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index 9da9557..f7188f7 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -28,8 +28,8 @@ BETA_CACHE = TTLCache(maxsize=256, ttl=3600) SHORT_CACHE = TTLCache(maxsize=256, ttl=3600) FINANCIALS_CACHE = TTLCache(maxsize=128, ttl=3600) -PERIODS = {"1m", "3m", "6m", "1y", "5y"} -YF_PERIOD_MAP = {"1m": "1mo", "3m": "3mo", "6m": "6mo", "1y": "1y", "5y": "5y"} +PERIODS = {"1m", "3m", "6m", "1y", "2y", "5y"} +YF_PERIOD_MAP = {"1m": "1mo", "3m": "3mo", "6m": "6mo", "1y": "1y", "2y": "2y", "5y": "5y"} _XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} _SHARE_LABELS = ( "Ordinary Shares Number", @@ -62,7 +62,10 @@ def _json_value(value: Any) -> Any: return None if isinstance(value, pd.Timestamp): return value.isoformat() - if pd.isna(value): + try: + if pd.isna(value): + return None + except (TypeError, ValueError): return None if hasattr(value, "item"): return _json_value(value.item()) @@ -852,7 +855,75 @@ def compute_ttm_ratios(symbol: str) -> dict[str, Any]: return {key: value for key, value in ratios.items() if value is not None} +@cached(BETA_CACHE) +def compute_beta(symbol: str) -> float | None: + """Compute trailing 2-year beta against SPY from weekly returns.""" + sym = normalize_symbol(symbol) + if sym == "SPY": + return 1.0 + ticker_history = get_price_history(sym, period="2y") + spy_history = get_price_history("SPY", period="2y") + if not ticker_history or not spy_history: + return None + try: + ticker_closes = {row["date"]: row["close"] for row in ticker_history if row.get("close") is not None} + spy_closes = {row["date"]: row["close"] for row in spy_history if row.get("close") is not None} + ticker_series = pd.Series(ticker_closes, dtype=float) + ticker_series.index = pd.to_datetime(ticker_series.index) + ticker_series = ticker_series.sort_index() + spy_series = pd.Series(spy_closes, dtype=float) + spy_series.index = pd.to_datetime(spy_series.index) + spy_series = spy_series.sort_index() + ticker_weekly = ticker_series.resample("W").last().pct_change(fill_method=None).dropna() + spy_weekly = spy_series.resample("W").last().pct_change(fill_method=None).dropna() + aligned = pd.concat([ticker_weekly, spy_weekly], axis=1, join="inner").dropna() + aligned.columns = ["ticker", "spy"] + if len(aligned) < 52: + return None + spy_var = aligned["spy"].var() + if spy_var == 0: + return None + beta = aligned["ticker"].cov(aligned["spy"]) / spy_var + beta = max(-3.0, min(3.0, beta)) + return round(beta, 4) + except Exception: + return None + + +@cached(SHORT_CACHE) +def get_fmp_short_interest(symbol: str) -> dict[str, Any]: + """Fetch short interest data from FMP as a fallback when yfinance returns nothing.""" + sym = normalize_symbol(symbol) + fmp_key = os.getenv("FMP_API_KEY") + if not fmp_key: + return {} + try: + with httpx.Client(timeout=3.0) as client: + res = client.get( + "https://financialmodelingprep.com/api/v4/short-of-float-symbol", + params={"symbol": sym, "apikey": fmp_key}, + ) + rows = res.json() + if not isinstance(rows, list) or not rows: + return {} + row = rows[0] or {} + result: dict[str, Any] = {} + short_pct = _safe_float(row.get("shortPercent")) + if short_pct is not None: + result["short_percent_of_float"] = short_pct + short_ratio = _safe_float(row.get("shortRatio")) + if short_ratio is not None: + result["short_ratio"] = short_ratio + shares_short = _safe_int(row.get("shortsVolume")) + if shares_short is not None: + result["shares_short"] = shares_short + return result + except Exception: + return {} + + def _build_quote_and_stats( + sym: str, info: dict[str, Any], fast_info: dict[str, Any], month_history: list[dict[str, Any]], @@ -926,6 +997,10 @@ def _build_quote_and_stats( 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"))) + if beta is None: + beta = compute_beta(sym) + if beta is not None: + field_sources["stats.beta"] = "computed" range_low = _safe_float( _field( source_map, @@ -1020,7 +1095,7 @@ def get_ticker_overview(symbol: str) -> dict[str, Any] | None: 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, computed, field_sources) + quote, stats, range_52w = _build_quote_and_stats(sym, info, fast_info, month_history, year_history, computed, field_sources) ratios = _build_ratios(computed, field_sources) short = _safe_int(info.get("sharesShort")) @@ -1036,6 +1111,11 @@ def get_ticker_overview(symbol: str) -> dict[str, Any] | None: "shares_short_delta_pct": short_delta, } + if all(v is None for v in short_interest.values()): + fmp_short = get_fmp_short_interest(sym) + if fmp_short: + short_interest.update(fmp_short) + if not _has_any_overview_data(profile, quote, stats, ratios, range_52w, short_interest, field_sources): return None diff --git a/docs/superpowers/specs/2026-05-17-beta-short-interest-fallbacks-design.md b/docs/superpowers/specs/2026-05-17-beta-short-interest-fallbacks-design.md new file mode 100644 index 0000000..5366032 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-beta-short-interest-fallbacks-design.md @@ -0,0 +1,105 @@ +# Design: Beta Computed Fallback + Short Interest FMP Fallback + +**Date:** 2026-05-17 +**Scope:** `backend/app/services/data_service.py` only — no schema changes, no frontend changes. + +--- + +## Problem + +Two fields in the Overview payload silently return `null` for certain tickers (e.g. NFLX): + +- `stats.beta` — read exclusively from `yfinance.Ticker.info["beta"]`, which is sparse. +- `short_interest.*` — all four fields read exclusively from `info`, which returns nothing for some tickers. + +The frontend already handles null gracefully (hides rows / hides card), but the data should be present whenever it can be computed or fetched. + +--- + +## Beta: Computed Fallback from Price History + +### Approach + +When `info["beta"]` is `None`, compute beta from 2 years of weekly price returns against SPY. + +**Formula:** `β = cov(r_ticker, r_spy) / var(r_spy)` + +### Implementation + +1. Add `"2y"` to `PERIODS` and `YF_PERIOD_MAP` (`"2y": "2y"`). +2. Add `compute_beta(symbol: str) -> float | None` cached in `RATIO_CACHE` (TTL=3600): + - Calls `get_price_history(symbol, "2y")` and `get_price_history("SPY", "2y")` — reuses existing cache. + - Converts close prices to weekly returns (resample to week-end, pct_change). + - Aligns on date index, drops NaN rows. Requires ≥ 52 observations; returns `None` if sparse. + - Caps result to `[-3.0, 3.0]` — extreme values are data artifacts. +3. In `_build_quote_and_stats`, change line 714: + ```python + # before + beta = _safe_float(_field(source_map, field_sources, "stats.beta", ("info", "beta"))) + # after + beta = _safe_float(_field(source_map, field_sources, "stats.beta", ("info", "beta"))) + if beta is None: + beta = compute_beta(sym) + if beta is not None: + field_sources["stats.beta"] = "computed" + ``` + `sym` is already threaded through `get_ticker_overview` → `_build_quote_and_stats`. + +### Trade-offs + +- No new API keys required. +- SPY history fetch is cached; cold path adds one extra `HISTORY_CACHE` lookup. +- Computed beta is a trailing market beta, not an adjusted/predicted beta. Acceptable for an overview display. + +--- + +## Short Interest: FMP Fallback + +### Approach + +When all four `info`-sourced short interest fields are `None`, call FMP `/v4/short-of-float-symbol`. + +### Implementation + +1. Add `SHORT_CACHE = TTLCache(maxsize=256, ttl=3600)` alongside the other caches at the top of the file. +2. Add `get_fmp_short_interest(symbol: str) -> dict[str, Any]` cached in `SHORT_CACHE`: + - Reads `FMP_API_KEY` from env; returns `{}` immediately if absent. + - `GET https://financialmodelingprep.com/api/v4/short-of-float-symbol?symbol=SYMBOL&apikey=KEY` + - Extracts from first row of response array: `shortPercent` → `short_percent_of_float`, `shortRatio` → `short_ratio`, `shortsVolume` → `shares_short`. + - Returns `{}` on any exception or unexpected response shape. +3. In `get_ticker_overview`, after building `short_interest` (lines 812–823): + ```python + if all(v is None for v in short_interest.values()): + fmp_short = get_fmp_short_interest(sym) + if fmp_short: + short_interest.update(fmp_short) + ``` +4. `shares_short_prior_month` and `shares_short_delta_pct` are not available from this FMP endpoint and stay `None`. + +### Trade-offs + +- Costs FMP quota (250 req/day free tier) only when yfinance returns nothing — not on every request. +- `shares_short_delta_pct` unavailable from FMP; partial data is acceptable (frontend handles it). +- Guard on "all fields None" avoids unnecessary FMP calls when yfinance returns partial data. + +--- + +## What Does Not Change + +- Response schema (`schemas.py`) — `ShortInterest` and `TickerStats` shapes unchanged. +- Frontend — no changes required. +- All existing tests — monkeypatching `data_service` functions continues to work. +- No new backend routes. + +--- + +## Testing + +Backend tests should: +- Verify `compute_beta` returns a float when given valid history for both ticker and SPY. +- Verify `compute_beta` returns `None` when history is too sparse (< 52 observations). +- Verify `compute_beta` caps extreme values at ±3. +- Verify `get_fmp_short_interest` returns mapped fields when FMP responds correctly. +- Verify `get_fmp_short_interest` returns `{}` when FMP key is absent or request fails. +- Verify `get_ticker_overview` uses computed beta when `info["beta"]` is None (monkeypatch). +- Verify `get_ticker_overview` populates short interest from FMP when all info fields are None (monkeypatch). diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 6f7e5b6..3bec411 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -499,6 +499,16 @@ function ProfileCard({ overview }: { overview: TickerOverview }) { function ShortInterestCard({ overview }: { overview: TickerOverview }) { const short = overview.short_interest; + const items = [ + { label: "Short Float", value: fmtPct(short.short_percent_of_float), missing: short.short_percent_of_float == null }, + { label: "Days Cover", value: fmtNumber(short.short_ratio), missing: short.short_ratio == null }, + { label: "Shares Short", value: fmtNumber(short.shares_short, 0), missing: short.shares_short == null }, + { label: "Prior Delta", value: fmtPct(short.shares_short_delta_pct, 1, true), missing: short.shares_short_delta_pct == null } + ]; + const visibleItems = items.filter((i) => !i.missing); + if (!visibleItems.length) return null; + const suppressed = items.length - visibleItems.length; + return ( <section className="psm-card"> <div className="psm-card-head"> @@ -508,11 +518,13 @@ function ShortInterestCard({ overview }: { overview: TickerOverview }) { </div> </div> <div className="psm-detail-grid"> - <DetailItem label="Short Float" value={fmtPct(short.short_percent_of_float)} missing={short.short_percent_of_float == null} /> - <DetailItem label="Days Cover" value={fmtNumber(short.short_ratio)} missing={short.short_ratio == null} /> - <DetailItem label="Shares Short" value={fmtNumber(short.shares_short, 0)} missing={short.shares_short == null} /> - <DetailItem label="Prior Delta" value={fmtPct(short.shares_short_delta_pct, 1, true)} missing={short.shares_short_delta_pct == null} /> + {visibleItems.map((item) => ( + <DetailItem key={item.label} label={item.label} value={item.value} missing={false} /> + ))} </div> + {suppressed > 0 && ( + <p className="psm-muted-copy" style={{ marginTop: "var(--sp-3)" }}>· Short interest data incomplete</p> + )} </section> ); } diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. |
