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,207 @@
1
+ from typing import Optional, Dict, Any, Union, List, Literal
2
+ import pandas as pd
3
+ import concurrent.futures
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ from ..providers.yfinance_fetcher import YFinanceFetcher
9
+ from ..providers.tiingo_fetcher import TiingoFetcher
10
+ from ..providers.alphavantage_fetcher import AlphaVantageFetcher
11
+ from ..providers.fmp_fetcher import FMPFetcher
12
+ from ..providers.hybrid_fetcher import HybridFetcher
13
+ from ..providers.stockfit_fetcher import StockFitFetcher
14
+
15
+ class Price:
16
+ """
17
+ Unified Facade API for fetching price data.
18
+ Hides provider-specific complexity from the caller.
19
+ """
20
+
21
+ @staticmethod
22
+ def get(
23
+ symbols: Union[str, List[str]],
24
+ start_date: Optional[str] = None,
25
+ end_date: Optional[str] = None,
26
+ provider: Literal["yfinance", "tiingo", "alphavantage", "fmp", "hybrid_av_tiingo", "stockfit"] = "yfinance",
27
+ provider_params: Optional[Dict[str, Any]] = None
28
+ ) -> Union[pd.DataFrame, Dict[str, pd.DataFrame]]:
29
+ """
30
+ Fetch historical end-of-day (EOD) price data for one or more symbols.
31
+
32
+ Args:
33
+ symbols (Union[str, List[str]]):
34
+ A single ticker symbol (e.g., 'AAPL') or a list of symbols (e.g., ['AAPL', 'MSFT']).
35
+ start_date (Optional[str], optional):
36
+ Start date in 'YYYY-MM-DD' format. Defaults to None.
37
+ end_date (Optional[str], optional):
38
+ End date in 'YYYY-MM-DD' format. Defaults to None.
39
+ provider (Literal["yfinance", "tiingo", "alphavantage", "fmp", "hybrid_av_tiingo", "stockfit"], optional):
40
+ The data provider to use. Defaults to "yfinance".
41
+ provider_params (Optional[Dict[str, Any]], optional):
42
+ Additional parameters passed directly to the specific provider's fetcher.
43
+
44
+ **Supported `provider_params` by provider:**
45
+
46
+ * **All Providers:**
47
+ * `adjusted` (bool): If True, returns fully adjusted prices (Splits & Dividends). Default is False.
48
+
49
+ * **yfinance:**
50
+ * `interval` (str): Data interval (e.g., "1d", "1wk", "1mo").
51
+ * `prepost` (bool): Include Pre and Post market data.
52
+
53
+ * **tiingo:**
54
+ * `resampleFreq` (str): Resample frequency (e.g., "daily", "weekly", "monthly").
55
+
56
+ * **alphavantage:**
57
+ * `outputsize` (str): "compact" (last 100 data points) or "full" (20+ years). Default is "compact".
58
+
59
+ * **fmp:**
60
+ * `outputsize` (str): "full" (all history).
61
+
62
+ * **stockfit:**
63
+ * `resolution` (str): "1d", "1wk", "1mo".
64
+
65
+ Returns:
66
+ Union[pd.DataFrame, Dict[str, pd.DataFrame]]:
67
+ - If `symbols` is a single string, returns a pandas DataFrame.
68
+ - If `symbols` is a list, returns a dictionary mapping symbols to DataFrames.
69
+
70
+ Examples:
71
+ >>> from alphadataforge.data.price import Price
72
+ >>> # 1. Default (yfinance)
73
+ >>> df = Price.get("AAPL", start_date="2023-01-01")
74
+ >>>
75
+ >>> # 2. Using FMP with adjusted prices
76
+ >>> df_fmp = Price.get("MSFT", provider="fmp", provider_params={"adjusted": True})
77
+ >>>
78
+ >>> # 3. Using StockFit
79
+ >>> df_stockfit = Price.get("NVDA", provider="stockfit", start_date="2023-01-01")
80
+ """
81
+
82
+ # Prevent error if no params are passed
83
+ provider_params = provider_params or {}
84
+
85
+ # 1. Route to the correct provider
86
+ if provider == "yfinance":
87
+ fetcher = YFinanceFetcher()
88
+ elif provider == "tiingo":
89
+ fetcher = TiingoFetcher()
90
+ elif provider == "alphavantage":
91
+ fetcher = AlphaVantageFetcher()
92
+ elif provider == "fmp":
93
+ fetcher = FMPFetcher()
94
+ elif provider == "hybrid_av_tiingo":
95
+ fetcher = HybridFetcher()
96
+ elif provider == "stockfit":
97
+ fetcher = StockFitFetcher()
98
+ else:
99
+ raise ValueError(f"Unsupported provider: '{provider}'. Choose 'yfinance', 'tiingo', 'alphavantage', 'fmp', 'hybrid_av_tiingo', or 'stockfit'.")
100
+
101
+ # 2. fetch price data (check whether list or string)
102
+ if isinstance(symbols, list):
103
+ result = fetcher.fetch_multiple(symbols, start_date, end_date, **provider_params)
104
+ else:
105
+ result = fetcher.fetch_single(symbols, start_date, end_date, **provider_params)
106
+
107
+ # 3. Return to caller
108
+ return result
109
+
110
+ @staticmethod
111
+ def compare(
112
+ symbol: str,
113
+ start_date: Optional[str] = None,
114
+ end_date: Optional[str] = None,
115
+ providers: Optional[List[str]] = None,
116
+ provider_params: Optional[Dict[str, Any]] = None,
117
+ custom_data: Optional[Dict[str, pd.DataFrame]] = None
118
+ ) -> pd.DataFrame:
119
+ """
120
+ Fetch historical price data from multiple providers concurrently and combine them
121
+ into a single MultiIndex DataFrame for easy comparison.
122
+
123
+ Args:
124
+ custom_data: Optional dictionary mapping provider names to custom pandas DataFrames.
125
+ These will be merged alongside the API-fetched data.
126
+ """
127
+ if providers is None:
128
+ providers = ["yfinance", "tiingo", "alphavantage", "fmp", "hybrid_av_tiingo"]
129
+
130
+ provider_params = provider_params or {}
131
+
132
+ def fetch_for_provider(p):
133
+ try:
134
+ return p, Price.get(
135
+ symbols=symbol,
136
+ start_date=start_date,
137
+ end_date=end_date,
138
+ provider=p,
139
+ provider_params=provider_params
140
+ )
141
+ except Exception as e:
142
+ logger.error(f"Failed to fetch from {p} for comparison: {type(e).__name__} {e}")
143
+ return p, pd.DataFrame()
144
+
145
+ results = {}
146
+ with concurrent.futures.ThreadPoolExecutor(max_workers=len(providers)) as executor:
147
+ futures = [executor.submit(fetch_for_provider, p) for p in providers]
148
+ for future in concurrent.futures.as_completed(futures):
149
+ p, df = future.result()
150
+ if not df.empty:
151
+ if df.index.tz is not None:
152
+ df.index = df.index.tz_localize(None)
153
+ results[p] = df
154
+
155
+ if custom_data:
156
+ for p, df in custom_data.items():
157
+ if not df.empty:
158
+ if df.index.tz is not None:
159
+ df.index = df.index.tz_localize(None)
160
+ results[p] = df
161
+
162
+ if not results:
163
+ return pd.DataFrame()
164
+
165
+ # Combine into MultiIndex DataFrame
166
+ # pd.concat(results, axis=1) creates MultiIndex where level 0 is the dictionary key (provider)
167
+ multi_df = pd.concat(results, axis=1)
168
+ return multi_df
169
+
170
+ @staticmethod
171
+ def consensus(
172
+ symbol: str,
173
+ start_date: Optional[str] = None,
174
+ end_date: Optional[str] = None,
175
+ providers: Optional[List[str]] = None,
176
+ provider_params: Optional[Dict[str, Any]] = None,
177
+ custom_data: Optional[Dict[str, pd.DataFrame]] = None,
178
+ method: Literal["mean", "median"] = "median"
179
+ ) -> pd.DataFrame:
180
+ """
181
+ Fetch from multiple providers and return a single consensus DataFrame
182
+ by calculating the mean or median across all available providers.
183
+ """
184
+ multi_df = Price.compare(symbol, start_date, end_date, providers, provider_params, custom_data)
185
+
186
+ if multi_df.empty:
187
+ return pd.DataFrame()
188
+
189
+ # Filter out non-numeric columns (like 'symbol' strings)
190
+ numeric_df = multi_df.select_dtypes(include='number')
191
+
192
+ # Group by level 1 (the column names like 'Close', 'Adj Close') and aggregate
193
+ # Using .T.groupby() to avoid deprecated axis=1
194
+ if method == "mean":
195
+ consensus_df = numeric_df.T.groupby(level=1).mean().T
196
+ elif method == "median":
197
+ consensus_df = numeric_df.T.groupby(level=1).median().T
198
+ else:
199
+ raise ValueError("Method must be 'mean' or 'median'")
200
+
201
+ # Sort columns to maintain standard OHLCV order
202
+ standard_cols = ['Open', 'High', 'Low', 'Close', 'Volume', 'Adj Open', 'Adj High', 'Adj Low', 'Adj Close', 'Adj Volume']
203
+ existing_cols = [c for c in standard_cols if c in consensus_df.columns]
204
+ other_cols = [c for c in consensus_df.columns if c not in standard_cols]
205
+ consensus_df = consensus_df[existing_cols + other_cols]
206
+
207
+ return consensus_df
File without changes
File without changes
@@ -0,0 +1,328 @@
1
+ import pandas as pd
2
+ from typing import Optional, List, Dict
3
+
4
+ import time
5
+ import threading
6
+ from ..utils.finance_math import calculate_adjusted_prices
7
+
8
+ from ..core.base_fetcher import BaseDataFetcher
9
+ from ..core.exceptions import ProviderConfigurationError, RateLimitExceededError, InvalidTickerError
10
+ from ..config.settings import config
11
+ from ..config.endpoints import Endpoints
12
+ from ..utils.logger import setup_logger
13
+
14
+ logger = setup_logger(__name__)
15
+
16
+
17
+ class AlphaVantageFetcher(BaseDataFetcher):
18
+ """
19
+ Data fetcher for Alpha Vantage using direct HTTP requests.
20
+ Supports: EOD stock price.
21
+
22
+ Requires ALPHAVANTAGE_API_KEY environment variable.
23
+ Free tier: 25 requests/day.
24
+ """
25
+
26
+ # Class-level lock to ensure only one thread makes an AlphaVantage request at a time
27
+ _rate_limit_lock = threading.Lock()
28
+
29
+ def __init__(self, api_key: Optional[str] = None):
30
+ super().__init__()
31
+ self.api_key = api_key or config.ALPHAVANTAGE_API_KEY
32
+ self.base_url = Endpoints.AlphaVantage.BASE_URL
33
+
34
+ def _require_api_key(self) -> None:
35
+ if not self.api_key:
36
+ raise ProviderConfigurationError(
37
+ "ALPHAVANTAGE_API_KEY is not set. Please set it in your .env file or environment variables."
38
+ )
39
+
40
+ def _make_request(self, **params) -> dict:
41
+ """
42
+ Executes HTTP GET request to Alpha Vantage API.
43
+ Raises ValueError for API-level errors (bad symbol, etc.).
44
+ Raises RuntimeError for rate-limit / information notices.
45
+ """
46
+ # AlphaVantage Free Tier allows max 1 request/second.
47
+ # Add automatic delay and thread lock to prevent concurrent rate limit hits.
48
+ with self._rate_limit_lock:
49
+ time.sleep(1.2)
50
+
51
+ self._require_api_key()
52
+ params['apikey'] = self.api_key
53
+ data = self._make_http_request(self.base_url, params=params)
54
+
55
+
56
+ # Alpha Vantage returns an error message in the JSON payload instead of HTTP status sometimes
57
+ if "Error Message" in data:
58
+ raise InvalidTickerError(f"AlphaVantage API Error: {data['Error Message']}")
59
+ if "Information" in data:
60
+ raise RateLimitExceededError(
61
+ f"AlphaVantage rate limit or notice: {data['Information']}"
62
+ )
63
+ return data
64
+
65
+ def _parse_response(self, raw_json: dict, time_series_key: str) -> pd.DataFrame:
66
+ """
67
+ Parses Alpha Vantage JSON response into a Pandas DataFrame.
68
+ Raises KeyError if the expected time series key is missing from the response.
69
+ """
70
+ if time_series_key not in raw_json:
71
+ logger.warning(
72
+ "Expected key '%s' not found in API response. "
73
+ "Available keys: %s", time_series_key, list(raw_json.keys())
74
+ )
75
+ return pd.DataFrame()
76
+
77
+ time_series_data = raw_json[time_series_key]
78
+
79
+ # Convert dictionary to DataFrame
80
+ df = pd.DataFrame.from_dict(time_series_data, orient='index')
81
+
82
+ # Alpha Vantage returns columns like "1. open", "2. high", etc.
83
+ # We clean them to match standard mappings ("open", "high", etc.)
84
+ # so that _normalize_ohlcv can capitalize them.
85
+ rename_map = {
86
+ '1. open': 'open',
87
+ '2. high': 'high',
88
+ '3. low': 'low',
89
+ '4. close': 'close',
90
+ '5. volume': 'volume',
91
+ '5. adjusted close': 'adjClose',
92
+ '6. volume': 'volume', # Adjusted daily uses 6 for volume
93
+ }
94
+ df.rename(columns=rename_map, inplace=True)
95
+
96
+ # Convert types from string to float
97
+ for col in df.columns:
98
+ df[col] = pd.to_numeric(df[col], errors='coerce')
99
+
100
+ return df
101
+
102
+ # ------------------------------------------------------------------
103
+ # Required by BaseDataFetcher — fetches PRICE for 1 symbol
104
+ # ------------------------------------------------------------------
105
+ def fetch_single(
106
+ self,
107
+ symbol: str,
108
+ start_date: Optional[str] = None,
109
+ end_date: Optional[str] = None,
110
+ outputsize: str = "compact", # "compact" (100 days) or "full" (20 years)
111
+ adjusted: bool = False,
112
+ **kwargs
113
+ ) -> pd.DataFrame:
114
+ """
115
+ Fetch EOD price data for a single stock/ETF symbol from Alpha Vantage.
116
+
117
+ Raises:
118
+ ValueError: If symbol is invalid or API returns an error.
119
+ RuntimeError: If rate limit is exceeded.
120
+ RequestException: If network request fails after retries.
121
+ """
122
+ logger.info("Fetching price for %s (size=%s, adjusted=%s)...", symbol, outputsize, adjusted)
123
+ self._validate_inputs(symbol, start_date, end_date)
124
+
125
+ # TIME_SERIES_DAILY_ADJUSTED is a premium endpoint now.
126
+ # We always use TIME_SERIES_DAILY and manually adjust it using DIVIDENDS/SPLITS.
127
+ function = Endpoints.AlphaVantage.FUNCTIONS["price_daily"]
128
+ time_series_key = "Time Series (Daily)"
129
+
130
+ # --- Step 1: Fetch price data (will raise on failure) ---
131
+ raw_json = self._make_request(
132
+ function=function,
133
+ symbol=symbol,
134
+ outputsize=outputsize
135
+ )
136
+ df = self._parse_response(raw_json, time_series_key)
137
+ if df.empty:
138
+ logger.warning("_parse_response returned empty for %s (key='%s').", symbol, time_series_key)
139
+
140
+ # Alpha Vantage doesn't let us filter by date in the API call directly
141
+ # We must filter it locally in pandas
142
+ if not df.empty:
143
+ df.index = pd.to_datetime(df.index)
144
+ if start_date:
145
+ df = df[df.index >= pd.to_datetime(start_date)]
146
+ if end_date:
147
+ df = df[df.index <= pd.to_datetime(end_date)]
148
+
149
+ # --- Step 2: Fetch Dividends & Splits (separate try/except) ---
150
+ if adjusted and not df.empty:
151
+
152
+ div_df = pd.DataFrame(columns=['Dividend'])
153
+ split_df = pd.DataFrame(columns=['SplitFactor'])
154
+
155
+ # --- 2a: Dividends ---
156
+ try:
157
+ logger.info("Fetching DIVIDENDS for %s...", symbol)
158
+ raw_json = self._make_request(function=Endpoints.AlphaVantage.FUNCTIONS["dividends"], symbol=symbol)
159
+ div_data = raw_json.get("data", [])
160
+ if div_data:
161
+ div_df = pd.DataFrame(div_data)
162
+ div_df['Dividend'] = pd.to_numeric(div_df['amount'], errors='coerce')
163
+ div_df.index = pd.to_datetime(div_df['ex_dividend_date'])
164
+ except Exception as e:
165
+ logger.warning(
166
+ "Could not fetch dividends for %s: %s. "
167
+ "Proceeding without dividend adjustment.", symbol, e
168
+ )
169
+
170
+ # --- 2b: Splits ---
171
+ try:
172
+ logger.info("Fetching SPLITS for %s...", symbol)
173
+ raw_json = self._make_request(function=Endpoints.AlphaVantage.FUNCTIONS["splits"], symbol=symbol)
174
+ split_data = raw_json.get("data", [])
175
+ if split_data:
176
+ split_df = pd.DataFrame(split_data)
177
+ split_df['SplitFactor'] = pd.to_numeric(split_df['split_factor'], errors='coerce')
178
+ split_df.index = pd.to_datetime(split_df['effective_date'])
179
+ except Exception as e:
180
+ logger.warning(
181
+ "Could not fetch splits for %s: %s. "
182
+ "Proceeding without split adjustment.", symbol, e
183
+ )
184
+
185
+ # --- 2c: Apply adjustment ---
186
+ df = self._normalize_ohlcv(df)
187
+ df = calculate_adjusted_prices(df, div_df, split_df)
188
+
189
+ return self._normalize_ohlcv(df)
190
+
191
+ # ------------------------------------------------------------------
192
+ # Fetch price for multiple symbols at once
193
+ # ------------------------------------------------------------------
194
+ def fetch_multiple(
195
+ self,
196
+ symbols: List[str],
197
+ start_date: Optional[str] = None,
198
+ end_date: Optional[str] = None,
199
+ outputsize: str = "compact",
200
+ adjusted: bool = False,
201
+ **kwargs
202
+ ) -> Dict[str, pd.DataFrame]:
203
+ """
204
+ Fetch price data for multiple symbols concurrently using ThreadPoolExecutor.
205
+ """
206
+ calls_per_symbol = 3 if adjusted else 1
207
+ total_calls = len(symbols) * calls_per_symbol
208
+ if total_calls > 25:
209
+ logger.warning(
210
+ "AlphaVantage quota warning: Fetching %d symbols (adjusted=%s) requires %d API calls. "
211
+ "The free tier limits you to 25 requests per day. This operation may fail.",
212
+ len(symbols), adjusted, total_calls
213
+ )
214
+ # Delegate to BaseDataFetcher.fetch_multiple which handles per-symbol
215
+ # error isolation and logging automatically.
216
+ return super().fetch_multiple(
217
+ symbols,
218
+ start_date=start_date,
219
+ end_date=end_date,
220
+ outputsize=outputsize,
221
+ adjusted=adjusted,
222
+ **kwargs
223
+ )
224
+
225
+ # ------------------------------------------------------------------
226
+ # Fetch fundamental data
227
+ # ------------------------------------------------------------------
228
+ def fetch_fundamental(self, symbol: str, function: str = "OVERVIEW") -> dict:
229
+ """
230
+ Fetch fundamental data from Alpha Vantage.
231
+ Supported functions: OVERVIEW, INCOME_STATEMENT, BALANCE_SHEET, CASH_FLOW, EARNINGS, etc.
232
+
233
+ Raises:
234
+ InvalidTickerError: If symbol is invalid or API returns an error.
235
+ RateLimitExceededError: If rate limit is exceeded.
236
+ """
237
+ logger.info("Fetching %s for %s...", function, symbol)
238
+ self._validate_inputs(symbol)
239
+
240
+ raw_json = self._make_request(
241
+ function=function,
242
+ symbol=symbol
243
+ )
244
+ return raw_json
245
+
246
+ def fetch_info(self, symbol: str) -> dict:
247
+ """
248
+ Fetch company profile (fundamentals like sector, industry, mktCap).
249
+ """
250
+ logger.info("Fetching info for %s via AlphaVantage...", symbol)
251
+ return self.fetch_fundamental(symbol, function=Endpoints.AlphaVantage.FUNCTIONS["overview"])
252
+
253
+ def fetch_financials(self, symbol: str, statement: str = "income", period: str = "annual") -> pd.DataFrame:
254
+ """
255
+ Fetch financial statements from AlphaVantage.
256
+ """
257
+ logger.info("Fetching %s statement for %s (%s) via AlphaVantage...", statement, symbol, period)
258
+
259
+ endpoint_map = Endpoints.AlphaVantage.FUNCTIONS
260
+ valid_statements = {"income", "balance", "cashflow", "shares_outstanding", "earnings"}
261
+
262
+ if statement not in valid_statements:
263
+ raise ValueError(f"Unknown statement type: '{statement}'. Choose 'income', 'balance', 'cashflow', 'shares_outstanding', or 'earnings'.")
264
+
265
+ function = endpoint_map[statement]
266
+ raw_json = self.fetch_fundamental(symbol, function=function)
267
+
268
+ # AlphaVantage uses different keys for different endpoints
269
+ if statement == "shares_outstanding":
270
+ # SHARES_OUTSTANDING uses a flat "data" list
271
+ reports = raw_json.get("data", [])
272
+ else:
273
+ if statement == "earnings":
274
+ report_key = "quarterlyEarnings" if period.lower() == "quarterly" else "annualEarnings"
275
+ else:
276
+ report_key = "quarterlyReports" if period.lower() == "quarterly" else "annualReports"
277
+ reports = raw_json.get(report_key, [])
278
+
279
+ if not reports:
280
+ logger.warning("No %s financial data found for %s in AlphaVantage.", period, symbol)
281
+ return pd.DataFrame()
282
+
283
+ df = pd.DataFrame(reports)
284
+
285
+ # fiscalDateEnding or date is the index
286
+ if 'fiscalDateEnding' in df.columns:
287
+ df.index = pd.to_datetime(df['fiscalDateEnding'])
288
+ df = df.drop(columns=['fiscalDateEnding'])
289
+ elif 'date' in df.columns:
290
+ df.index = pd.to_datetime(df['date'])
291
+ df = df.drop(columns=['date'])
292
+
293
+ return self._normalize_financials(df)
294
+
295
+ def fetch_earnings_calendar(self, horizon: str = "3month", symbol: Optional[str] = None) -> pd.DataFrame:
296
+ """
297
+ Fetch earnings calendar.
298
+ horizon: '3month', '6month', or '12month'
299
+ symbol: Optional specific symbol to fetch for.
300
+ """
301
+ logger.info("Fetching earnings calendar (horizon=%s) via AlphaVantage...", horizon)
302
+
303
+ # AlphaVantage Free Tier allows max 1 request/second.
304
+ time.sleep(1.2)
305
+
306
+ # This endpoint returns CSV directly, so we use pandas read_csv instead of _make_request
307
+ url = f"{self.base_url}?function=EARNINGS_CALENDAR&horizon={horizon}&apikey={self.api_key}"
308
+ if symbol:
309
+ url += f"&symbol={symbol}"
310
+
311
+ try:
312
+ df = pd.read_csv(url)
313
+ # Check for API rate limit (AlphaVantage returns JSON error if rate limited, which breaks read_csv, or returns a 1-row DataFrame with the error)
314
+ if not df.empty and len(df.columns) == 1 and "Thank you for using Alpha Vantage!" in str(df.iloc[0, 0]):
315
+ raise RateLimitExceededError(f"AlphaVantage rate limit or notice: {df.iloc[0, 0]}")
316
+
317
+ # The CSV columns are: symbol, name, reportDate, fiscalDateEnding, estimate, currency
318
+ if 'reportDate' in df.columns:
319
+ df['reportDate'] = pd.to_datetime(df['reportDate'], errors='coerce')
320
+ if 'fiscalDateEnding' in df.columns:
321
+ df['fiscalDateEnding'] = pd.to_datetime(df['fiscalDateEnding'], errors='coerce')
322
+
323
+ return df
324
+ except Exception as e:
325
+ if isinstance(e, RateLimitExceededError):
326
+ raise
327
+ logger.error("Failed to fetch earnings calendar: %s", e)
328
+ return pd.DataFrame()