From 71e045e4344216ee3d929e5cdb51b7d041dfc95b Mon Sep 17 00:00:00 2001 From: shawnkim1997 Date: Wed, 22 Apr 2026 11:22:37 +0100 Subject: [PATCH] phase 6: ownership holders --- .../components/overview/EquityOverview.tsx | 2 + .../src/app/components/overview/Ownership.tsx | 159 ++++++++++++++++++ atlas-terminal/server/core/providers/fmp.py | 25 ++- .../server/core/providers/yfinance.py | 20 ++- atlas-terminal/server/routers/market_data.py | 116 +++++++++++++ atlas-terminal/tests/test_smoke.py | 31 +++- 6 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 atlas-terminal/apps/web/src/app/components/overview/Ownership.tsx diff --git a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx index ef32a0d..190b7d5 100644 --- a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx +++ b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { KpiSection, type KpiHistoryData } from "./KpiSection"; import { PeerComparison, type PeerComparisonData } from "./PeerComparison"; import { FinancialStatements } from "./FinancialStatements"; +import { Ownership } from "./Ownership"; import { Card } from "../ui/Card"; import { SectionHeading } from "../ui/SectionHeading"; import { StatCard } from "../ui/StatCard"; @@ -86,6 +87,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) + ); } diff --git a/atlas-terminal/apps/web/src/app/components/overview/Ownership.tsx b/atlas-terminal/apps/web/src/app/components/overview/Ownership.tsx new file mode 100644 index 0000000..d2e0619 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/overview/Ownership.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { ErrorBanner } from "../ui/ErrorBanner"; +import { LoadingPulse } from "../ui/LoadingPulse"; +import { StatCard } from "../ui/StatCard"; +import { flags } from "../../lib/flags"; +import { useApi } from "../../lib/use-api"; + +interface HolderRow { + name: string; + shares: number | null; + pct: number | null; + change: number | null; + value: number | null; +} + +interface OwnershipResponse { + ticker: string; + available: boolean; + source: string | null; + institutional_pct: number | null; + insider_pct: number | null; + float_pct: number | null; + institutions: HolderRow[]; + insiders: HolderRow[]; +} + +function formatPct(value: number | null): string { + return value == null ? "—" : `${value.toFixed(1)}%`; +} + +function formatShares(value: number | null): string { + if (value == null) return "—"; + const abs = Math.abs(value); + if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}B`; + if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}M`; + if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}K`; + return value.toFixed(0); +} + +function formatChange(value: number | null): string { + if (value == null) return "—"; + const sign = value > 0 ? "+" : ""; + return `${sign}${formatShares(value)}`; +} + +function formatValue(value: number | null): string { + if (value == null) return "—"; + const abs = Math.abs(value); + if (abs >= 1e12) return `$${(value / 1e12).toFixed(2)}T`; + if (abs >= 1e9) return `$${(value / 1e9).toFixed(1)}B`; + if (abs >= 1e6) return `$${(value / 1e6).toFixed(1)}M`; + return `$${value.toFixed(0)}`; +} + +function HolderTable({ title, rows }: { title: string; rows: HolderRow[] }) { + return ( +
+
+ {title} +
+
+ + + + + + + + + + + + {rows.length > 0 ? rows.map((row) => ( + + + + + + + + )) : ( + + + + )} + +
HolderShares%ChangeValue
{row.name}{formatShares(row.shares)}{formatPct(row.pct)}= 0 ? "text-fin-positive" : "text-fin-negative"}`}> + {formatChange(row.change)} + {formatValue(row.value)}
No holder rows available.
+
+
+ ); +} + +function OwnershipBar({ data }: { data: OwnershipResponse }) { + const institutional = data.institutional_pct ?? 0; + const insider = data.insider_pct ?? 0; + const float = data.float_pct ?? Math.max(0, 100 - institutional - insider); + const total = institutional + insider + float || 100; + const instWidth = (institutional / total) * 100; + const insiderWidth = (insider / total) * 100; + const floatWidth = Math.max(0, 100 - instWidth - insiderWidth); + + return ( +
+
+ Institutional + Insider + Float / Retail +
+
+
+
+
+
+
+ ); +} + +export function Ownership({ ticker }: { ticker: string }) { + const url = flags.ownership ? `/api/market/ownership/${encodeURIComponent(ticker)}` : null; + const { data, loading, error } = useApi(url, { cacheTtlMs: 300_000 }); + + if (!flags.ownership) return null; + + return ( +
+
+
+

Ownership

+

+ Institutional and insider holder snapshot{data?.source ? ` via ${data.source}` : ""}. +

+
+
+
+ + {loading ? : ( + <> + {!data?.available && ( + + )} +
+ + + +
+ {data && } +
+ + +
+ + )} +
+
+ ); +} diff --git a/atlas-terminal/server/core/providers/fmp.py b/atlas-terminal/server/core/providers/fmp.py index 35879e8..c584582 100644 --- a/atlas-terminal/server/core/providers/fmp.py +++ b/atlas-terminal/server/core/providers/fmp.py @@ -2,7 +2,7 @@ from __future__ import annotations -from server.core.data_gateway import Profile, Quote, Segment +from server.core.data_gateway import HoldersData, Profile, Quote, Segment from server.core.providers.base import BaseProvider, ProviderError, ProviderNotConfigured, ProviderNotImplemented from server.services import fmp_client @@ -95,3 +95,26 @@ class FMPProvider(BaseProvider): "periods": periods, "line_items": line_items, } + + async def holders(self, symbol: str) -> HoldersData: + normalized = symbol.strip().upper() + inst: object = [] + insider: object = [] + try: + inst = await self._get_json(f"/institutional-holder/{normalized}", {}) + except ProviderError: + inst = [] + try: + insider = await self._get_json("/insider-trading", {"symbol": normalized, "limit": 25}) + except ProviderError: + insider = [] + institutions = inst if isinstance(inst, list) else [] + insiders = insider if isinstance(insider, list) else [] + if not institutions and not insiders: + raise ProviderError("missing holders rows") + return HoldersData( + symbol=normalized, + institutions=institutions[:25], + insiders=insiders[:25], + source=self.name, + ) diff --git a/atlas-terminal/server/core/providers/yfinance.py b/atlas-terminal/server/core/providers/yfinance.py index 634fe68..f7b34e9 100644 --- a/atlas-terminal/server/core/providers/yfinance.py +++ b/atlas-terminal/server/core/providers/yfinance.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import date from typing import Any -from server.core.data_gateway import Fundamentals, OHLCV, OHLCVBar, Profile, Quote +from server.core.data_gateway import Fundamentals, HoldersData, OHLCV, OHLCVBar, Profile, Quote from server.core.providers.base import BaseProvider, ProviderError from server.utils.peer_universe import peer_symbols_for_profile @@ -149,6 +149,24 @@ class YFinanceProvider(BaseProvider): return await self._to_thread(fetch) + async def holders(self, symbol: str) -> HoldersData: + def fetch() -> HoldersData: + import pandas as pd + + normalized = symbol.strip().upper() + ticker = self._ticker(normalized) + institutions: list[dict[str, Any]] = [] + insiders: list[dict[str, Any]] = [] + inst_df = getattr(ticker, "institutional_holders", None) + if isinstance(inst_df, pd.DataFrame) and not inst_df.empty: + institutions = inst_df.where(inst_df.notna(), None).to_dict(orient="records") + insider_df = getattr(ticker, "insider_roster_holders", None) + if isinstance(insider_df, pd.DataFrame) and not insider_df.empty: + insiders = insider_df.where(insider_df.notna(), None).to_dict(orient="records") + return HoldersData(symbol=normalized, institutions=institutions[:25], insiders=insiders[:25], source=self.name) + + return await self._to_thread(fetch) + async def history(self, symbol: str, range: str = "1y") -> OHLCV: def fetch() -> OHLCV: hist = self._ticker(symbol).history(period=range) diff --git a/atlas-terminal/server/routers/market_data.py b/atlas-terminal/server/routers/market_data.py index 10c9e26..8a4c1c0 100644 --- a/atlas-terminal/server/routers/market_data.py +++ b/atlas-terminal/server/routers/market_data.py @@ -2,6 +2,7 @@ import asyncio import logging +import math from typing import Any, Dict, List from fastapi import APIRouter, Query @@ -24,6 +25,79 @@ def _safe_float(val, default=0.0): return default +def _json_safe(value: Any) -> Any: + """Convert provider rows into JSON-safe values without importing pandas here.""" + if value is None: + return None + if isinstance(value, float) and (math.isnan(value) or math.isinf(value)): + return None + if isinstance(value, (str, int, float, bool)): + return value + if hasattr(value, "isoformat"): + try: + return value.isoformat() + except Exception: + return str(value) + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, list): + return [_json_safe(item) for item in value] + return str(value) + + +def _pick(row: dict[str, Any], keys: list[str]) -> Any: + lower = {str(key).lower(): value for key, value in row.items()} + for key in keys: + if key in row and row[key] not in (None, ""): + return row[key] + val = lower.get(key.lower()) + if val not in (None, ""): + return val + return None + + +def _pct_value(value: Any) -> float | None: + pct = _safe_float(value, None) + if pct is None: + return None + normalized = pct * 100 if abs(pct) <= 1 else pct + return max(0.0, min(100.0, normalized)) + + +def _normalize_holder_rows(rows: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]: + normalized = [] + for raw_row in rows[:25]: + if not isinstance(raw_row, dict): + continue + row = _json_safe(raw_row) + if not isinstance(row, dict): + continue + name = _pick(row, ["holder", "Holder", "name", "Name", "investorName", "reportingName", "filingName"]) + shares = _safe_float(_pick(row, ["shares", "Shares", "sharesHeld", "securitiesOwned", "Shares Owned Directly"]), None) + pct = _pct_value(_pick(row, ["pctHeld", "percent", "ownershipPercentage", "weightPercent", "% Out"])) + change = _safe_float(_pick(row, ["change", "Change", "transactionShares", "changeInShares"]), None) + value = _safe_float(_pick(row, ["value", "Value", "marketValue"]), None) + normalized.append( + { + "name": str(name) if name else "Unknown holder", + "shares": shares, + "pct": pct, + "change": change, + "value": value, + "kind": kind, + "raw": row, + } + ) + return normalized + + +def _sum_pct(rows: list[dict[str, Any]]) -> float | None: + values = [row.get("pct") for row in rows if isinstance(row.get("pct"), (int, float))] + if not values: + return None + return round(min(100.0, sum(float(value) for value in values)), 2) + + @router.get("/indices", summary="Major market indices") async def market_indices(): try: @@ -258,6 +332,48 @@ async def peer_valuation_multiples( } +@router.get("/ownership/{ticker}", summary="Institutional and insider ownership") +async def ownership_snapshot(ticker: str): + """Gateway-backed ownership view for overview pages. + + Providers expose different holder field names, so this endpoint normalizes + the top rows into a small frontend contract while preserving raw rows for + drill-down/debugging. + """ + normalized = ticker.strip().upper() + try: + data = await get_data_gateway().holders(normalized) + institutions = _normalize_holder_rows(data.institutions, "institution") + insiders = _normalize_holder_rows(data.insiders, "insider") + institutional_pct = _sum_pct(institutions) + insider_pct = _sum_pct(insiders) + float_pct = None + if institutional_pct is not None or insider_pct is not None: + float_pct = round(max(0.0, 100.0 - (institutional_pct or 0.0) - (insider_pct or 0.0)), 2) + return { + "ticker": normalized, + "available": bool(institutions or insiders), + "source": data.source, + "institutional_pct": institutional_pct, + "insider_pct": insider_pct, + "float_pct": float_pct, + "institutions": institutions[:10], + "insiders": insiders[:10], + } + except Exception: + logger.exception("ownership/%s failed", normalized) + return { + "ticker": normalized, + "available": False, + "source": None, + "institutional_pct": None, + "insider_pct": None, + "float_pct": None, + "institutions": [], + "insiders": [], + } + + @router.get("/comps", summary="Industry comparable companies") async def industry_comps(tickers: str = Query(..., description="Comma-separated tickers")): try: diff --git a/atlas-terminal/tests/test_smoke.py b/atlas-terminal/tests/test_smoke.py index e85243e..ce20fe4 100644 --- a/atlas-terminal/tests/test_smoke.py +++ b/atlas-terminal/tests/test_smoke.py @@ -3,7 +3,7 @@ from fastapi.testclient import TestClient from server.core.providers.base import DataUnavailable -from server.core.data_gateway import Fundamentals, Profile, Quote +from server.core.data_gateway import Fundamentals, HoldersData, Profile, Quote from server.main import app @@ -195,3 +195,32 @@ def test_financial_statement_table_uses_gateway(monkeypatch) -> None: assert response.status_code == 200 assert response.json()["line_items"]["revenue"] == [120.0, 100.0] + + +def test_ownership_endpoint_normalizes_gateway_rows(monkeypatch) -> None: + from server.routers import market_data + + class FakeGateway: + async def holders(self, ticker: str) -> HoldersData: + return HoldersData( + symbol=ticker.upper(), + institutions=[ + {"Holder": "Vanguard", "Shares": 1000, "pctHeld": 0.12, "Value": 250000, "Change": 25}, + ], + insiders=[ + {"Name": "CEO Example", "Shares Owned Directly": 100, "change": -5}, + ], + source="fake", + ) + + monkeypatch.setattr(market_data, "get_data_gateway", lambda: FakeGateway()) + + with TestClient(app) as client: + response = client.get("/api/market/ownership/NVDA") + + assert response.status_code == 200 + data = response.json() + assert data["available"] is True + assert data["institutional_pct"] == 12.0 + assert data["institutions"][0]["name"] == "Vanguard" + assert data["insiders"][0]["name"] == "CEO Example"