diff options
Diffstat (limited to 'backend/app')
| -rw-r--r-- | backend/app/services/data_service.py | 88 |
1 files changed, 84 insertions, 4 deletions
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 |
