Skip to content

Commit a3b197b

Browse files
committed
feat: Implement in-memory TTL cache for SEC EDGAR API responses and integrate with services
1 parent 8ee92b4 commit a3b197b

14 files changed

Lines changed: 669 additions & 7 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **edgar/cache.py**: In-memory TTL cache for SEC EDGAR API responses.
13+
- `TTLCache` class with `get()`, `set()`, `invalidate()`, `clear()`, `__len__()`, `__repr__()`.
14+
- Uses `time.monotonic()` for expiration immune to wall-clock adjustments.
15+
- Module-level TTL constants: `TTL_TICKERS` (24h), `TTL_TAXONOMY` (24h), `TTL_SUBMISSIONS` (1h).
16+
- **tests/test_cache.py**: 29 unit tests for `TTLCache` (get/set, expiration, invalidate, clear, len/repr, constants) and cache integration with `Tickers`, `Submissions`, and `Xbrl` services.
1217
- **edgar/\_\_init\_\_.py**: Top-level convenience functions for reduced boilerplate.
1318
- `edgar.company("AAPL")` — create a `Company` without instantiating `EdgarClient`.
1419
- `edgar.get_filings("AAPL", form="10-K")` — fetch filings in one call.
@@ -37,6 +42,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3742

3843
### Changed
3944

