diff options
Diffstat (limited to 'backend/app/services')
| -rw-r--r-- | backend/app/services/data_service.py | 96 |
1 files changed, 96 insertions, 0 deletions
diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index 9662227..108d816 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -32,6 +32,7 @@ VALUATION_CACHE = TTLCache(maxsize=128, ttl=3600) 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) PERIODS = {"1m", "3m", "6m", "1y", "2y", "5y"} YF_PERIOD_MAP = {"1m": "1mo", "3m": "3mo", "6m": "6mo", "1y": "1y", "2y": "2y", "5y": "5y"} @@ -1770,3 +1771,98 @@ def get_ticker_overview(symbol: str) -> dict[str, Any] | None: "sources": field_sources, }, } + + +def _classify_insider(text: str) -> str: + t = str(text or "").lower() + if any(k in t for k in ("sale", "sold", "disposition")): + return "sell" + if any(k in t for k in ("purchase", "bought", "acquisition", "grant", "award", "exercise")): + return "buy" + return "other" + + +@cached(INSIDERS_CACHE) +def get_insider_transactions(symbol: str) -> dict: + sym = normalize_symbol(symbol) + try: + t = yf.Ticker(sym) + df = t.insider_transactions + except Exception: + df = None + + if df is None or (hasattr(df, "empty") and df.empty): + return { + "summary": {"buy_count": 0, "sell_count": 0, "buy_value": 0.0, "sell_value": 0.0}, + "monthly_chart": [], + "transactions": [], + } + + df = df.copy() + df["direction"] = df["Text"].apply(_classify_insider) + + def _to_dt(val: object) -> "pd.Timestamp | None": + try: + return pd.to_datetime(val) + except Exception: + return None + + df["_date"] = df["Start Date"].apply(_to_dt) + + cutoff = pd.Timestamp.now() - pd.Timedelta(days=180) + recent = df[df["_date"] >= cutoff] + + def _total_value(subset: "pd.DataFrame") -> float: + try: + return float(subset["Value"].dropna().astype(float).sum()) + except Exception: + return 0.0 + + buys = recent[recent["direction"] == "buy"] + sells = recent[recent["direction"] == "sell"] + + summary = { + "buy_count": int(len(buys)), + "sell_count": int(len(sells)), + "buy_value": _total_value(buys), + "sell_value": _total_value(sells), + } + + # Monthly chart: last 6 months, buys positive, sells negative (in $M) + monthly: dict[str, dict[str, float]] = {} + for _, row in recent.iterrows(): + dt = row["_date"] + if dt is None or pd.isna(dt): + continue + key = dt.strftime("%Y-%m") + monthly.setdefault(key, {"buy": 0.0, "sell": 0.0}) + try: + val = float(row["Value"]) if pd.notna(row["Value"]) else 0.0 + except (TypeError, ValueError): + val = 0.0 + if row["direction"] in ("buy", "sell"): + monthly[key][row["direction"]] += val + + monthly_chart = [ + {"month": m, "buy": monthly[m]["buy"] / 1e6, "sell": monthly[m]["sell"] / 1e6} + for m in sorted(monthly.keys()) + ] + + # All transactions (newest first) + transactions = [] + for _, row in df.sort_values("_date", ascending=False).iterrows(): + dt = row["_date"] + transactions.append({ + "date": dt.strftime("%Y-%m-%d") if dt is not None and not pd.isna(dt) else None, + "insider": str(row.get("Insider", "") or ""), + "position": str(row.get("Position", "") or ""), + "direction": row["direction"], + "shares": int(row["Shares"]) if pd.notna(row.get("Shares")) else None, + "value": _safe_float(row.get("Value")), + }) + + return { + "summary": summary, + "monthly_chart": monthly_chart, + "transactions": transactions, + } |
