# ============================================================ # Project: EDGAR Research Database # Description: SEC EDGAR Research Infrastructure # # Authors: # - Manel Kammoun — manel.kammoun@uqo.ca # - Djerrycharli Tandjambianda — djerrycharli.tandjambianda@uqo.ca # - Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca # # Institution: # Université du Québec en Outaouais (UQO) # # License: # Research / Academic Use # ============================================================ from __future__ import annotations import json import os import time from datetime import datetime, timezone from pathlib import Path import httpx from src.utils.constants import DATA_DIRS, SEC_COMPANY_TICKERS_URL from src.utils.logging import get_logger logger = get_logger("acquisition.cik_mapper") CIK_DIGITS = 10 class CIKMapper: def __init__(self, cache_dir: Path | None = None) -> None: self._cache_dir = cache_dir or DATA_DIRS["metadata"] self._cache_path = self._cache_dir / "cik_mapping.json" self._user_agent = os.environ.get( "SEC_USER_AGENT", "Research Agent research@example.com" ) self._ticker_to_cik: dict[str, str] = {} self._cik_to_ticker: dict[str, str] = {} self._last_request_time: float = 0.0 def build_mapping(self) -> dict[str, str]: self._rate_limit() headers = {"User-Agent": self._user_agent, "Accept": "application/json"} with httpx.Client(timeout=30, headers=headers) as client: response = client.get(SEC_COMPANY_TICKERS_URL) response.raise_for_status() data = response.json() ticker_to_cik: dict[str, str] = {} cik_to_ticker: dict[str, str] = {} for entry in data.values(): ticker = str(entry.get("ticker", "")).upper().strip() raw_cik = entry.get("cik_str", "") cik = str(raw_cik).zfill(CIK_DIGITS) if ticker and cik: ticker_to_cik[ticker] = cik cik_to_ticker[cik] = ticker self._ticker_to_cik = ticker_to_cik self._cik_to_ticker = cik_to_ticker logger.info("Built CIK mapping: %d tickers", len(ticker_to_cik)) self._save_cache() return dict(ticker_to_cik) def get_cik(self, ticker: str) -> str | None: if not self._ticker_to_cik: self._load_or_build() return self._ticker_to_cik.get(ticker.upper().strip()) def get_ticker(self, cik: str) -> str | None: if not self._cik_to_ticker: self._load_or_build() padded = cik.zfill(CIK_DIGITS) return self._cik_to_ticker.get(padded) def _load_or_build(self) -> None: if self._cache_path.exists(): self._load_cache() else: self.build_mapping() def _save_cache(self) -> None: self._cache_dir.mkdir(parents=True, exist_ok=True) payload = { "fetched_at": datetime.now(timezone.utc).isoformat(), "count": len(self._ticker_to_cik), "ticker_to_cik": self._ticker_to_cik, "cik_to_ticker": self._cik_to_ticker, } with open(self._cache_path, "w") as f: json.dump(payload, f, indent=2) logger.info("Saved CIK mapping cache to %s", self._cache_path) def _load_cache(self) -> None: with open(self._cache_path) as f: data = json.load(f) self._ticker_to_cik = data.get("ticker_to_cik", {}) self._cik_to_ticker = data.get("cik_to_ticker", {}) logger.info( "Loaded CIK mapping cache: %d tickers (fetched %s)", len(self._ticker_to_cik), data.get("fetched_at", "unknown"), ) def _rate_limit(self) -> None: elapsed = time.monotonic() - self._last_request_time min_interval = 0.1 if elapsed < min_interval: time.sleep(min_interval - elapsed) self._last_request_time = time.monotonic()