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 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