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.
- alphadataforge/__init__.py +2 -0
- alphadataforge/config/__init__.py +0 -0
- alphadataforge/config/endpoints.py +52 -0
- alphadataforge/config/settings.py +18 -0
- alphadataforge/core/__init__.py +0 -0
- alphadataforge/core/base_fetcher.py +357 -0
- alphadataforge/core/exceptions.py +19 -0
- alphadataforge/data/fundamental.py +107 -0
- alphadataforge/data/price.py +207 -0
- alphadataforge/features/__init__.py +0 -0
- alphadataforge/providers/__init__.py +0 -0
- alphadataforge/providers/alphavantage_fetcher.py +328 -0
- alphadataforge/providers/fmp_fetcher.py +183 -0
- alphadataforge/providers/hybrid_fetcher.py +87 -0
- alphadataforge/providers/stockfit_fetcher.py +265 -0
- alphadataforge/providers/tiingo_fetcher.py +312 -0
- alphadataforge/providers/yfinance_fetcher.py +282 -0
- alphadataforge/utils/__init__.py +0 -0
- alphadataforge/utils/finance_math.py +125 -0
- alphadataforge/utils/logger.py +30 -0
- alphadataforge-0.1.0.dist-info/METADATA +770 -0
- alphadataforge-0.1.0.dist-info/RECORD +24 -0
- alphadataforge-0.1.0.dist-info/WHEEL +5 -0
- alphadataforge-0.1.0.dist-info/top_level.txt +1 -0
|
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)
|