arkleon 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arkleon/__init__.py +48 -0
- arkleon/api/__init__.py +49 -0
- arkleon/api/auth.py +58 -0
- arkleon/api/client.py +378 -0
- arkleon/api/errors.py +202 -0
- arkleon/api/pagination.py +51 -0
- arkleon/edgar/__init__.py +30 -0
- arkleon/edgar/fetch.py +134 -0
- arkleon/edgar/frames.py +43 -0
- arkleon/edgar/fsds.py +61 -0
- arkleon/edgar/identifiers.py +47 -0
- arkleon/edgar/models.py +39 -0
- arkleon/edgar/parse.py +160 -0
- arkleon/edgar/useragent.py +17 -0
- arkleon/mcp/__init__.py +31 -0
- arkleon/mcp/server.py +121 -0
- arkleon/mcp/tools.py +466 -0
- arkleon/py.typed +0 -0
- arkleon-0.1.0.dist-info/METADATA +206 -0
- arkleon-0.1.0.dist-info/RECORD +23 -0
- arkleon-0.1.0.dist-info/WHEEL +4 -0
- arkleon-0.1.0.dist-info/entry_points.txt +2 -0
- arkleon-0.1.0.dist-info/licenses/LICENSE +21 -0
arkleon/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Free EDGAR core imported by default; api and mcp are optional extras."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from .edgar import (
|
|
8
|
+
AS_OF_WARNING,
|
|
9
|
+
Company,
|
|
10
|
+
EdgarClient,
|
|
11
|
+
Fact,
|
|
12
|
+
FactSet,
|
|
13
|
+
Filing,
|
|
14
|
+
MissingUserAgentError,
|
|
15
|
+
RateLimiter,
|
|
16
|
+
as_of,
|
|
17
|
+
build_ticker_map,
|
|
18
|
+
cik_to_int,
|
|
19
|
+
facts_to_dataframe,
|
|
20
|
+
normalize_cik,
|
|
21
|
+
parse_company_concept,
|
|
22
|
+
parse_company_facts,
|
|
23
|
+
parse_fsds_zip,
|
|
24
|
+
parse_submissions,
|
|
25
|
+
resolve_user_agent,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AS_OF_WARNING",
|
|
30
|
+
"Company",
|
|
31
|
+
"EdgarClient",
|
|
32
|
+
"Fact",
|
|
33
|
+
"FactSet",
|
|
34
|
+
"Filing",
|
|
35
|
+
"MissingUserAgentError",
|
|
36
|
+
"RateLimiter",
|
|
37
|
+
"as_of",
|
|
38
|
+
"build_ticker_map",
|
|
39
|
+
"cik_to_int",
|
|
40
|
+
"facts_to_dataframe",
|
|
41
|
+
"normalize_cik",
|
|
42
|
+
"parse_company_concept",
|
|
43
|
+
"parse_company_facts",
|
|
44
|
+
"parse_fsds_zip",
|
|
45
|
+
"parse_submissions",
|
|
46
|
+
"resolve_user_agent",
|
|
47
|
+
"__version__",
|
|
48
|
+
]
|
arkleon/api/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Optional paid /v1 client for the Arkleon Data API.
|
|
2
|
+
|
|
3
|
+
Component (b) of the arkleon package (spec section 6). This subpackage is
|
|
4
|
+
always importable because its HTTP dependency (httpx) is a base dependency;
|
|
5
|
+
there is no import gate. The paid gate is a RUNTIME requirement instead: the
|
|
6
|
+
client refuses to construct without an ak_-prefixed key (contract section 6.2),
|
|
7
|
+
resolved from the constructor argument or the ARKLEON_API_KEY environment
|
|
8
|
+
variable.
|
|
9
|
+
|
|
10
|
+
Nothing here activates without a key. Installing the client does not activate
|
|
11
|
+
the paid path; a key must still be supplied at runtime.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .client import DataClient
|
|
17
|
+
from .errors import (
|
|
18
|
+
ArkleonAPIError,
|
|
19
|
+
AuthError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
NotServedError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
RequestError,
|
|
25
|
+
ScopeError,
|
|
26
|
+
ServerError,
|
|
27
|
+
UnprocessableError,
|
|
28
|
+
error_from_envelope,
|
|
29
|
+
)
|
|
30
|
+
from .errors import NotImplementedError # noqa: A004 (contract alias, spec section 6.5)
|
|
31
|
+
from .pagination import Page
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DataClient",
|
|
35
|
+
"Page",
|
|
36
|
+
"error_from_envelope",
|
|
37
|
+
"ArkleonAPIError",
|
|
38
|
+
"AuthError",
|
|
39
|
+
"ScopeError",
|
|
40
|
+
"RequestError",
|
|
41
|
+
"NotFoundError",
|
|
42
|
+
"ConflictError",
|
|
43
|
+
"UnprocessableError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"NotServedError",
|
|
46
|
+
"ServerError",
|
|
47
|
+
# Alias exported per spec section 6.5; primary name is NotServedError.
|
|
48
|
+
"NotImplementedError",
|
|
49
|
+
]
|
arkleon/api/auth.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The paid gate: ak_ Bearer key resolution.
|
|
2
|
+
|
|
3
|
+
The key is read from an explicit argument or, failing that, the
|
|
4
|
+
``ARKLEON_API_KEY`` environment variable (contract section 6.2). The client
|
|
5
|
+
refuses to operate without an ak_-prefixed key, raising locally before any
|
|
6
|
+
network contact so an unauthenticated request is never sent. Keys are
|
|
7
|
+
presented, never stored or logged: nothing here writes the key to a file, a
|
|
8
|
+
log line, or an exception message.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from .errors import AuthError
|
|
16
|
+
|
|
17
|
+
__all__ = ["resolve_api_key", "optional_api_key", "API_KEY_ENV_VAR", "API_KEY_PREFIX"]
|
|
18
|
+
|
|
19
|
+
API_KEY_ENV_VAR = "ARKLEON_API_KEY"
|
|
20
|
+
API_KEY_PREFIX = "ak_"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_api_key(explicit: str | None) -> str:
|
|
24
|
+
"""Return an ak_-prefixed key or raise AuthError locally.
|
|
25
|
+
|
|
26
|
+
Resolution order: the explicit argument, then ``ARKLEON_API_KEY``. The key
|
|
27
|
+
is required and validated for the ak_ prefix here, before any request, so
|
|
28
|
+
the client fails closed rather than issuing a silent unauthenticated call
|
|
29
|
+
(contract section 6.2). The raised message never contains the key value.
|
|
30
|
+
"""
|
|
31
|
+
key = explicit if explicit is not None else os.environ.get(API_KEY_ENV_VAR)
|
|
32
|
+
if not isinstance(key, str) or not key.strip():
|
|
33
|
+
raise AuthError(
|
|
34
|
+
"An Arkleon /v1 API key is required. Pass api_key=... or set the "
|
|
35
|
+
f"{API_KEY_ENV_VAR} environment variable to an {API_KEY_PREFIX}-prefixed key.",
|
|
36
|
+
error="unauthorized",
|
|
37
|
+
)
|
|
38
|
+
key = key.strip()
|
|
39
|
+
if not key.startswith(API_KEY_PREFIX):
|
|
40
|
+
raise AuthError(
|
|
41
|
+
f"Arkleon /v1 API keys must be {API_KEY_PREFIX}-prefixed. The supplied "
|
|
42
|
+
"value is not a valid key.",
|
|
43
|
+
error="unauthorized",
|
|
44
|
+
)
|
|
45
|
+
return key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def optional_api_key(explicit: str | None) -> str | None:
|
|
49
|
+
"""Return a resolvable ak_ key, or None when none is available.
|
|
50
|
+
|
|
51
|
+
A soft companion to resolve_api_key for callers that must not raise when no
|
|
52
|
+
key is present, such as the MCP server deciding whether to register the paid
|
|
53
|
+
tools (spec section 7.1). It never raises and never logs the key.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
return resolve_api_key(explicit)
|
|
57
|
+
except AuthError:
|
|
58
|
+
return None
|
arkleon/api/client.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Thin, faithful client for the Arkleon /v1 Data API.
|
|
2
|
+
|
|
3
|
+
This client adds no semantics the contract at
|
|
4
|
+
``docs/data-layer/api-v1-contract.md`` does not define. It is a transport
|
|
5
|
+
wrapper: it presents an ak_ key on every request (the paid gate, section 6.2),
|
|
6
|
+
enforces the caller-preventable validations the contract specifies locally,
|
|
7
|
+
maps the closed error alphabet to typed exceptions (section 4.2), paginates by
|
|
8
|
+
opaque cursor while keeping as_of stable (section 2.1.3), and exposes only the
|
|
9
|
+
public /v1 vocabulary on returned facts, never the internal FSDS or ingest
|
|
10
|
+
telemetry columns (section 3.1.1).
|
|
11
|
+
|
|
12
|
+
The value this client has over the free EDGAR core's best-effort as_of filter
|
|
13
|
+
is the corpus's permanent-reproducibility guarantee (contract section 3.2):
|
|
14
|
+
identical query plus identical as_of returns identical data, permanently. The
|
|
15
|
+
client does nothing to undermine that: it never caches then serves a stale
|
|
16
|
+
value as if it were authoritative, and it never substitutes "today" for a
|
|
17
|
+
missing as_of.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from .auth import resolve_api_key
|
|
29
|
+
from .errors import RateLimitError, RequestError, error_from_envelope
|
|
30
|
+
from .pagination import Page, paginate
|
|
31
|
+
|
|
32
|
+
__all__ = ["DataClient"]
|
|
33
|
+
|
|
34
|
+
DEFAULT_BASE_URL = "https://api.arkleon.com/v1"
|
|
35
|
+
|
|
36
|
+
# Internal ingest telemetry the contract forbids on /v1 (section 3.1.1). The
|
|
37
|
+
# server MUST NOT emit these; the client strips them as defense in depth so a
|
|
38
|
+
# non-conforming server can never leak them to a caller.
|
|
39
|
+
_INTERNAL_FIELDS = frozenset({"created_via", "ingested_at", "run_id", "accepted"})
|
|
40
|
+
|
|
41
|
+
# Ceiling on a single honored Retry-After sleep, so a pathological or hostile
|
|
42
|
+
# Retry-After value cannot block a caller unboundedly.
|
|
43
|
+
_MAX_RETRY_AFTER_SLEEP_SECONDS = 120.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _strip_internal(record: Any) -> Any:
|
|
47
|
+
"""Remove forbidden internal telemetry fields from one record."""
|
|
48
|
+
if isinstance(record, dict):
|
|
49
|
+
return {key: value for key, value in record.items() if key not in _INTERNAL_FIELDS}
|
|
50
|
+
return record
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DataClient:
|
|
54
|
+
"""Client for the three /v1 endpoints: facts, filings, companies."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
api_key: str | None = None,
|
|
59
|
+
*,
|
|
60
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
61
|
+
timeout: float = 30.0,
|
|
62
|
+
client: httpx.Client | None = None,
|
|
63
|
+
max_rate_limit_retries: int = 2,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Construct the client and resolve the paid gate immediately.
|
|
66
|
+
|
|
67
|
+
``api_key`` (or ARKLEON_API_KEY) must be an ak_-prefixed key or
|
|
68
|
+
construction raises AuthError locally, before any network contact
|
|
69
|
+
(contract section 6.2). ``base_url`` must be https. Pass ``client`` to
|
|
70
|
+
inject a preconfigured httpx.Client (for tests or custom transports);
|
|
71
|
+
otherwise one is created and owned by this instance.
|
|
72
|
+
``max_rate_limit_retries`` bounds how many times a 429 is retried while
|
|
73
|
+
honoring the server's Retry-After (contract section 1.3).
|
|
74
|
+
"""
|
|
75
|
+
# Fail closed at construction: no client exists without a valid key.
|
|
76
|
+
self._api_key = resolve_api_key(api_key)
|
|
77
|
+
if not base_url.lower().startswith("https://"):
|
|
78
|
+
raise ValueError("base_url must be https; /v1 is HTTPS only (contract section 6.1).")
|
|
79
|
+
self._base_url = base_url.rstrip("/")
|
|
80
|
+
self._max_rate_limit_retries = max(0, int(max_rate_limit_retries))
|
|
81
|
+
self._owns_client = client is None
|
|
82
|
+
self._client = client if client is not None else httpx.Client(timeout=timeout)
|
|
83
|
+
|
|
84
|
+
# -- lifecycle ---------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
"""Close the underlying httpx client if this instance owns it."""
|
|
88
|
+
if self._owns_client:
|
|
89
|
+
self._client.close()
|
|
90
|
+
|
|
91
|
+
def __enter__(self) -> DataClient:
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(self, *exc: object) -> None:
|
|
95
|
+
self.close()
|
|
96
|
+
|
|
97
|
+
# -- transport ---------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _headers(self) -> dict[str, str]:
|
|
100
|
+
# The key is presented on every request and never logged (section 6.2).
|
|
101
|
+
return {
|
|
102
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
103
|
+
"Accept": "application/json",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _clean_params(params: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
# Drop unset filters so the server sees only supplied values. The cursor
|
|
109
|
+
# is passed through untouched and never parsed (section 2.1.3).
|
|
110
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
111
|
+
|
|
112
|
+
def _request(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
url = f"{self._base_url}{path}"
|
|
114
|
+
cleaned = self._clean_params(params)
|
|
115
|
+
attempts = self._max_rate_limit_retries + 1
|
|
116
|
+
for attempt in range(attempts):
|
|
117
|
+
response = self._client.get(url, params=cleaned, headers=self._headers())
|
|
118
|
+
if response.status_code < 400:
|
|
119
|
+
return self._parse_success(response)
|
|
120
|
+
exception = self._exception_for(response)
|
|
121
|
+
if (
|
|
122
|
+
isinstance(exception, RateLimitError)
|
|
123
|
+
and attempt < attempts - 1
|
|
124
|
+
and exception.retry_after is not None
|
|
125
|
+
):
|
|
126
|
+
# Honor the server's Retry-After rather than a fixed schedule
|
|
127
|
+
# (contract section 1.3), capped to avoid unbounded blocking.
|
|
128
|
+
time.sleep(min(float(exception.retry_after), _MAX_RETRY_AFTER_SLEEP_SECONDS))
|
|
129
|
+
continue
|
|
130
|
+
raise exception
|
|
131
|
+
# Unreachable: the loop either returns or raises on the final attempt.
|
|
132
|
+
raise RuntimeError("unreachable pagination retry loop")
|
|
133
|
+
|
|
134
|
+
def _exception_for(self, response: httpx.Response) -> Exception:
|
|
135
|
+
try:
|
|
136
|
+
body = response.json()
|
|
137
|
+
except ValueError:
|
|
138
|
+
body = {}
|
|
139
|
+
if not isinstance(body, dict):
|
|
140
|
+
body = {}
|
|
141
|
+
exception = error_from_envelope(response.status_code, body)
|
|
142
|
+
# If the 429 envelope did not carry retry_after, take it from the
|
|
143
|
+
# mandatory Retry-After header (contract section 1.3).
|
|
144
|
+
if isinstance(exception, RateLimitError) and exception.retry_after is None:
|
|
145
|
+
header = response.headers.get("Retry-After")
|
|
146
|
+
if header is not None:
|
|
147
|
+
try:
|
|
148
|
+
exception.retry_after = float(header)
|
|
149
|
+
except ValueError:
|
|
150
|
+
exception.retry_after = None
|
|
151
|
+
return exception
|
|
152
|
+
|
|
153
|
+
def _parse_success(self, response: httpx.Response) -> dict[str, Any]:
|
|
154
|
+
try:
|
|
155
|
+
payload = response.json()
|
|
156
|
+
except ValueError as error:
|
|
157
|
+
raise error_from_envelope(
|
|
158
|
+
502, {"error": "internal_error", "message": "Malformed response body"}
|
|
159
|
+
) from error
|
|
160
|
+
if not isinstance(payload, dict):
|
|
161
|
+
raise error_from_envelope(
|
|
162
|
+
502, {"error": "internal_error", "message": "Malformed response body"}
|
|
163
|
+
)
|
|
164
|
+
data = payload.get("data", [])
|
|
165
|
+
if not isinstance(data, list):
|
|
166
|
+
data = []
|
|
167
|
+
payload["data"] = [_strip_internal(record) for record in data]
|
|
168
|
+
return payload
|
|
169
|
+
|
|
170
|
+
def _page(self, path: str, params: dict[str, Any], *, as_of: str | None) -> Page:
|
|
171
|
+
payload = self._request(path, params)
|
|
172
|
+
next_cursor = payload.get("next_cursor")
|
|
173
|
+
next_cursor = next_cursor if isinstance(next_cursor, str) else None
|
|
174
|
+
request_context = {
|
|
175
|
+
key: value for key, value in self._clean_params(params).items() if key != "cursor"
|
|
176
|
+
}
|
|
177
|
+
return Page(
|
|
178
|
+
data=list(payload["data"]),
|
|
179
|
+
next_cursor=next_cursor,
|
|
180
|
+
as_of=as_of,
|
|
181
|
+
request=request_context,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# -- /v1/facts ---------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def facts(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
as_of: str,
|
|
190
|
+
cik: int | None = None,
|
|
191
|
+
tag: str | None = None,
|
|
192
|
+
taxonomy: str | None = None,
|
|
193
|
+
period_start: str | None = None,
|
|
194
|
+
period_end: str | None = None,
|
|
195
|
+
duration_quarters: int | None = None,
|
|
196
|
+
unit: str | None = None,
|
|
197
|
+
form: str | None = None,
|
|
198
|
+
ticker: str | None = None,
|
|
199
|
+
limit: int = 100,
|
|
200
|
+
cursor: str | None = None,
|
|
201
|
+
) -> Page:
|
|
202
|
+
"""GET /v1/facts: certified point-in-time numeric facts.
|
|
203
|
+
|
|
204
|
+
``as_of`` is REQUIRED and has NO default. It filters on the FILING date
|
|
205
|
+
(``filed <= as_of``), never on the period a fact describes: a fact from
|
|
206
|
+
a filing submitted after as_of never appears, regardless of the period
|
|
207
|
+
it reports (contract section 2.1.1). Omitting as_of raises RequestError
|
|
208
|
+
locally, before any request: the client MUST NOT substitute "today,"
|
|
209
|
+
because the caller who forgot as_of is the caller who most needs to be
|
|
210
|
+
stopped. Use period_start / period_end to bound the fact's period.
|
|
211
|
+
|
|
212
|
+
``cik`` and ``ticker`` MUST NOT both be supplied (contract section
|
|
213
|
+
2.1.2); doing so raises RequestError locally. ``ticker`` is not served
|
|
214
|
+
in v1: the request is forwarded and the server's 501 surfaces as
|
|
215
|
+
NotServedError, never a silent free-core resolution (contract section
|
|
216
|
+
2.3.1, spec section 6.4). Address facts by ``cik``.
|
|
217
|
+
"""
|
|
218
|
+
if as_of is None:
|
|
219
|
+
raise RequestError(
|
|
220
|
+
"as_of is required on facts() and has no default; the client will "
|
|
221
|
+
"not substitute today (contract section 2.1.1).",
|
|
222
|
+
error="invalid_request",
|
|
223
|
+
)
|
|
224
|
+
if cik is not None and ticker is not None:
|
|
225
|
+
raise RequestError(
|
|
226
|
+
"cik and ticker must not both be supplied (contract section 2.1.2).",
|
|
227
|
+
error="invalid_request",
|
|
228
|
+
)
|
|
229
|
+
params: dict[str, Any] = {
|
|
230
|
+
"as_of": as_of,
|
|
231
|
+
"cik": cik,
|
|
232
|
+
"tag": tag,
|
|
233
|
+
"taxonomy": taxonomy,
|
|
234
|
+
"period_start": period_start,
|
|
235
|
+
"period_end": period_end,
|
|
236
|
+
"duration_quarters": duration_quarters,
|
|
237
|
+
"unit": unit,
|
|
238
|
+
"form": form,
|
|
239
|
+
"ticker": ticker,
|
|
240
|
+
"limit": limit,
|
|
241
|
+
"cursor": cursor,
|
|
242
|
+
}
|
|
243
|
+
return self._page("/facts", params, as_of=as_of)
|
|
244
|
+
|
|
245
|
+
def facts_iter(self, **kwargs: Any) -> Iterator[dict[str, Any]]:
|
|
246
|
+
"""Iterate every fact across pages, resending the same as_of.
|
|
247
|
+
|
|
248
|
+
Accepts the same keyword arguments as facts(). The same as_of is
|
|
249
|
+
resent on every page so the result set stays as_of-stable; a mismatched
|
|
250
|
+
as_of on a cursor request is a 400 the iterator prevents by
|
|
251
|
+
construction (contract section 2.1.3). Any incoming ``cursor`` is
|
|
252
|
+
ignored: iteration always starts from the beginning.
|
|
253
|
+
"""
|
|
254
|
+
kwargs.pop("cursor", None)
|
|
255
|
+
|
|
256
|
+
def fetch(cursor: str | None) -> Page:
|
|
257
|
+
return self.facts(cursor=cursor, **kwargs)
|
|
258
|
+
|
|
259
|
+
return paginate(fetch)
|
|
260
|
+
|
|
261
|
+
# -- /v1/filings -------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def filings(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
as_of: str | None = None,
|
|
267
|
+
cik: int | None = None,
|
|
268
|
+
ticker: str | None = None,
|
|
269
|
+
form: str | None = None,
|
|
270
|
+
filed_start: str | None = None,
|
|
271
|
+
filed_end: str | None = None,
|
|
272
|
+
period_start: str | None = None,
|
|
273
|
+
period_end: str | None = None,
|
|
274
|
+
limit: int = 100,
|
|
275
|
+
cursor: str | None = None,
|
|
276
|
+
) -> Page:
|
|
277
|
+
"""GET /v1/filings: submissions metadata.
|
|
278
|
+
|
|
279
|
+
``as_of`` is optional here and means the same thing: return only filings
|
|
280
|
+
filed on or before the date (contract section 2.2). ``ticker`` is not
|
|
281
|
+
served in v1; the server's 501 surfaces as NotServedError. ``cik`` and
|
|
282
|
+
``ticker`` MUST NOT both be supplied.
|
|
283
|
+
"""
|
|
284
|
+
if cik is not None and ticker is not None:
|
|
285
|
+
raise RequestError(
|
|
286
|
+
"cik and ticker must not both be supplied (contract section 2.1.2).",
|
|
287
|
+
error="invalid_request",
|
|
288
|
+
)
|
|
289
|
+
params: dict[str, Any] = {
|
|
290
|
+
"as_of": as_of,
|
|
291
|
+
"cik": cik,
|
|
292
|
+
"ticker": ticker,
|
|
293
|
+
"form": form,
|
|
294
|
+
"filed_start": filed_start,
|
|
295
|
+
"filed_end": filed_end,
|
|
296
|
+
"period_start": period_start,
|
|
297
|
+
"period_end": period_end,
|
|
298
|
+
"limit": limit,
|
|
299
|
+
"cursor": cursor,
|
|
300
|
+
}
|
|
301
|
+
return self._page("/filings", params, as_of=as_of)
|
|
302
|
+
|
|
303
|
+
def filings_iter(self, **kwargs: Any) -> Iterator[dict[str, Any]]:
|
|
304
|
+
"""Iterate every filing across pages, resending the same as_of."""
|
|
305
|
+
kwargs.pop("cursor", None)
|
|
306
|
+
|
|
307
|
+
def fetch(cursor: str | None) -> Page:
|
|
308
|
+
return self.filings(cursor=cursor, **kwargs)
|
|
309
|
+
|
|
310
|
+
return paginate(fetch)
|
|
311
|
+
|
|
312
|
+
# -- /v1/companies -----------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def companies(
|
|
315
|
+
self,
|
|
316
|
+
*,
|
|
317
|
+
cik: int | None = None,
|
|
318
|
+
name: str | None = None,
|
|
319
|
+
ticker: str | None = None,
|
|
320
|
+
limit: int = 100,
|
|
321
|
+
cursor: str | None = None,
|
|
322
|
+
) -> Page:
|
|
323
|
+
"""GET /v1/companies: identifier resolution, cik- and name-addressable.
|
|
324
|
+
|
|
325
|
+
In v1 the endpoint is cik- and name-addressable only. ``ticker`` is not
|
|
326
|
+
served and the server's 501 surfaces as NotServedError; resolution rules
|
|
327
|
+
1 to 4 of the contract describe the endpoint's completed form and are
|
|
328
|
+
dormant until a persisted point-in-time ticker to CIK mapping exists
|
|
329
|
+
(contract section 2.3.1). ``cik`` and ``ticker`` MUST NOT both be
|
|
330
|
+
supplied.
|
|
331
|
+
"""
|
|
332
|
+
if cik is not None and ticker is not None:
|
|
333
|
+
raise RequestError(
|
|
334
|
+
"cik and ticker must not both be supplied (contract section 2.1.2).",
|
|
335
|
+
error="invalid_request",
|
|
336
|
+
)
|
|
337
|
+
params: dict[str, Any] = {
|
|
338
|
+
"cik": cik,
|
|
339
|
+
"name": name,
|
|
340
|
+
"ticker": ticker,
|
|
341
|
+
"limit": limit,
|
|
342
|
+
"cursor": cursor,
|
|
343
|
+
}
|
|
344
|
+
return self._page("/companies", params, as_of=None)
|
|
345
|
+
|
|
346
|
+
# -- opt-in, non-point-in-time convenience -----------------------------
|
|
347
|
+
|
|
348
|
+
def facts_by_ticker_via_edgar_snapshot(
|
|
349
|
+
self,
|
|
350
|
+
*,
|
|
351
|
+
ticker: str,
|
|
352
|
+
as_of: str,
|
|
353
|
+
user_agent: str | None = None,
|
|
354
|
+
**facts_kwargs: Any,
|
|
355
|
+
) -> Page:
|
|
356
|
+
"""Resolve a ticker to a CIK via the free EDGAR snapshot, then query facts.
|
|
357
|
+
|
|
358
|
+
WARNING: OPT-IN, CURRENT-SNAPSHOT, NON-POINT-IN-TIME. This convenience
|
|
359
|
+
resolves ``ticker`` through SEC's current company_tickers.json snapshot,
|
|
360
|
+
which has no history: a ticker string is reassigned over time and one
|
|
361
|
+
CIK carries several symbols at once. The resolved CIK reflects TODAY's
|
|
362
|
+
assignment, not the assignment in effect on ``as_of``. Using it in a
|
|
363
|
+
backtest can silently hold the wrong company, which is exactly the
|
|
364
|
+
failure the contract's 501-on-ticker rule exists to prevent (contract
|
|
365
|
+
section 2.3.1, spec section 6.4).
|
|
366
|
+
|
|
367
|
+
The default facts() path NEVER does this. This method exists only for a
|
|
368
|
+
caller who asks for it by name and accepts the non-point-in-time
|
|
369
|
+
resolution. Prefer addressing facts by ``cik`` directly.
|
|
370
|
+
"""
|
|
371
|
+
# arkleon.api MAY depend on arkleon.edgar; the boundary is one
|
|
372
|
+
# directional (edgar never imports api). Imported lazily so the paid
|
|
373
|
+
# client does not pull the free core unless this method is called.
|
|
374
|
+
from arkleon.edgar import EdgarClient
|
|
375
|
+
|
|
376
|
+
edgar = EdgarClient(user_agent=user_agent)
|
|
377
|
+
cik = edgar.resolve_cik(ticker)
|
|
378
|
+
return self.facts(as_of=as_of, cik=cik, **facts_kwargs)
|