kuznets 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.
- kuznets/__init__.py +74 -0
- kuznets/_output.py +337 -0
- kuznets/_testing.py +29 -0
- kuznets/_utils.py +128 -0
- kuznets/av/__init__.py +111 -0
- kuznets/av/forex.py +124 -0
- kuznets/av/time_series.py +154 -0
- kuznets/bankofcanada.py +49 -0
- kuznets/base.py +674 -0
- kuznets/compat/__init__.py +33 -0
- kuznets/config.py +211 -0
- kuznets/conftest.py +60 -0
- kuznets/data.py +522 -0
- kuznets/econdb.py +151 -0
- kuznets/eurostat.py +47 -0
- kuznets/famafrench.py +183 -0
- kuznets/fred.py +100 -0
- kuznets/io/__init__.py +3 -0
- kuznets/io/jsdmx.py +49 -0
- kuznets/io/jstat.py +54 -0
- kuznets/io/sdmx.py +237 -0
- kuznets/io/util.py +192 -0
- kuznets/moex.py +258 -0
- kuznets/nasdaq_trader.py +137 -0
- kuznets/naver.py +161 -0
- kuznets/oecd.py +57 -0
- kuznets/quandl.py +184 -0
- kuznets/stooq.py +52 -0
- kuznets/tiingo.py +387 -0
- kuznets/tsp.py +116 -0
- kuznets/wb.py +931 -0
- kuznets/yahoo/__init__.py +0 -0
- kuznets/yahoo/_auth.py +28 -0
- kuznets/yahoo/actions.py +93 -0
- kuznets/yahoo/daily.py +235 -0
- kuznets/yahoo/fundamentals.py +331 -0
- kuznets/yahoo/fx.py +114 -0
- kuznets/yahoo/headers.py +14 -0
- kuznets/yahoo/options.py +804 -0
- kuznets/yahoo/quotes.py +71 -0
- kuznets-1.0.dist-info/METADATA +177 -0
- kuznets-1.0.dist-info/RECORD +44 -0
- kuznets-1.0.dist-info/WHEEL +4 -0
- kuznets-1.0.dist-info/licenses/LICENSE.md +49 -0
kuznets/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
__version__ = version("kuznets")
|
|
6
|
+
|
|
7
|
+
from kuznets.config import options
|
|
8
|
+
from kuznets.data import (
|
|
9
|
+
DataReader,
|
|
10
|
+
Options,
|
|
11
|
+
get_data_alphavantage,
|
|
12
|
+
get_data_econdb,
|
|
13
|
+
get_data_famafrench,
|
|
14
|
+
get_data_fred,
|
|
15
|
+
get_data_moex,
|
|
16
|
+
get_data_quandl,
|
|
17
|
+
get_data_stooq,
|
|
18
|
+
get_data_tiingo,
|
|
19
|
+
get_data_yahoo,
|
|
20
|
+
get_data_yahoo_actions,
|
|
21
|
+
get_data_yahoo_fundamentals,
|
|
22
|
+
get_iex_data_tiingo,
|
|
23
|
+
get_nasdaq_symbols,
|
|
24
|
+
get_quote_yahoo,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
PKG = Path(__file__).parent
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
"options",
|
|
32
|
+
"get_data_econdb",
|
|
33
|
+
"get_data_famafrench",
|
|
34
|
+
"get_data_yahoo",
|
|
35
|
+
"get_data_yahoo_actions",
|
|
36
|
+
"get_data_yahoo_fundamentals",
|
|
37
|
+
"get_quote_yahoo",
|
|
38
|
+
"get_nasdaq_symbols",
|
|
39
|
+
"get_data_quandl",
|
|
40
|
+
"get_data_moex",
|
|
41
|
+
"get_data_fred",
|
|
42
|
+
"get_data_stooq",
|
|
43
|
+
"DataReader",
|
|
44
|
+
"Options",
|
|
45
|
+
"get_data_tiingo",
|
|
46
|
+
"get_iex_data_tiingo",
|
|
47
|
+
"get_data_alphavantage",
|
|
48
|
+
"test",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test(extra_args=None):
|
|
53
|
+
"""
|
|
54
|
+
Run the test suite
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
extra_args : {str, List[str]}
|
|
59
|
+
A string or list of strings to pass to pytest. Default is ["--only-stable",
|
|
60
|
+
"--skip-requires-api-key"]
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
import pytest
|
|
64
|
+
except ImportError as err:
|
|
65
|
+
raise ImportError("Need pytest>=5.0.1 to run tests") from err
|
|
66
|
+
cmd = ["--only-stable", "--skip-requires-api-key"]
|
|
67
|
+
if extra_args:
|
|
68
|
+
if not isinstance(extra_args, list):
|
|
69
|
+
extra_args = [extra_args]
|
|
70
|
+
cmd = extra_args
|
|
71
|
+
cmd += [str(PKG)]
|
|
72
|
+
joined = " ".join(cmd)
|
|
73
|
+
print(f"running: pytest {joined}")
|
|
74
|
+
sys.exit(pytest.main(cmd))
|
kuznets/_output.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
import datetime
|
|
3
|
+
import importlib.util
|
|
4
|
+
|
|
5
|
+
import narwhals.stable.v2 as nw
|
|
6
|
+
from narwhals.stable.v2.typing import IntoFrame
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
PANDAS = "pandas"
|
|
10
|
+
|
|
11
|
+
# Canonical backend name -> (module probed for availability, pip extra that installs it). pandas is
|
|
12
|
+
# absent here because it is a hard dependency and needs no availability check.
|
|
13
|
+
_OPTIONAL_BACKENDS = {
|
|
14
|
+
"polars": ("polars", "polars"),
|
|
15
|
+
"pyarrow": ("pyarrow", "pyarrow"),
|
|
16
|
+
"dask": ("dask.dataframe", "dask"),
|
|
17
|
+
}
|
|
18
|
+
_ALIASES = {"arrow": "pyarrow"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_output_type(output_type: str) -> str:
|
|
22
|
+
"""Canonicalize an ``output_type`` value and verify its backend is importable.
|
|
23
|
+
|
|
24
|
+
Call before issuing any network request so an invalid value or missing backend fails fast.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
output_type : str
|
|
29
|
+
Requested output backend, case-insensitive. One of 'pandas', 'polars', 'pyarrow' (alias
|
|
30
|
+
'arrow'), or 'dask'.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
str
|
|
35
|
+
The canonical backend name.
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
TypeError
|
|
40
|
+
If ``output_type`` is not a str.
|
|
41
|
+
ValueError
|
|
42
|
+
If ``output_type`` does not name a recognized backend.
|
|
43
|
+
ImportError
|
|
44
|
+
If the backend is recognized but its package is not installed.
|
|
45
|
+
"""
|
|
46
|
+
if not isinstance(output_type, str):
|
|
47
|
+
raise TypeError(f"output_type must be a str, got {type(output_type).__name__}")
|
|
48
|
+
lowered = output_type.lower()
|
|
49
|
+
canonical = _ALIASES.get(lowered, lowered)
|
|
50
|
+
if canonical == PANDAS:
|
|
51
|
+
return canonical
|
|
52
|
+
if canonical not in _OPTIONAL_BACKENDS:
|
|
53
|
+
valid = ", ".join(repr(name) for name in sorted([PANDAS, *_OPTIONAL_BACKENDS, *_ALIASES]))
|
|
54
|
+
raise ValueError(f"output_type={output_type!r} is not supported; choose one of {valid}")
|
|
55
|
+
_require_backend(canonical)
|
|
56
|
+
return canonical
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _require_backend(canonical: str) -> None:
|
|
60
|
+
"""Raise a helpful ImportError if the package backing *canonical* is not installed."""
|
|
61
|
+
module, extra = _OPTIONAL_BACKENDS[canonical]
|
|
62
|
+
package = module.partition(".")[0]
|
|
63
|
+
try:
|
|
64
|
+
missing = importlib.util.find_spec(module) is None
|
|
65
|
+
except ModuleNotFoundError:
|
|
66
|
+
# find_spec on a dotted name (dask.dataframe) raises when the parent package is absent.
|
|
67
|
+
missing = True
|
|
68
|
+
if missing:
|
|
69
|
+
raise ImportError(
|
|
70
|
+
f"output_type={canonical!r} requires the optional dependency {package!r}. "
|
|
71
|
+
f"Install it with 'pip install {package}' or 'pip install kuznets[{extra}]'."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detach_index(df: pd.DataFrame) -> tuple[pd.DataFrame, list[str]]:
|
|
76
|
+
"""Move a frame's index into ordinary columns.
|
|
77
|
+
|
|
78
|
+
An unnamed datetime-like index is named ``Date``; any other unnamed index is named ``index``;
|
|
79
|
+
unnamed MultiIndex levels get ``level_{position}`` names. The returned names let a pandas
|
|
80
|
+
presenter restore today's index via :func:`attach_index`.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
df : DataFrame
|
|
85
|
+
Frame whose index carries meaning (dates, symbols, dimension levels).
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
tuple of (DataFrame, list of str)
|
|
90
|
+
The index-free frame and the column names the index became, in level order.
|
|
91
|
+
"""
|
|
92
|
+
index = df.index
|
|
93
|
+
if isinstance(index, pd.MultiIndex):
|
|
94
|
+
names = [name if name is not None else f"level_{position}" for position, name in enumerate(index.names)]
|
|
95
|
+
index = index.set_names(names)
|
|
96
|
+
else:
|
|
97
|
+
if index.name is None:
|
|
98
|
+
fallback = "Date" if isinstance(index, pd.DatetimeIndex | pd.PeriodIndex) else "index"
|
|
99
|
+
index = index.rename(fallback)
|
|
100
|
+
names = [index.name]
|
|
101
|
+
return df.set_axis(index, axis=0).reset_index(), names
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def attach_index(df: pd.DataFrame, index_cols: list[str]) -> pd.DataFrame:
|
|
105
|
+
"""Restore columns produced by :func:`detach_index` as the frame's index.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
df : DataFrame
|
|
110
|
+
Tidy frame containing every column named in ``index_cols``.
|
|
111
|
+
index_cols : list of str
|
|
112
|
+
Column names to promote, in level order.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
DataFrame
|
|
117
|
+
Frame indexed by ``index_cols`` (a MultiIndex when more than one name is given).
|
|
118
|
+
"""
|
|
119
|
+
return df.set_index(index_cols[0] if len(index_cols) == 1 else index_cols)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def from_pandas(df: pd.DataFrame, output_type: str):
|
|
123
|
+
"""Convert a tidy pandas frame to the requested backend.
|
|
124
|
+
|
|
125
|
+
The tidy contract is enforced, not repaired: an index that carries data (MultiIndex, named
|
|
126
|
+
index, or datetime-like index) raises, because the narwhals backends disagree on whether to
|
|
127
|
+
keep or drop it -- callers must run :func:`detach_index` first. A leftover positional index
|
|
128
|
+
(e.g. non-contiguous after a boolean filter) is silently discarded. Non-string column labels
|
|
129
|
+
are cast to str, and all-null object columns to float64, since polars and pyarrow accept
|
|
130
|
+
neither.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
df : DataFrame
|
|
135
|
+
Tidy frame: data in columns only.
|
|
136
|
+
output_type : str
|
|
137
|
+
Canonical backend name from :func:`validate_output_type`.
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
DataFrame or Table
|
|
142
|
+
``df`` unchanged for 'pandas'; otherwise a native frame of the requested backend
|
|
143
|
+
(``polars.DataFrame``, ``pyarrow.Table``, or a dask collection).
|
|
144
|
+
"""
|
|
145
|
+
if output_type == PANDAS:
|
|
146
|
+
return df
|
|
147
|
+
if isinstance(df.index, pd.MultiIndex) or isinstance(df.columns, pd.MultiIndex):
|
|
148
|
+
raise TypeError("from_pandas requires a tidy frame with no MultiIndex; call detach_index first")
|
|
149
|
+
if df.index.name is not None or isinstance(df.index, pd.DatetimeIndex | pd.PeriodIndex):
|
|
150
|
+
raise TypeError(
|
|
151
|
+
f"from_pandas would silently drop the meaningful index {df.index.name!r} "
|
|
152
|
+
f"({type(df.index).__name__}); call detach_index first"
|
|
153
|
+
)
|
|
154
|
+
prepared = df if isinstance(df.index, pd.RangeIndex) else df.reset_index(drop=True)
|
|
155
|
+
if not all(isinstance(label, str) for label in prepared.columns):
|
|
156
|
+
prepared = prepared.set_axis([str(label) for label in prepared.columns], axis=1)
|
|
157
|
+
all_null_object = [
|
|
158
|
+
name for name in prepared.columns if prepared[name].dtype == object and prepared[name].isna().all()
|
|
159
|
+
]
|
|
160
|
+
if all_null_object:
|
|
161
|
+
prepared = prepared.astype(dict.fromkeys(all_null_object, "float64"))
|
|
162
|
+
frame = nw.from_native(prepared, eager_only=True)
|
|
163
|
+
try:
|
|
164
|
+
if output_type == "polars":
|
|
165
|
+
return frame.to_polars()
|
|
166
|
+
if output_type == "pyarrow":
|
|
167
|
+
return frame.to_arrow()
|
|
168
|
+
return frame.lazy(backend="dask").to_native()
|
|
169
|
+
except ModuleNotFoundError as exc:
|
|
170
|
+
# Catches transitive gaps find_spec cannot see, e.g. polars needing pyarrow for from_pandas.
|
|
171
|
+
_require_backend(output_type)
|
|
172
|
+
raise ImportError(f"output_type={output_type!r} conversion failed: {exc}") from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def make_frame(records: list[dict] | dict[str, list], output_type: str, schema: dict | None = None):
|
|
176
|
+
"""Build a native frame of the requested backend directly from parsed records.
|
|
177
|
+
|
|
178
|
+
Keys missing from a record become nulls; column order follows first appearance across the
|
|
179
|
+
records.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
records : list of dict or dict of str to list
|
|
184
|
+
Row records as parsed from a JSON or SDMX payload, or ready-made columns.
|
|
185
|
+
output_type : str
|
|
186
|
+
Canonical backend name from :func:`validate_output_type`.
|
|
187
|
+
schema : dict mapping str to narwhals dtype, optional
|
|
188
|
+
Column dtypes to impose, guaranteeing identical schemas on every backend. When omitted each
|
|
189
|
+
backend infers its own dtypes. Default None.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
DataFrame or Table
|
|
194
|
+
A native frame of the requested backend.
|
|
195
|
+
"""
|
|
196
|
+
if isinstance(records, dict):
|
|
197
|
+
columns = records
|
|
198
|
+
else:
|
|
199
|
+
keys = {}
|
|
200
|
+
for record in records:
|
|
201
|
+
for key in record:
|
|
202
|
+
keys[key] = None
|
|
203
|
+
columns = {key: [record.get(key) for record in records] for key in keys}
|
|
204
|
+
if not columns and schema is not None:
|
|
205
|
+
columns = {name: [] for name in schema}
|
|
206
|
+
if output_type == "dask":
|
|
207
|
+
# narwhals from_dict is eager-only; build in pandas and hand off lazily.
|
|
208
|
+
return nw.from_dict(columns, schema=schema, backend=PANDAS).lazy(backend="dask").to_native()
|
|
209
|
+
return nw.from_dict(columns, schema=schema, backend=output_type).to_native()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def observation_schema(dimension_names) -> dict:
|
|
213
|
+
"""Narwhals schema for long-form observation records: string dimensions plus a float64 value.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
dimension_names : iterable of str
|
|
218
|
+
Names of the dimension columns.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
dict
|
|
223
|
+
Mapping of column name to narwhals dtype, ready for :func:`make_frame`.
|
|
224
|
+
"""
|
|
225
|
+
return {name: nw.String() for name in dimension_names} | {"value": nw.Float64()}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def filter_date_range(
|
|
229
|
+
frame: IntoFrame,
|
|
230
|
+
column: str | None = None,
|
|
231
|
+
start: str | datetime.date | datetime.datetime | pd.Timestamp | None = None,
|
|
232
|
+
end: str | datetime.date | datetime.datetime | pd.Timestamp | None = None,
|
|
233
|
+
):
|
|
234
|
+
"""Keep rows whose ``column`` value lies in the inclusive range [start, end].
|
|
235
|
+
|
|
236
|
+
Inclusivity on both endpoints matches pandas ``truncate`` and label slicing. A column that is
|
|
237
|
+
not datetime- or date-typed -- e.g. non-calendar period codes like '2013-S1' -- comes back
|
|
238
|
+
unfiltered, leaving only the server-side query bounds in effect.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
frame : DataFrame or Table
|
|
243
|
+
Any narwhals-compatible native frame, eager or lazy.
|
|
244
|
+
column : str, optional
|
|
245
|
+
Name of the date column to filter on. When omitted, the frame's single datetime- or
|
|
246
|
+
date-typed column is used; a frame with none comes back unfiltered, and one with several
|
|
247
|
+
raises ValueError. Default None.
|
|
248
|
+
start : datetime-like, optional
|
|
249
|
+
Inclusive lower bound; no lower bound when None. Default None.
|
|
250
|
+
end : datetime-like, optional
|
|
251
|
+
Inclusive upper bound; no upper bound when None. Default None.
|
|
252
|
+
|
|
253
|
+
Returns
|
|
254
|
+
-------
|
|
255
|
+
DataFrame or Table
|
|
256
|
+
The filtered frame in its original backend.
|
|
257
|
+
"""
|
|
258
|
+
if start is None and end is None:
|
|
259
|
+
return frame
|
|
260
|
+
ndf = nw.from_native(frame)
|
|
261
|
+
schema = ndf.collect_schema()
|
|
262
|
+
if column is None:
|
|
263
|
+
datetime_columns = [name for name, dtype in schema.items() if dtype == nw.Datetime or dtype == nw.Date]
|
|
264
|
+
if not datetime_columns:
|
|
265
|
+
return frame
|
|
266
|
+
if len(datetime_columns) > 1:
|
|
267
|
+
raise ValueError(f"multiple datetime columns {datetime_columns}; pass column= explicitly")
|
|
268
|
+
column = datetime_columns[0]
|
|
269
|
+
dtype = schema[column]
|
|
270
|
+
if dtype == nw.Date:
|
|
271
|
+
lower = None if start is None else pd.Timestamp(start).date()
|
|
272
|
+
upper = None if end is None else pd.Timestamp(end).date()
|
|
273
|
+
elif dtype == nw.Datetime:
|
|
274
|
+
lower = None if start is None else pd.Timestamp(start).to_pydatetime()
|
|
275
|
+
upper = None if end is None else pd.Timestamp(end).to_pydatetime()
|
|
276
|
+
else:
|
|
277
|
+
return frame
|
|
278
|
+
target = nw.col(column)
|
|
279
|
+
if lower is not None and upper is not None:
|
|
280
|
+
condition = target.is_between(lower, upper, closed="both")
|
|
281
|
+
elif lower is not None:
|
|
282
|
+
condition = target >= lower
|
|
283
|
+
else:
|
|
284
|
+
condition = target <= upper
|
|
285
|
+
return ndf.filter(condition).to_native()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def to_datetime_col(frame: IntoFrame, column: str):
|
|
289
|
+
"""Cast a string ``column`` to datetime, keeping the strings when they are not calendar dates.
|
|
290
|
+
|
|
291
|
+
The datetime format is inferred by the backend. Values that defeat inference leave the frame
|
|
292
|
+
unchanged, as does a column that is not string-typed.
|
|
293
|
+
|
|
294
|
+
Parameters
|
|
295
|
+
----------
|
|
296
|
+
frame : DataFrame or Table
|
|
297
|
+
Any narwhals-compatible native frame.
|
|
298
|
+
column : str
|
|
299
|
+
Name of the column to cast.
|
|
300
|
+
|
|
301
|
+
Returns
|
|
302
|
+
-------
|
|
303
|
+
DataFrame or Table
|
|
304
|
+
The frame in its original backend, with ``column`` cast when possible.
|
|
305
|
+
"""
|
|
306
|
+
ndf = nw.from_native(frame)
|
|
307
|
+
if ndf.collect_schema()[column] != nw.String:
|
|
308
|
+
return frame
|
|
309
|
+
try:
|
|
310
|
+
return ndf.with_columns(nw.col(column).str.to_datetime()).to_native()
|
|
311
|
+
except Exception:
|
|
312
|
+
# Backends raise different error types for unparseable dates; keeping the strings is the
|
|
313
|
+
# designed fallback, not a swallowed failure.
|
|
314
|
+
return frame
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def concat_frames(frames: Sequence[IntoFrame]):
|
|
318
|
+
"""Concatenate native frames of the same backend vertically.
|
|
319
|
+
|
|
320
|
+
Parameters
|
|
321
|
+
----------
|
|
322
|
+
frames : sequence of DataFrame or Table
|
|
323
|
+
Frames of one backend with matching schemas.
|
|
324
|
+
|
|
325
|
+
Returns
|
|
326
|
+
-------
|
|
327
|
+
DataFrame or Table
|
|
328
|
+
The stacked frame in the shared backend.
|
|
329
|
+
|
|
330
|
+
Raises
|
|
331
|
+
------
|
|
332
|
+
ValueError
|
|
333
|
+
If ``frames`` is empty.
|
|
334
|
+
"""
|
|
335
|
+
if not frames:
|
|
336
|
+
raise ValueError("concat_frames requires at least one frame")
|
|
337
|
+
return nw.concat([nw.from_native(frame) for frame in frames], how="vertical").to_native()
|
kuznets/_testing.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for testing purposes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import wrapt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def skip_on_exception(exp):
|
|
9
|
+
"""
|
|
10
|
+
Skip a test if a specific Exception is raised. This is because the Exception is raised for
|
|
11
|
+
reasons beyond our control (e.g. flakey 3rd-party API).
|
|
12
|
+
|
|
13
|
+
a signature-preserving decorator
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
exp : The Exception under which to execute try-except.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from pytest import skip
|
|
21
|
+
|
|
22
|
+
@wrapt.decorator
|
|
23
|
+
def wrapper(wrapped, instance, args, kwargs):
|
|
24
|
+
try:
|
|
25
|
+
return wrapped(*args, **kwargs)
|
|
26
|
+
except exp as e:
|
|
27
|
+
skip(str(e))
|
|
28
|
+
|
|
29
|
+
return wrapper
|
kuznets/_utils.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
3
|
+
|
|
4
|
+
from pandas import Timestamp, to_datetime
|
|
5
|
+
import requests
|
|
6
|
+
from requests.adapters import HTTPAdapter
|
|
7
|
+
from urllib3.util import Retry
|
|
8
|
+
|
|
9
|
+
from kuznets.compat import is_number
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
DEFAULT_USER_AGENT = f"kuznets/{version('kuznets')}"
|
|
13
|
+
except PackageNotFoundError: # pragma: no cover
|
|
14
|
+
DEFAULT_USER_AGENT = "kuznets"
|
|
15
|
+
|
|
16
|
+
# Transient statuses worth retrying. Other 4xx (e.g. 404) won't recover, so they fall straight
|
|
17
|
+
# through to the caller. 429 and 503 carry a ``Retry-After`` that the Retry strategy honors.
|
|
18
|
+
RETRYABLE_STATUS_CODES = (413, 429, 500, 502, 503, 504)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SymbolWarning(UserWarning):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RemoteDataError(IOError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sanitize_dates(
|
|
30
|
+
start: str | int | dt.date | dt.datetime | Timestamp,
|
|
31
|
+
end: str | int | dt.date | dt.datetime | Timestamp,
|
|
32
|
+
) -> tuple[Timestamp, Timestamp]:
|
|
33
|
+
"""
|
|
34
|
+
Return (timestamp_start, timestamp_end) tuple.
|
|
35
|
+
|
|
36
|
+
If start is None, default is 5 years before the current date. If end is None, default is today.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
start : str, int, date, datetime, or Timestamp
|
|
41
|
+
Desired start date.
|
|
42
|
+
end : str, int, date, datetime, or Timestamp
|
|
43
|
+
Desired end date.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
start : Timestamp
|
|
48
|
+
Sanitized start date.
|
|
49
|
+
end : Timestamp
|
|
50
|
+
Sanitized end date.
|
|
51
|
+
"""
|
|
52
|
+
if is_number(start):
|
|
53
|
+
# regard int as year
|
|
54
|
+
start = dt.datetime(start, 1, 1)
|
|
55
|
+
|
|
56
|
+
if is_number(end):
|
|
57
|
+
end = dt.datetime(end, 1, 1)
|
|
58
|
+
|
|
59
|
+
if start is None:
|
|
60
|
+
# default to 5 years before today
|
|
61
|
+
today = dt.date.today()
|
|
62
|
+
start = today - dt.timedelta(days=365 * 5)
|
|
63
|
+
if end is None:
|
|
64
|
+
# default to today
|
|
65
|
+
end = dt.date.today()
|
|
66
|
+
try:
|
|
67
|
+
start = to_datetime(start)
|
|
68
|
+
end = to_datetime(end)
|
|
69
|
+
except (TypeError, ValueError) as exc:
|
|
70
|
+
raise ValueError("Invalid date format.") from exc
|
|
71
|
+
if start > end:
|
|
72
|
+
raise ValueError("start must be an earlier date than end")
|
|
73
|
+
return start, end
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _init_session(
|
|
77
|
+
session: requests.Session | None,
|
|
78
|
+
retry_count: int = 3,
|
|
79
|
+
pause: float = 0.1,
|
|
80
|
+
headers: dict | None = None,
|
|
81
|
+
) -> requests.Session:
|
|
82
|
+
"""
|
|
83
|
+
Initialize a requests session with a retry strategy.
|
|
84
|
+
|
|
85
|
+
Mount an :class:`~urllib3.util.Retry`-backed adapter so urllib3 handles retry counting,
|
|
86
|
+
exponential backoff, and ``Retry-After`` for transient failures. ``raise_on_status`` is left
|
|
87
|
+
off so the exhausted response flows back to :meth:`~kuznets.base._BaseReader._get_response`,
|
|
88
|
+
which raises a ``RemoteDataError`` carrying the response body.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
session : Session or None
|
|
93
|
+
``requests.sessions.Session`` instance to be used, or ``None`` to create a new session.
|
|
94
|
+
retry_count : int, optional
|
|
95
|
+
Maximum number of retries for transient failures. Default 3.
|
|
96
|
+
pause : float, optional
|
|
97
|
+
Backoff factor, in seconds, between retries. The nth retry waits ``pause * 2 ** (n - 1)``
|
|
98
|
+
seconds. Default 0.1.
|
|
99
|
+
headers : dict, optional
|
|
100
|
+
Headers to apply to the session, taking precedence over the defaults.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
session : Session
|
|
105
|
+
The initialized session.
|
|
106
|
+
"""
|
|
107
|
+
if session is None:
|
|
108
|
+
session = requests.Session()
|
|
109
|
+
# Identify ourselves so hosts can throttle politely rather than blocking the anonymous
|
|
110
|
+
# ``python-requests`` agent that requests sets by default.
|
|
111
|
+
session.headers["User-Agent"] = DEFAULT_USER_AGENT
|
|
112
|
+
elif not isinstance(session, requests.Session):
|
|
113
|
+
raise TypeError("session must be a request.Session")
|
|
114
|
+
|
|
115
|
+
if headers:
|
|
116
|
+
session.headers.update(headers)
|
|
117
|
+
|
|
118
|
+
retry = Retry(
|
|
119
|
+
total=retry_count,
|
|
120
|
+
backoff_factor=pause,
|
|
121
|
+
status_forcelist=RETRYABLE_STATUS_CODES,
|
|
122
|
+
respect_retry_after_header=True,
|
|
123
|
+
raise_on_status=False,
|
|
124
|
+
)
|
|
125
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
126
|
+
session.mount("https://", adapter)
|
|
127
|
+
session.mount("http://", adapter)
|
|
128
|
+
return session
|
kuznets/av/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
from kuznets._utils import RemoteDataError
|
|
4
|
+
from kuznets.base import _BaseReader
|
|
5
|
+
from kuznets.config import get_api_key
|
|
6
|
+
|
|
7
|
+
AV_BASE_URL = "https://www.alphavantage.co/query"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AlphaVantage(_BaseReader):
|
|
11
|
+
"""Base class for all Alpha Vantage queries."""
|
|
12
|
+
|
|
13
|
+
_format = "json"
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
symbols: str | list[str] | None = None,
|
|
18
|
+
start=None,
|
|
19
|
+
end=None,
|
|
20
|
+
retry_count: int | None = None,
|
|
21
|
+
pause: float | None = None,
|
|
22
|
+
session=None,
|
|
23
|
+
api_key: str | None = None,
|
|
24
|
+
output_type: str = "pandas",
|
|
25
|
+
) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Initialize the reader.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
symbols : str or list of str, optional
|
|
32
|
+
String symbol or list of symbols.
|
|
33
|
+
start : str, int, date, datetime, or Timestamp, optional
|
|
34
|
+
Starting date.
|
|
35
|
+
end : str, int, date, datetime, or Timestamp, optional
|
|
36
|
+
Ending date.
|
|
37
|
+
retry_count : int, optional
|
|
38
|
+
Number of times to retry query request. Falls back to the configured default.
|
|
39
|
+
pause : float, optional
|
|
40
|
+
Time, in seconds, of the pause between retries. Falls back to the configured default.
|
|
41
|
+
session : Session, optional
|
|
42
|
+
``requests.sessions.Session`` instance to be used.
|
|
43
|
+
api_key : str, optional
|
|
44
|
+
Alpha Vantage API key. Resolved through :func:`kuznets.config.get_api_key`
|
|
45
|
+
(argument, ``options.api_keys['alphavantage']``, ``ALPHAVANTAGE_API_KEY``, then the
|
|
46
|
+
config file). The API key is *required*.
|
|
47
|
+
output_type : str, optional
|
|
48
|
+
Backend of the returned data: 'pandas', 'polars', 'pyarrow' (alias 'arrow'), or 'dask'.
|
|
49
|
+
Backends other than pandas must be installed separately. Default 'pandas'.
|
|
50
|
+
|
|
51
|
+
Notes
|
|
52
|
+
-----
|
|
53
|
+
See `Alpha Vantage <https://www.alphavantage.co/>`__
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(
|
|
56
|
+
symbols=symbols,
|
|
57
|
+
start=start,
|
|
58
|
+
end=end,
|
|
59
|
+
retry_count=retry_count,
|
|
60
|
+
pause=pause,
|
|
61
|
+
session=session,
|
|
62
|
+
output_type=output_type,
|
|
63
|
+
)
|
|
64
|
+
self.api_key = get_api_key("alphavantage", api_key)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def url(self) -> str:
|
|
68
|
+
"""API URL."""
|
|
69
|
+
return AV_BASE_URL
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def params(self) -> dict:
|
|
73
|
+
"""Parameters to use in API calls."""
|
|
74
|
+
return {"function": self.function, "apikey": self.api_key}
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def function(self) -> str:
|
|
78
|
+
"""Alpha Vantage endpoint function. Must be overridden in subclass."""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def data_key(self) -> str:
|
|
83
|
+
"""Key of data returned from Alpha Vantage. Must be overridden in subclass."""
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
def _read_lines(self, out: dict) -> pd.DataFrame:
|
|
87
|
+
"""Parse Alpha Vantage JSON response.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
out : dict
|
|
92
|
+
Parsed JSON response.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
df : DataFrame
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
df = pd.DataFrame.from_dict(out[self.data_key], orient="index")
|
|
100
|
+
except KeyError as exc:
|
|
101
|
+
if "Error Message" in out:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"The requested symbol {self.symbols} could not be retrieved. Check valid ticker."
|
|
104
|
+
) from exc
|
|
105
|
+
else:
|
|
106
|
+
raise RemoteDataError(
|
|
107
|
+
f" Their was an issue from the data vendor side, here is their response: {out}"
|
|
108
|
+
) from exc
|
|
109
|
+
df = df[sorted(df.columns)]
|
|
110
|
+
df.columns = [id[3:] for id in df.columns]
|
|
111
|
+
return df
|