diff options
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/app/services/data_service.py | 140 |
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, + } |
