summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/app/services/data_service.py81
-rw-r--r--backend/tests/test_api.py2
2 files changed, 80 insertions, 3 deletions
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
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 1c99cf9..22df555 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -14,6 +14,8 @@ def clear_service_caches() -> None:
data_service.STATEMENT_CACHE.clear()
data_service.SHARES_CACHE.clear()
data_service.RATIO_CACHE.clear()
+ data_service.BETA_CACHE.clear()
+ data_service.SHORT_CACHE.clear()
def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame: