parsimony-eodhd 0.0.1__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.
@@ -0,0 +1,662 @@
1
+ """EODHD source: end-of-day, intraday, fundamentals, news, calendars, macro.
2
+
3
+ API docs: https://eodhd.com/financial-apis/
4
+ Authentication: ``api_token`` query parameter (in the transport sensitive-param
5
+ set, so it is redacted from every log line and never reaches a surfaced URL).
6
+ Base URL: https://eodhd.com/api
7
+
8
+ Provides 17 plain ``@connector`` verbs over the EODHD REST surface:
9
+ - Market data: EOD prices, live quotes, intraday, bulk EOD
10
+ - Corporate actions: dividends, splits
11
+ - Reference: search, exchanges, exchange symbol lists
12
+ - Fundamentals (raw nested dict)
13
+ - Calendars: earnings, IPO, trends, splits
14
+ - News
15
+ - Macro indicators (single + bulk)
16
+ - Technical indicators
17
+ - Insider transactions
18
+ - Screener
19
+
20
+ The API key is declared as a secret (stripped from provenance) and supplied
21
+ either by binding (``load(api_key=...)`` / ``Connector.bind``) or, as a dev
22
+ fallback, from the ``EODHD_API_KEY`` environment variable. A missing key fails
23
+ fast with :class:`UnauthorizedError` naming the env var.
24
+
25
+ Status semantics (verified live 2026-06-04): an invalid key returns 401
26
+ (→ :class:`UnauthorizedError`); a plan-restricted endpoint returns 403, and a
27
+ bulk plan-restriction returns 423 Locked — both surfaced as
28
+ :class:`PaymentRequiredError`. Many verbs require a paid EODHD plan; their
29
+ docstrings tag the minimum plan as ``[Free+]``, ``[EOD+Intraday+]``, or
30
+ ``[Fundamentals+]`` and they return :class:`PaymentRequiredError` on a free key.
31
+
32
+ Internal layout (not part of the public contract):
33
+
34
+ * :mod:`parsimony_eodhd._http` — keyed client builder and error mapping.
35
+ * :mod:`parsimony_eodhd.outputs` — declarative :class:`OutputConfig` schemas.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import re
42
+ from typing import Annotated, Any, Literal
43
+
44
+ import pandas as pd
45
+ from parsimony import Namespace
46
+ from parsimony.connector import Connectors, connector
47
+ from parsimony.errors import EmptyDataError, InvalidParameterError, ParseError
48
+
49
+ from parsimony_eodhd._http import _client, eodhd_get
50
+ from parsimony_eodhd.outputs import BULK_EOD_OUTPUT as _BULK_EOD_OUTPUT
51
+ from parsimony_eodhd.outputs import CALENDAR_OUTPUT as _CALENDAR_OUTPUT
52
+ from parsimony_eodhd.outputs import DIVIDENDS_OUTPUT as _DIVIDENDS_OUTPUT
53
+ from parsimony_eodhd.outputs import EOD_OUTPUT as _EOD_OUTPUT
54
+ from parsimony_eodhd.outputs import EXCHANGE_SYMBOLS_OUTPUT as _EXCHANGE_SYMBOLS_OUTPUT
55
+ from parsimony_eodhd.outputs import EXCHANGES_OUTPUT as _EXCHANGES_OUTPUT
56
+ from parsimony_eodhd.outputs import INSIDER_OUTPUT as _INSIDER_OUTPUT
57
+ from parsimony_eodhd.outputs import INTRADAY_OUTPUT as _INTRADAY_OUTPUT
58
+ from parsimony_eodhd.outputs import LIVE_OUTPUT as _LIVE_OUTPUT
59
+ from parsimony_eodhd.outputs import MACRO_OUTPUT as _MACRO_OUTPUT
60
+ from parsimony_eodhd.outputs import NEWS_OUTPUT as _NEWS_OUTPUT
61
+ from parsimony_eodhd.outputs import SCREENER_OUTPUT as _SCREENER_OUTPUT
62
+ from parsimony_eodhd.outputs import SEARCH_OUTPUT as _SEARCH_OUTPUT
63
+ from parsimony_eodhd.outputs import SPLITS_OUTPUT as _SPLITS_OUTPUT
64
+ from parsimony_eodhd.outputs import TECHNICAL_OUTPUT as _TECHNICAL_OUTPUT
65
+
66
+ __all__ = ["CONNECTORS", "load"]
67
+
68
+ _PROVIDER = "eodhd"
69
+
70
+ _LATENCY_TIMEOUT: float = 10.0
71
+ _BULK_TIMEOUT: float = 60.0
72
+
73
+ # Technical-indicator function names accepted by eodhd_technical.
74
+ _TechnicalFunction = Literal[
75
+ "sma",
76
+ "ema",
77
+ "wma",
78
+ "volatility",
79
+ "stochastic",
80
+ "rsi",
81
+ "stddev",
82
+ "stochrsi",
83
+ "slope",
84
+ "dmi",
85
+ "adx",
86
+ "macd",
87
+ "atr",
88
+ "cci",
89
+ "sar",
90
+ "bbands",
91
+ "splitadjusted",
92
+ "avgvol",
93
+ "avgvolacave",
94
+ "williams_r",
95
+ ]
96
+
97
+ # Guard for values interpolated directly into request paths
98
+ # (``/eod/<ticker>``, ``/exchange-symbol-list/<exchange>`` etc.).
99
+ _PATH_TOKEN_RE = re.compile(r"^[A-Za-z0-9._\-]+$")
100
+
101
+
102
+ def _safe_path_token(value: str, name: str) -> str:
103
+ """Validate and return a value that is interpolated into a request path."""
104
+ cleaned = value.strip()
105
+ if not cleaned:
106
+ raise InvalidParameterError(_PROVIDER, f"{name} must be non-empty")
107
+ if not _PATH_TOKEN_RE.match(cleaned):
108
+ raise InvalidParameterError(_PROVIDER, f"{name} contains unsafe characters for a URL path: {value!r}")
109
+ return cleaned
110
+
111
+
112
+ def _select_declared(df: pd.DataFrame, output: Any) -> pd.DataFrame:
113
+ """Project a frame to the columns the schema declares, in declared order.
114
+
115
+ Drops provider extras not in the schema. Missing declared columns are filled
116
+ with ``NA`` so :class:`~parsimony.result.OutputConfig` can shape sparse
117
+ payloads (calendar types, dividend-adjusted prices, etc.) without folding
118
+ extras in as stray DATA columns. Wildcard (``"*"``) schemas keep unmapped
119
+ columns after the fixed prefix.
120
+ """
121
+ names = [c.name for c in output.columns]
122
+ fixed = [n for n in names if n != "*"]
123
+ out = df.copy()
124
+ for n in fixed:
125
+ if n not in out.columns:
126
+ out[n] = pd.NA
127
+ if "*" in names:
128
+ extra = [c for c in out.columns if c not in fixed]
129
+ return out[fixed + extra]
130
+ return out[fixed]
131
+
132
+
133
+ def _rows_to_frame(data: Any, op_name: str, query_params: dict[str, Any]) -> pd.DataFrame:
134
+ """Build a DataFrame from a JSON list of records; guard empty/parse failures."""
135
+ if not isinstance(data, list):
136
+ raise ParseError(_PROVIDER, f"{op_name} response was not a JSON array")
137
+ if not data:
138
+ raise EmptyDataError(_PROVIDER, query_params=query_params)
139
+ df = pd.DataFrame(data)
140
+ if df.empty:
141
+ raise EmptyDataError(_PROVIDER, query_params=query_params)
142
+ return df
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Reference Data — search / exchanges / exchange symbols
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ @connector(output=_SEARCH_OUTPUT, tags=["eodhd", "tool"], secrets=("api_key",))
151
+ def eodhd_search(
152
+ query: str,
153
+ limit: int = 50,
154
+ type: Literal["stock", "etf", "fund", "bond", "index"] | None = None,
155
+ api_key: str = "",
156
+ ) -> pd.DataFrame:
157
+ """[Free+] Search EODHD for instruments by company name or partial ticker.
158
+ Resolves names to EODHD ticker codes (format TICKER.EXCHANGE, e.g. AAPL.US).
159
+ Returns Code, Name, Exchange, Type, Country, Currency, ISIN. Optionally
160
+ filter by type: stock, etf, fund, bond, index.
161
+ """
162
+ q = query.strip()
163
+ if not q:
164
+ raise InvalidParameterError(_PROVIDER, "query must be non-empty")
165
+ if limit < 1 or limit > 500:
166
+ raise InvalidParameterError(_PROVIDER, "limit must be between 1 and 500")
167
+
168
+ http = _client(api_key)
169
+ data = eodhd_get(
170
+ http,
171
+ path=f"search/{q}",
172
+ params={"limit": limit, "type": type},
173
+ op_name="eodhd_search",
174
+ )
175
+ df = _rows_to_frame(data, "eodhd_search", {"query": q})
176
+ return _select_declared(df, _SEARCH_OUTPUT)
177
+
178
+
179
+ @connector(output=_EXCHANGES_OUTPUT, tags=["eodhd", "tool"], secrets=("api_key",))
180
+ def eodhd_exchanges(api_key: str = "") -> pd.DataFrame:
181
+ """[Free+] List all exchanges supported by EODHD. Returns Code, Name, country,
182
+ currency and operating MIC. Use the Code with eodhd_bulk_eod and
183
+ eodhd_exchange_symbols.
184
+ """
185
+ http = _client(api_key)
186
+ data = eodhd_get(http, path="exchanges-list", op_name="eodhd_exchanges")
187
+ df = _rows_to_frame(data, "eodhd_exchanges", {})
188
+ return _select_declared(df, _EXCHANGES_OUTPUT)
189
+
190
+
191
+ @connector(output=_EXCHANGE_SYMBOLS_OUTPUT, tags=["eodhd"], secrets=("api_key",))
192
+ def eodhd_exchange_symbols(
193
+ exchange: str,
194
+ type: Literal["common_stock", "preferred_stock", "stock", "etf", "fund"] | None = None,
195
+ api_key: str = "",
196
+ ) -> pd.DataFrame:
197
+ """[Free+] List all symbols traded on an exchange (returns Code, Name, country,
198
+ exchange, currency, type, ISIN). Large response for major exchanges (US has
199
+ 50 000+ symbols) — use the type filter to narrow. Empty result may indicate
200
+ an invalid exchange code.
201
+ """
202
+ ex = _safe_path_token(exchange, "exchange")
203
+ http = _client(api_key, timeout=_BULK_TIMEOUT)
204
+ data = eodhd_get(
205
+ http,
206
+ path=f"exchange-symbol-list/{ex}",
207
+ params={"type": type},
208
+ op_name="eodhd_exchange_symbols",
209
+ )
210
+ df = _rows_to_frame(data, "eodhd_exchange_symbols", {"exchange": ex})
211
+ return _select_declared(df, _EXCHANGE_SYMBOLS_OUTPUT)
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Market Data — EOD / live / intraday / bulk
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ @connector(output=_EOD_OUTPUT, tags=["eodhd", "equity"], secrets=("api_key",))
220
+ def eodhd_eod(
221
+ ticker: Annotated[str, Namespace("eodhd_symbols")],
222
+ from_date: str | None = None,
223
+ to_date: str | None = None,
224
+ period: Literal["d", "w", "m"] | None = None,
225
+ api_key: str = "",
226
+ ) -> pd.DataFrame:
227
+ """[Free+] Fetch end-of-day OHLCV prices for a ticker. Supports daily, weekly,
228
+ and monthly aggregation via period. Use from_date/to_date (ISO 8601) to limit
229
+ the range. Empty result may indicate an invalid ticker or exchange code —
230
+ verify with eodhd_search first. Free tier is limited to ~1 year of history.
231
+ """
232
+ t = _safe_path_token(ticker, "ticker")
233
+ http = _client(api_key)
234
+ data = eodhd_get(
235
+ http,
236
+ path=f"eod/{t}",
237
+ params={"from": from_date, "to": to_date, "period": period},
238
+ op_name="eodhd_eod",
239
+ )
240
+ df = _rows_to_frame(data, "eodhd_eod", {"ticker": t})
241
+ return _select_declared(df, _EOD_OUTPUT)
242
+
243
+
244
+ @connector(output=_LIVE_OUTPUT, tags=["eodhd", "equity", "tool"], secrets=("api_key",))
245
+ def eodhd_live(ticker: Annotated[str, Namespace("eodhd_symbols")], api_key: str = "") -> pd.DataFrame:
246
+ """[Free+] Fetch the live (real-time or 15-min delayed) quote for a ticker:
247
+ code, timestamp, OHLC, volume, previous close, and change. Use eodhd_search
248
+ to resolve a company name to its EODHD ticker (e.g. AAPL.US).
249
+ """
250
+ t = _safe_path_token(ticker, "ticker")
251
+ http = _client(api_key, timeout=_LATENCY_TIMEOUT)
252
+ data = eodhd_get(http, path=f"real-time/{t}", op_name="eodhd_live")
253
+ if not isinstance(data, dict) or not data.get("code"):
254
+ raise EmptyDataError(_PROVIDER, query_params={"ticker": t})
255
+ df = pd.DataFrame([data])
256
+ return _select_declared(df, _LIVE_OUTPUT)
257
+
258
+
259
+ @connector(output=_INTRADAY_OUTPUT, tags=["eodhd", "equity"], secrets=("api_key",))
260
+ def eodhd_intraday(
261
+ ticker: Annotated[str, Namespace("eodhd_symbols")],
262
+ interval: Literal["1m", "5m", "1h"],
263
+ from_unix: int | None = None,
264
+ to_unix: int | None = None,
265
+ api_key: str = "",
266
+ ) -> pd.DataFrame:
267
+ """[EOD+Intraday+] Fetch intraday OHLCV data for a ticker at 1m, 5m, or 1h
268
+ intervals. Provide from_unix / to_unix (Unix timestamps in seconds) to bound
269
+ the range; otherwise the most recent points are returned. Requires a paid
270
+ EOD+Intraday plan — a free key returns PaymentRequiredError.
271
+ """
272
+ t = _safe_path_token(ticker, "ticker")
273
+ http = _client(api_key, timeout=_LATENCY_TIMEOUT)
274
+ data = eodhd_get(
275
+ http,
276
+ path=f"intraday/{t}",
277
+ params={"interval": interval, "from": from_unix, "to": to_unix},
278
+ op_name="eodhd_intraday",
279
+ )
280
+ df = _rows_to_frame(data, "eodhd_intraday", {"ticker": t})
281
+ return _select_declared(df, _INTRADAY_OUTPUT)
282
+
283
+
284
+ @connector(output=_BULK_EOD_OUTPUT, tags=["eodhd", "equity"], secrets=("api_key",))
285
+ def eodhd_bulk_eod(exchange: str, date: str | None = None, api_key: str = "") -> pd.DataFrame:
286
+ """[EOD Historical+] Fetch end-of-day prices for every symbol on an exchange
287
+ in one request (returns code, name, date, OHLCV). Defaults to the last
288
+ trading day; pass date to fetch a specific day. Large response — use for
289
+ batch ingestion, not per-ticker lookups. Requires a paid plan; a free key
290
+ returns PaymentRequiredError.
291
+ """
292
+ ex = _safe_path_token(exchange, "exchange")
293
+ http = _client(api_key, timeout=_BULK_TIMEOUT)
294
+ data = eodhd_get(
295
+ http,
296
+ path=f"eod-bulk-last-day/{ex}",
297
+ params={"date": date},
298
+ op_name="eodhd_bulk_eod",
299
+ )
300
+ df = _rows_to_frame(data, "eodhd_bulk_eod", {"exchange": ex})
301
+ return _select_declared(df, _BULK_EOD_OUTPUT)
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Corporate Actions — dividends / splits
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ @connector(output=_DIVIDENDS_OUTPUT, tags=["eodhd", "equity"], secrets=("api_key",))
310
+ def eodhd_dividends(
311
+ ticker: Annotated[str, Namespace("eodhd_symbols")],
312
+ from_date: str | None = None,
313
+ to_date: str | None = None,
314
+ api_key: str = "",
315
+ ) -> pd.DataFrame:
316
+ """[Free+] Fetch dividend history for a ticker: ex-date, declaration/record/
317
+ payment dates, period, value, unadjusted value, and currency. Use from_date/
318
+ to_date (ISO 8601) to limit the range.
319
+ """
320
+ t = _safe_path_token(ticker, "ticker")
321
+ http = _client(api_key)
322
+ data = eodhd_get(
323
+ http,
324
+ path=f"div/{t}",
325
+ params={"from": from_date, "to": to_date},
326
+ op_name="eodhd_dividends",
327
+ )
328
+ df = _rows_to_frame(data, "eodhd_dividends", {"ticker": t})
329
+ return _select_declared(df, _DIVIDENDS_OUTPUT)
330
+
331
+
332
+ @connector(output=_SPLITS_OUTPUT, tags=["eodhd", "equity"], secrets=("api_key",))
333
+ def eodhd_splits(
334
+ ticker: Annotated[str, Namespace("eodhd_symbols")],
335
+ from_date: str | None = None,
336
+ to_date: str | None = None,
337
+ api_key: str = "",
338
+ ) -> pd.DataFrame:
339
+ """[Free+] Fetch stock split history for a ticker. The split column carries the
340
+ ratio string as returned by the API (e.g. "2.000000/1.000000" for a 2-for-1
341
+ split). Use from_date/to_date (ISO 8601) to limit the range.
342
+ """
343
+ t = _safe_path_token(ticker, "ticker")
344
+ http = _client(api_key)
345
+ data = eodhd_get(
346
+ http,
347
+ path=f"splits/{t}",
348
+ params={"from": from_date, "to": to_date},
349
+ op_name="eodhd_splits",
350
+ )
351
+ df = _rows_to_frame(data, "eodhd_splits", {"ticker": t})
352
+ return _select_declared(df, _SPLITS_OUTPUT)
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Fundamentals — raw nested dict (no output schema)
357
+ # ---------------------------------------------------------------------------
358
+
359
+
360
+ @connector(tags=["eodhd", "equity"], secrets=("api_key",))
361
+ def eodhd_fundamentals(ticker: Annotated[str, Namespace("eodhd_symbols")], api_key: str = "") -> dict[str, Any]:
362
+ """[Fundamentals+] Fetch full fundamentals for a stock or ETF as a large nested
363
+ dict (not a DataFrame). Typical equity top-level keys: General, Highlights,
364
+ Valuation, SharesStats, Technicals, SplitsDividends, AnalystRatings, Holders,
365
+ InsiderTransactions, Financials, Earnings. ETFs differ (General, Technicals,
366
+ ETF_Data). Navigate via result.data, e.g.
367
+ result.data['Highlights']['MarketCapitalization']. Requires a paid plan; a
368
+ free key returns PaymentRequiredError.
369
+ """
370
+ t = _safe_path_token(ticker, "ticker")
371
+ http = _client(api_key, timeout=_BULK_TIMEOUT)
372
+ data = eodhd_get(http, path=f"fundamentals/{t}", op_name="eodhd_fundamentals")
373
+ if not isinstance(data, dict) or not data:
374
+ raise EmptyDataError(_PROVIDER, query_params={"ticker": t})
375
+ return data
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Calendars — earnings / ipos / trends / splits
380
+ # ---------------------------------------------------------------------------
381
+
382
+
383
+ @connector(output=_CALENDAR_OUTPUT, tags=["eodhd", "equity"], secrets=("api_key",))
384
+ def eodhd_calendar(
385
+ type: Literal["earnings", "ipos", "trends", "splits"],
386
+ from_date: str | None = None,
387
+ to_date: str | None = None,
388
+ symbols: str | None = None,
389
+ api_key: str = "",
390
+ ) -> pd.DataFrame:
391
+ """[Fundamentals+] Fetch market calendar data. Types: earnings (upcoming EPS
392
+ announcements with estimates/actuals), ipos (upcoming and recent IPO
393
+ listings), trends (analyst recommendation trends), splits (upcoming splits).
394
+ Use from_date/to_date to narrow the window and symbols to filter. Requires a
395
+ paid plan; a free key returns PaymentRequiredError.
396
+ """
397
+ http = _client(api_key)
398
+ data = eodhd_get(
399
+ http,
400
+ path=f"calendar/{type}",
401
+ params={"from": from_date, "to": to_date, "symbols": symbols},
402
+ op_name="eodhd_calendar",
403
+ )
404
+ # The calendar endpoints wrap rows under a type-specific key.
405
+ if isinstance(data, dict):
406
+ for key in ("earnings", "ipos", "trends", "splits", "data"):
407
+ rows = data.get(key)
408
+ if isinstance(rows, list):
409
+ data = rows
410
+ break
411
+ df = _rows_to_frame(data, "eodhd_calendar", {"type": type})
412
+ return _select_declared(df, _CALENDAR_OUTPUT)
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # News
417
+ # ---------------------------------------------------------------------------
418
+
419
+
420
+ @connector(output=_NEWS_OUTPUT, tags=["eodhd", "tool"], secrets=("api_key",))
421
+ def eodhd_news(
422
+ ticker: str | None = None,
423
+ from_date: str | None = None,
424
+ to_date: str | None = None,
425
+ limit: int = 50,
426
+ offset: int = 0,
427
+ api_key: str = "",
428
+ ) -> pd.DataFrame:
429
+ """[Free+] Fetch financial news articles (date, title, content, link, related
430
+ symbols, tags). Filter by ticker (e.g. AAPL.US) or omit for broad market
431
+ news. Use from_date/to_date for date filtering and limit/offset to page.
432
+ Empty result may indicate no news in the range for the ticker.
433
+ """
434
+ if limit < 1 or limit > 1000:
435
+ raise InvalidParameterError(_PROVIDER, "limit must be between 1 and 1000")
436
+ if offset < 0:
437
+ raise InvalidParameterError(_PROVIDER, "offset must be non-negative")
438
+
439
+ http = _client(api_key)
440
+ data = eodhd_get(
441
+ http,
442
+ path="news",
443
+ params={
444
+ "s": ticker, # EODHD uses ``s=`` for symbol filtering on the news endpoint
445
+ "from": from_date,
446
+ "to": to_date,
447
+ "limit": limit,
448
+ "offset": offset,
449
+ },
450
+ op_name="eodhd_news",
451
+ )
452
+ df = _rows_to_frame(data, "eodhd_news", {"ticker": ticker or ""})
453
+ return _select_declared(df, _NEWS_OUTPUT)
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Macro indicators — single / bulk
458
+ # ---------------------------------------------------------------------------
459
+
460
+
461
+ @connector(output=_MACRO_OUTPUT, tags=["eodhd", "macro"], secrets=("api_key",))
462
+ def eodhd_macro(country: str, indicator: str, api_key: str = "") -> pd.DataFrame:
463
+ """[Fundamentals+] Fetch a macro indicator time series for a country (returns
464
+ Date, Value, Period). Country must be an ISO 3-letter code (e.g. USA, DEU).
465
+ Common indicators: gdp_current_usd, unemployment_total_percent,
466
+ inflation_consumer_prices_annual, real_interest_rate, population_total.
467
+ Requires a paid plan; a free key returns PaymentRequiredError.
468
+ """
469
+ c = _safe_path_token(country, "country")
470
+ ind = indicator.strip()
471
+ if not ind:
472
+ raise InvalidParameterError(_PROVIDER, "indicator must be non-empty")
473
+ http = _client(api_key)
474
+ data = eodhd_get(
475
+ http,
476
+ path=f"macro-indicator/{c}",
477
+ params={"indicator": ind},
478
+ op_name="eodhd_macro",
479
+ )
480
+ df = _rows_to_frame(data, "eodhd_macro", {"country": c, "indicator": ind})
481
+ return _select_declared(df, _MACRO_OUTPUT)
482
+
483
+
484
+ @connector(output=_MACRO_OUTPUT, tags=["eodhd", "macro"], secrets=("api_key",))
485
+ def eodhd_macro_bulk(country: str, indicator: str | None = None, api_key: str = "") -> pd.DataFrame:
486
+ """[Fundamentals+] Fetch macro indicator data for a country. The EODHD
487
+ macro-indicator endpoint requires an indicator; pass one explicitly or rely
488
+ on the default. Returns Date, Value, Period. Country must be an ISO 3-letter
489
+ code (e.g. USA). Requires a paid plan; a free key returns
490
+ PaymentRequiredError.
491
+ """
492
+ c = _safe_path_token(country, "country")
493
+ http = _client(api_key, timeout=_BULK_TIMEOUT)
494
+ data = eodhd_get(
495
+ http,
496
+ path=f"macro-indicator/{c}",
497
+ params={"indicator": indicator},
498
+ op_name="eodhd_macro_bulk",
499
+ )
500
+ df = _rows_to_frame(data, "eodhd_macro_bulk", {"country": c})
501
+ return _select_declared(df, _MACRO_OUTPUT)
502
+
503
+
504
+ # ---------------------------------------------------------------------------
505
+ # Technical indicators
506
+ # ---------------------------------------------------------------------------
507
+
508
+
509
+ @connector(output=_TECHNICAL_OUTPUT, tags=["eodhd", "equity"], secrets=("api_key",))
510
+ def eodhd_technical(
511
+ ticker: Annotated[str, Namespace("eodhd_symbols")],
512
+ function: _TechnicalFunction,
513
+ period: int = 50,
514
+ from_date: str | None = None,
515
+ to_date: str | None = None,
516
+ order: Literal["a", "d"] = "d",
517
+ api_key: str = "",
518
+ ) -> pd.DataFrame:
519
+ """[EOD+Intraday+] Fetch technical indicator values for a ticker. The output
520
+ columns vary by function: sma/ema/wma → the indicator column; macd → macd,
521
+ macd_signal, macd_hist; bbands → uband, mband, lband; stochastic → k/d;
522
+ adx/dmi → adx, plusDI, minusDI. Use period for the lookback window (default
523
+ 50). Requires a paid plan; a free key returns PaymentRequiredError.
524
+ """
525
+ t = _safe_path_token(ticker, "ticker")
526
+ if period < 1:
527
+ raise InvalidParameterError(_PROVIDER, "period must be a positive integer")
528
+ http = _client(api_key)
529
+ data = eodhd_get(
530
+ http,
531
+ path=f"technical/{t}",
532
+ params={
533
+ "function": function,
534
+ "period": period,
535
+ "order": order,
536
+ "from": from_date,
537
+ "to": to_date,
538
+ },
539
+ op_name="eodhd_technical",
540
+ )
541
+ df = _rows_to_frame(data, "eodhd_technical", {"ticker": t, "function": function})
542
+ return _select_declared(df, _TECHNICAL_OUTPUT)
543
+
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # Insider transactions
547
+ # ---------------------------------------------------------------------------
548
+
549
+
550
+ @connector(output=_INSIDER_OUTPUT, tags=["eodhd", "equity"], secrets=("api_key",))
551
+ def eodhd_insider(
552
+ ticker: Annotated[str, Namespace("eodhd_symbols")] | None = None,
553
+ limit: int = 100,
554
+ offset: int = 0,
555
+ api_key: str = "",
556
+ ) -> pd.DataFrame:
557
+ """[Fundamentals+] Fetch insider (executive and director) transactions. Filter
558
+ by ticker (e.g. AAPL.US) or omit for recent cross-market transactions. Use
559
+ limit/offset to page. Requires a paid plan; a free key returns
560
+ PaymentRequiredError.
561
+ """
562
+ if limit < 1 or limit > 1000:
563
+ raise InvalidParameterError(_PROVIDER, "limit must be between 1 and 1000")
564
+ if offset < 0:
565
+ raise InvalidParameterError(_PROVIDER, "offset must be non-negative")
566
+ code = ticker.strip() if ticker else None
567
+ http = _client(api_key)
568
+ data = eodhd_get(
569
+ http,
570
+ path="insider-transactions",
571
+ params={"code": code, "limit": limit, "offset": offset},
572
+ op_name="eodhd_insider",
573
+ )
574
+ df = _rows_to_frame(data, "eodhd_insider", {"ticker": code or ""})
575
+ return _select_declared(df, _INSIDER_OUTPUT)
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # Screener
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ @connector(output=_SCREENER_OUTPUT, tags=["eodhd", "equity", "tool"], secrets=("api_key",))
584
+ def eodhd_screener(
585
+ filters: list[tuple[str, str, str]] | None = None,
586
+ signals: str | None = None,
587
+ sort: str | None = None,
588
+ order: Literal["asc", "desc"] = "desc",
589
+ limit: int = 50,
590
+ offset: int = 0,
591
+ api_key: str = "",
592
+ ) -> pd.DataFrame:
593
+ """[EOD+Intraday+] Screen stocks by fundamental, price, and exchange criteria.
594
+ filters is a list of [field, operator, value] triples, e.g.
595
+ [["market_capitalization", ">", "1000000000"], ["exchange", "=", "us"]].
596
+ Operators: >, <, =, >=, <=. Common fields: market_capitalization,
597
+ earnings_share, dividend_yield, refund_1d_p, sector, exchange. Requires a
598
+ paid plan; a free key returns PaymentRequiredError.
599
+ """
600
+ if limit < 1 or limit > 100:
601
+ raise InvalidParameterError(_PROVIDER, "limit must be between 1 and 100")
602
+ if offset < 0:
603
+ raise InvalidParameterError(_PROVIDER, "offset must be non-negative")
604
+
605
+ params: dict[str, Any] = {"limit": limit, "offset": offset, "sort": sort, "signals": signals}
606
+ if filters:
607
+ params["filters"] = json.dumps([[f[0], f[1], f[2]] for f in filters])
608
+ if sort and order:
609
+ # EODHD sort syntax is ``field.direction`` (e.g. market_capitalization.desc).
610
+ params["sort"] = f"{sort}.{order}"
611
+
612
+ http = _client(api_key)
613
+ data = eodhd_get(http, path="screener", params=params, op_name="eodhd_screener")
614
+ # The screener wraps rows under a top-level ``data`` key.
615
+ if isinstance(data, dict):
616
+ rows = data.get("data")
617
+ if isinstance(rows, list):
618
+ data = rows
619
+ df = _rows_to_frame(data, "eodhd_screener", {"limit": limit})
620
+ return df
621
+
622
+
623
+ # ---------------------------------------------------------------------------
624
+ # Connector collection
625
+ # ---------------------------------------------------------------------------
626
+
627
+ CONNECTORS = Connectors(
628
+ [
629
+ # Reference / discovery
630
+ eodhd_search,
631
+ eodhd_exchanges,
632
+ eodhd_exchange_symbols,
633
+ # Market data
634
+ eodhd_eod,
635
+ eodhd_live,
636
+ eodhd_intraday,
637
+ eodhd_bulk_eod,
638
+ # Corporate actions
639
+ eodhd_dividends,
640
+ eodhd_splits,
641
+ # Fundamentals
642
+ eodhd_fundamentals,
643
+ # Calendars
644
+ eodhd_calendar,
645
+ # News
646
+ eodhd_news,
647
+ # Macro
648
+ eodhd_macro,
649
+ eodhd_macro_bulk,
650
+ # Technical
651
+ eodhd_technical,
652
+ # Transactions
653
+ eodhd_insider,
654
+ # Screener
655
+ eodhd_screener,
656
+ ]
657
+ )
658
+
659
+
660
+ def load(*, api_key: str) -> Connectors:
661
+ """Return :data:`CONNECTORS` with ``api_key`` bound on every connector that accepts it."""
662
+ return CONNECTORS.bind(api_key=api_key)
@@ -0,0 +1,112 @@
1
+ """EODHD transport — keyed client builder and unified error mapping.
2
+
3
+ Every EODHD connector resolves its client through :func:`_client` (the
4
+ canonical §4.3 keyed template: arg → env fallback → fast-fail) and routes its
5
+ GET through :func:`eodhd_get` (the package error-mapping chokepoint).
6
+
7
+ EODHD's status semantics differ from the canonical transport table on two
8
+ points, which is why this package drops to a raw ``HttpClient`` plus a
9
+ hand-written mapper instead of :func:`parsimony.transport.helpers.fetch_json`
10
+ (verified live 2026-06-04):
11
+
12
+ * an **invalid / missing** key returns **401** (body ``Unauthenticated``), and
13
+ * a **plan restriction** (an endpoint or range not in the caller's plan, e.g.
14
+ fundamentals / intraday / macro on a free key) returns **403** (body
15
+ ``Only EOD data allowed for free users``), and
16
+ * a **bulk** plan restriction returns **423 Locked** (body
17
+ ``Bulk requests are prohibited for free users``).
18
+
19
+ The canonical mapper folds 403 into :class:`UnauthorizedError`; for EODHD a 403
20
+ (and a 423) means "your plan does not grant this," so both map to
21
+ :class:`PaymentRequiredError`. Because invalid-key is unambiguously 401, this is
22
+ a status-only disambiguation (the finnhub case), not a body-sniffing one (the
23
+ tiingo dual-403 case). The 401 path still maps to :class:`UnauthorizedError`.
24
+ Every other status flows through the canonical :func:`map_http_error` /
25
+ :func:`map_timeout_error`.
26
+
27
+ Auth rides as the ``api_token`` query parameter (alongside EODHD's ``fmt=json``
28
+ convention). ``api_token`` is in the transport layer's sensitive-param set, so
29
+ it is redacted from every log line and never appears in a request URL surfaced
30
+ to the agent. Error bodies are ``text/html`` even on failures, so this module
31
+ never parses an error body — it branches on the HTTP status alone.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from typing import Any
37
+
38
+ import httpx
39
+ from parsimony.errors import PaymentRequiredError
40
+ from parsimony.transport import HttpClient, map_http_error, map_timeout_error
41
+ from parsimony.transport.helpers import make_http_client, require_key
42
+
43
+ _PROVIDER = "eodhd"
44
+ _BASE_URL = "https://eodhd.com/api"
45
+ _ENV_VAR = "EODHD_API_KEY"
46
+
47
+ # EODHD's REST endpoints are not streaming; 15s is a conservative ceiling.
48
+ # Bulk endpoints (bulk_eod, exchange_symbols, macro_bulk, fundamentals) override
49
+ # this with a longer value via ``timeout=``.
50
+ _DEFAULT_TIMEOUT_SECONDS = 15.0
51
+
52
+ # HTTP statuses EODHD uses for a plan-tier restriction (not a credential
53
+ # failure): 403 ("Only EOD data allowed for free users") and 423 Locked
54
+ # ("Bulk requests are prohibited for free users"). Both map to
55
+ # PaymentRequiredError. Invalid-key is 401, so this is unambiguous on status.
56
+ _PLAN_RESTRICTION_STATUSES: frozenset[int] = frozenset({403, 423})
57
+
58
+
59
+ def _client(api_key: str, *, timeout: float = _DEFAULT_TIMEOUT_SECONDS) -> HttpClient:
60
+ """Resolve the API key (arg → env fallback) and build the EODHD client.
61
+
62
+ Fast-fails with :class:`UnauthorizedError` before any network call when no
63
+ key is available. Auth is the ``api_token`` query parameter (redacted by the
64
+ transport layer), carried alongside EODHD's ``fmt=json`` convention as a
65
+ fixed default param — hence ``make_http_client`` with explicit
66
+ ``query_params`` rather than ``make_api_key_client`` (which can set only the
67
+ key and hardcodes ``apikey``).
68
+ """
69
+ key = require_key(api_key, env_var=_ENV_VAR, provider=_PROVIDER)
70
+ return make_http_client(
71
+ _BASE_URL,
72
+ query_params={"api_token": key, "fmt": "json"},
73
+ timeout=timeout,
74
+ )
75
+
76
+
77
+ def eodhd_get(
78
+ http: HttpClient,
79
+ *,
80
+ path: str,
81
+ params: dict[str, Any] | None = None,
82
+ op_name: str,
83
+ ) -> Any:
84
+ """Shared EODHD GET with EODHD-specific error mapping; returns parsed JSON.
85
+
86
+ Drops ``None``-valued params, raises for status, then maps:
87
+
88
+ * 403 / 423 → :class:`PaymentRequiredError` (plan restriction — EODHD-specific),
89
+ * everything else → :func:`map_http_error` (401 → Unauthorized, 402 →
90
+ Payment, 429 → RateLimit, other → Provider),
91
+ * timeout → :func:`map_timeout_error` (→ ``ProviderError(408)``).
92
+
93
+ Error bodies are ``text/html`` and are never parsed (the EODHD API token is
94
+ a query param, redacted, and never reaches an exception message).
95
+ """
96
+ filtered = {k: v for k, v in (params or {}).items() if v is not None}
97
+ try:
98
+ response = http.request("GET", f"/{path.lstrip('/')}", params=filtered or None)
99
+ response.raise_for_status()
100
+ except httpx.HTTPStatusError as exc:
101
+ if exc.response.status_code in _PLAN_RESTRICTION_STATUSES:
102
+ raise PaymentRequiredError(
103
+ _PROVIDER,
104
+ message=f"eodhd plan does not grant access to '{op_name}'",
105
+ ) from exc
106
+ map_http_error(exc, provider=_PROVIDER, op_name=op_name)
107
+ except httpx.TimeoutException as exc:
108
+ map_timeout_error(exc, provider=_PROVIDER, op_name=op_name)
109
+ return response.json()
110
+
111
+
112
+ __all__ = ["_client", "eodhd_get"]
@@ -0,0 +1,200 @@
1
+ """Declarative output schemas for the EODHD connectors.
2
+
3
+ One :class:`OutputConfig` per connector that projects a shaped DataFrame
4
+ out of EODHD's raw JSON. Columns declared here are the contract with the
5
+ MCP tool catalog — renaming or re-ordering them is a breaking change.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from parsimony.result import Column, ColumnRole, OutputConfig
11
+
12
+ EOD_OUTPUT = OutputConfig(
13
+ columns=[
14
+ Column(name="date", role=ColumnRole.KEY, dtype="date"),
15
+ Column(name="open", dtype="numeric"),
16
+ Column(name="high", dtype="numeric"),
17
+ Column(name="low", dtype="numeric"),
18
+ Column(name="close", dtype="numeric"),
19
+ Column(name="adjusted_close", dtype="numeric"),
20
+ Column(name="volume", dtype="numeric"),
21
+ ]
22
+ )
23
+
24
+ LIVE_OUTPUT = OutputConfig(
25
+ columns=[
26
+ Column(name="code", role=ColumnRole.KEY),
27
+ # EODHD returns ``timestamp`` as a raw Unix epoch int; keep it un-coerced
28
+ # (dtype="auto") — a "timestamp" coercion would mis-read the seconds value.
29
+ Column(name="timestamp", role=ColumnRole.METADATA),
30
+ Column(name="gmtoffset", role=ColumnRole.METADATA),
31
+ Column(name="open", dtype="numeric"),
32
+ Column(name="high", dtype="numeric"),
33
+ Column(name="low", dtype="numeric"),
34
+ Column(name="close", dtype="numeric"),
35
+ Column(name="volume", dtype="numeric"),
36
+ Column(name="previousClose", dtype="numeric"),
37
+ Column(name="change", dtype="numeric"),
38
+ Column(name="change_p", dtype="numeric"),
39
+ ]
40
+ )
41
+
42
+ INTRADAY_OUTPUT = OutputConfig(
43
+ columns=[
44
+ Column(name="timestamp", role=ColumnRole.KEY, dtype="timestamp"),
45
+ Column(name="datetime", role=ColumnRole.METADATA),
46
+ Column(name="open", dtype="numeric"),
47
+ Column(name="high", dtype="numeric"),
48
+ Column(name="low", dtype="numeric"),
49
+ Column(name="close", dtype="numeric"),
50
+ Column(name="volume", dtype="numeric"),
51
+ ]
52
+ )
53
+
54
+ BULK_EOD_OUTPUT = OutputConfig(
55
+ columns=[
56
+ Column(name="code", role=ColumnRole.KEY),
57
+ Column(name="name", role=ColumnRole.TITLE),
58
+ Column(name="exchange_short_name", role=ColumnRole.METADATA),
59
+ Column(name="date", dtype="date"),
60
+ Column(name="open", dtype="numeric"),
61
+ Column(name="high", dtype="numeric"),
62
+ Column(name="low", dtype="numeric"),
63
+ Column(name="close", dtype="numeric"),
64
+ Column(name="adjusted_close", dtype="numeric"),
65
+ Column(name="volume", dtype="numeric"),
66
+ ]
67
+ )
68
+
69
+ DIVIDENDS_OUTPUT = OutputConfig(
70
+ columns=[
71
+ Column(name="date", role=ColumnRole.KEY, dtype="date"),
72
+ Column(name="declarationDate", dtype="date"),
73
+ Column(name="recordDate", dtype="date"),
74
+ Column(name="paymentDate", dtype="date"),
75
+ Column(name="period", role=ColumnRole.METADATA),
76
+ Column(name="value", dtype="numeric"),
77
+ Column(name="unadjustedValue", dtype="numeric"),
78
+ Column(name="currency", role=ColumnRole.METADATA),
79
+ ]
80
+ )
81
+
82
+ SPLITS_OUTPUT = OutputConfig(
83
+ columns=[
84
+ Column(name="date", role=ColumnRole.KEY, dtype="date"),
85
+ Column(name="split", dtype="auto"),
86
+ ]
87
+ )
88
+
89
+ SEARCH_OUTPUT = OutputConfig(
90
+ columns=[
91
+ Column(name="Code", role=ColumnRole.KEY, namespace="eodhd_symbols"),
92
+ Column(name="Name", role=ColumnRole.TITLE),
93
+ Column(name="Exchange", role=ColumnRole.METADATA),
94
+ Column(name="Type", role=ColumnRole.METADATA),
95
+ Column(name="Country", role=ColumnRole.METADATA),
96
+ Column(name="Currency", role=ColumnRole.METADATA),
97
+ Column(name="ISIN", role=ColumnRole.METADATA),
98
+ ]
99
+ )
100
+
101
+ EXCHANGES_OUTPUT = OutputConfig(
102
+ columns=[
103
+ Column(name="Code", role=ColumnRole.KEY),
104
+ Column(name="Name", role=ColumnRole.TITLE),
105
+ Column(name="OperatingMIC", role=ColumnRole.METADATA),
106
+ Column(name="Country", role=ColumnRole.METADATA),
107
+ Column(name="Currency", role=ColumnRole.METADATA),
108
+ Column(name="CountryISO2", role=ColumnRole.METADATA),
109
+ Column(name="CountryISO3", role=ColumnRole.METADATA),
110
+ ]
111
+ )
112
+
113
+ EXCHANGE_SYMBOLS_OUTPUT = OutputConfig(
114
+ columns=[
115
+ Column(name="Code", role=ColumnRole.KEY, namespace="eodhd_symbols"),
116
+ Column(name="Name", role=ColumnRole.TITLE),
117
+ Column(name="Country", role=ColumnRole.METADATA),
118
+ Column(name="Exchange", role=ColumnRole.METADATA),
119
+ Column(name="Currency", role=ColumnRole.METADATA),
120
+ Column(name="Type", role=ColumnRole.METADATA),
121
+ # Live payload key is ``Isin`` (not ``ISIN`` as on the search endpoint).
122
+ Column(name="Isin", role=ColumnRole.METADATA),
123
+ ]
124
+ )
125
+
126
+ CALENDAR_OUTPUT = OutputConfig(
127
+ columns=[
128
+ Column(name="code", role=ColumnRole.KEY),
129
+ Column(name="date", dtype="date"),
130
+ Column(name="report_date", dtype="date"),
131
+ Column(name="before_after_market", role=ColumnRole.METADATA),
132
+ Column(name="currency", role=ColumnRole.METADATA),
133
+ Column(name="actual", dtype="numeric"),
134
+ Column(name="estimate", dtype="numeric"),
135
+ Column(name="difference", dtype="numeric"),
136
+ Column(name="percent", dtype="numeric"),
137
+ ]
138
+ )
139
+
140
+ NEWS_OUTPUT = OutputConfig(
141
+ columns=[
142
+ Column(name="date", role=ColumnRole.KEY, dtype="datetime"),
143
+ Column(name="title", role=ColumnRole.TITLE),
144
+ Column(name="content"),
145
+ Column(name="link", role=ColumnRole.METADATA),
146
+ Column(name="symbols", role=ColumnRole.METADATA),
147
+ Column(name="tags", role=ColumnRole.METADATA),
148
+ ]
149
+ )
150
+
151
+ MACRO_OUTPUT = OutputConfig(
152
+ columns=[
153
+ # EODHD macro-indicator rows: CountryCode, CountryName, Indicator, Date,
154
+ # Period, Value. (No LastUpdated — do not declare a column the payload
155
+ # cannot populate.) CountryName/Indicator/CountryCode fold in as DATA.
156
+ Column(name="Date", role=ColumnRole.KEY, dtype="date"),
157
+ Column(name="Value", dtype="numeric"),
158
+ Column(name="Period", role=ColumnRole.METADATA),
159
+ ]
160
+ )
161
+
162
+ TECHNICAL_OUTPUT = OutputConfig(
163
+ columns=[
164
+ Column(name="date", role=ColumnRole.KEY, dtype="date"),
165
+ Column(name="open", dtype="numeric"),
166
+ Column(name="high", dtype="numeric"),
167
+ Column(name="low", dtype="numeric"),
168
+ Column(name="close", dtype="numeric"),
169
+ Column(name="volume", dtype="numeric"),
170
+ Column(name="*"), # indicator-specific columns vary by function
171
+ ]
172
+ )
173
+
174
+ INSIDER_OUTPUT = OutputConfig(
175
+ columns=[
176
+ Column(name="code", role=ColumnRole.KEY),
177
+ Column(name="date", dtype="date"),
178
+ Column(name="ownerName", role=ColumnRole.METADATA),
179
+ Column(name="ownerCik", role=ColumnRole.METADATA),
180
+ Column(name="transactionType", role=ColumnRole.METADATA),
181
+ Column(name="transactionDate", dtype="date"),
182
+ Column(name="value", dtype="numeric"),
183
+ Column(name="sharesOwned", dtype="numeric"),
184
+ Column(name="change", dtype="numeric"),
185
+ Column(name="*"),
186
+ ]
187
+ )
188
+
189
+ SCREENER_OUTPUT = OutputConfig(
190
+ columns=[
191
+ Column(name="code", role=ColumnRole.KEY, namespace="eodhd_symbols"),
192
+ Column(name="name", role=ColumnRole.TITLE),
193
+ Column(name="exchange", role=ColumnRole.METADATA),
194
+ Column(name="currency", role=ColumnRole.METADATA),
195
+ Column(name="sector", role=ColumnRole.METADATA),
196
+ Column(name="industry", role=ColumnRole.METADATA),
197
+ Column(name="market_capitalization", dtype="numeric"),
198
+ Column(name="*"),
199
+ ]
200
+ )
File without changes
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: parsimony-eodhd
3
+ Version: 0.0.1
4
+ Summary: EODHD connector for the parsimony framework
5
+ Project-URL: Homepage, https://eodhd.com
6
+ Project-URL: Repository, https://github.com/ockham-sh/parsimony-connectors
7
+ Project-URL: Issues, https://github.com/ockham-sh/parsimony-connectors/issues
8
+ Author-email: "Ockham.sh" <team@ockham.sh>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: connectors,data,eodhd,finance,parsimony
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: pandas<3,>=2.3.0
25
+ Requires-Dist: parsimony-core>=0.0.1
26
+ Requires-Dist: pydantic<3,>=2.11.1
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.10; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
30
+ Requires-Dist: pytest>=9.0.3; extra == 'dev'
31
+ Requires-Dist: respx>=0.22.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.15.10; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # parsimony-eodhd
36
+
37
+ EODHD connector — end-of-day, intraday, fundamentals, news, calendars, macro and technical indicators from the EODHD REST API.
38
+
39
+ Part of the [parsimony-connectors](https://github.com/ockham-sh/parsimony-connectors) monorepo. Distributed standalone on PyPI as `parsimony-eodhd`.
40
+
41
+ ## Connectors
42
+
43
+ 17 connectors grouped by capability:
44
+
45
+ | Name | Kind | Description |
46
+ |---|---|---|
47
+ | `eodhd_search` | fetch | Resolve company names / partial tickers to EODHD ticker codes (`AAPL.US`). |
48
+ | `eodhd_exchanges` | fetch | List supported exchanges. |
49
+ | `eodhd_exchange_symbols` | fetch | List all symbols on an exchange. |
50
+ | `eodhd_eod` | fetch | End-of-day OHLCV for a ticker (daily/weekly/monthly). |
51
+ | `eodhd_live` | fetch | Live (real-time or 15-min delayed) quote. |
52
+ | `eodhd_intraday` | fetch | Intraday OHLCV at 1m / 5m / 1h. |
53
+ | `eodhd_bulk_eod` | fetch | EOD prices for every symbol on an exchange in one request. |
54
+ | `eodhd_dividends` | fetch | Dividend history for a ticker. |
55
+ | `eodhd_splits` | fetch | Stock split history for a ticker. |
56
+ | `eodhd_fundamentals` | fetch | Full fundamentals for a stock or ETF (raw nested dict). |
57
+ | `eodhd_calendar` | fetch | Earnings / IPO / analyst trends / splits calendars. |
58
+ | `eodhd_news` | fetch | Financial news, optionally filtered by ticker. |
59
+ | `eodhd_macro` | fetch | Single macro indicator time series for a country. |
60
+ | `eodhd_macro_bulk` | fetch | All available macro indicators for a country. |
61
+ | `eodhd_technical` | fetch | Technical indicators (SMA, EMA, MACD, BBANDS, ADX, etc.). |
62
+ | `eodhd_insider` | fetch | Insider (executive / director) transactions. |
63
+ | `eodhd_screener` | fetch | Screen stocks by structured filter triples. |
64
+
65
+ Several endpoints require paid EODHD plans (EOD+Intraday, Fundamentals); per-connector docstrings tag the minimum plan as `[Free+]`, `[EOD+Intraday+]`, or `[Fundamentals+]`. On a free key a plan-gated endpoint returns HTTP 403 (or 423 for bulk), surfaced as `PaymentRequiredError` — not an auth error.
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install parsimony-eodhd
71
+ ```
72
+
73
+ Pulls in `parsimony-core>=0.7,<0.8` automatically. Verify discovery:
74
+
75
+ ```bash
76
+ python -c "from parsimony import discover; print([p.name for p in discover.iter_providers()])"
77
+ ```
78
+
79
+ ## Configuration
80
+
81
+ Set the following environment variable:
82
+
83
+ ```bash
84
+ export EODHD_API_KEY="<your-key>"
85
+ ```
86
+
87
+ Get a key at https://eodhd.com/register.
88
+
89
+ ## Quick start
90
+
91
+ ```python
92
+ import os
93
+ from parsimony_eodhd import load
94
+
95
+ # load() binds the API key off the call surface (and out of provenance).
96
+ connectors = load(api_key=os.environ["EODHD_API_KEY"])
97
+ result = connectors["eodhd_eod"](ticker="AAPL.US")
98
+ print(result.data.head())
99
+ ```
100
+
101
+ For multi-plugin composition:
102
+
103
+ ```python
104
+ from parsimony import discover
105
+ connectors = discover.load_all()
106
+ ```
107
+
108
+ ## Provider
109
+
110
+ - Homepage: https://eodhd.com
111
+ - API docs: https://eodhd.com/financial-apis/
112
+
113
+ ## License
114
+
115
+ See [LICENSE](./LICENSE).
@@ -0,0 +1,9 @@
1
+ parsimony_eodhd/__init__.py,sha256=VBy1BqF_gRiR_bJnM-9Ac90ve0vCAXCJeVKvBWt6PJU,26000
2
+ parsimony_eodhd/_http.py,sha256=cp0_Rqussav7SAVPujMO28WJbUaunBcir7A0th0NaRs,5010
3
+ parsimony_eodhd/outputs.py,sha256=pBC5rXbGdQujWSGKRHyv5Vc0KLurPNbOYQ6vl88pNjM,7526
4
+ parsimony_eodhd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ parsimony_eodhd-0.0.1.dist-info/METADATA,sha256=_SIciiZ78MGlKEQFuW3Uv75mXMeUgdvM7Rr7LsrCYiY,4339
6
+ parsimony_eodhd-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ parsimony_eodhd-0.0.1.dist-info/entry_points.txt,sha256=FKkSMd44tFkRnvcAbuEnWR0V0pC1PM81CQtuIeKaBtU,46
8
+ parsimony_eodhd-0.0.1.dist-info/licenses/LICENSE,sha256=PtHUFTCSwal_QX2Ijk2cx_bpsPV6ooZUMCYAxKBHNu0,10760
9
+ parsimony_eodhd-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [parsimony.providers]
2
+ eodhd = parsimony_eodhd
@@ -0,0 +1,190 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to the Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by the Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding any notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2026 Ockham.sh
179
+
180
+ Licensed under the Apache License, Version 2.0 (the "License");
181
+ you may not use this file except in compliance with the License.
182
+ You may obtain a copy of the License at
183
+
184
+ http://www.apache.org/licenses/LICENSE-2.0
185
+
186
+ Unless required by applicable law or agreed to in writing, software
187
+ distributed under the License is distributed on an "AS IS" BASIS,
188
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189
+ See the License for the specific language governing permissions and
190
+ limitations under the License.