summaryrefslogtreecommitdiff
path: root/backend/app/services
diff options
context:
space:
mode:
Diffstat (limited to 'backend/app/services')
-rw-r--r--backend/app/services/data_service.py96
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,
+ }