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,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)
|