phase 6: ownership holders

This commit is contained in:
shawnkim1997
2026-04-22 11:22:37 +01:00
parent 553ec5347f
commit 71e045e434
6 changed files with 350 additions and 3 deletions
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { KpiSection, type KpiHistoryData } from "./KpiSection"; import { KpiSection, type KpiHistoryData } from "./KpiSection";
import { PeerComparison, type PeerComparisonData } from "./PeerComparison"; import { PeerComparison, type PeerComparisonData } from "./PeerComparison";
import { FinancialStatements } from "./FinancialStatements"; import { FinancialStatements } from "./FinancialStatements";
import { Ownership } from "./Ownership";
import { Card } from "../ui/Card"; import { Card } from "../ui/Card";
import { SectionHeading } from "../ui/SectionHeading"; import { SectionHeading } from "../ui/SectionHeading";
import { StatCard } from "../ui/StatCard"; import { StatCard } from "../ui/StatCard";
@@ -86,6 +87,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
</Card> </Card>
<PeerComparison currentTicker={ticker} data={peerData} /> <PeerComparison currentTicker={ticker} data={peerData} />
<FinancialStatements ticker={ticker} /> <FinancialStatements ticker={ticker} />
<Ownership ticker={ticker} />
</div> </div>
); );
} }
@@ -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 (
<div className="overflow-hidden rounded-md border border-border">
<div className="border-b border-border bg-surface-sunken px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-brand-navy">
{title}
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] text-sm">
<thead>
<tr className="border-b border-border text-[11px] uppercase tracking-[0.1em] text-text-muted">
<th className="px-4 py-2 text-left font-semibold">Holder</th>
<th className="px-3 py-2 text-right font-semibold">Shares</th>
<th className="px-3 py-2 text-right font-semibold">%</th>
<th className="px-3 py-2 text-right font-semibold">Change</th>
<th className="px-4 py-2 text-right font-semibold">Value</th>
</tr>
</thead>
<tbody>
{rows.length > 0 ? rows.map((row) => (
<tr key={`${title}-${row.name}`} className="border-b border-border/60 last:border-0 hover:bg-surface-sunken">
<td className="max-w-[240px] truncate px-4 py-2.5 font-semibold text-brand-navy">{row.name}</td>
<td className="px-3 py-2.5 text-right font-mono tabular-nums text-text-primary">{formatShares(row.shares)}</td>
<td className="px-3 py-2.5 text-right font-mono tabular-nums text-text-primary">{formatPct(row.pct)}</td>
<td className={`px-3 py-2.5 text-right font-mono tabular-nums ${row.change == null ? "text-text-muted" : row.change >= 0 ? "text-fin-positive" : "text-fin-negative"}`}>
{formatChange(row.change)}
</td>
<td className="px-4 py-2.5 text-right font-mono tabular-nums text-text-secondary">{formatValue(row.value)}</td>
</tr>
)) : (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-text-muted">No holder rows available.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
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 (
<div>
<div className="mb-2 flex justify-between text-[11px] font-mono uppercase tracking-[0.08em] text-text-muted">
<span>Institutional</span>
<span>Insider</span>
<span>Float / Retail</span>
</div>
<div className="flex h-4 overflow-hidden rounded-full border border-border bg-surface-sunken" aria-label="Ownership breakdown">
<div className="bg-brand-navy" style={{ width: `${instWidth}%` }} />
<div className="bg-brand-gold" style={{ width: `${insiderWidth}%` }} />
<div className="bg-brand-blue/35" style={{ width: `${floatWidth}%` }} />
</div>
</div>
);
}
export function Ownership({ ticker }: { ticker: string }) {
const url = flags.ownership ? `/api/market/ownership/${encodeURIComponent(ticker)}` : null;
const { data, loading, error } = useApi<OwnershipResponse>(url, { cacheTtlMs: 300_000 });
if (!flags.ownership) return null;
return (
<section className="atlas-card mt-6">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-5 py-4">
<div>
<h3 className="font-serif text-lg font-bold text-brand-navy">Ownership</h3>
<p className="mt-1 text-xs text-text-muted">
Institutional and insider holder snapshot{data?.source ? ` via ${data.source}` : ""}.
</p>
</div>
</div>
<div className="space-y-5 p-5">
<ErrorBanner variant="error" message={error} />
{loading ? <LoadingPulse height="h-32" label="Loading ownership..." /> : (
<>
{!data?.available && (
<ErrorBanner variant="info" message="Ownership data is not available for this ticker yet." />
)}
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<StatCard label="Institutional" value={formatPct(data?.institutional_pct ?? null)} />
<StatCard label="Insider" value={formatPct(data?.insider_pct ?? null)} tone="accent" />
<StatCard label="Float / Retail" value={formatPct(data?.float_pct ?? null)} />
</div>
{data && <OwnershipBar data={data} />}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<HolderTable title="Top Institutional Holders" rows={data?.institutions ?? []} />
<HolderTable title="Insider Activity / Holders" rows={data?.insiders ?? []} />
</div>
</>
)}
</div>
</section>
);
}
+24 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations 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.core.providers.base import BaseProvider, ProviderError, ProviderNotConfigured, ProviderNotImplemented
from server.services import fmp_client from server.services import fmp_client
@@ -95,3 +95,26 @@ class FMPProvider(BaseProvider):
"periods": periods, "periods": periods,
"line_items": line_items, "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,
)
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from typing import Any 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.core.providers.base import BaseProvider, ProviderError
from server.utils.peer_universe import peer_symbols_for_profile from server.utils.peer_universe import peer_symbols_for_profile
@@ -149,6 +149,24 @@ class YFinanceProvider(BaseProvider):
return await self._to_thread(fetch) 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: async def history(self, symbol: str, range: str = "1y") -> OHLCV:
def fetch() -> OHLCV: def fetch() -> OHLCV:
hist = self._ticker(symbol).history(period=range) hist = self._ticker(symbol).history(period=range)
@@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
import math
from typing import Any, Dict, List from typing import Any, Dict, List
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
@@ -24,6 +25,79 @@ def _safe_float(val, default=0.0):
return default 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") @router.get("/indices", summary="Major market indices")
async def market_indices(): async def market_indices():
try: 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") @router.get("/comps", summary="Industry comparable companies")
async def industry_comps(tickers: str = Query(..., description="Comma-separated tickers")): async def industry_comps(tickers: str = Query(..., description="Comma-separated tickers")):
try: try:
+30 -1
View File
@@ -3,7 +3,7 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from server.core.providers.base import DataUnavailable 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 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.status_code == 200
assert response.json()["line_items"]["revenue"] == [120.0, 100.0] 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"