45+
- **edgar/client.py**: `EdgarClient` now accepts `rate_limit` parameter: `EdgarClient(user_agent="...", rate_limit=5)`.
46+
- Defaults to 10 (SEC's maximum). Validates range 1–10.
47+
- Passed through to `EdgarSession` for sliding-window enforcement.
48+
- **edgar/client.py**: `EdgarClient` now accepts `cache` parameter (`bool`, default `True`).
49+
- `cache=True` creates a shared `TTLCache` passed to `EdgarSession`.
50+
- `cache=False` disables caching; all requests hit the network.
51+
- **edgar/session.py**: `EdgarSession` accepts optional `cache` parameter storing a `TTLCache` instance.
52+
- **edgar/tickers.py**: `Tickers._load()` checks/stores data in the TTL cache (`TTL_TICKERS`).
53+
- **edgar/submissions.py**: `Submissions.get_submissions()` checks/stores responses in the TTL cache (`TTL_SUBMISSIONS`).
54+
- **edgar/xbrl.py**: `Xbrl.company_facts()` checks/stores responses in the TTL cache (`TTL_TAXONOMY`).
55+
- **tests/test_rate_limiter.py**: 8 new tests for configurable rate limit (custom values, boundary validation, client passthrough). Total: 17 tests.
4056
- **xbrl.py**: `company_concepts()` and `frames()` now accept an optional `taxonomy` parameter (default `"us-gaap"`). Previously hardcoded to `us-gaap`, now supports `"ifrs-full"`, `"dei"`, or any other taxonomy.
4157
- **edgar/tickers.py**: New `Tickers` service for ticker/CIK/company name resolution via `sec.gov/files/company_tickers.json`.
4258
- `resolve_ticker("AAPL")` → zero-padded CIK string (`"0000320193"`).

edgar/cache.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""In-memory TTL cache for SEC EDGAR API responses."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
7+
8+
# Default TTLs in seconds.
9+
TTL_TICKERS = 86400 # 24 hours — ticker data changes ~quarterly
10+
TTL_TAXONOMY = 86400 # 24 hours — static reference data
11+
TTL_SUBMISSIONS = 3600 # 1 hour — changes on new filings
12+
13+
14+
class TTLCache:
15+
"""Simple in-memory cache with per-key time-to-live expiration.
16+
17+
Uses ``time.monotonic()`` so expiration is immune to wall-clock
18+
adjustments.
19+
"""
20+
21+
def __init__(self) -> None:
22+
self._store: dict[str, tuple[object, float]] = {}
23+
24+
def get(self, key: str) -> object | None:
25+
"""Return the cached value for *key*, or ``None`` if missing/expired."""
26+
27+
entry = self._store.get(key)
28+
if entry is None:
29+
return None
30+
value, expires_at = entry
31+
if time.monotonic() >= expires_at:
32+
del self._store[key]
33+
return None
34+
return value
35+
36+
def set(self, key: str, value: object, ttl: float) -> None:
37+
"""Store *value* under *key* with a TTL of *ttl* seconds."""
38+
39+
self._store[key] = (value, time.monotonic() + ttl)
40+
41+
def invalidate(self, key: str) -> None:
42+
"""Remove a single key from the cache."""
43+
44+
self._store.pop(key, None)
45+
46+
def clear(self) -> None:
47+
"""Remove all entries from the cache."""
48+
49+
self._store.clear()
50+
51+
def __len__(self) -> int:
52+
"""Return the count of non-expired entries."""
53+
54+
now = time.monotonic()
55+
return sum(1 for _, (_, exp) in self._store.items() if now < exp)
56+
57+
def __repr__(self) -> str:
58+
return f"<TTLCache entries={len(self)}>"

edgar/client.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Main entry-point client for the SEC EDGAR API."""
22

3+
from edgar.cache import TTLCache
34
from edgar.xbrl import Xbrl
45
from edgar.series import Series
56
from edgar.search import Search
@@ -26,15 +27,36 @@ class EdgarClient:
2627
instantiate the different endpoints.
2728
"""
2829

29-
def __init__(self, user_agent: str) -> None:
30+
def __init__(self, user_agent: str, rate_limit: int = 10, cache: bool = True) -> None:
3031
"""Initializes the `EdgarClient`.
3132
33+
### Parameters
34+
----
35+
user_agent : str
36+
SEC EDGAR requires a User-Agent header in the format
37+
``"Company/Name email@example.com"``.
38+
39+
rate_limit : int (optional, Default=10)
40+
Maximum requests per second. SEC allows 10 req/s.
41+
Set lower to be more conservative.
42+
43+
cache : bool (optional, Default=True)
44+
Enable in-memory TTL caching for ticker resolution (24h),
45+
submission metadata (1h), and taxonomy data (24h).
46+
Set ``False`` to always fetch fresh data from SEC.
47+
3248
### Usage
3349
----
3450
>>> edgar_client = EdgarClient(user_agent="Your Name your-email@example.com")
51+
>>> edgar_client = EdgarClient(user_agent="...", rate_limit=5)
52+
>>> edgar_client = EdgarClient(user_agent="...", cache=False)
3553
"""
3654

37-
self.edgar_session = EdgarSession(client=self, user_agent=user_agent)
55+
self._ttl_cache = TTLCache() if cache else None
56+
self.edgar_session = EdgarSession(
57+
client=self, user_agent=user_agent, rate_limit=rate_limit,
58+
cache=self._ttl_cache,
59+
)
3860
self._services: dict = {}
3961

4062
def __repr__(self) -> str:

edgar/session.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,50 @@ class EdgarSession:
3333
handles all the requests made to EDGAR.
3434
"""
3535

36-
def __init__(self, client: EdgarClient, user_agent: str) -> None:
36+
def __init__(
37+
self,
38+
client: EdgarClient,
39+
user_agent: str,
40+
rate_limit: int = MAX_REQUESTS_PER_SECOND,
41+
cache: object | None = None,
42+
) -> None:
3743
"""Initializes the `EdgarSession` client.
3844
3945
### Parameters
4046
----
41-
client (str): The `edgar.EdgarClient` Python Client.
47+
client : EdgarClient
48+
The `edgar.EdgarClient` Python Client.
49+
50+
user_agent : str
51+
SEC EDGAR User-Agent header value.
52+
53+
rate_limit : int (optional, Default=MAX_REQUESTS_PER_SECOND)
54+
Maximum requests per second. Must be between 1 and 10.
55+
56+
cache : TTLCache | None (optional, Default=None)
57+
Shared TTL cache instance. ``None`` disables caching.
4258
4359
### Usage
4460
----
4561
>>> edgar_client = EdgarClient(user_agent="Your Name your-email@example.com")
4662
>>> edgar_session = EdgarSession(client=edgar_client, user_agent="your_user_agent")
4763
"""
4864

65+
if not 1 <= rate_limit <= MAX_REQUESTS_PER_SECOND:
66+
raise ValueError(
67+
f"rate_limit must be between 1 and {MAX_REQUESTS_PER_SECOND}, "
68+
f"got {rate_limit}"
69+
)
70+
4971
self.client: EdgarClient = client
5072
self.resource = "https://www.sec.gov"
5173
self.api_resource = "https://data.sec.gov"
5274
self.user_agent = user_agent
75+
self.cache = cache
5376

5477
# Sliding-window rate limiter: track timestamps of recent requests.
5578
self._request_times: deque[float] = deque()
56-
self._rate_limit = MAX_REQUESTS_PER_SECOND
79+
self._rate_limit = rate_limit
5780

5881
# Create a single reusable session with connection pooling.
5982
self.http_session = requests.Session()

edgar/submissions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from edgar.cache import TTL_SUBMISSIONS
56
from edgar.session import EdgarSession
67

78

@@ -69,11 +70,23 @@ def get_submissions(self, cik: str) -> dict | None:
6970
num_of_zeros = 10 - len(cik)
7071
cik = num_of_zeros*"0" + cik
7172

73+
# Check TTL cache.
74+
cache = self.edgar_session.cache
75+
cache_key = f"submissions:{cik}"
76+
if cache is not None:
77+
cached = cache.get(cache_key)
78+
if cached is not None:
79+
return cached
80+
7281
# Grab the Data.
7382
response = self.edgar_session.make_request(
7483
method='get',
7584
endpoint=f'/submissions/CIK{cik}.json',
7685
use_api=True
7786
)
7887

88+
# Store in TTL cache.
89+
if cache is not None and response is not None:
90+
cache.set(cache_key, response, TTL_SUBMISSIONS)
91+
7992
return response

edgar/tickers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
from typing import TYPE_CHECKING
77

8+
from edgar.cache import TTL_TICKERS
89
from edgar.exceptions import EdgarRequestError
910

1011
if TYPE_CHECKING:
@@ -13,6 +14,7 @@
1314
logger = logging.getLogger(__name__)
1415

1516
TICKERS_ENDPOINT = "/files/company_tickers.json"
17+
_CACHE_KEY = "tickers"
1618

1719

1820
class Tickers:
@@ -30,6 +32,14 @@ def _load(self) -> None:
3032
if self._data is not None:
3133
return
3234

35+
# Check TTL cache for previously fetched data.
36+
cache = self._session.cache
37+
if cache is not None:
38+
cached = cache.get(_CACHE_KEY)
39+
if cached is not None:
40+
self._data, self._ticker_to_cik, self._cik_to_entries = cached
41+
return
42+
3343
raw = self._session.make_request(
3444
method="GET",
3545
endpoint=TICKERS_ENDPOINT,
@@ -53,6 +63,14 @@ def _load(self) -> None:
5363

5464
self._cik_to_entries.setdefault(cik, []).append(entry)
5565

66+
# Store in TTL cache for reuse across service re-instantiations.
67+
if cache is not None:
68+
cache.set(
69+
_CACHE_KEY,
70+
(self._data, self._ticker_to_cik, self._cik_to_entries),
71+
TTL_TICKERS,
72+
)
73+
5674
def resolve_ticker(self, ticker: str) -> str:
5775
"""Resolves a ticker symbol to a zero-padded CIK string.
5876

edgar/xbrl.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from enum import Enum
66
from typing import Union
7+
8+
from edgar.cache import TTL_TAXONOMY
79
from edgar.models import Facts
810
from edgar.session import EdgarSession
911

@@ -133,6 +135,14 @@ def company_facts(self, cik: str) -> dict | None:
133135
num_of_zeros = 10 - len(cik)
134136
cik = num_of_zeros*"0" + cik
135137

138+
# Check TTL cache.
139+
cache = self.edgar_session.cache
140+
cache_key = f"company_facts:{cik}"
141+
if cache is not None:
142+
cached = cache.get(cache_key)
143+
if cached is not None:
144+
return cached
145+
136146
endpoint = f'/api/xbrl/companyfacts/CIK{cik}.json'
137147

138148
# Grab the Data.
@@ -142,6 +152,10 @@ def company_facts(self, cik: str) -> dict | None:
142152
use_api=True
143153
)
144154

155+
# Store in TTL cache.
156+
if cache is not None and response is not None:
157+
cache.set(cache_key, response, TTL_TAXONOMY)
158+
145159
return response
146160

147161
def get_facts(self, cik: str) -> object:

0 commit comments

Comments
 (0)