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,183 @@
1
+ import pandas as pd
2
+ import requests
3
+ from typing import Optional, List, Dict, Any
4
+
5
+ from ..core.base_fetcher import BaseDataFetcher
6
+ from ..core.exceptions import ProviderConfigurationError
7
+ from ..config.settings import config
8
+ from ..config.endpoints import Endpoints
9
+ from ..utils.logger import setup_logger
10
+
11
+ logger = setup_logger(__name__)
12
+
13
+ class FMPFetcher(BaseDataFetcher):
14
+ """
15
+ Data fetcher for Financial Modeling Prep (FMP).
16
+ Handles EOD prices, splits, and dividends.
17
+ """
18
+
19
+ def __init__(self, api_key: Optional[str] = None):
20
+ super().__init__()
21
+ self.api_key = api_key or config.FMP_API_KEY
22
+ self.base_url = Endpoints.FMP.BASE_URL
23
+
24
+ def _require_api_key(self) -> None:
25
+ if not self.api_key:
26
+ raise ProviderConfigurationError(
27
+ "FMP_API_KEY is not set. Please set it in your .env file or environment variables."
28
+ )
29
+
30
+ def _make_request(self, endpoint: str, **params) -> dict:
31
+ """
32
+ Executes HTTP GET request to FMP API.
33
+ """
34
+ self._require_api_key()
35
+ params['apikey'] = self.api_key
36
+ url = f"{self.base_url}/{endpoint}"
37
+ return self._make_http_request(url, params=params)
38
+
39
+ def fetch_single(
40
+ self,
41
+ symbol: str,
42
+ start_date: Optional[str] = None,
43
+ end_date: Optional[str] = None,
44
+ outputsize: str = "full",
45
+ adjusted: bool = False,
46
+ **kwargs
47
+ ) -> pd.DataFrame:
48
+ """
49
+ Fetch EOD price data for a single symbol from FMP.
50
+ FMP provides 'adjClose' by default, but if `adjusted=True`, this will fully calculate
51
+ Adj Open, Adj High, Adj Low, and Adj Volume via the finance_math utility.
52
+ """
53
+ logger.info("Fetching price for %s (adjusted=%s) via FMP...", symbol, adjusted)
54
+ self._validate_inputs(symbol, start_date, end_date)
55
+
56
+ # --- Step 1: Fetch raw price data ---
57
+ # Note: We request 'from' and 'to' in the API to save bandwidth
58
+ params = {'symbol': symbol}
59
+ if start_date:
60
+ params['from'] = start_date
61
+ if end_date:
62
+ params['to'] = end_date
63
+
64
+ # Use the appropriate endpoint based on 'adjusted' flag
65
+ # 'dividend-adjusted' = Fully adjusted (splits + dividends)
66
+ # 'price_adjusted' = Fully adjusted (splits + dividends)
67
+ # 'price_raw' = Standard price (split-adjusted only)
68
+ if adjusted:
69
+ endpoint = Endpoints.FMP.PATHS["price_adjusted"]
70
+ else:
71
+ endpoint = Endpoints.FMP.PATHS["price_raw"]
72
+
73
+ raw_json = self._make_request(endpoint, **params)
74
+
75
+ historical = raw_json if isinstance(raw_json, list) else raw_json.get("historical", [])
76
+ if not historical:
77
+ logger.warning("No historical data found for %s in FMP.", symbol)
78
+ return pd.DataFrame()
79
+
80
+ df = pd.DataFrame(historical)
81
+ if 'date' in df.columns:
82
+ df.index = pd.to_datetime(df['date'])
83
+ df = df.drop(columns=['date'])
84
+
85
+ # Since FMP's dividend-adjusted endpoint already provides adjusted OHLC,
86
+ # we don't need to manually fetch splits/dividends and calculate it.
87
+ # We just normalize the columns and return.
88
+
89
+ return self._normalize_ohlcv(df)
90
+
91
+ def fetch_multiple(
92
+ self,
93
+ symbols: List[str],
94
+ start_date: Optional[str] = None,
95
+ end_date: Optional[str] = None,
96
+ outputsize: str = "full",
97
+ adjusted: bool = False,
98
+ **kwargs
99
+ ) -> Dict[str, pd.DataFrame]:
100
+ """
101
+ Fetch price data for multiple symbols.
102
+ Passes outputsize and adjusted down to fetch_single.
103
+ """
104
+ return super().fetch_multiple(
105
+ symbols,
106
+ adjusted=adjusted,
107
+ **kwargs
108
+ )
109
+
110
+ def fetch_info(self, symbol: str) -> dict:
111
+ """
112
+ Fetch company profile (fundamentals like sector, industry, mktCap).
113
+ """
114
+ logger.info("Fetching info for %s via FMP...", symbol)
115
+ self._validate_inputs(symbol)
116
+
117
+ # FMP profile API
118
+ # using the v3 endpoint, but our base url is stable, wait, we need to adjust the base_url or override it
119
+ # self.base_url is "https://financialmodelingprep.com/stable" but profile is usually "https://financialmodelingprep.com/api/v3/profile/AAPL"
120
+ # Let's override the url completely here.
121
+ url = f"{self.base_url}/{Endpoints.FMP.PATHS['profile']}"
122
+ params = {'apikey': self.api_key, 'symbol': symbol}
123
+
124
+ try:
125
+
126
+ data = self._make_http_request(url, params=params)
127
+ if isinstance(data, list) and len(data) > 0:
128
+ return data[0]
129
+ return {}
130
+ except requests.exceptions.HTTPError as e:
131
+ if e.response is not None and e.response.status_code == 403:
132
+ raise RuntimeError("FMP Fundamentals data (like company profile) requires a premium API key.") from e
133
+ raise
134
+
135
+ def fetch_financials(self, symbol: str, statement: str = "income", period: str = "annual") -> pd.DataFrame:
136
+ """
137
+ Fetch financial statements from FMP.
138
+ """
139
+ logger.info("Fetching %s statement for %s (%s) via FMP...", statement, symbol, period)
140
+ self._validate_inputs(symbol)
141
+
142
+ # FMP expects period as 'annual' (default/omit) or 'quarter'
143
+ fmp_period = "quarter" if period.lower() == "quarterly" else "annual"
144
+
145
+ endpoint_map = Endpoints.FMP.PATHS
146
+ valid_statements = {"income", "balance", "cashflow", "shares_float"}
147
+
148
+ if statement not in valid_statements:
149
+ raise ValueError(f"Unknown statement type: '{statement}'. Choose 'income', 'balance', 'cashflow', or 'shares_float'.")
150
+
151
+ if statement == "shares_float":
152
+ url = f"{self.base_url}/{Endpoints.FMP.PATHS['shares_float']}"
153
+ params = {'apikey': self.api_key, 'symbol': symbol}
154
+ else:
155
+ url = f"{self.base_url}/{endpoint_map[statement]}"
156
+ params = {'apikey': self.api_key, 'symbol': symbol, 'limit': 5} # limit 5 for free tier
157
+ if fmp_period == "quarter":
158
+ params['period'] = "quarter"
159
+
160
+
161
+ try:
162
+ data = self._make_http_request(url, params=params)
163
+ except requests.exceptions.HTTPError as e:
164
+ if e.response is not None and e.response.status_code == 403:
165
+ raise RuntimeError(f"FMP Financial Statements data requires a premium API key.") from e
166
+ raise
167
+
168
+ if not data or not isinstance(data, list):
169
+ logger.warning("No financial data found for %s in FMP.", symbol)
170
+ return pd.DataFrame()
171
+
172
+ df = pd.DataFrame(data)
173
+
174
+ # FMP returns dates in the 'date' column.
175
+ if 'date' in df.columns:
176
+ df.index = pd.to_datetime(df['date'])
177
+ df = df.drop(columns=['date'])
178
+
179
+ # Normalize and return
180
+ if statement == "shares_float":
181
+ # Just return the DataFrame directly for shares_float
182
+ return df
183
+ return self._normalize_financials(df)
@@ -0,0 +1,87 @@
1
+ import pandas as pd
2
+ from typing import Optional, List, Dict
3
+
4
+ from ..core.base_fetcher import BaseDataFetcher
5
+ from .alphavantage_fetcher import AlphaVantageFetcher
6
+ from .tiingo_fetcher import TiingoFetcher
7
+ from ..utils.finance_math import calculate_adjusted_prices
8
+ from ..utils.logger import setup_logger
9
+
10
+ logger = setup_logger(__name__)
11
+
12
+ class HybridFetcher(BaseDataFetcher):
13
+ """
14
+ Hybrid Data Fetcher that combines multiple providers for optimal rate limit usage.
15
+ Primary use case:
16
+ - Fetches long-history raw prices from Alpha Vantage.
17
+ - Fetches dividend and split events from Tiingo.
18
+ - Computes full adjusted prices locally.
19
+ Saves 2 API calls per symbol on Alpha Vantage compared to using AV's native adjusted fetch.
20
+ """
21
+
22
+ def __init__(self):
23
+ super().__init__()
24
+ self.av_fetcher = AlphaVantageFetcher()
25
+ self.tiingo_fetcher = TiingoFetcher()
26
+
27
+ def fetch_single(
28
+ self,
29
+ symbol: str,
30
+ start_date: Optional[str] = None,
31
+ end_date: Optional[str] = None,
32
+ **kwargs
33
+ ) -> pd.DataFrame:
34
+ """
35
+ Fetch hybrid data for a single symbol.
36
+ """
37
+ logger.info("Fetching hybrid price data for %s (AlphaVantage + Tiingo)...", symbol)
38
+
39
+ # Pop adjusted if present to avoid multiple values conflict
40
+ kwargs.pop("adjusted", None)
41
+
42
+ # 1. Fetch raw prices from Alpha Vantage
43
+ # Force adjusted=False to save API calls, we just need raw OHLCV
44
+ df_raw = self.av_fetcher.fetch_single(
45
+ symbol=symbol,
46
+ start_date=start_date,
47
+ end_date=end_date,
48
+ adjusted=False,
49
+ **kwargs
50
+ )
51
+
52
+ if df_raw.empty:
53
+ return df_raw
54
+
55
+ # 2. Fetch splits and dividends from Tiingo
56
+ try:
57
+ df_tiingo = self.tiingo_fetcher.fetch_single(
58
+ symbol=symbol,
59
+ start_date=start_date,
60
+ end_date=end_date,
61
+ frequency="daily"
62
+ )
63
+ except Exception as e:
64
+ logger.warning("Failed to fetch Tiingo data for %s: %s. Returning raw AV data.", symbol, e)
65
+ return df_raw
66
+
67
+ if df_tiingo.empty:
68
+ # No Tiingo data, return raw AV data
69
+ return df_raw
70
+
71
+ # Extract dividends and splits
72
+ dividends_df = df_tiingo[['divCash']].rename(columns={'divCash': 'Dividend'})
73
+ dividends_df = dividends_df[dividends_df['Dividend'] > 0]
74
+
75
+ splits_df = df_tiingo[['splitFactor']].rename(columns={'splitFactor': 'SplitFactor'})
76
+ splits_df = splits_df[splits_df['SplitFactor'] != 1.0]
77
+
78
+ # 3. Calculate adjusted prices using our math utility
79
+ df_adj = calculate_adjusted_prices(df_raw, dividends_df, splits_df)
80
+
81
+ return df_adj
82
+
83
+ def fetch_info(self, symbol: str, **kwargs) -> dict:
84
+ raise NotImplementedError("HybridFetcher only supports price fetching.")
85
+
86
+ def fetch_financials(self, symbol: str, statement: str = "income", **kwargs) -> pd.DataFrame:
87
+ raise NotImplementedError("HybridFetcher only supports price fetching.")
@@ -0,0 +1,265 @@
1
+ import pandas as pd
2
+ from typing import Optional, List, Dict, Any, Union
3
+ import logging
4
+
5
+ from ..core.base_fetcher import BaseDataFetcher
6
+ from ..core.exceptions import ProviderConfigurationError
7
+ from ..config.settings import config
8
+ from ..config.endpoints import Endpoints
9
+ from ..utils.logger import setup_logger
10
+
11
+ logger = setup_logger(__name__)
12
+
13
+
14
+ class StockFitFetcher(BaseDataFetcher):
15
+ """
16
+ Data fetcher for StockFit API (https://stockfit.io).
17
+ Extracts structured, point-in-time intelligence directly from official SEC EDGAR XBRL filings.
18
+
19
+ Provides:
20
+ - 15-20+ years of normalized financial statements (income, balance, cashflow)
21
+ - SEC filing traceability (10-K, 10-Q accession numbers)
22
+ - Financial health scores (Piotroski F-score, etc.)
23
+ - Company profiles and price history
24
+ """
25
+
26
+ def __init__(self, api_key: Optional[str] = None):
27
+ super().__init__()
28
+ self.api_key = api_key or config.STOCKFIT_API_KEY
29
+ self.base_url = Endpoints.StockFit.BASE_URL
30
+
31
+ def _require_api_key(self) -> None:
32
+ if not self.api_key:
33
+ raise ProviderConfigurationError(
34
+ "STOCKFIT_API_KEY is not set. Please set it in your .env file or pass it to StockFitFetcher(api_key=...)."
35
+ )
36
+
37
+ def _make_request(self, endpoint_path: str, **params) -> Any:
38
+ """
39
+ Executes HTTP GET request to StockFit API with Bearer token authentication.
40
+ """
41
+ self._require_api_key()
42
+ url = f"{self.base_url}/{endpoint_path}"
43
+ headers = {
44
+ "Authorization": f"Bearer {self.api_key}",
45
+ "Accept": "application/json"
46
+ }
47
+ # Filter out None params
48
+ filtered_params = {k: v for k, v in params.items() if v is not None}
49
+ return self._make_http_request(url, params=filtered_params, headers=headers)
50
+
51
+ # ------------------------------------------------------------------
52
+ # Price
53
+ # ------------------------------------------------------------------
54
+ def fetch_single(
55
+ self,
56
+ symbol: str,
57
+ start_date: Optional[str] = None,
58
+ end_date: Optional[str] = None,
59
+ adjusted: bool = True,
60
+ resolution: str = "1d",
61
+ **kwargs
62
+ ) -> pd.DataFrame:
63
+ """
64
+ Fetch historical close prices from StockFit.
65
+
66
+ Args:
67
+ symbol: Ticker symbol (e.g. 'AAPL').
68
+ start_date: 'YYYY-MM-DD' lower bound.
69
+ end_date: 'YYYY-MM-DD' upper bound.
70
+ adjusted: Split/dividend adjusted close (default True).
71
+ resolution: Bar resolution ('1d', '1wk', '1mo').
72
+ """
73
+ logger.info("Fetching price history for %s via StockFit...", symbol)
74
+ self._validate_inputs(symbol, start_date, end_date)
75
+
76
+ params = {
77
+ "symbol": symbol,
78
+ "resolution": resolution,
79
+ "adjusted": adjusted
80
+ }
81
+ if start_date:
82
+ params["from"] = start_date
83
+ if end_date:
84
+ params["to"] = end_date
85
+
86
+ try:
87
+ data = self._make_request(Endpoints.StockFit.PATHS["price_history"], **params)
88
+ except Exception as e:
89
+ logger.error("Failed to fetch price history for %s via StockFit: %s", symbol, e)
90
+ return pd.DataFrame()
91
+
92
+ bars = data.get("data", [])
93
+ if not bars:
94
+ return pd.DataFrame()
95
+
96
+ records = []
97
+ for item in bars:
98
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
99
+ ts_ms, val = item[0], item[1]
100
+ records.append({
101
+ "Date": pd.to_datetime(ts_ms, unit="ms"),
102
+ "Close": val,
103
+ "Adj Close": val if adjusted else None
104
+ })
105
+
106
+ if not records:
107
+ return pd.DataFrame()
108
+
109
+ df = pd.DataFrame(records).set_index("Date")
110
+ df.index = df.index.tz_localize(None) if df.index.tz is not None else df.index
111
+ df.index.name = "Date"
112
+
113
+ # Backfill standard columns if needed
114
+ return self._normalize_ohlcv(df)
115
+
116
+ # ------------------------------------------------------------------
117
+ # Company Profile / Info
118
+ # ------------------------------------------------------------------
119
+ def fetch_info(self, symbol: str) -> Dict[str, Any]:
120
+ """
121
+ Fetch company profile and basic information from StockFit.
122
+ """
123
+ logger.info("Fetching company details for %s via StockFit...", symbol)
124
+ self._validate_inputs(symbol)
125
+
126
+ try:
127
+ data = self._make_request(Endpoints.StockFit.PATHS["details"], symbol=symbol)
128
+ if isinstance(data, dict):
129
+ return data
130
+ return {}
131
+ except Exception as e:
132
+ logger.error("Failed to fetch company details for %s via StockFit: %s", symbol, e)
133
+ return {}
134
+
135
+ # ------------------------------------------------------------------
136
+ # Financial Statements
137
+ # ------------------------------------------------------------------
138
+ def fetch_financials(
139
+ self,
140
+ symbol: str,
141
+ statement: str = "income",
142
+ period: str = "annual",
143
+ limit: int = 100,
144
+ **kwargs
145
+ ) -> pd.DataFrame:
146
+ """
147
+ Fetch historical SEC-reported financial statements from StockFit.
148
+
149
+ Args:
150
+ symbol: Ticker symbol (e.g., 'AAPL').
151
+ statement: "income", "balance", "cashflow", "growth", "scores", or "key_metrics".
152
+ period: "annual" (default) or "quarterly".
153
+ limit: Number of historical periods to retrieve (default 100, up to 20 years).
154
+
155
+ Returns:
156
+ DataFrame with normalized financial metrics and DatetimeIndex.
157
+ """
158
+ logger.info("Fetching %s statement for %s (%s) via StockFit...", statement, symbol, period)
159
+ self._validate_inputs(symbol)
160
+
161
+ statement_clean = statement.lower().replace("-", "").replace("_", "")
162
+ period_clean = "quarterly" if period.lower() in ("quarterly", "quarter", "q") else "annual"
163
+
164
+ valid_statements = {
165
+ "income": Endpoints.StockFit.PATHS["income"],
166
+ "balance": Endpoints.StockFit.PATHS["balance"],
167
+ "cashflow": Endpoints.StockFit.PATHS["cashflow"],
168
+ "growth": Endpoints.StockFit.PATHS["growth"],
169
+ "scores": Endpoints.StockFit.PATHS["scores"],
170
+ "keymetrics": Endpoints.StockFit.PATHS["key_metrics"],
171
+ "asreported": Endpoints.StockFit.PATHS["as_reported"],
172
+ }
173
+
174
+ if statement_clean not in valid_statements:
175
+ raise ValueError(
176
+ f"Unknown statement type: '{statement}'. Choose from {list(valid_statements.keys())}."
177
+ )
178
+
179
+ endpoint_path = valid_statements[statement_clean]
180
+ params = {"symbol": symbol}
181
+
182
+ # Statement and growth take period and limit
183
+ if statement_clean in ("income", "balance", "cashflow", "growth", "asreported"):
184
+ params["period"] = period_clean
185
+ params["limit"] = limit
186
+ if "splitAdjust" in kwargs:
187
+ params["splitAdjust"] = kwargs["splitAdjust"]
188
+
189
+ try:
190
+ data = self._make_request(endpoint_path, **params)
191
+ except Exception as e:
192
+ logger.error("Failed to fetch %s for %s via StockFit: %s", statement, symbol, e)
193
+ return pd.DataFrame()
194
+
195
+ if not data:
196
+ logger.warning("No %s financial data returned for %s in StockFit.", statement, symbol)
197
+ return pd.DataFrame()
198
+
199
+ # Handle score endpoints which return a single dict
200
+ if statement_clean == "scores" and isinstance(data, dict):
201
+ row = {k: v for k, v in data.items() if not isinstance(v, dict)}
202
+ if "piotroskiDetails" in data and isinstance(data["piotroskiDetails"], dict):
203
+ for k, v in data["piotroskiDetails"].items():
204
+ row[f"piotroski_{k}"] = v
205
+ df = pd.DataFrame([row])
206
+ if "period" in df.columns:
207
+ df.index = pd.to_datetime(df["period"])
208
+ df = df.drop(columns=["period"])
209
+ df.index.name = "Date"
210
+ return df
211
+
212
+ # Handle growth or keymetrics
213
+ if statement_clean == "growth" and isinstance(data, list):
214
+ df = pd.DataFrame(data)
215
+ if not df.empty and "period" in df.columns:
216
+ df.index = pd.to_datetime(df["period"])
217
+ df = df.drop(columns=["period"])
218
+ df.index.name = "Date"
219
+ return df
220
+
221
+ # Standard financial statements: unpack 'facts' object
222
+ if not isinstance(data, list):
223
+ return pd.DataFrame()
224
+
225
+ rows = []
226
+ for item in data:
227
+ if not isinstance(item, dict):
228
+ continue
229
+ facts = item.get("facts", {})
230
+ row = facts.copy() if isinstance(facts, dict) else {}
231
+ row["period"] = item.get("period")
232
+ if "fiscalYear" in item:
233
+ row["fiscalYear"] = item.get("fiscalYear")
234
+ if "fiscalPeriod" in item:
235
+ row["fiscalPeriod"] = item.get("fiscalPeriod")
236
+ if "dateFiled" in item:
237
+ row["dateFiled"] = item.get("dateFiled")
238
+ rows.append(row)
239
+
240
+ if not rows:
241
+ return pd.DataFrame()
242
+
243
+ df = pd.DataFrame(rows)
244
+ if "period" in df.columns:
245
+ df.index = pd.to_datetime(df["period"])
246
+ df = df.drop(columns=["period"])
247
+ df.index.name = "Date"
248
+
249
+ # Apply standardized column normalization
250
+ return self._normalize_financials(df)
251
+
252
+ # ------------------------------------------------------------------
253
+ # Additional Specialized Intelligence
254
+ # ------------------------------------------------------------------
255
+ def fetch_scores(self, symbol: str) -> Dict[str, Any]:
256
+ """
257
+ Fetch Piotroski F-score and financial health metrics.
258
+ """
259
+ return self._make_request(Endpoints.StockFit.PATHS["scores"], symbol=symbol)
260
+
261
+ def fetch_growth(self, symbol: str, period: str = "annual") -> pd.DataFrame:
262
+ """
263
+ Fetch historical growth rates (revenue, gross profit, net income, EPS).
264
+ """
265
+ return self.fetch_financials(symbol, statement="growth", period=period)