summaryrefslogtreecommitdiff
path: root/backend/app
diff options
context:
space:
mode:
Diffstat (limited to 'backend/app')
-rw-r--r--backend/app/services/data_service.py140
1 files changed, 140 insertions, 0 deletions
diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py
index 108d816..e9f3dab 100644
--- a/backend/app/services/data_service.py
+++ b/backend/app/services/data_service.py
@@ -33,6 +33,7 @@ HIST_RATIOS_CACHE: TTLCache = TTLCache(maxsize=128, ttl=3600)
RATIOS_ENDPOINT_CACHE: TTLCache = TTLCache(maxsize=128, ttl=3600)
SECTOR_BENCHMARK_CACHE: TTLCache = TTLCache(maxsize=128, ttl=3600)
INSIDERS_CACHE: TTLCache = TTLCache(maxsize=256, ttl=3600)
+FILINGS_CACHE: TTLCache = TTLCache(maxsize=256, ttl=3600)
PERIODS = {"1m", "3m", "6m", "1y", "2y", "5y"}
YF_PERIOD_MAP = {"1m": "1mo", "3m": "3mo", "6m": "6mo", "1y": "1y", "2y": "2y", "5y": "5y"}
@@ -1866,3 +1867,142 @@ def get_insider_transactions(symbol: str) -> dict:
"monthly_chart": monthly_chart,
"transactions": transactions,
}
+
+
+_FORM_DESCRIPTIONS: dict[str, str] = {
+ "10-K": "Annual report",
+ "10-Q": "Quarterly report",
+ "8-K": "Material event disclosure",
+ "DEF 14A": "Proxy statement",
+ "S-1": "IPO registration",
+ "S-3": "Securities registration",
+ "4": "Insider ownership change",
+ "SC 13G": "Beneficial ownership (passive)",
+ "SC 13D": "Beneficial ownership (active)",
+}
+
+_PRIMARY_FORMS = {"10-K", "10-Q", "8-K"}
+
+
+@cached(cache=FILINGS_CACHE)
+def get_sec_filings(symbol: str) -> dict:
+ """Fetch and pre-process SEC filings for a ticker.
+
+ Returns a dict matching the FilingsResponse schema.
+ Data source: yfinance `sec_filings`. FMP fallback if FMP_API_KEY is set
+ and yfinance returns empty.
+ """
+ import datetime
+ from collections import defaultdict
+
+ sym = normalize_symbol(symbol)
+ raw: list[dict] = []
+
+ # --- yfinance primary ---
+ try:
+ t = yf.Ticker(sym)
+ yf_filings = t.sec_filings
+ if yf_filings:
+ raw = list(yf_filings)
+ except Exception:
+ pass
+
+ # --- FMP fallback ---
+ fmp_key = os.environ.get("FMP_API_KEY", "")
+ if not raw and fmp_key:
+ try:
+ url = f"https://financialmodelingprep.com/api/v3/sec_filings/{sym}?limit=100&apikey={fmp_key}"
+ resp = httpx.get(url, timeout=10)
+ if resp.status_code == 200:
+ raw = resp.json() or []
+ except Exception:
+ pass
+
+ # --- Normalize ---
+ items: list[dict] = []
+ for r in raw:
+ # yfinance shape: {date, type, title, edgarUrl, exhibits: {}}
+ # FMP shape: {date, type, link, ...}
+ date_raw = r.get("date") or r.get("filingDate") or ""
+ form = str(r.get("type") or r.get("form") or "").strip()
+ title = str(r.get("title") or _FORM_DESCRIPTIONS.get(form, form)).strip()
+ # URL: prefer primary form exhibit, fall back to edgarUrl/link
+ exhibits = r.get("exhibits") or {}
+ url = exhibits.get(form) or r.get("edgarUrl") or r.get("link") or None
+
+ if not date_raw or not form:
+ continue
+
+ # Normalize date to YYYY-MM-DD
+ try:
+ if isinstance(date_raw, int):
+ date_str = str(date_raw)
+ else:
+ date_str = str(date_raw)[:10]
+ # validate
+ datetime.date.fromisoformat(date_str)
+ except (ValueError, TypeError):
+ continue
+
+ items.append({"date": date_str, "form": form, "title": title, "url": url})
+
+ # Sort descending by date
+ items.sort(key=lambda x: x["date"], reverse=True)
+
+ # --- KPIs ---
+ total = len(items)
+ count_10k = sum(1 for f in items if f["form"] == "10-K")
+ count_10q = sum(1 for f in items if f["form"] == "10-Q")
+ count_8k = sum(1 for f in items if f["form"] == "8-K")
+ distinct_forms = len({f["form"] for f in items})
+ kpis = {
+ "total": total,
+ "count_10k": count_10k,
+ "count_10q": count_10q,
+ "count_8k": count_8k,
+ "distinct_forms": distinct_forms,
+ }
+
+ # --- Cadence (monthly, stacked by form bucket) ---
+ month_map: dict[str, dict[str, int]] = defaultdict(lambda: {"count_10k": 0, "count_10q": 0, "count_8k": 0, "count_other": 0})
+ for f in items:
+ m = f["date"][:7]
+ if f["form"] == "10-K":
+ month_map[m]["count_10k"] += 1
+ elif f["form"] == "10-Q":
+ month_map[m]["count_10q"] += 1
+ elif f["form"] == "8-K":
+ month_map[m]["count_8k"] += 1
+ else:
+ month_map[m]["count_other"] += 1
+ cadence = [{"month": m, **counts} for m, counts in sorted(month_map.items())]
+
+ # --- Form mix (top 8 by count) ---
+ form_counts: dict[str, int] = defaultdict(int)
+ for f in items:
+ form_counts[f["form"]] += 1
+ sorted_forms = sorted(form_counts.items(), key=lambda x: x[1], reverse=True)[:8]
+ form_mix = [
+ {"form": form, "count": cnt, "pct": round(cnt / total * 100, 1) if total else None}
+ for form, cnt in sorted_forms
+ ]
+
+ # --- Readout ---
+ if not items:
+ readout = "No SEC filings found."
+ else:
+ dominant = sorted_forms[0][0] if sorted_forms else "—"
+ latest = items[0]["date"]
+ readout = (
+ f"Most common form: {dominant} · "
+ f"Latest filing: {latest} · "
+ f"{distinct_forms} distinct form type{'s' if distinct_forms != 1 else ''} on record"
+ )
+
+ return {
+ "kpis": kpis,
+ "cadence": cadence,
+ "form_mix": form_mix,
+ "readout": readout,
+ "filings": items,
+ }