summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--backend/app/services/data_service.py88
-rw-r--r--docs/superpowers/specs/2026-05-17-beta-short-interest-fallbacks-design.md105
-rw-r--r--frontend/app/page.tsx20
-rw-r--r--frontend/next-env.d.ts2
5 files changed, 207 insertions, 9 deletions
diff --git a/.gitignore b/.gitignore
index 6e3fa68..c859b86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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.