From 5cced76ce44db80b6f45021152153138e0c8bc5b Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Sun, 17 May 2026 14:03:58 -0700 Subject: Add computed beta fallback and FMP short interest fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compute_beta: 2y weekly returns vs SPY, cov/var formula, capped ±3, BETA_CACHE TTL=3600 - get_fmp_short_interest: calls FMP /v4/short-of-float-symbol when all yfinance short fields are None - _build_quote_and_stats: accepts sym, uses compute_beta when info["beta"] is None - PERIODS/YF_PERIOD_MAP: added "2y" support - test_api: clear BETA_CACHE and SHORT_CACHE in clear_service_caches Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/data_service.py | 81 ++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) (limited to 'backend/app/services/data_service.py') diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index 6f8587f..31bdb05 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -21,9 +21,11 @@ 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) +BETA_CACHE = TTLCache(maxsize=256, ttl=3600) +SHORT_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"} +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", @@ -638,7 +640,71 @@ 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).sort_index() + spy_series = pd.Series(spy_closes).sort_index() + ticker_weekly = ticker_series.resample("W").last().pct_change().dropna() + spy_weekly = spy_series.resample("W").last().pct_change().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]], @@ -712,6 +778,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, @@ -806,7 +876,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")) @@ -822,6 +892,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 -- cgit v1.3-2-g0d8e