mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-26 00:38:04 +00:00
phase 6: ownership holders
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user