w4yg-{{kwP_
zoFgjNmIAI~^EppKZub~_G1-ik#vP|9J0(C#;$}>D|pl3KHaz-Gn
zq;raz%%x<5c`u*O5D#o;cvdp`q-wa+In`jYNp;Qeq?KGUXSfxW7%Y)DGD%qBfl+$H
z2)s?5^dcd7@`Z~~UopHJNutP-LR~HzeKwa^%V*`prXrI>iewYezn@OYNGy9%Q$YGE|#VY>+P(%%Opt$oOP@VfSri~`_(L`~l!G}v~lMR*Q
z4+M=J)7i0oggwWahlb1Y-GO@pC66^Z{$R6mRAVP}c7ihe1~-MVYW&M#O-SfMqQ<}T
z^@XqA_-0d^y{OM#gkec7sXxrse*N~h=e|Ez^S!h0a(g1p(8$M1W$y0wz3n|gtPA4q
z_%6|e)4FhaPgtl63*Vgihf5O{bzyN&xLg-5Yr>nl@TL}eOAozO@|L^@O>UTOAL#(|
z0ws0d4b+2!gMWvGdkd8nEgaXw@ftT%nk~)l&exb3B@8NfJbZp+aT-0D4lFLXpPa=2
zdr|{U3ubC~9gt5P0ov;rJKz*WD{$zFRx3E%2iyij!ZHm(4gnJp0JN-2Xx=kCH`ekQ
znH&Xtn9hv>D5B(hd3K+aNjth7L
z(_4rxh`p%Dl0jf6H2IJa1w#M~W<8(I5o%tDtBp)rZO0Ou%E-x_9ZZ(G8CCv3B{ATp
z!J#Apv;w0m6oxhSWu1N5^s>({eD=ofrWTviV{<0K&AFbV9>$vk|U6|AaQ5Qrl
zIHd=tikBMPQ0bRtsm2^8$6%KpYfHIUtY
z5s2<92CL?;OX-wKYjrA5Bt>Klc2!oBD%5W#lU7v2e|2laD%ynF8M6BnbYvPpkGUC=
zzoC#BAk$CD0pJn++bOsBwmxe7Jn6e5rC-i%jT|
z3ETDu&4J;Mw`-i~BSX~q7BE+q{ID8I;SiKUh33gmg)``H;q!j?KYbWrk5Q;X>Gw1T
z;HRry6;$2ddV}`)2}yDUC?s_Hm~ve)@F~&V6++&O`6v>x`4Cd*hEXeJ`%>xOT{qMA
z91vfHPdNzyiX;~-ZQfeAv#`e>OAI8o=s8h2`Usm7c#Q?NK>
zl@00_Rxq=Y^>2gqdh@DV8|mCC@zQy-%Fr5N)oogzt<%ORM#GpS0Koqu;4Y?_2|eBy
z$lS_2`7__SY+l;T$ghzFn4(uwUIhpi!nlFBqWRZAI6d^j+7L-
zL!_)!uHM}?Q71U+$3s*WbQPv@wK`r^s;~U{xB4g?5NJ*?it$W&5u6#%RIJX#p9f6!
F{uf5O^rZj*
literal 0
HcmV?d00001
diff --git a/services/edgar/edgar_service/app.py b/services/edgar/edgar_service/app.py
new file mode 100644
index 000000000..bc57190ff
--- /dev/null
+++ b/services/edgar/edgar_service/app.py
@@ -0,0 +1,117 @@
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from fastapi import Depends, FastAPI, HTTPException, Query, Request
+from fastapi.responses import JSONResponse
+
+from . import VERSION, sec
+from .auth import require_secret
+from .config import settings
+
+
+@asynccontextmanager
+async def lifespan(_: FastAPI) -> AsyncIterator[None]:
+ sec.configure(settings())
+ yield
+
+
+app = FastAPI(title="CRM EDGAR service", version=VERSION, docs_url=None, redoc_url=None, lifespan=lifespan)
+
+
+@app.exception_handler(HTTPException)
+async def http_error(_: Request, error: HTTPException) -> JSONResponse:
+ detail = error.detail if isinstance(error.detail, dict) else {"reason": str(error.detail)}
+ return JSONResponse(status_code=error.status_code, content=detail)
+
+
+@app.exception_handler(sec.NotFound)
+async def not_found(_: Request, error: sec.NotFound) -> JSONResponse:
+ return JSONResponse(status_code=404, content={"reason": str(error)})
+
+
+@app.exception_handler(sec.Upstream)
+async def upstream(_: Request, error: sec.Upstream) -> JSONResponse:
+ return JSONResponse(status_code=502, content={"reason": str(error)})
+
+
+@app.exception_handler(Exception)
+async def unexpected(_: Request, error: Exception) -> JSONResponse:
+ return JSONResponse(status_code=502, content={"reason": f"SEC lookup failed: {error}"})
+
+
+@app.get("/health")
+def health() -> dict:
+ return {
+ "ok": True,
+ "version": VERSION,
+ "edgartools": sec.edgartools_version(),
+ "identitySet": sec.identity_set(),
+ }
+
+
+guarded = [Depends(require_secret)]
+
+
+@app.get("/companies/search", dependencies=guarded)
+def companies_search(q: str = Query(min_length=1), limit: int = Query(10, ge=1, le=settings().max_search)) -> dict:
+ return {"companies": sec.search_companies(q, limit)}
+
+
+@app.get("/companies/{key}", dependencies=guarded)
+def company(key: str) -> dict:
+ return sec.company(key)
+
+
+@app.get("/companies/{key}/filings", dependencies=guarded)
+def company_filings(
+ key: str,
+ form: str | None = None,
+ from_: str | None = Query(None, alias="from"),
+ to: str | None = None,
+ limit: int = Query(20, ge=1, le=settings().max_filings),
+) -> dict:
+ return sec.filings(key, form, from_, to, limit)
+
+
+@app.get("/filings/search", dependencies=guarded)
+def filings_search(
+ q: str = Query(min_length=2),
+ form: str | None = None,
+ from_: str | None = Query(None, alias="from"),
+ to: str | None = None,
+ limit: int = Query(20, ge=1, le=settings().max_filings),
+) -> dict:
+ return sec.search_filings(q, form, from_, to, limit)
+
+
+@app.get("/companies/{key}/owners", dependencies=guarded)
+def company_owners(
+ key: str,
+ minPercent: float = Query(5, ge=0, le=100),
+ form: str = Query("all", pattern="^(13D|13G|all)$"),
+ limit: int = Query(20, ge=1, le=settings().max_owners),
+) -> dict:
+ return sec.owners(key, minPercent, form, limit)
+
+
+@app.get("/companies/{key}/insiders", dependencies=guarded)
+def company_insiders(key: str, limit: int = Query(20, ge=1, le=settings().max_insiders)) -> dict:
+ return sec.insiders(key, limit)
+
+
+@app.get("/companies/{key}/proxy", dependencies=guarded)
+def company_proxy(key: str, years: int = Query(3, ge=1, le=settings().max_years)) -> dict:
+ return sec.proxy(key, years)
+
+
+@app.get("/compensation/compare", dependencies=guarded)
+def compensation_compare(
+ tickers: str = Query(min_length=1),
+ years: int = Query(3, ge=1, le=settings().max_years),
+) -> dict:
+ names = [t.strip().upper() for t in tickers.split(",") if t.strip()]
+ if not names:
+ raise HTTPException(status_code=422, detail={"reason": "Give at least one ticker."})
+ if len(names) > settings().max_tickers:
+ raise HTTPException(status_code=422, detail={"reason": f"At most {settings().max_tickers} tickers."})
+ return {"rows": sec.compare(names, years)}
diff --git a/services/edgar/edgar_service/auth.py b/services/edgar/edgar_service/auth.py
new file mode 100644
index 000000000..33be65e1c
--- /dev/null
+++ b/services/edgar/edgar_service/auth.py
@@ -0,0 +1,24 @@
+import hmac
+
+from fastapi import HTTPException, Request
+
+from .config import settings
+
+LOOPBACK = {"127.0.0.1", "::1", "localhost"}
+
+
+def require_secret(request: Request) -> None:
+ secret = settings().secret
+ if not secret:
+ host = request.client.host if request.client else ""
+ if host in LOOPBACK:
+ return
+ raise HTTPException(
+ status_code=401,
+ detail={"reason": "EDGAR_SECRET is not set, so only loopback callers are accepted."},
+ )
+
+ header = request.headers.get("authorization", "")
+ given = header[7:].strip() if header.lower().startswith("bearer ") else ""
+ if not given or not hmac.compare_digest(given, secret):
+ raise HTTPException(status_code=401, detail={"reason": "Bad or missing bearer secret."})
diff --git a/services/edgar/edgar_service/config.py b/services/edgar/edgar_service/config.py
new file mode 100644
index 000000000..79a864b04
--- /dev/null
+++ b/services/edgar/edgar_service/config.py
@@ -0,0 +1,33 @@
+import os
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Settings:
+ identity: str
+ secret: str
+ port: int
+ data_dir: str
+ max_search: int
+ max_filings: int
+ max_owner_filings: int
+ max_owners: int
+ max_insiders: int
+ max_years: int
+ max_tickers: int
+
+
+def settings() -> Settings:
+ return Settings(
+ identity=os.environ.get("EDGAR_IDENTITY", "").strip(),
+ secret=os.environ.get("EDGAR_SECRET", "").strip(),
+ port=int(os.environ.get("EDGAR_PORT", "2100")),
+ data_dir=os.environ.get("EDGAR_DATA_DIR", "").strip(),
+ max_search=25,
+ max_filings=100,
+ max_owner_filings=60,
+ max_owners=50,
+ max_insiders=100,
+ max_years=5,
+ max_tickers=10,
+ )
diff --git a/services/edgar/edgar_service/sec.py b/services/edgar/edgar_service/sec.py
new file mode 100644
index 000000000..d9aa56cab
--- /dev/null
+++ b/services/edgar/edgar_service/sec.py
@@ -0,0 +1,460 @@
+import os
+import re
+from datetime import date
+from importlib import metadata
+from typing import Any
+
+import edgar
+from edgar import Company, CompanyNotFoundError, find_company, get_identity, set_identity
+from edgar import search_filings as efts_search
+
+from .config import Settings
+from .values import cik_text, day, number, rows, text, whole
+
+BROWSE_URL = "https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK="
+OWNER_FORMS = {
+ "13D": ["SCHEDULE 13D", "SC 13D"],
+ "13G": ["SCHEDULE 13G", "SC 13G"],
+}
+INSIDER_FORMS = ["3", "4", "5"]
+PROXY_FORM = "DEF 14A"
+EFTS_FIRST_DAY = "2001-01-01"
+DISPLAY_NAME = re.compile(r"^(.*?)\s+\((?:[A-Z.\-]+\)\s+\()?CIK")
+HOLDER_SHARE_KEYS = ("shares", "amount", "shares_beneficially_owned", "amount_beneficially_owned")
+
+_settings: Settings | None = None
+
+
+class NotFound(Exception):
+ pass
+
+
+class Upstream(Exception):
+ pass
+
+
+def configure(settings: Settings) -> None:
+ global _settings
+ _settings = settings
+ if settings.data_dir:
+ os.environ.setdefault("EDGAR_LOCAL_DATA_DIR", settings.data_dir)
+ if settings.identity:
+ set_identity(settings.identity)
+
+
+def edgartools_version() -> str:
+ try:
+ return metadata.version("edgartools")
+ except metadata.PackageNotFoundError:
+ return getattr(edgar, "__version__", "unknown")
+
+
+def identity_set() -> bool:
+ try:
+ return bool(get_identity())
+ except Exception:
+ return False
+
+
+def _limits() -> Settings:
+ if _settings is None:
+ raise Upstream("The service is not configured.")
+ return _settings
+
+
+def _company(key: str) -> Any:
+ cleaned = key.strip()
+ try:
+ company = Company(int(cleaned)) if cleaned.isdigit() else Company(cleaned.upper())
+ except CompanyNotFoundError as error:
+ raise NotFound(f"No SEC filer matches {cleaned}.") from error
+ except Exception as error:
+ if "not found" in str(error).lower():
+ raise NotFound(f"No SEC filer matches {cleaned}.") from error
+ raise Upstream(f"SEC lookup failed for {cleaned}: {error}") from error
+ if company is None or getattr(company, "not_found", False):
+ raise NotFound(f"No SEC filer matches {cleaned}.")
+ return company
+
+
+def _match(company: Any) -> dict[str, Any]:
+ data = company.data
+ tickers = list(getattr(data, "tickers", None) or [])
+ exchanges = list(getattr(data, "exchanges", None) or [])
+ return {
+ "cik": cik_text(company.cik),
+ "name": text(company.name) or f"CIK {cik_text(company.cik)}",
+ "ticker": tickers[0] if tickers else None,
+ "exchange": exchanges[0] if exchanges else None,
+ }
+
+
+def search_companies(query: str, limit: int) -> list[dict[str, Any]]:
+ found: dict[str, dict[str, Any]] = {}
+ cleaned = query.strip()
+
+ if cleaned.isdigit() or (cleaned.isalpha() and len(cleaned) <= 6):
+ try:
+ match = _match(_company(cleaned))
+ found[match["cik"]] = match
+ except (NotFound, Upstream):
+ pass
+
+ try:
+ results = find_company(cleaned, top_n=limit)
+ for index in range(len(results)):
+ match = _match(results[index])
+ found.setdefault(match["cik"], match)
+ except Exception as error:
+ if not found:
+ raise Upstream(f"SEC company search failed: {error}") from error
+
+ return list(found.values())[:limit]
+
+
+def _address(company: Any) -> dict[str, Any] | None:
+ try:
+ address = company.business_address()
+ except Exception:
+ return None
+ if address is None or getattr(address, "empty", False):
+ return None
+ street = " ".join(part for part in [text(address.street1), text(address.street2)] if part)
+ return {
+ "street": street or None,
+ "city": text(address.city),
+ "state": text(address.state_or_country),
+ "zip": text(address.zipcode),
+ }
+
+
+def company(key: str) -> dict[str, Any]:
+ entity = _company(key)
+ data = entity.data
+ former = getattr(data, "former_names", None) or []
+ return {
+ "cik": cik_text(entity.cik),
+ "name": text(entity.name) or f"CIK {cik_text(entity.cik)}",
+ "tickers": [t for t in (getattr(data, "tickers", None) or []) if t],
+ "exchanges": [e for e in (getattr(data, "exchanges", None) or []) if e],
+ "sic": text(getattr(data, "sic", None)),
+ "sicDescription": text(getattr(data, "sic_description", None)),
+ "stateOfIncorporation": text(getattr(data, "state_of_incorporation", None)),
+ "fiscalYearEnd": text(getattr(data, "fiscal_year_end", None)),
+ "category": text(getattr(data, "category", None)),
+ "businessAddress": _address(entity),
+ "website": text(getattr(data, "website", None)),
+ "formerNames": [name for name in (text(item.get("name")) for item in former if isinstance(item, dict)) if name],
+ "url": f"{BROWSE_URL}{cik_text(entity.cik)}",
+ }
+
+
+def _filing_row(filing: Any) -> dict[str, Any]:
+ return {
+ "accession": text(filing.accession_no) or "",
+ "form": text(filing.form) or "",
+ "filedAt": day(filing.filing_date) or "",
+ "reportDate": day(getattr(filing, "report_date", None)),
+ "description": text(getattr(filing, "primary_doc_description", None)),
+ "url": filing.homepage_url,
+ }
+
+
+def _entity_filings(entity: Any, form: Any, start: str | None, end: str | None) -> Any:
+ try:
+ filings = entity.get_filings(form=form) if form else entity.get_filings()
+ if filings is not None and (start or end):
+ filings = filings.filter(filing_date=f"{start or ''}:{end or ''}")
+ except Exception as error:
+ raise Upstream(f"SEC filings lookup failed: {error}") from error
+ return filings
+
+
+def filings(key: str, form: str | None, start: str | None, end: str | None, limit: int) -> dict[str, Any]:
+ entity = _company(key)
+ listing = _entity_filings(entity, form.strip() if form else None, start, end)
+ if listing is None or getattr(listing, "empty", False):
+ return {"filings": [], "truncated": False}
+ page = list(listing.head(limit + 1))
+ return {
+ "filings": [_filing_row(filing) for filing in page[:limit]],
+ "truncated": len(page) > limit,
+ }
+
+
+def _hit_company(hit: Any) -> dict[str, Any]:
+ raw = text(getattr(hit, "company", None)) or ""
+ match = DISPLAY_NAME.match(raw)
+ return {"cik": cik_text(hit.cik), "name": (match.group(1) if match else raw) or f"CIK {cik_text(hit.cik)}"}
+
+
+def search_filings(query: str, form: str | None, start: str | None, end: str | None, limit: int) -> dict[str, Any]:
+ if start or end:
+ start = start or EFTS_FIRST_DAY
+ end = end or date.today().isoformat()
+ try:
+ results = efts_search(
+ query.strip(),
+ forms=form.strip() if form else None,
+ start_date=start,
+ end_date=end,
+ limit=limit,
+ )
+ hits = list(iter(results.sort_by("filed")))[:limit]
+ total = whole(getattr(results, "total", None)) or len(hits)
+ except Exception as error:
+ raise Upstream(f"SEC full-text search failed: {error}") from error
+
+ out = []
+ for hit in hits:
+ accession = text(hit.accession_number) or ""
+ cik = cik_text(hit.cik)
+ out.append(
+ {
+ "accession": accession,
+ "form": text(hit.form) or "",
+ "filedAt": day(hit.filed) or "",
+ "reportDate": day(getattr(hit, "period", None)),
+ "description": text(getattr(hit, "file_description", None)),
+ "url": f"https://www.sec.gov/Archives/edgar/data/{cik}/{accession.replace('-', '')}/{accession}-index.html",
+ "company": _hit_company(hit),
+ }
+ )
+ return {"filings": out, "total": total}
+
+
+def owners(key: str, min_percent: float, form: str, limit: int) -> dict[str, Any]:
+ entity = _company(key)
+ forms = OWNER_FORMS["13D"] + OWNER_FORMS["13G"] if form == "all" else OWNER_FORMS[form]
+ listing = _entity_filings(entity, forms, None, None)
+ if listing is None or getattr(listing, "empty", False):
+ return {"owners": [], "filingsRead": 0}
+
+ seen: set[str] = set()
+ out: list[dict[str, Any]] = []
+ read = 0
+ for filing in listing.head(_limits().max_owner_filings):
+ read += 1
+ try:
+ schedule = filing.obj()
+ except Exception:
+ continue
+ persons = getattr(schedule, "reporting_persons", None) or []
+ items = getattr(schedule, "items", None)
+ purpose = text(getattr(items, "item4_purpose_of_transaction", None)) if "13D" in str(filing.form) else None
+ for person in persons:
+ name = text(getattr(person, "name", None))
+ if not name or name.lower() in seen:
+ continue
+ seen.add(name.lower())
+ percent = number(getattr(person, "percent_of_class", None))
+ if percent is None or percent < min_percent:
+ continue
+ out.append(
+ {
+ "filer": name,
+ "form": text(filing.form) or "",
+ "filedAt": day(filing.filing_date) or "",
+ "shares": number(getattr(person, "aggregate_amount", None)),
+ "percent": percent,
+ "soleVoting": number(getattr(person, "sole_voting_power", None)),
+ "sharedVoting": number(getattr(person, "shared_voting_power", None)),
+ "purpose": purpose,
+ "url": filing.homepage_url,
+ }
+ )
+ if len(out) >= limit:
+ return {"owners": out, "filingsRead": read}
+ return {"owners": out, "filingsRead": read}
+
+
+def insiders(key: str, limit: int) -> dict[str, Any]:
+ entity = _company(key)
+ listing = _entity_filings(entity, INSIDER_FORMS, None, None)
+ if listing is None or getattr(listing, "empty", False):
+ return {"transactions": []}
+
+ out = []
+ for filing in listing.head(limit):
+ try:
+ form = filing.obj()
+ summary = form.get_ownership_summary()
+ except Exception:
+ continue
+ activities = list(getattr(summary, "transactions", None) or [])
+ primary = activities[0] if activities else None
+ insider = text(getattr(form, "insider_name", None))
+ if not insider:
+ continue
+ out.append(
+ {
+ "insider": insider,
+ "title": text(getattr(form, "position", None)),
+ "form": text(filing.form) or "",
+ "filedAt": day(filing.filing_date) or "",
+ "kind": text(getattr(primary, "transaction_type", None)) or text(getattr(summary, "primary_activity", None)),
+ "shares": number(getattr(primary, "shares", None)),
+ "price": number(getattr(primary, "price_per_share", None)),
+ "url": filing.homepage_url,
+ }
+ )
+ return {"transactions": out}
+
+
+def _latest_proxy(entity: Any) -> tuple[Any, Any]:
+ listing = _entity_filings(entity, PROXY_FORM, None, None)
+ if listing is None or getattr(listing, "empty", False):
+ raise NotFound(f"No {PROXY_FORM} on file for {text(entity.name)}.")
+ filing = list(listing.head(1))[0]
+ try:
+ statement = filing.obj()
+ except Exception as error:
+ raise Upstream(f"The proxy statement could not be parsed: {error}") from error
+ if statement is None or not hasattr(statement, "peo_name"):
+ raise Upstream("The proxy statement could not be parsed.")
+ return filing, statement
+
+
+def _by_year(frame: Any, years: int) -> list[dict[str, Any]]:
+ items = rows(frame)
+ items.sort(key=lambda row: str(row.get("fiscal_year_end") or ""), reverse=True)
+ return items[:years]
+
+
+def _holder_shares(row: dict[str, Any]) -> float | None:
+ for key in HOLDER_SHARE_KEYS:
+ if key in row:
+ return number(row[key])
+ return None
+
+
+def proxy(key: str, years: int) -> dict[str, Any]:
+ entity = _company(key)
+ filing, statement = _latest_proxy(entity)
+ ratio = getattr(statement, "ceo_pay_ratio", None)
+ executives = []
+ for row in rows(getattr(statement, "summary_compensation_table", None)):
+ name = text(row.get("name"))
+ if not name:
+ continue
+ executives.append(
+ {
+ "name": name,
+ "title": text(row.get("title")),
+ "year": whole(row.get("year")),
+ "salary": number(row.get("salary")),
+ "bonus": number(row.get("bonus")),
+ "stockAwards": number(row.get("stock_awards")),
+ "optionAwards": number(row.get("option_awards")),
+ "nonEquityIncentive": number(row.get("non_equity_incentive")),
+ "otherCompensation": number(row.get("other_compensation")),
+ "total": number(row.get("total")),
+ }
+ )
+ holders = []
+ for row in rows(getattr(statement, "beneficial_ownership", None)):
+ name = text(row.get("holder_name") or row.get("name"))
+ if not name:
+ continue
+ holders.append({"name": name, "percentOfClass": number(row.get("percent_of_class")), "shares": _holder_shares(row)})
+ proposals = [
+ {
+ "number": whole(getattr(item, "number", None)),
+ "description": text(getattr(item, "description", None)) or "",
+ "type": text(getattr(item, "proposal_type", None)),
+ }
+ for item in (getattr(statement, "voting_proposals", None) or [])
+ ]
+ return {
+ "accession": text(filing.accession_no) or "",
+ "filedAt": day(filing.filing_date) or "",
+ "url": filing.homepage_url,
+ "peo": {
+ "name": text(getattr(statement, "peo_name", None)),
+ "totalComp": number(getattr(statement, "peo_total_comp", None)),
+ "actuallyPaidComp": number(getattr(statement, "peo_actually_paid_comp", None)),
+ },
+ "neoAverage": {
+ "totalComp": number(getattr(statement, "neo_avg_total_comp", None)),
+ "actuallyPaidComp": number(getattr(statement, "neo_avg_actually_paid_comp", None)),
+ },
+ "compensationByYear": [
+ {
+ "fiscalYearEnd": day(row.get("fiscal_year_end")),
+ "peoTotalComp": number(row.get("peo_total_comp")),
+ "peoActuallyPaidComp": number(row.get("peo_actually_paid_comp")),
+ "neoAverageTotalComp": number(row.get("neo_avg_total_comp")),
+ "neoAverageActuallyPaidComp": number(row.get("neo_avg_actually_paid_comp")),
+ }
+ for row in _by_year(getattr(statement, "executive_compensation", None), years)
+ ],
+ "payVsPerformance": [
+ {
+ "fiscalYearEnd": day(row.get("fiscal_year_end")),
+ "peoActuallyPaidComp": number(row.get("peo_actually_paid_comp")),
+ "neoAverageActuallyPaidComp": number(row.get("neo_avg_actually_paid_comp")),
+ "tsr": number(row.get("total_shareholder_return")),
+ "peerTsr": number(row.get("peer_group_tsr")),
+ "netIncome": number(row.get("net_income")),
+ "selectedMeasureValue": number(row.get("company_selected_measure_value")),
+ }
+ for row in _by_year(getattr(statement, "pay_vs_performance", None), years)
+ ],
+ "executives": executives,
+ "holders": holders,
+ "proposals": proposals,
+ "performanceMeasures": [m for m in (text(item) for item in (getattr(statement, "performance_measures", None) or [])) if m],
+ "selectedMeasureName": text(getattr(statement, "company_selected_measure", None)),
+ "ceoPayRatio": None
+ if ratio is None
+ else {
+ "ceo": number(getattr(ratio, "ceo_compensation", None)),
+ "medianEmployee": number(getattr(ratio, "median_employee_compensation", None)),
+ "ratio": number(getattr(ratio, "ratio", None)),
+ },
+ "insiderTradingPolicyAdopted": getattr(statement, "insider_trading_policy_adopted", None)
+ if isinstance(getattr(statement, "insider_trading_policy_adopted", None), bool)
+ else None,
+ }
+
+
+def compare(tickers: list[str], years: int) -> list[dict[str, Any]]:
+ out: list[dict[str, Any]] = []
+ for ticker in tickers:
+ try:
+ entity = _company(ticker)
+ statement = proxy(ticker, years)
+ except (NotFound, Upstream) as error:
+ out.append(
+ {
+ "ticker": ticker,
+ "cik": None,
+ "name": None,
+ "fiscalYearEnd": None,
+ "peoName": None,
+ "peoTotalComp": None,
+ "peoActuallyPaidComp": None,
+ "tsr": None,
+ "netIncome": None,
+ "reason": str(error),
+ }
+ )
+ continue
+ performance = {row["fiscalYearEnd"]: row for row in statement["payVsPerformance"]}
+ for row in statement["compensationByYear"]:
+ year = performance.get(row["fiscalYearEnd"], {})
+ out.append(
+ {
+ "ticker": ticker,
+ "cik": cik_text(entity.cik),
+ "name": text(entity.name),
+ "fiscalYearEnd": row["fiscalYearEnd"],
+ "peoName": statement["peo"]["name"],
+ "peoTotalComp": row["peoTotalComp"],
+ "peoActuallyPaidComp": row["peoActuallyPaidComp"],
+ "tsr": year.get("tsr"),
+ "netIncome": year.get("netIncome"),
+ "reason": None,
+ }
+ )
+ return out
diff --git a/services/edgar/edgar_service/values.py b/services/edgar/edgar_service/values.py
new file mode 100644
index 000000000..f61ad5ebe
--- /dev/null
+++ b/services/edgar/edgar_service/values.py
@@ -0,0 +1,62 @@
+import math
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Any
+
+
+def number(value: Any) -> float | None:
+ if value is None or isinstance(value, bool):
+ return None
+ if isinstance(value, Decimal):
+ value = float(value)
+ if isinstance(value, (int, float)):
+ return None if isinstance(value, float) and math.isnan(value) else float(value)
+ try:
+ parsed = float(str(value).replace(",", "").replace("$", "").strip())
+ except ValueError:
+ return None
+ return None if math.isnan(parsed) else parsed
+
+
+def whole(value: Any) -> int | None:
+ parsed = number(value)
+ return None if parsed is None else int(parsed)
+
+
+def text(value: Any) -> str | None:
+ if value is None:
+ return None
+ if isinstance(value, float) and math.isnan(value):
+ return None
+ cleaned = " ".join(str(value).split())
+ return cleaned or None
+
+
+def day(value: Any) -> str | None:
+ if value is None:
+ return None
+ if isinstance(value, datetime):
+ return value.date().isoformat()
+ if isinstance(value, date):
+ return value.isoformat()
+ cleaned = str(value).strip()[:10]
+ try:
+ return date.fromisoformat(cleaned).isoformat()
+ except ValueError:
+ return None
+
+
+def rows(frame: Any) -> list[dict[str, Any]]:
+ if frame is None:
+ return []
+ to_dict = getattr(frame, "to_dict", None)
+ if to_dict is None:
+ return []
+ try:
+ return list(to_dict("records"))
+ except (TypeError, ValueError):
+ return []
+
+
+def cik_text(value: Any) -> str:
+ return str(value).strip().lstrip("0") or "0"
diff --git a/services/edgar/pyproject.toml b/services/edgar/pyproject.toml
new file mode 100644
index 000000000..302d1825c
--- /dev/null
+++ b/services/edgar/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "edgar-service"
+version = "0.1.0"
+description = "SEC EDGAR research for the CRM, on edgartools"
+requires-python = ">=3.11"
+dependencies = [
+ "edgartools==5.56.0",
+ "fastapi==0.141.1",
+ "uvicorn[standard]==0.41.0",
+ "pydantic>=2.9",
+]
+
+[project.optional-dependencies]
+dev = ["pytest>=8.3", "httpx>=0.28"]
+
+[build-system]
+requires = ["setuptools>=70"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+include = ["edgar_service*"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/services/edgar/requirements.txt b/services/edgar/requirements.txt
new file mode 100644
index 000000000..d8f7ac2d2
--- /dev/null
+++ b/services/edgar/requirements.txt
@@ -0,0 +1,4 @@
+edgartools==5.56.0
+fastapi==0.141.1
+uvicorn[standard]==0.41.0
+pydantic>=2.9
diff --git a/services/edgar/tests/__init__.py b/services/edgar/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/services/edgar/tests/__pycache__/__init__.cpython-311.pyc b/services/edgar/tests/__pycache__/__init__.cpython-311.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..d6e5dc80434edfbb440eb6008b5591d547c27707
GIT binary patch
literal 156
zcmZ3^%ge<81SjfeW`XF(AOZ#$p^VRLK*n^26oz01O-8?!3`I;p{%4TnFMa)t{M=Oi
z(&E%2{p6xteIQ+ynVedzpPG`MSfpQ)T3k}BA0MBYmst`YuUAm{i^C>2KczG$)vkyY
Ys2^lQF+Y&_z|6?V_<;dN6fpzE0AIQ!6951J
literal 0
HcmV?d00001
diff --git a/services/edgar/tests/__pycache__/conftest.cpython-311-pytest-9.1.1.pyc b/services/edgar/tests/__pycache__/conftest.cpython-311-pytest-9.1.1.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..492e8e8459a1582631afbd19dfb17bc80ed0258b
GIT binary patch
literal 1717
zcma(RO-~y~bar>_^%@&eA|O;sOI0OV`L*+aZyo`r|M~zsvr*+V@kw{7vqwn7j-yQH{lsY
zLqR0IDZe0W9rm>?_8him<0tU#XRxCITKZI};&GafR$%>1m
zTv|sc4*2`9&yzs?3@H%&E;{^&Ko_+YTYb}c5s<5R>Yf6r(d?M3d3XU0hgMEbutHYf%zVQ8`Gz#)u^jvr^$H$A&66LA}ICV09fs
zU<{$_u+K%Bp{+y-)#bv^h>DV*!$z-jxeebVxhf+xXHhQ)=ayp;mLvApCe4Y8v7F_X
z*9DfX)L$oNT+@4Ko3lGNELs2TjO$yb%Vq(II0ImqiElR0^Fjkrku5$b4FT>Y7eIXHYA>zeSwIMYl
zu|AQ%Qj{?!-uoB~83vC>R5}V&VUqm?;CGk}bczRF;^7lK{OH@m+A$t);_(oVxA1#m
z?`q)>!rmn{M^5m_fp+lK(+5u<9OLOGo(}PJOW2G!;mteY-33_3IN!wi5a)k2u0L9M
zJ`@_0$HrvSm^?#D?ClpHwfaZHM0=fSK$3I`UI~dm1?bYQ#4au_uH;Du8-&=8ZpG%O
zw4*6JKcgRgtN`R`vl?vp)Y&mbt#;C%5R<~+C3+Q^0iJkRb=<%yGoG5O)JvDQ(Nd)@
zJJ)1TaG{%}WScqx;l?G2>m6DWlx3kz%2l87cy#51>n01#iWB|8WZTPna~;~np?;Ys
zp$%-ZZdToZ>+4P}sDgi@q8!hEURZjgb>S6xzFl?6EWHgf;-Rwd0YGjQr6nHD71Llv2)N38W
SFfwi&yc_D71{PC3+P?vnh^MLm
literal 0
HcmV?d00001
diff --git a/services/edgar/tests/__pycache__/test_app.cpython-311-pytest-9.1.1.pyc b/services/edgar/tests/__pycache__/test_app.cpython-311-pytest-9.1.1.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..f86423c29ecef3a1a325e83dbc4ac7598b697ae2
GIT binary patch
literal 16648
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
literal 0
HcmV?d00001
diff --git a/services/edgar/tests/__pycache__/test_sec.cpython-311-pytest-9.1.1.pyc b/services/edgar/tests/__pycache__/test_sec.cpython-311-pytest-9.1.1.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..d4fbc0e9852141b31f96de901e91118e347ec122
GIT binary patch
literal 25178
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