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
|
@@ -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()
|