alphadataforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ from .data.price import Price
2
+ from .data.fundamental import Fundamentals
File without changes
@@ -0,0 +1,52 @@
1
+ class Endpoints:
2
+ """
3
+ Centralized configuration for API provider endpoints.
4
+ """
5
+ class AlphaVantage:
6
+ BASE_URL = "https://www.alphavantage.co/query"
7
+
8
+ # Mappings for the `function` parameter in AlphaVantage
9
+ FUNCTIONS = {
10
+ "income": "INCOME_STATEMENT",
11
+ "balance": "BALANCE_SHEET",
12
+ "cashflow": "CASH_FLOW",
13
+ "shares_outstanding": "SHARES_OUTSTANDING",
14
+ "earnings": "EARNINGS",
15
+ "overview": "OVERVIEW",
16
+ "price_daily": "TIME_SERIES_DAILY",
17
+ "dividends": "DIVIDENDS",
18
+ "splits": "SPLITS"
19
+ }
20
+
21
+ class FMP:
22
+ BASE_URL = "https://financialmodelingprep.com/stable"
23
+
24
+ # Paths appended to the FMP BASE_URL
25
+ PATHS = {
26
+ "income": "income-statement",
27
+ "balance": "balance-sheet-statement",
28
+ "cashflow": "cash-flow-statement",
29
+ "shares_float": "shares-float",
30
+ "profile": "profile",
31
+ "price_adjusted": "historical-price-eod/dividend-adjusted",
32
+ "price_raw": "historical-price-eod/full"
33
+ }
34
+
35
+ class Tiingo:
36
+ BASE_URL = "https://api.tiingo.com/tiingo/daily"
37
+
38
+ class StockFit:
39
+ BASE_URL = "https://api.stockfit.io/v1/api"
40
+ PATHS = {
41
+ "income": "financials/income-statement",
42
+ "balance": "financials/balance-sheet",
43
+ "cashflow": "financials/cash-flow-statement",
44
+ "growth": "financials/growth",
45
+ "scores": "financials/scores",
46
+ "key_metrics": "financials/key-metrics",
47
+ "as_reported": "financials/as-reported",
48
+ "details": "company/details",
49
+ "research_summary": "company/research-summary",
50
+ "price_history": "price/history",
51
+ "quote": "price/quote"
52
+ }
@@ -0,0 +1,18 @@
1
+ import os
2
+ from typing import Optional
3
+ from dotenv import load_dotenv
4
+
5
+ # Use override=True so that if the user updates .env while the Jupyter kernel is running,
6
+ # and autoreload triggers, it will actually load the new API keys instead of keeping the old ones in os.environ.
7
+ load_dotenv(override=True)
8
+
9
+ class Settings:
10
+ """
11
+ Settings of the package
12
+ """
13
+ TIINGO_API_KEY: Optional[str] = os.getenv("TIINGO_API_KEY")
14
+ ALPHAVANTAGE_API_KEY: Optional[str] = os.getenv("ALPHAVANTAGE_API_KEY")
15
+ FMP_API_KEY: Optional[str] = os.getenv("FMP_API_KEY")
16
+ STOCKFIT_API_KEY: Optional[str] = os.getenv("STOCKFIT_API_KEY")
17
+
18
+ config = Settings()
File without changes
@@ -0,0 +1,357 @@
1
+ from abc import ABC, abstractmethod
2
+ import pandas as pd
3
+ from typing import Optional, List, Dict
4
+ import random as rd
5
+ import time
6
+ from datetime import datetime
7
+ import gzip
8
+ import zlib
9
+ import json
10
+
11
+ import requests
12
+ import requests_cache
13
+ from requests.exceptions import RequestException, HTTPError, Timeout, ConnectionError
14
+ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception, before_sleep_log
15
+ import concurrent.futures
16
+ import tempfile
17
+ import os
18
+
19
+ from .exceptions import RateLimitExceededError
20
+
21
+ cache_path = os.path.join(tempfile.gettempdir(), 'alphadataforge_cache')
22
+ def _filter_responses(response):
23
+ # Don't cache AlphaVantage rate limit messages (which return HTTP 200)
24
+ if "Thank you for using Alpha Vantage!" in response.text:
25
+ return False
26
+ return True
27
+
28
+ from ..utils.logger import setup_logger
29
+ from ..utils.finance_math import backfill_adjusted_columns
30
+
31
+ logger = setup_logger(__name__)
32
+
33
+ def _is_retryable_exception(e: BaseException) -> bool:
34
+ """Determine if an exception should be retried."""
35
+ if isinstance(e, RateLimitExceededError):
36
+ return True
37
+ if isinstance(e, (Timeout, ConnectionError)):
38
+ return True
39
+ if isinstance(e, HTTPError):
40
+ # Retry only on 5xx (Server Error). Do not retry 400-404.
41
+ if e.response is not None and e.response.status_code >= 500:
42
+ return True
43
+ if e.response is not None and e.response.status_code == 429:
44
+ return True
45
+ return False
46
+
47
+ class BaseDataFetcher(ABC):
48
+ """
49
+ Abstract Base Class for all data fetchers.
50
+ Acts as a contract ensuring all fetchers implement the fetch_single method.
51
+ """
52
+
53
+ def __init__(self):
54
+ import sys
55
+ if "pytest" not in sys.modules and os.environ.get("DISABLE_REQUESTS_CACHE") != "1":
56
+ self.session = requests_cache.CachedSession(
57
+ cache_name=cache_path,
58
+ backend='sqlite',
59
+ expire_after=86400,
60
+ filter_fn=_filter_responses
61
+ )
62
+ else:
63
+ self.session = requests.Session()
64
+
65
+ @abstractmethod
66
+ def fetch_single(
67
+ self,
68
+ symbol: str,
69
+ start_date: Optional[str] = None,
70
+ end_date: Optional[str] = None,
71
+ **kwargs
72
+ ) -> pd.DataFrame:
73
+ """
74
+ Fetch data for a single symbol.
75
+ Every provider must implement this method.
76
+ """
77
+ pass
78
+
79
+ @abstractmethod
80
+ def fetch_info(self, symbol: str) -> dict:
81
+ """
82
+ Fetch company profile and basic information.
83
+ Must return a dictionary.
84
+ """
85
+ pass
86
+
87
+ @abstractmethod
88
+ def fetch_financials(self, symbol: str, statement: str = "income", period: str = "annual") -> pd.DataFrame:
89
+ """
90
+ Fetch financial statements (income, balance, cashflow).
91
+ period: "annual" or "quarterly".
92
+ Must return a DataFrame with dates as the index.
93
+ """
94
+ pass
95
+
96
+ def _validate_inputs(self, symbol: str, start_date: Optional[str] = None, end_date: Optional[str] = None):
97
+ """Validates standard inputs before fetching."""
98
+ if not symbol or not isinstance(symbol, str) or symbol.strip() == "":
99
+ raise ValueError("Symbol must be a non-empty string.")
100
+
101
+ def validate_date(date_str, name):
102
+ if date_str:
103
+ try:
104
+ return datetime.strptime(date_str, "%Y-%m-%d")
105
+ except ValueError:
106
+ raise ValueError(f"{name} format must be YYYY-MM-DD, got: '{date_str}'")
107
+ return None
108
+
109
+ start = validate_date(start_date, "start_date")
110
+ end = validate_date(end_date, "end_date")
111
+
112
+ if start and end and start > end:
113
+ raise ValueError("start_date cannot be after end_date.")
114
+
115
+ @retry(
116
+ stop=stop_after_attempt(3),
117
+ wait=wait_exponential(multiplier=1, min=2, max=30),
118
+ retry=retry_if_exception(_is_retryable_exception),
119
+ before_sleep=before_sleep_log(logger, log_level=20), # 20 = INFO
120
+ reraise=True,
121
+ )
122
+ def _make_http_request(self, url: str, params: Optional[dict] = None, headers: Optional[dict] = None) -> dict:
123
+ """
124
+ Generic HTTP GET request helper with automatic retry.
125
+ Retries up to 3 times with exponential backoff for transient network errors.
126
+ Raises HTTPError for non-retryable server errors (4xx).
127
+ """
128
+ logger.debug("GET %s params=%s", url, list((params or {}).keys()))
129
+
130
+ headers = headers or {}
131
+ # Force gzip/deflate to avoid unsupported Brotli (br) encoding
132
+ if "Accept-Encoding" not in headers:
133
+ headers["Accept-Encoding"] = "gzip, deflate"
134
+
135
+ response = self.session.get(url, params=params, headers=headers, timeout=30)
136
+
137
+ # Raise specific error for rate-limiting so tenacity can retry it
138
+ if response.status_code == 429:
139
+ logger.warning("Rate limited (HTTP 429). Retrying after backoff...")
140
+ raise RateLimitExceededError(f"Rate limited by server (HTTP 429) for URL: {url}")
141
+
142
+ response.raise_for_status()
143
+ try:
144
+ # requests usually auto-decompresses; prefer response.json first
145
+ return response.json()
146
+ except ValueError as first_err:
147
+ raw = response.content or b""
148
+ enc = (response.headers.get("Content-Encoding") or "").lower()
149
+
150
+ try:
151
+ if "gzip" in enc or raw[:2] == b'\x1f\x8b':
152
+ raw = gzip.decompress(raw)
153
+ elif "deflate" in enc:
154
+ raw = zlib.decompress(raw)
155
+
156
+ # try UTF-8 decode + json parse
157
+ if isinstance(raw, bytes):
158
+ raw = raw.decode("utf-8", errors="replace")
159
+
160
+ return json.loads(raw)
161
+ except Exception as second_err:
162
+ snippet = (raw[:200] if isinstance(raw, str) else repr(raw[:80]))
163
+ logger.error(
164
+ "Failed to parse JSON response from %s (status=%s, encoding=%s). Snippet: %s",
165
+ url, response.status_code, enc, snippet
166
+ )
167
+ raise ValueError(
168
+ f"Invalid JSON response from API: {second_err or first_err}"
169
+ ) from second_err
170
+
171
+ def _normalize_ohlcv(self, df: pd.DataFrame) -> pd.DataFrame:
172
+ """
173
+ Standardizes the DataFrame columns to Title Case (Open, High, Low, Close, Volume, Adj Close)
174
+ and ensures the index is a DatetimeIndex.
175
+ """
176
+ if df.empty:
177
+ return pd.DataFrame()
178
+
179
+ # If all values are NaN (e.g. yfinance returning NaNs for invalid tickers in batch), return empty
180
+ if df.isna().all().all():
181
+ logger.warning("All values are NaN after fetch — returning empty DataFrame.")
182
+ return pd.DataFrame()
183
+
184
+ # Flatten MultiIndex columns (e.g., from new yfinance versions returning Price/Ticker)
185
+ if isinstance(df.columns, pd.MultiIndex):
186
+ # We assume level 0 is the metric (Close, High, etc.) and level 1 is the ticker
187
+ df.columns = df.columns.get_level_values(0)
188
+
189
+ # Title case mapping for common variants
190
+ col_mapping = {
191
+ 'open': 'Open',
192
+ 'high': 'High',
193
+ 'low': 'Low',
194
+ 'close': 'Close',
195
+ 'volume': 'Volume',
196
+ 'adjclose': 'Adj Close',
197
+ 'adj_close': 'Adj Close',
198
+ 'adjClose': 'Adj Close',
199
+ 'adjOpen': 'Adj Open',
200
+ 'adjHigh': 'Adj High',
201
+ 'adjLow': 'Adj Low',
202
+ 'adjVolume': 'Adj Volume',
203
+ }
204
+ df = df.rename(columns=col_mapping)
205
+
206
+ # Ensure index is datetime and named 'Date'
207
+ if not isinstance(df.index, pd.DatetimeIndex):
208
+ try:
209
+ df.index = pd.to_datetime(df.index)
210
+ except Exception as e:
211
+ logger.warning(
212
+ "Could not convert index to DatetimeIndex: %s. "
213
+ "Falling back to pd.to_datetime with errors='coerce'.", e
214
+ )
215
+ df.index = pd.to_datetime(df.index, errors='coerce')
216
+ df.index.name = "Date"
217
+
218
+ # Automatically backfill 'Adj Open', 'Adj High', 'Adj Low' if we only have 'Adj Close'
219
+ df = backfill_adjusted_columns(df)
220
+
221
+ return df
222
+
223
+ def _normalize_financials(self, df: pd.DataFrame) -> pd.DataFrame:
224
+ """
225
+ Standardizes financial statement column names across different providers.
226
+ Targets: Net_Income, Total_Equity, Shares_Outstanding, Total_Revenue,
227
+ Operating_Income, Free_Cash_Flow, Total_Assets, Total_Liabilities.
228
+ """
229
+ if df.empty:
230
+ return df
231
+
232
+ # Common variations for fundamental metrics
233
+ col_mapping = {
234
+ # Net Income
235
+ 'netIncome': 'Net_Income',
236
+ 'Net Income': 'Net_Income',
237
+ 'netIncomeLoss': 'Net_Income',
238
+ 'net_income': 'Net_Income',
239
+
240
+ # Total Equity
241
+ 'totalStockholdersEquity': 'Total_Equity',
242
+ 'stockholdersEquity': 'Total_Equity',
243
+ 'Total Stockholder Equity': 'Total_Equity',
244
+ 'Stockholders Equity': 'Total_Equity',
245
+ 'totalEquity': 'Total_Equity',
246
+ 'total_equity': 'Total_Equity',
247
+ 'totalShareholderEquity': 'Total_Equity',
248
+
249
+ # Shares Outstanding
250
+ 'sharesOutstanding': 'Shares_Outstanding',
251
+ 'commonStockSharesOutstanding': 'Shares_Outstanding',
252
+ 'weightedAverageSharesOutstanding': 'Shares_Outstanding',
253
+ 'Basic Average Shares': 'Shares_Outstanding',
254
+ 'shares_outstanding': 'Shares_Outstanding',
255
+ 'shares_outstanding_basic': 'Shares_Outstanding',
256
+
257
+ # Total Revenue
258
+ 'totalRevenue': 'Total_Revenue',
259
+ 'Total Revenue': 'Total_Revenue',
260
+ 'revenue': 'Total_Revenue',
261
+
262
+ # Operating Income
263
+ 'operatingIncome': 'Operating_Income',
264
+ 'Operating Income': 'Operating_Income',
265
+ 'operating_income': 'Operating_Income',
266
+ 'ebit': 'Operating_Income',
267
+ 'EBIT': 'Operating_Income',
268
+
269
+ # Free Cash Flow
270
+ 'freeCashFlow': 'Free_Cash_Flow',
271
+ 'Free Cash Flow': 'Free_Cash_Flow',
272
+ 'free_cash_flow': 'Free_Cash_Flow',
273
+
274
+ # Total Assets
275
+ 'totalAssets': 'Total_Assets',
276
+ 'Total Assets': 'Total_Assets',
277
+ 'total_assets': 'Total_Assets',
278
+ 'assets': 'Total_Assets',
279
+
280
+ # Total Liabilities
281
+ 'totalLiabilities': 'Total_Liabilities',
282
+ 'Total Liabilities': 'Total_Liabilities',
283
+ 'Total Liabilities Net Minority Interest': 'Total_Liabilities',
284
+ 'total_liabilities': 'Total_Liabilities',
285
+ 'liabilities': 'Total_Liabilities'
286
+ }
287
+
288
+ # Rename columns if they exist in mapping
289
+ df = df.rename(columns=col_mapping)
290
+
291
+ # Deduplicate columns if multiple raw metrics mapped to the same standard column
292
+ if df.columns.duplicated().any():
293
+ df = df.loc[:, ~df.columns.duplicated(keep='first')]
294
+
295
+ # Ensure index is datetime and named 'Date'
296
+ if not isinstance(df.index, pd.DatetimeIndex):
297
+ try:
298
+ # Some APIs return dates in index, some as a column. Assume index here.
299
+ df.index = pd.to_datetime(df.index)
300
+ except Exception:
301
+ pass
302
+ df.index.name = "Date"
303
+
304
+ return df
305
+
306
+ def fetch_multiple(
307
+ self,
308
+ symbols: List[str],
309
+ start_date: Optional[str] = None,
310
+ end_date: Optional[str] = None,
311
+ max_workers: int = 5,
312
+ **kwargs
313
+ ) -> Dict[str, pd.DataFrame]:
314
+ """
315
+ Fetch data for multiple tickers, returning a dictionary of DataFrames.
316
+ Symbols that fail are logged and excluded from the result.
317
+ Uses ThreadPoolExecutor for concurrent fetching to improve performance.
318
+ """
319
+ if not symbols:
320
+ return {}
321
+
322
+ results = {}
323
+
324
+ def _fetch_one(symbol):
325
+ try:
326
+ # Add random delay to prevent instantly hitting rate limits
327
+ time.sleep(rd.uniform(0.1, 0.5))
328
+ df = self.fetch_single(symbol, start_date, end_date, **kwargs)
329
+ return symbol, df, None
330
+ except Exception as e:
331
+ return symbol, None, e
332
+
333
+ logger.info(
334
+ "Batch fetching %d symbols concurrently (max_workers=%d)...",
335
+ len(symbols), max_workers
336
+ )
337
+
338
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
339
+ futures = {executor.submit(_fetch_one, sym): sym for sym in symbols}
340
+ for future in concurrent.futures.as_completed(futures):
341
+ sym, df, err = future.result()
342
+ if err:
343
+ logger.error("Failed to fetch %s — skipping. Error: %s", sym, err)
344
+ else:
345
+ results[sym] = df
346
+
347
+ succeeded = len(results)
348
+ failed = len(symbols) - succeeded
349
+ if failed > 0:
350
+ logger.warning(
351
+ "fetch_multiple finished: %d/%d succeeded, %d failed.",
352
+ succeeded, len(symbols), failed,
353
+ )
354
+ else:
355
+ logger.info("fetch_multiple finished: all %d symbols succeeded.", succeeded)
356
+
357
+ return results
@@ -0,0 +1,19 @@
1
+ class AlphaDataForgeError(Exception):
2
+ """Base class for all exceptions raised by the alphadataforge library."""
3
+ pass
4
+
5
+ class ProviderConfigurationError(AlphaDataForgeError):
6
+ """Raised when a provider is improperly configured (e.g., missing API keys)."""
7
+ pass
8
+
9
+ class RateLimitExceededError(AlphaDataForgeError):
10
+ """Raised when an API rate limit is exceeded (HTTP 429 or API notice)."""
11
+ pass
12
+
13
+ class InvalidTickerError(AlphaDataForgeError):
14
+ """Raised when an API explicitly states the ticker is invalid or not found."""
15
+ pass
16
+
17
+ class DataFetchError(AlphaDataForgeError):
18
+ """Raised when a general error occurs while fetching data from the API."""
19
+ pass
@@ -0,0 +1,107 @@
1
+ import pandas as pd
2
+ from typing import Optional, Dict, Any
3
+
4
+ from ..providers.yfinance_fetcher import YFinanceFetcher
5
+ from ..providers.fmp_fetcher import FMPFetcher
6
+ from ..providers.alphavantage_fetcher import AlphaVantageFetcher
7
+ from ..providers.stockfit_fetcher import StockFitFetcher
8
+ from ..utils.logger import setup_logger
9
+ from ..config.settings import config
10
+
11
+ logger = setup_logger(__name__)
12
+
13
+ class Fundamentals:
14
+ """
15
+ Facade for fetching fundamental data (company profile, financial statements)
16
+ across multiple providers (YFinance, FMP, AlphaVantage, StockFit).
17
+ """
18
+
19
+ @staticmethod
20
+ def _get_provider(provider_name: str):
21
+ provider_name = provider_name.lower()
22
+ if provider_name == "yfinance":
23
+ return YFinanceFetcher()
24
+ elif provider_name == "fmp":
25
+ return FMPFetcher()
26
+ elif provider_name == "alphavantage":
27
+ return AlphaVantageFetcher()
28
+ elif provider_name == "stockfit":
29
+ return StockFitFetcher()
30
+ else:
31
+ raise ValueError(f"Unsupported provider: '{provider_name}'. Choose 'yfinance', 'stockfit', 'alphavantage', or 'fmp'.")
32
+
33
+ @staticmethod
34
+ def get_info(symbol: str, provider: str = "yfinance") -> Dict[str, Any]:
35
+ """
36
+ Fetch company profile and basic information.
37
+
38
+ Args:
39
+ symbol: Ticker symbol (e.g., "AAPL").
40
+ provider: "yfinance" (default), "stockfit", "fmp", or "alphavantage".
41
+
42
+ Returns:
43
+ Dictionary containing company info (sector, industry, marketCap, etc.).
44
+ """
45
+ fetcher = Fundamentals._get_provider(provider)
46
+ return fetcher.fetch_info(symbol)
47
+
48
+ @staticmethod
49
+ def get_financials(
50
+ symbol: str,
51
+ statement: str = "income",
52
+ period: str = "annual",
53
+ provider: str = "yfinance",
54
+ **kwargs
55
+ ) -> pd.DataFrame:
56
+ """
57
+ Fetch financial statements (Income, Balance Sheet, Cash Flow).
58
+
59
+ Args:
60
+ symbol: Ticker symbol (e.g., "AAPL").
61
+ statement: "income", "balance", "cashflow", "growth", "scores", "shares_outstanding", "earnings", or "shares_float".
62
+ period: "annual" (default) or "quarterly".
63
+ provider: "yfinance" (default), "stockfit", "fmp", or "alphavantage".
64
+ **kwargs: Extra parameters passed to the provider (e.g. limit=100).
65
+
66
+ Returns:
67
+ DataFrame with normalized columns (e.g., Net_Income, Total_Revenue)
68
+ and DatetimeIndex for the reporting periods.
69
+ """
70
+ fetcher = Fundamentals._get_provider(provider)
71
+ return fetcher.fetch_financials(symbol, statement=statement, period=period, **kwargs)
72
+
73
+ @staticmethod
74
+ def get_scores(symbol: str, provider: str = "stockfit") -> Dict[str, Any]:
75
+ """
76
+ Fetch Piotroski F-Score and financial health scores.
77
+
78
+ Args:
79
+ symbol: Ticker symbol (e.g., "AAPL").
80
+ provider: "stockfit" (default).
81
+ """
82
+ fetcher = Fundamentals._get_provider(provider)
83
+ if hasattr(fetcher, "fetch_scores"):
84
+ return fetcher.fetch_scores(symbol)
85
+ raise NotImplementedError(f"Provider '{provider}' does not support get_scores.")
86
+
87
+ @staticmethod
88
+ def get_earnings_calendar(
89
+ horizon: str = "3month",
90
+ symbol: Optional[str] = None,
91
+ provider: str = "alphavantage"
92
+ ) -> pd.DataFrame:
93
+ """
94
+ Fetch earnings calendar.
95
+
96
+ Args:
97
+ horizon: '3month' (default), '6month', or '12month'.
98
+ symbol: Optional ticker symbol to fetch for specifically.
99
+ provider: Defaults to "alphavantage" (currently the only supported provider for this).
100
+
101
+ Returns:
102
+ DataFrame containing the earnings calendar.
103
+ """
104
+ fetcher = Fundamentals._get_provider(provider)
105
+ if not hasattr(fetcher, 'fetch_earnings_calendar'):
106
+ raise NotImplementedError(f"Provider '{provider}' does not support get_earnings_calendar.")
107
+ return fetcher.fetch_earnings_calendar(horizon=horizon, symbol=symbol)