david-data 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.
- david_data/__init__.py +41 -0
- david_data/_http.py +172 -0
- david_data/_pandas.py +27 -0
- david_data/_version.py +1 -0
- david_data/client.py +125 -0
- david_data/errors.py +121 -0
- david_data/py.typed +0 -0
- david_data/resources/__init__.py +27 -0
- david_data/resources/base.py +62 -0
- david_data/resources/company.py +59 -0
- david_data/resources/documents.py +129 -0
- david_data/resources/estimates.py +132 -0
- david_data/resources/financials.py +248 -0
- david_data/resources/macro.py +66 -0
- david_data/resources/metadata.py +37 -0
- david_data/resources/ownership.py +153 -0
- david_data/resources/prices.py +79 -0
- david_data/resources/scenarios.py +78 -0
- david_data-0.1.0.dist-info/METADATA +171 -0
- david_data-0.1.0.dist-info/RECORD +22 -0
- david_data-0.1.0.dist-info/WHEEL +4 -0
- david_data-0.1.0.dist-info/licenses/LICENSE +21 -0
david_data/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""David Data — official Python client for the David Data financial-data API.
|
|
2
|
+
|
|
3
|
+
from david_data import DavidData
|
|
4
|
+
|
|
5
|
+
dd = DavidData(api_key="...")
|
|
6
|
+
prices = dd.prices.get("AAPL", start_date="2024-01-01")
|
|
7
|
+
|
|
8
|
+
See https://api.davidhf.com/docs for the full endpoint reference.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._pandas import to_df
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .client import DavidData
|
|
14
|
+
from .errors import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APIStatusError,
|
|
17
|
+
APITimeoutError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
BadRequestError,
|
|
20
|
+
DavidDataError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
PermissionDeniedError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ServerError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DavidData",
|
|
29
|
+
"to_df",
|
|
30
|
+
"__version__",
|
|
31
|
+
"DavidDataError",
|
|
32
|
+
"APIConnectionError",
|
|
33
|
+
"APITimeoutError",
|
|
34
|
+
"APIStatusError",
|
|
35
|
+
"BadRequestError",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"PermissionDeniedError",
|
|
38
|
+
"NotFoundError",
|
|
39
|
+
"RateLimitError",
|
|
40
|
+
"ServerError",
|
|
41
|
+
]
|
david_data/_http.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Low-level transport: auth header, retries with backoff, error mapping.
|
|
2
|
+
|
|
3
|
+
This module is intentionally small and dependency-light (httpx only). The public
|
|
4
|
+
:class:`~david_data.client.DavidData` wraps it and exposes the resource groups.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Mapping, Optional, Union
|
|
12
|
+
from urllib.parse import urljoin
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .errors import (
|
|
18
|
+
APIConnectionError,
|
|
19
|
+
APITimeoutError,
|
|
20
|
+
DavidDataError,
|
|
21
|
+
error_for_status,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.davidhf.com"
|
|
25
|
+
ENV_API_KEY = "DAVID_DATA_API_KEY"
|
|
26
|
+
ENV_BASE_URL = "DAVID_DATA_BASE_URL"
|
|
27
|
+
|
|
28
|
+
# Status codes worth retrying: rate limits and transient server faults.
|
|
29
|
+
_RETRY_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Transport:
|
|
33
|
+
"""Owns the httpx client, applies auth, and retries idempotent failures."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: Optional[str],
|
|
38
|
+
*,
|
|
39
|
+
base_url: Optional[str] = None,
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
max_retries: int = 3,
|
|
42
|
+
http_client: Optional[httpx.Client] = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
api_key = api_key or os.environ.get(ENV_API_KEY)
|
|
45
|
+
if not api_key:
|
|
46
|
+
raise DavidDataError(
|
|
47
|
+
"No API key provided. Pass api_key=... or set the "
|
|
48
|
+
f"{ENV_API_KEY} environment variable. Get a key at https://davidhf.com."
|
|
49
|
+
)
|
|
50
|
+
base_url = (base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/") + "/"
|
|
51
|
+
self.api_key = api_key
|
|
52
|
+
self.base_url = base_url
|
|
53
|
+
self.max_retries = max(0, int(max_retries))
|
|
54
|
+
self._owns_client = http_client is None
|
|
55
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
56
|
+
self._headers = {
|
|
57
|
+
"X-API-KEY": api_key,
|
|
58
|
+
"Accept": "application/json",
|
|
59
|
+
"User-Agent": f"david-data-python/{__version__}",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# -- lifecycle ---------------------------------------------------------
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
if self._owns_client:
|
|
65
|
+
self._client.close()
|
|
66
|
+
|
|
67
|
+
def __enter__(self) -> "Transport":
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *exc: Any) -> None:
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
# -- requests ----------------------------------------------------------
|
|
74
|
+
def request(
|
|
75
|
+
self,
|
|
76
|
+
method: str,
|
|
77
|
+
path: str,
|
|
78
|
+
*,
|
|
79
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
80
|
+
json: Any = None,
|
|
81
|
+
) -> Any:
|
|
82
|
+
url = urljoin(self.base_url, path.lstrip("/"))
|
|
83
|
+
clean_params = _clean_params(params) if params else None
|
|
84
|
+
|
|
85
|
+
last_exc: Optional[Exception] = None
|
|
86
|
+
for attempt in range(self.max_retries + 1):
|
|
87
|
+
try:
|
|
88
|
+
response = self._client.request(
|
|
89
|
+
method, url, params=clean_params, json=json, headers=self._headers
|
|
90
|
+
)
|
|
91
|
+
except httpx.TimeoutException as exc:
|
|
92
|
+
last_exc = APITimeoutError(f"Request to {url} timed out: {exc}")
|
|
93
|
+
except httpx.HTTPError as exc:
|
|
94
|
+
last_exc = APIConnectionError(f"Could not reach {url}: {exc}")
|
|
95
|
+
else:
|
|
96
|
+
if response.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
|
97
|
+
time.sleep(_retry_delay(response, attempt))
|
|
98
|
+
continue
|
|
99
|
+
return _handle_response(response)
|
|
100
|
+
|
|
101
|
+
if attempt < self.max_retries:
|
|
102
|
+
time.sleep(_backoff(attempt))
|
|
103
|
+
assert last_exc is not None
|
|
104
|
+
raise last_exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _handle_response(response: httpx.Response) -> Any:
|
|
108
|
+
request_id = response.headers.get("x-request-id")
|
|
109
|
+
if response.is_success:
|
|
110
|
+
if not response.content:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
return response.json()
|
|
114
|
+
except ValueError:
|
|
115
|
+
return response.text
|
|
116
|
+
body = _safe_body(response)
|
|
117
|
+
raise error_for_status(
|
|
118
|
+
response.status_code,
|
|
119
|
+
body=body,
|
|
120
|
+
request_id=request_id,
|
|
121
|
+
retry_after=_parse_retry_after(response),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _safe_body(response: httpx.Response) -> Any:
|
|
126
|
+
try:
|
|
127
|
+
return response.json()
|
|
128
|
+
except ValueError:
|
|
129
|
+
return response.text
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _retry_delay(response: httpx.Response, attempt: int) -> float:
|
|
133
|
+
retry_after = _parse_retry_after(response)
|
|
134
|
+
if retry_after is not None:
|
|
135
|
+
return retry_after
|
|
136
|
+
return _backoff(attempt)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_retry_after(response: httpx.Response) -> Optional[float]:
|
|
140
|
+
raw = response.headers.get("retry-after")
|
|
141
|
+
if not raw:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return max(0.0, float(raw))
|
|
145
|
+
except ValueError:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _backoff(attempt: int) -> float:
|
|
150
|
+
# Exponential backoff, capped. attempt is 0-based.
|
|
151
|
+
return min(8.0, 0.5 * (2 ** attempt))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _clean_params(params: Mapping[str, Any]) -> dict:
|
|
155
|
+
"""Drop None values and normalise dates/bools so callers can pass natural types."""
|
|
156
|
+
out: dict[str, Any] = {}
|
|
157
|
+
for key, value in params.items():
|
|
158
|
+
if value is None:
|
|
159
|
+
continue
|
|
160
|
+
out[key] = _coerce(value)
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _coerce(value: Any) -> Union[str, int, float, list]:
|
|
165
|
+
if isinstance(value, bool):
|
|
166
|
+
return "true" if value else "false"
|
|
167
|
+
# date / datetime -> ISO string without importing datetime eagerly.
|
|
168
|
+
if hasattr(value, "isoformat") and not isinstance(value, (str, bytes)):
|
|
169
|
+
return value.isoformat()
|
|
170
|
+
if isinstance(value, (list, tuple)):
|
|
171
|
+
return [_coerce(v) for v in value]
|
|
172
|
+
return value
|
david_data/_pandas.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Optional pandas helpers. pandas is only imported when these are called."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_df(data: Any): # noqa: ANN401 - returns a pandas.DataFrame
|
|
9
|
+
"""Convert an API result into a :class:`pandas.DataFrame`.
|
|
10
|
+
|
|
11
|
+
Accepts a list of records (the common case — prices, statements, news, …)
|
|
12
|
+
or a single dict. Requires the ``pandas`` extra: ``pip install david-data[pandas]``.
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
import pandas as pd
|
|
16
|
+
except ImportError as exc: # pragma: no cover - exercised via extra
|
|
17
|
+
raise ImportError(
|
|
18
|
+
"pandas is required for to_df(). Install it with: pip install david-data[pandas]"
|
|
19
|
+
) from exc
|
|
20
|
+
|
|
21
|
+
if isinstance(data, dict):
|
|
22
|
+
# A list-bearing envelope that slipped through, else a single record.
|
|
23
|
+
list_values = [v for v in data.values() if isinstance(v, list)]
|
|
24
|
+
if len(list_values) == 1:
|
|
25
|
+
return pd.DataFrame(list_values[0])
|
|
26
|
+
return pd.DataFrame([data])
|
|
27
|
+
return pd.DataFrame(data)
|
david_data/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
david_data/client.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The David Data client.
|
|
2
|
+
|
|
3
|
+
from david_data import DavidData
|
|
4
|
+
|
|
5
|
+
dd = DavidData(api_key="...") # or set DAVID_DATA_API_KEY
|
|
6
|
+
scenario = dd.scenarios.list(limit=1)[0]["id"]
|
|
7
|
+
bars = dd.prices.get("AAPL", scenario_id=scenario)
|
|
8
|
+
|
|
9
|
+
Every data call is keyed by a ``scenario_id`` (a synthetic world). Discover them
|
|
10
|
+
with ``dd.scenarios.list()``. Either pass ``scenario_id=`` per call, or set a
|
|
11
|
+
default once with ``DavidData(..., scenario_id="my-scenario")`` and omit it
|
|
12
|
+
thereafter. Calling a data endpoint with no scenario_id (and no client default)
|
|
13
|
+
raises a clear error rather than guessing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from . import resources as _r
|
|
23
|
+
from ._http import Transport
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DavidData:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
*,
|
|
32
|
+
scenario_id: Optional[str] = None,
|
|
33
|
+
base_url: Optional[str] = None,
|
|
34
|
+
timeout: float = 30.0,
|
|
35
|
+
max_retries: int = 3,
|
|
36
|
+
http_client: Optional[httpx.Client] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create a client.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
api_key: Your David Data API key. Falls back to the
|
|
42
|
+
``DAVID_DATA_API_KEY`` environment variable.
|
|
43
|
+
scenario_id: Default scenario applied to every data call when you
|
|
44
|
+
don't pass one explicitly. Leave it ``None`` to require an
|
|
45
|
+
explicit ``scenario_id=`` on each call. Discover ids with
|
|
46
|
+
``client.scenarios.list()``.
|
|
47
|
+
base_url: API root. Defaults to ``DAVID_DATA_BASE_URL`` or
|
|
48
|
+
``https://api.davidhf.com``.
|
|
49
|
+
timeout: Per-request timeout in seconds.
|
|
50
|
+
max_retries: Retries for rate limits (429) and transient 5xx errors,
|
|
51
|
+
honouring the server's ``Retry-After`` header.
|
|
52
|
+
http_client: Bring your own configured ``httpx.Client`` (proxies,
|
|
53
|
+
custom transport, etc.). The SDK will not close a client you pass.
|
|
54
|
+
"""
|
|
55
|
+
self._t = Transport(
|
|
56
|
+
api_key,
|
|
57
|
+
base_url=base_url,
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
max_retries=max_retries,
|
|
60
|
+
http_client=http_client,
|
|
61
|
+
)
|
|
62
|
+
self.scenario_id = scenario_id
|
|
63
|
+
|
|
64
|
+
s = scenario_id
|
|
65
|
+
t = self._t
|
|
66
|
+
#: OHLCV prices and snapshots.
|
|
67
|
+
self.prices = _r.Prices(t, s)
|
|
68
|
+
#: Financial statements, metrics, segments, KPIs, and the screener.
|
|
69
|
+
self.financials = _r.Financials(t, s)
|
|
70
|
+
#: Company directory, facts, and identifier lookups.
|
|
71
|
+
self.company = _r.Company(t, s)
|
|
72
|
+
#: News articles.
|
|
73
|
+
self.news = _r.News(t, s)
|
|
74
|
+
#: SEC-style filings and item search.
|
|
75
|
+
self.filings = _r.Filings(t, s)
|
|
76
|
+
#: Reported earnings and the earnings calendar.
|
|
77
|
+
self.earnings = _r.Earnings(t, s)
|
|
78
|
+
#: Analyst estimates and notes.
|
|
79
|
+
self.analyst = _r.Analyst(t, s)
|
|
80
|
+
#: The per-scenario event timeline.
|
|
81
|
+
self.events = _r.Events(t, s)
|
|
82
|
+
#: Insider trades and transactions.
|
|
83
|
+
self.insiders = _r.Insiders(t, s)
|
|
84
|
+
#: 13F institutional holdings.
|
|
85
|
+
self.institutional = _r.Institutional(t, s)
|
|
86
|
+
#: Index-fund constituents.
|
|
87
|
+
self.index_funds = _r.IndexFunds(t, s)
|
|
88
|
+
#: Corporate actions (splits, dividends).
|
|
89
|
+
self.corporate_actions = _r.CorporateActions(t, s)
|
|
90
|
+
#: Macro series and central-bank rates.
|
|
91
|
+
self.macro = _r.Macro(t, s)
|
|
92
|
+
#: Discover and generate scenarios.
|
|
93
|
+
self.scenarios = _r.Scenarios(t, s)
|
|
94
|
+
#: API coverage, presets, themes, and audits.
|
|
95
|
+
self.metadata = _r.Metadata(t, s)
|
|
96
|
+
|
|
97
|
+
# -- escape hatch ------------------------------------------------------
|
|
98
|
+
def get(self, path: str, **params: Any) -> Any:
|
|
99
|
+
"""Call any GET endpoint directly. ``scenario_id`` is *not* injected."""
|
|
100
|
+
return self._t.request("GET", path, params=params)
|
|
101
|
+
|
|
102
|
+
def post(self, path: str, *, json: Any = None, **params: Any) -> Any:
|
|
103
|
+
"""Call any POST endpoint directly."""
|
|
104
|
+
return self._t.request("POST", path, params=params or None, json=json)
|
|
105
|
+
|
|
106
|
+
def health(self) -> dict:
|
|
107
|
+
"""Liveness probe (unauthenticated on the server, but routed through here)."""
|
|
108
|
+
return self._t.request("GET", "/health")
|
|
109
|
+
|
|
110
|
+
# -- lifecycle ---------------------------------------------------------
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
"""Close the underlying HTTP connection pool."""
|
|
113
|
+
self._t.close()
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> "DavidData":
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *exc: Any) -> None:
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> str:
|
|
122
|
+
return (
|
|
123
|
+
f"DavidData(base_url={self._t.base_url!r}, scenario_id={self.scenario_id!r}, "
|
|
124
|
+
f"version={__version__!r})"
|
|
125
|
+
)
|
david_data/errors.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Exception hierarchy for the David Data client.
|
|
2
|
+
|
|
3
|
+
Every error raised by the SDK is a subclass of :class:`DavidDataError`, so you
|
|
4
|
+
can catch all of them with a single ``except DavidDataError``. HTTP failures
|
|
5
|
+
carry the parsed response body and status code when available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DavidDataError(Exception):
|
|
14
|
+
"""Base class for every error raised by the SDK."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APIConnectionError(DavidDataError):
|
|
18
|
+
"""The request never produced a response (DNS, TLS, timeout, connection reset)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APITimeoutError(APIConnectionError):
|
|
22
|
+
"""The request exceeded the configured timeout."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class APIStatusError(DavidDataError):
|
|
26
|
+
"""The server returned a non-2xx status code.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
status_code: The HTTP status code.
|
|
30
|
+
body: The parsed JSON body, or the raw text if it was not JSON.
|
|
31
|
+
detail: The server's ``detail`` message when present (FastAPI style).
|
|
32
|
+
request_id: The value of any ``x-request-id`` response header.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
status_code: int,
|
|
40
|
+
body: Any = None,
|
|
41
|
+
request_id: Optional[str] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.status_code = status_code
|
|
45
|
+
self.body = body
|
|
46
|
+
self.request_id = request_id
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def detail(self) -> Optional[str]:
|
|
50
|
+
if isinstance(self.body, dict):
|
|
51
|
+
d = self.body.get("detail")
|
|
52
|
+
if isinstance(d, str):
|
|
53
|
+
return d
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BadRequestError(APIStatusError):
|
|
58
|
+
"""400 — the request parameters were rejected by the server."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthenticationError(APIStatusError):
|
|
62
|
+
"""401 — missing or invalid API key."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PermissionDeniedError(APIStatusError):
|
|
66
|
+
"""403 — the key is valid but not allowed (e.g. plan quota, admin-only route)."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NotFoundError(APIStatusError):
|
|
70
|
+
"""404 — the scenario, ticker, or resource does not exist."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RateLimitError(APIStatusError):
|
|
74
|
+
"""429 — the per-minute rate limit was exceeded.
|
|
75
|
+
|
|
76
|
+
``retry_after`` is the server-advised wait in seconds, when provided.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *args: Any, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
80
|
+
super().__init__(*args, **kwargs)
|
|
81
|
+
self.retry_after = retry_after
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ServerError(APIStatusError):
|
|
85
|
+
"""5xx — the server failed to handle a valid request."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_STATUS_TO_ERROR = {
|
|
89
|
+
400: BadRequestError,
|
|
90
|
+
401: AuthenticationError,
|
|
91
|
+
403: PermissionDeniedError,
|
|
92
|
+
404: NotFoundError,
|
|
93
|
+
429: RateLimitError,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def error_for_status(
|
|
98
|
+
status_code: int, *, body: Any, request_id: Optional[str], retry_after: Optional[float] = None
|
|
99
|
+
) -> APIStatusError:
|
|
100
|
+
"""Build the most specific :class:`APIStatusError` subclass for a status code."""
|
|
101
|
+
message = _message_from_body(body) or f"HTTP {status_code}"
|
|
102
|
+
if status_code == 429:
|
|
103
|
+
return RateLimitError(
|
|
104
|
+
message, status_code=status_code, body=body, request_id=request_id, retry_after=retry_after
|
|
105
|
+
)
|
|
106
|
+
cls = _STATUS_TO_ERROR.get(status_code)
|
|
107
|
+
if cls is None:
|
|
108
|
+
cls = ServerError if status_code >= 500 else APIStatusError
|
|
109
|
+
return cls(message, status_code=status_code, body=body, request_id=request_id)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _message_from_body(body: Any) -> Optional[str]:
|
|
113
|
+
if isinstance(body, dict):
|
|
114
|
+
detail = body.get("detail")
|
|
115
|
+
if isinstance(detail, str):
|
|
116
|
+
return detail
|
|
117
|
+
if detail is not None:
|
|
118
|
+
return str(detail)
|
|
119
|
+
if isinstance(body, str) and body.strip():
|
|
120
|
+
return body.strip()[:500]
|
|
121
|
+
return None
|
david_data/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .company import Company
|
|
2
|
+
from .documents import Filings, News
|
|
3
|
+
from .estimates import Analyst, Earnings, Events
|
|
4
|
+
from .financials import Financials
|
|
5
|
+
from .macro import Macro
|
|
6
|
+
from .metadata import Metadata
|
|
7
|
+
from .ownership import CorporateActions, IndexFunds, Insiders, Institutional
|
|
8
|
+
from .prices import Prices
|
|
9
|
+
from .scenarios import Scenarios
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Analyst",
|
|
13
|
+
"Company",
|
|
14
|
+
"CorporateActions",
|
|
15
|
+
"Earnings",
|
|
16
|
+
"Events",
|
|
17
|
+
"Filings",
|
|
18
|
+
"Financials",
|
|
19
|
+
"IndexFunds",
|
|
20
|
+
"Insiders",
|
|
21
|
+
"Institutional",
|
|
22
|
+
"Macro",
|
|
23
|
+
"Metadata",
|
|
24
|
+
"News",
|
|
25
|
+
"Prices",
|
|
26
|
+
"Scenarios",
|
|
27
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Shared base for resource groups.
|
|
2
|
+
|
|
3
|
+
A resource group is a thin namespace (``client.prices``, ``client.financials``,
|
|
4
|
+
…) whose methods translate friendly arguments into a single HTTP call and
|
|
5
|
+
return the useful part of the JSON envelope.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
from .._http import Transport
|
|
13
|
+
from ..errors import DavidDataError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Resource:
|
|
17
|
+
def __init__(self, transport: Transport, default_scenario_id: Optional[str]) -> None:
|
|
18
|
+
self._t = transport
|
|
19
|
+
self._default_scenario_id = default_scenario_id
|
|
20
|
+
|
|
21
|
+
def _scenario(self, scenario_id: Optional[str]) -> str:
|
|
22
|
+
sid = scenario_id or self._default_scenario_id
|
|
23
|
+
if not sid:
|
|
24
|
+
raise DavidDataError(
|
|
25
|
+
"This endpoint requires a scenario_id. Pass scenario_id=... to the call, "
|
|
26
|
+
"or set a default on the client: DavidData(scenario_id='...'). "
|
|
27
|
+
"Browse available scenarios with client.scenarios.list()."
|
|
28
|
+
)
|
|
29
|
+
return sid
|
|
30
|
+
|
|
31
|
+
def _get(
|
|
32
|
+
self,
|
|
33
|
+
path: str,
|
|
34
|
+
*,
|
|
35
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
36
|
+
unwrap: Optional[str] = None,
|
|
37
|
+
) -> Any:
|
|
38
|
+
data = self._t.request("GET", path, params=params)
|
|
39
|
+
return _unwrap(data, unwrap)
|
|
40
|
+
|
|
41
|
+
def _post(
|
|
42
|
+
self,
|
|
43
|
+
path: str,
|
|
44
|
+
*,
|
|
45
|
+
json: Any = None,
|
|
46
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
47
|
+
unwrap: Optional[str] = None,
|
|
48
|
+
) -> Any:
|
|
49
|
+
data = self._t.request("POST", path, params=params, json=json)
|
|
50
|
+
return _unwrap(data, unwrap)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _unwrap(data: Any, key: Optional[str]) -> Any:
|
|
54
|
+
"""Return ``data[key]`` when present, else the whole payload.
|
|
55
|
+
|
|
56
|
+
The API wraps list/object results in a single-key envelope (e.g.
|
|
57
|
+
``{"prices": [...]}``). We return the inner value so callers get the data
|
|
58
|
+
directly, FMP-style, while still tolerating a missing/renamed key.
|
|
59
|
+
"""
|
|
60
|
+
if key and isinstance(data, dict) and key in data:
|
|
61
|
+
return data[key]
|
|
62
|
+
return data
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
from .base import Resource
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Company(Resource):
|
|
9
|
+
"""Company reference data: directory, facts, and identifier lookups."""
|
|
10
|
+
|
|
11
|
+
def list(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
scenario_id: Optional[str] = None,
|
|
15
|
+
ticker: Optional[str] = None,
|
|
16
|
+
search: Optional[str] = None,
|
|
17
|
+
limit: int = 100,
|
|
18
|
+
offset: int = 0,
|
|
19
|
+
) -> Any:
|
|
20
|
+
"""Browse or search the company directory."""
|
|
21
|
+
return self._get(
|
|
22
|
+
"/companies",
|
|
23
|
+
params={
|
|
24
|
+
"scenario_id": self._scenario(scenario_id),
|
|
25
|
+
"ticker": ticker,
|
|
26
|
+
"search": search,
|
|
27
|
+
"limit": limit,
|
|
28
|
+
"offset": offset,
|
|
29
|
+
},
|
|
30
|
+
unwrap="companies",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def facts(
|
|
34
|
+
self,
|
|
35
|
+
ticker: Optional[str] = None,
|
|
36
|
+
*,
|
|
37
|
+
cik: Optional[str] = None,
|
|
38
|
+
scenario_id: Optional[str] = None,
|
|
39
|
+
) -> Any:
|
|
40
|
+
"""Company facts for a single ``ticker`` or ``cik``."""
|
|
41
|
+
return self._get(
|
|
42
|
+
"/company/facts",
|
|
43
|
+
params={"scenario_id": self._scenario(scenario_id), "ticker": ticker, "cik": cik},
|
|
44
|
+
unwrap="company_facts",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def tickers(self, *, scenario_id: Optional[str] = None) -> List[str]:
|
|
48
|
+
return self._get(
|
|
49
|
+
"/company/facts/tickers",
|
|
50
|
+
params={"scenario_id": self._scenario(scenario_id)},
|
|
51
|
+
unwrap="tickers",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def ciks(self, *, scenario_id: Optional[str] = None) -> Any:
|
|
55
|
+
return self._get(
|
|
56
|
+
"/company/facts/ciks",
|
|
57
|
+
params={"scenario_id": self._scenario(scenario_id)},
|
|
58
|
+
unwrap="ciks",
|
|
59
|
+
)
|