finfetch 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.
- finfetch/__init__.py +25 -0
- finfetch/core.py +622 -0
- finfetch/exceptions.py +57 -0
- finfetch/models.py +315 -0
- finfetch/parser.py +212 -0
- finfetch/utils.py +128 -0
- finfetch-0.1.0.dist-info/METADATA +188 -0
- finfetch-0.1.0.dist-info/RECORD +11 -0
- finfetch-0.1.0.dist-info/WHEEL +5 -0
- finfetch-0.1.0.dist-info/licenses/LICENSE +21 -0
- finfetch-0.1.0.dist-info/top_level.txt +1 -0
finfetch/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""finfetch — Free financial data for Indian stocks. No API key needed."""
|
|
2
|
+
|
|
3
|
+
from .core import Ticker, Tickers, get_price
|
|
4
|
+
from .models import search
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
DataUnavailableError,
|
|
7
|
+
FinFetchError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
ScrapingError,
|
|
10
|
+
TickerNotFoundError,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Ticker",
|
|
16
|
+
"Tickers",
|
|
17
|
+
"get_price",
|
|
18
|
+
"search",
|
|
19
|
+
# exceptions
|
|
20
|
+
"FinFetchError",
|
|
21
|
+
"TickerNotFoundError",
|
|
22
|
+
"DataUnavailableError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"ScrapingError",
|
|
25
|
+
]
|
finfetch/core.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"""Core module: Ticker / Tickers classes and data-source implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import requests
|
|
11
|
+
from bs4 import BeautifulSoup
|
|
12
|
+
|
|
13
|
+
from .exceptions import DataUnavailableError, RateLimitError, ScrapingError
|
|
14
|
+
from .models import TickerInfo, canonical_to_snake, resolve_ticker
|
|
15
|
+
from .parser import (
|
|
16
|
+
parse_html_table,
|
|
17
|
+
parse_screener_top_ratios,
|
|
18
|
+
standardize_dataframe,
|
|
19
|
+
)
|
|
20
|
+
from .utils import (
|
|
21
|
+
DEFAULT_HEADERS,
|
|
22
|
+
TTLCache,
|
|
23
|
+
_rate_limiter,
|
|
24
|
+
retry_with_backoff,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("finfetch")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ======================================================================
|
|
31
|
+
# Internal data sources
|
|
32
|
+
# ======================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _BaseSource(ABC):
|
|
36
|
+
"""Abstract base for all internal data sources."""
|
|
37
|
+
|
|
38
|
+
name: str = "base"
|
|
39
|
+
SUPPORTED_SECTIONS: list[str] = []
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def fetch(self, ticker: str, section: str, **kw) -> pd.DataFrame | None: ...
|
|
43
|
+
|
|
44
|
+
def supports(self, section: str) -> bool:
|
|
45
|
+
return section in self.SUPPORTED_SECTIONS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ----------------------------------------------------------------------
|
|
49
|
+
# Screener.in
|
|
50
|
+
# ----------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _ScreenerSource(_BaseSource):
|
|
54
|
+
name = "screener"
|
|
55
|
+
BASE_URL = "https://www.screener.in"
|
|
56
|
+
SUPPORTED_SECTIONS = [
|
|
57
|
+
"profit-loss", "quarters", "balance-sheet",
|
|
58
|
+
"cash-flow", "ratios", "shareholding",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._session = requests.Session()
|
|
63
|
+
self._session.headers.update(
|
|
64
|
+
{**DEFAULT_HEADERS, "Referer": f"{self.BASE_URL}/"}
|
|
65
|
+
)
|
|
66
|
+
self._logged_in = False
|
|
67
|
+
email = os.getenv("SCREENER_EMAIL", "")
|
|
68
|
+
password = os.getenv("SCREENER_PASSWORD", "")
|
|
69
|
+
if email and password:
|
|
70
|
+
self._login(email, password)
|
|
71
|
+
|
|
72
|
+
def _login(self, email: str, password: str) -> None:
|
|
73
|
+
_rate_limiter.wait("screener.in")
|
|
74
|
+
page = self._session.get(f"{self.BASE_URL}/login/", timeout=15)
|
|
75
|
+
page.raise_for_status()
|
|
76
|
+
soup = BeautifulSoup(page.text, "lxml")
|
|
77
|
+
csrf = soup.find("input", {"name": "csrfmiddlewaretoken"})
|
|
78
|
+
if not csrf:
|
|
79
|
+
return
|
|
80
|
+
_rate_limiter.wait("screener.in")
|
|
81
|
+
resp = self._session.post(
|
|
82
|
+
f"{self.BASE_URL}/login/",
|
|
83
|
+
data={
|
|
84
|
+
"username": email,
|
|
85
|
+
"password": password,
|
|
86
|
+
"csrfmiddlewaretoken": csrf["value"],
|
|
87
|
+
},
|
|
88
|
+
headers={"Referer": f"{self.BASE_URL}/login/"},
|
|
89
|
+
allow_redirects=True,
|
|
90
|
+
timeout=15,
|
|
91
|
+
)
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
self._logged_in = "login" not in resp.url.lower()
|
|
94
|
+
|
|
95
|
+
def fetch(self, ticker: str, section: str, **kw) -> pd.DataFrame | None:
|
|
96
|
+
consolidated = kw.get("consolidated", True)
|
|
97
|
+
url_type = "consolidated" if consolidated else "standalone"
|
|
98
|
+
url = f"{self.BASE_URL}/company/{ticker}/{url_type}/"
|
|
99
|
+
|
|
100
|
+
def _do() -> requests.Response:
|
|
101
|
+
_rate_limiter.wait("screener.in")
|
|
102
|
+
r = self._session.get(url, timeout=15)
|
|
103
|
+
if r.status_code == 429:
|
|
104
|
+
raise RateLimitError("screener.in")
|
|
105
|
+
r.raise_for_status()
|
|
106
|
+
return r
|
|
107
|
+
|
|
108
|
+
resp = retry_with_backoff(_do)
|
|
109
|
+
soup = BeautifulSoup(resp.text, "lxml")
|
|
110
|
+
|
|
111
|
+
el = soup.find("section", id=section) or soup.find("div", id=section)
|
|
112
|
+
if el is None:
|
|
113
|
+
return None
|
|
114
|
+
table = el.find("table")
|
|
115
|
+
if table is None:
|
|
116
|
+
return None
|
|
117
|
+
return parse_html_table(table)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ----------------------------------------------------------------------
|
|
121
|
+
# Trendlyne
|
|
122
|
+
# ----------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _TrendlyneSource(_BaseSource):
|
|
126
|
+
name = "trendlyne"
|
|
127
|
+
BASE_URL = "https://trendlyne.com"
|
|
128
|
+
SUPPORTED_SECTIONS = [
|
|
129
|
+
"profit-loss", "quarters", "balance-sheet", "cash-flow", "ratios",
|
|
130
|
+
]
|
|
131
|
+
_SECTION_URL = {
|
|
132
|
+
"profit-loss": "income-statement",
|
|
133
|
+
"quarters": "quarterly-results",
|
|
134
|
+
"balance-sheet": "balance-sheet",
|
|
135
|
+
"cash-flow": "cash-flow",
|
|
136
|
+
"ratios": "financial-ratios",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def __init__(self) -> None:
|
|
140
|
+
self._session = requests.Session()
|
|
141
|
+
self._session.headers.update(
|
|
142
|
+
{**DEFAULT_HEADERS, "Referer": f"{self.BASE_URL}/"}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def fetch(self, ticker: str, section: str, **kw) -> pd.DataFrame | None:
|
|
146
|
+
seg = self._SECTION_URL.get(section)
|
|
147
|
+
if seg is None:
|
|
148
|
+
return None
|
|
149
|
+
url = f"{self.BASE_URL}/fundamentals/{seg}/{ticker}/"
|
|
150
|
+
|
|
151
|
+
def _do() -> requests.Response:
|
|
152
|
+
_rate_limiter.wait("trendlyne.com")
|
|
153
|
+
r = self._session.get(url, timeout=15)
|
|
154
|
+
if r.status_code == 429:
|
|
155
|
+
raise RateLimitError("trendlyne.com")
|
|
156
|
+
r.raise_for_status()
|
|
157
|
+
return r
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
resp = retry_with_backoff(_do)
|
|
161
|
+
except Exception:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
soup = BeautifulSoup(resp.text, "lxml")
|
|
165
|
+
table = soup.find("table", class_="financial-table") or soup.find("table")
|
|
166
|
+
if table is None:
|
|
167
|
+
return None
|
|
168
|
+
return parse_html_table(table)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ----------------------------------------------------------------------
|
|
172
|
+
# MoneyControl
|
|
173
|
+
# ----------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _MoneyControlSource(_BaseSource):
|
|
177
|
+
name = "moneycontrol"
|
|
178
|
+
BASE_URL = "https://www.moneycontrol.com"
|
|
179
|
+
SEARCH_URL = (
|
|
180
|
+
"https://www.moneycontrol.com/mccode/common/autosuggestion_solr.php"
|
|
181
|
+
)
|
|
182
|
+
SUPPORTED_SECTIONS = [
|
|
183
|
+
"profit-loss", "quarters", "balance-sheet", "cash-flow", "ratios",
|
|
184
|
+
]
|
|
185
|
+
_SECTION_URL = {
|
|
186
|
+
"profit-loss": "profit-lossVI",
|
|
187
|
+
"quarters": "quarterly-resultsVI",
|
|
188
|
+
"balance-sheet": "balance-sheetVI",
|
|
189
|
+
"cash-flow": "cash-flowVI",
|
|
190
|
+
"ratios": "ratiosVI",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def __init__(self) -> None:
|
|
194
|
+
self._session = requests.Session()
|
|
195
|
+
self._session.headers.update(
|
|
196
|
+
{**DEFAULT_HEADERS, "Referer": f"{self.BASE_URL}/"}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _search(self, ticker: str) -> dict | None:
|
|
200
|
+
_rate_limiter.wait("moneycontrol.com")
|
|
201
|
+
try:
|
|
202
|
+
resp = self._session.get(
|
|
203
|
+
self.SEARCH_URL,
|
|
204
|
+
params={"classic": "true", "query": ticker, "type": "1", "format": "json"},
|
|
205
|
+
timeout=10,
|
|
206
|
+
)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
for line in resp.text.strip().split("\n"):
|
|
209
|
+
parts = line.split("|")
|
|
210
|
+
if len(parts) >= 3:
|
|
211
|
+
return {"name": parts[0].strip(), "id": parts[1].strip(), "url_path": parts[2].strip()}
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
def fetch(self, ticker: str, section: str, **kw) -> pd.DataFrame | None:
|
|
217
|
+
seg = self._SECTION_URL.get(section)
|
|
218
|
+
if seg is None:
|
|
219
|
+
return None
|
|
220
|
+
company = self._search(ticker)
|
|
221
|
+
if company is None:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
consolidated = kw.get("consolidated", True)
|
|
225
|
+
cons = "consolidated" if consolidated else "standalone"
|
|
226
|
+
url = f"{self.BASE_URL}/financials/{company['url_path']}/{seg}/{company['id']}/{cons}"
|
|
227
|
+
|
|
228
|
+
def _do() -> requests.Response:
|
|
229
|
+
_rate_limiter.wait("moneycontrol.com")
|
|
230
|
+
r = self._session.get(url, timeout=15)
|
|
231
|
+
if r.status_code == 429:
|
|
232
|
+
raise RateLimitError("moneycontrol.com")
|
|
233
|
+
r.raise_for_status()
|
|
234
|
+
return r
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
resp = retry_with_backoff(_do)
|
|
238
|
+
except Exception:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
soup = BeautifulSoup(resp.text, "lxml")
|
|
242
|
+
table = (
|
|
243
|
+
soup.find("table", class_="mctable1")
|
|
244
|
+
or soup.find("table", class_="table4")
|
|
245
|
+
or soup.find("table")
|
|
246
|
+
)
|
|
247
|
+
if table is None:
|
|
248
|
+
return None
|
|
249
|
+
return parse_html_table(table)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ----------------------------------------------------------------------
|
|
253
|
+
# Yahoo Finance (via yfinance)
|
|
254
|
+
# ----------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class _YFinanceSource(_BaseSource):
|
|
258
|
+
name = "yfinance"
|
|
259
|
+
SUPPORTED_SECTIONS = [
|
|
260
|
+
"profit-loss", "quarters", "balance-sheet", "cash-flow",
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
_ITEM_MAP = {
|
|
264
|
+
"Total Revenue": "Revenue",
|
|
265
|
+
"Cost Of Revenue": "COGS",
|
|
266
|
+
"Gross Profit": "Gross Profit",
|
|
267
|
+
"Operating Income": "Operating Profit",
|
|
268
|
+
"Operating Expense": "Operating Expenses",
|
|
269
|
+
"Ebitda": "EBITDA",
|
|
270
|
+
"EBITDA": "EBITDA",
|
|
271
|
+
"Interest Expense": "Interest",
|
|
272
|
+
"Net Income": "Net Profit",
|
|
273
|
+
"Basic EPS": "EPS",
|
|
274
|
+
"Diluted EPS": "Diluted EPS",
|
|
275
|
+
"Tax Provision": "Tax",
|
|
276
|
+
"Pretax Income": "Profit Before Tax",
|
|
277
|
+
"Total Assets": "Total Assets",
|
|
278
|
+
"Total Liabilities Net Minority Interest": "Total Liabilities",
|
|
279
|
+
"Stockholders Equity": "Shareholders Equity",
|
|
280
|
+
"Total Debt": "Total Debt",
|
|
281
|
+
"Current Assets": "Current Assets",
|
|
282
|
+
"Current Liabilities": "Current Liabilities",
|
|
283
|
+
"Cash And Cash Equivalents": "Cash",
|
|
284
|
+
"Net PPE": "Fixed Assets",
|
|
285
|
+
"Inventory": "Inventory",
|
|
286
|
+
"Accounts Receivable": "Receivables",
|
|
287
|
+
"Operating Cash Flow": "Operating Cash Flow",
|
|
288
|
+
"Investing Cash Flow": "Investing Cash Flow",
|
|
289
|
+
"Financing Cash Flow": "Financing Cash Flow",
|
|
290
|
+
"Free Cash Flow": "Free Cash Flow",
|
|
291
|
+
"Capital Expenditure": "CapEx",
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _yf_symbol(ticker: str) -> str:
|
|
296
|
+
if not ticker.endswith((".NS", ".BO")):
|
|
297
|
+
return f"{ticker}.NS"
|
|
298
|
+
return ticker
|
|
299
|
+
|
|
300
|
+
def fetch(self, ticker: str, section: str, **kw) -> pd.DataFrame | None:
|
|
301
|
+
import yfinance as yf
|
|
302
|
+
|
|
303
|
+
sym = self._yf_symbol(ticker)
|
|
304
|
+
t = yf.Ticker(sym)
|
|
305
|
+
try:
|
|
306
|
+
raw: pd.DataFrame | None = {
|
|
307
|
+
"profit-loss": t.financials,
|
|
308
|
+
"quarters": t.quarterly_financials,
|
|
309
|
+
"balance-sheet": t.balance_sheet,
|
|
310
|
+
"cash-flow": t.cashflow,
|
|
311
|
+
}.get(section)
|
|
312
|
+
except Exception:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
if raw is None or raw.empty:
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
# Rename index items
|
|
319
|
+
raw.index = [self._ITEM_MAP.get(i, i) for i in raw.index]
|
|
320
|
+
|
|
321
|
+
# Column dates → period strings
|
|
322
|
+
raw.columns = [
|
|
323
|
+
c.strftime("%b %Y") if hasattr(c, "strftime") else str(c)
|
|
324
|
+
for c in raw.columns
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
# Yahoo gives values in INR; convert to Crores
|
|
328
|
+
numeric = raw.apply(pd.to_numeric, errors="coerce") / 1e7
|
|
329
|
+
|
|
330
|
+
# Oldest first
|
|
331
|
+
return numeric[numeric.columns[::-1]]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ======================================================================
|
|
335
|
+
# Source ordering (lazy-initialised singletons)
|
|
336
|
+
# ======================================================================
|
|
337
|
+
|
|
338
|
+
_sources: list[_BaseSource] | None = None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _get_sources() -> list[_BaseSource]:
|
|
342
|
+
global _sources
|
|
343
|
+
if _sources is not None:
|
|
344
|
+
return _sources
|
|
345
|
+
|
|
346
|
+
_sources = []
|
|
347
|
+
for cls in (_ScreenerSource, _TrendlyneSource, _MoneyControlSource, _YFinanceSource):
|
|
348
|
+
try:
|
|
349
|
+
_sources.append(cls())
|
|
350
|
+
except Exception as exc:
|
|
351
|
+
logger.debug("Skipping source %s: %s", cls.name, exc)
|
|
352
|
+
return _sources
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ======================================================================
|
|
356
|
+
# Ticker
|
|
357
|
+
# ======================================================================
|
|
358
|
+
|
|
359
|
+
# Normalise shorthand period strings for history()
|
|
360
|
+
_PERIOD_MAP = {"1m": "1mo", "3m": "3mo", "6m": "6mo"}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class Ticker:
|
|
364
|
+
"""Primary interface — wraps a single NSE-listed stock.
|
|
365
|
+
|
|
366
|
+
All heavy data is **lazily loaded** on first access and cached for
|
|
367
|
+
5 minutes by default.
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
symbol : str
|
|
372
|
+
NSE ticker symbol or company name (fuzzy-matched).
|
|
373
|
+
consolidated : bool
|
|
374
|
+
Use consolidated financial statements (default ``True``).
|
|
375
|
+
cache_ttl : int
|
|
376
|
+
Cache lifetime in seconds (default 300 = 5 min).
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def __init__(
|
|
380
|
+
self,
|
|
381
|
+
symbol: str,
|
|
382
|
+
*,
|
|
383
|
+
consolidated: bool = True,
|
|
384
|
+
cache_ttl: int = 300,
|
|
385
|
+
) -> None:
|
|
386
|
+
self._symbol = resolve_ticker(symbol)
|
|
387
|
+
self._consolidated = consolidated
|
|
388
|
+
self._cache = TTLCache(ttl=cache_ttl)
|
|
389
|
+
|
|
390
|
+
def __repr__(self) -> str:
|
|
391
|
+
return f"Ticker('{self._symbol}')"
|
|
392
|
+
|
|
393
|
+
# ----- identity -----
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def symbol(self) -> str:
|
|
397
|
+
"""Resolved NSE symbol."""
|
|
398
|
+
return self._symbol
|
|
399
|
+
|
|
400
|
+
# ----- price / history (via yfinance) -----
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def price(self) -> float:
|
|
404
|
+
"""Current market price (INR)."""
|
|
405
|
+
return self._cache.get_or_fetch("price", self._fetch_price)
|
|
406
|
+
|
|
407
|
+
def history(
|
|
408
|
+
self,
|
|
409
|
+
period: str = "1mo",
|
|
410
|
+
interval: str = "1d",
|
|
411
|
+
) -> pd.DataFrame:
|
|
412
|
+
"""OHLCV price history.
|
|
413
|
+
|
|
414
|
+
Parameters
|
|
415
|
+
----------
|
|
416
|
+
period : str
|
|
417
|
+
Look-back window, e.g. ``"1mo"``, ``"3m"``, ``"1y"``, ``"max"``.
|
|
418
|
+
interval : str
|
|
419
|
+
Bar size, e.g. ``"1d"``, ``"1wk"``, ``"1mo"``.
|
|
420
|
+
|
|
421
|
+
Returns
|
|
422
|
+
-------
|
|
423
|
+
pandas.DataFrame
|
|
424
|
+
Columns: ``open``, ``high``, ``low``, ``close``, ``volume``.
|
|
425
|
+
Index: :class:`~pandas.DatetimeIndex`.
|
|
426
|
+
"""
|
|
427
|
+
period = _PERIOD_MAP.get(period, period)
|
|
428
|
+
key = f"history_{period}_{interval}"
|
|
429
|
+
return self._cache.get_or_fetch(
|
|
430
|
+
key, lambda: self._fetch_history(period, interval)
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# ----- financial statements -----
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def financials(self) -> pd.DataFrame:
|
|
437
|
+
"""Annual income statement."""
|
|
438
|
+
return self._cache.get_or_fetch("financials", lambda: self._fetch("profit-loss"))
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def quarterly_financials(self) -> pd.DataFrame:
|
|
442
|
+
"""Quarterly income statement."""
|
|
443
|
+
return self._cache.get_or_fetch("quarters", lambda: self._fetch("quarters"))
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def balance_sheet(self) -> pd.DataFrame:
|
|
447
|
+
"""Annual balance sheet."""
|
|
448
|
+
return self._cache.get_or_fetch("balance_sheet", lambda: self._fetch("balance-sheet"))
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def cashflow(self) -> pd.DataFrame:
|
|
452
|
+
"""Annual cash-flow statement."""
|
|
453
|
+
return self._cache.get_or_fetch("cashflow", lambda: self._fetch("cash-flow"))
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def ratios(self) -> pd.DataFrame:
|
|
457
|
+
"""Historical financial ratios."""
|
|
458
|
+
return self._cache.get_or_fetch("ratios", lambda: self._fetch("ratios"))
|
|
459
|
+
|
|
460
|
+
@property
|
|
461
|
+
def shareholding(self) -> pd.DataFrame:
|
|
462
|
+
"""Shareholding pattern."""
|
|
463
|
+
return self._cache.get_or_fetch("shareholding", lambda: self._fetch("shareholding"))
|
|
464
|
+
|
|
465
|
+
# ----- info dict -----
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def info(self) -> dict:
|
|
469
|
+
"""Key company information and ratios as a plain dict."""
|
|
470
|
+
return self._cache.get_or_fetch("info", self._fetch_info)
|
|
471
|
+
|
|
472
|
+
# ----- cache control -----
|
|
473
|
+
|
|
474
|
+
def clear_cache(self) -> None:
|
|
475
|
+
"""Manually expire all cached data for this ticker."""
|
|
476
|
+
self._cache.clear()
|
|
477
|
+
|
|
478
|
+
# ==================================================================
|
|
479
|
+
# Private helpers
|
|
480
|
+
# ==================================================================
|
|
481
|
+
|
|
482
|
+
def _fetch(self, section: str) -> pd.DataFrame:
|
|
483
|
+
"""Try every source in priority order and return a standardised DataFrame."""
|
|
484
|
+
errors: list[tuple[str, str]] = []
|
|
485
|
+
for src in _get_sources():
|
|
486
|
+
if not src.supports(section):
|
|
487
|
+
continue
|
|
488
|
+
try:
|
|
489
|
+
raw = src.fetch(self._symbol, section, consolidated=self._consolidated)
|
|
490
|
+
if raw is not None and not raw.empty:
|
|
491
|
+
return standardize_dataframe(raw)
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
errors.append((src.name, str(exc)))
|
|
494
|
+
raise DataUnavailableError(self._symbol, section, errors)
|
|
495
|
+
|
|
496
|
+
def _fetch_price(self) -> float:
|
|
497
|
+
import yfinance as yf
|
|
498
|
+
|
|
499
|
+
t = yf.Ticker(_YFinanceSource._yf_symbol(self._symbol))
|
|
500
|
+
try:
|
|
501
|
+
info = t.info
|
|
502
|
+
except Exception as exc:
|
|
503
|
+
raise DataUnavailableError(self._symbol, "price", [("yfinance", str(exc))]) from exc
|
|
504
|
+
price = info.get("currentPrice") or info.get("regularMarketPrice")
|
|
505
|
+
if price is None:
|
|
506
|
+
raise DataUnavailableError(self._symbol, "price", [("yfinance", "no price field")])
|
|
507
|
+
return float(price)
|
|
508
|
+
|
|
509
|
+
def _fetch_history(self, period: str, interval: str) -> pd.DataFrame:
|
|
510
|
+
import yfinance as yf
|
|
511
|
+
|
|
512
|
+
t = yf.Ticker(_YFinanceSource._yf_symbol(self._symbol))
|
|
513
|
+
df = t.history(period=period, interval=interval)
|
|
514
|
+
if df.empty:
|
|
515
|
+
raise DataUnavailableError(self._symbol, "history", [("yfinance", "empty result")])
|
|
516
|
+
df.columns = [c.lower().replace(" ", "_") for c in df.columns]
|
|
517
|
+
return df
|
|
518
|
+
|
|
519
|
+
def _fetch_info(self) -> dict:
|
|
520
|
+
import yfinance as yf
|
|
521
|
+
|
|
522
|
+
info: dict = {}
|
|
523
|
+
|
|
524
|
+
# --- yfinance info ---
|
|
525
|
+
try:
|
|
526
|
+
t = yf.Ticker(_YFinanceSource._yf_symbol(self._symbol))
|
|
527
|
+
yf_info = t.info or {}
|
|
528
|
+
except Exception:
|
|
529
|
+
yf_info = {}
|
|
530
|
+
|
|
531
|
+
if yf_info:
|
|
532
|
+
info["symbol"] = self._symbol
|
|
533
|
+
info["name"] = yf_info.get("longName") or yf_info.get("shortName", "")
|
|
534
|
+
info["sector"] = yf_info.get("sector", "")
|
|
535
|
+
info["industry"] = yf_info.get("industry", "")
|
|
536
|
+
mc = yf_info.get("marketCap")
|
|
537
|
+
if mc is not None:
|
|
538
|
+
info["market_cap_cr"] = round(mc / 1e7, 2)
|
|
539
|
+
for src_key, dst_key in (
|
|
540
|
+
("currentPrice", "current_price"),
|
|
541
|
+
("trailingPE", "pe_ratio"),
|
|
542
|
+
("forwardPE", "forward_pe"),
|
|
543
|
+
("priceToBook", "pb_ratio"),
|
|
544
|
+
("enterpriseToEbitda", "ev_ebitda"),
|
|
545
|
+
("returnOnEquity", "roe"),
|
|
546
|
+
("debtToEquity", "debt_to_equity"),
|
|
547
|
+
("currentRatio", "current_ratio"),
|
|
548
|
+
("dividendYield", "dividend_yield"),
|
|
549
|
+
("bookValue", "book_value"),
|
|
550
|
+
("fiftyTwoWeekHigh", "high_52w"),
|
|
551
|
+
("fiftyTwoWeekLow", "low_52w"),
|
|
552
|
+
):
|
|
553
|
+
v = yf_info.get(src_key)
|
|
554
|
+
if v is not None:
|
|
555
|
+
info[dst_key] = round(float(v), 4) if isinstance(v, float) else v
|
|
556
|
+
|
|
557
|
+
# --- Screener top-ratios (best-effort merge) ---
|
|
558
|
+
try:
|
|
559
|
+
src = _ScreenerSource()
|
|
560
|
+
_rate_limiter.wait("screener.in")
|
|
561
|
+
resp = src._session.get(
|
|
562
|
+
f"{src.BASE_URL}/company/{self._symbol}/consolidated/",
|
|
563
|
+
timeout=15,
|
|
564
|
+
)
|
|
565
|
+
resp.raise_for_status()
|
|
566
|
+
soup = BeautifulSoup(resp.text, "lxml")
|
|
567
|
+
ratios = parse_screener_top_ratios(soup)
|
|
568
|
+
for key, val in ratios.items():
|
|
569
|
+
snake = canonical_to_snake(key)
|
|
570
|
+
if snake not in info:
|
|
571
|
+
info[snake] = val
|
|
572
|
+
except Exception:
|
|
573
|
+
pass
|
|
574
|
+
|
|
575
|
+
if not info:
|
|
576
|
+
raise DataUnavailableError(self._symbol, "info")
|
|
577
|
+
|
|
578
|
+
return info
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ======================================================================
|
|
582
|
+
# Tickers (multi-stock convenience)
|
|
583
|
+
# ======================================================================
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
class Tickers:
|
|
587
|
+
"""Convenience wrapper for multiple tickers at once.
|
|
588
|
+
|
|
589
|
+
Parameters
|
|
590
|
+
----------
|
|
591
|
+
symbols : str
|
|
592
|
+
Space-separated ticker symbols, e.g. ``"RELIANCE TCS INFY"``.
|
|
593
|
+
"""
|
|
594
|
+
|
|
595
|
+
def __init__(self, symbols: str, **kw) -> None:
|
|
596
|
+
self.symbols = [s.strip() for s in symbols.split() if s.strip()]
|
|
597
|
+
self.tickers: dict[str, Ticker] = {
|
|
598
|
+
s: Ticker(s, **kw) for s in self.symbols
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
def __repr__(self) -> str:
|
|
602
|
+
return f"Tickers('{' '.join(self.symbols)}')"
|
|
603
|
+
|
|
604
|
+
def __getitem__(self, symbol: str) -> Ticker:
|
|
605
|
+
return self.tickers[symbol.upper()]
|
|
606
|
+
|
|
607
|
+
def __iter__(self):
|
|
608
|
+
return iter(self.tickers.values())
|
|
609
|
+
|
|
610
|
+
def history(self, period: str = "1mo", interval: str = "1d") -> dict[str, pd.DataFrame]:
|
|
611
|
+
"""Fetch price history for every ticker."""
|
|
612
|
+
return {s: t.history(period=period, interval=interval) for s, t in self.tickers.items()}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# ======================================================================
|
|
616
|
+
# Module-level convenience functions
|
|
617
|
+
# ======================================================================
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def get_price(symbol: str) -> float:
|
|
621
|
+
"""Quick helper — return the current price for *symbol*."""
|
|
622
|
+
return Ticker(symbol).price
|
finfetch/exceptions.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Custom exceptions for finfetch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FinFetchError(Exception):
|
|
7
|
+
"""Base exception for all finfetch errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TickerNotFoundError(FinFetchError):
|
|
11
|
+
"""Raised when a ticker symbol cannot be resolved."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, symbol: str, suggestions: list[str] | None = None):
|
|
14
|
+
self.symbol = symbol
|
|
15
|
+
self.suggestions = suggestions or []
|
|
16
|
+
msg = f"Ticker '{symbol}' not found"
|
|
17
|
+
if self.suggestions:
|
|
18
|
+
msg += f". Did you mean: {', '.join(self.suggestions)}?"
|
|
19
|
+
super().__init__(msg)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DataUnavailableError(FinFetchError):
|
|
23
|
+
"""Raised when data cannot be fetched from any source."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
symbol: str,
|
|
28
|
+
section: str,
|
|
29
|
+
errors: list[tuple[str, str]] | None = None,
|
|
30
|
+
):
|
|
31
|
+
self.symbol = symbol
|
|
32
|
+
self.section = section
|
|
33
|
+
self.errors = errors or []
|
|
34
|
+
msg = f"Could not fetch '{section}' data for '{symbol}'"
|
|
35
|
+
if self.errors:
|
|
36
|
+
details = "; ".join(f"{src}: {err}" for src, err in self.errors)
|
|
37
|
+
msg += f" [{details}]"
|
|
38
|
+
super().__init__(msg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RateLimitError(FinFetchError):
|
|
42
|
+
"""Raised when a source returns HTTP 429."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, source: str):
|
|
45
|
+
self.source = source
|
|
46
|
+
super().__init__(f"Rate limited by {source}. Try again later.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ScrapingError(FinFetchError):
|
|
50
|
+
"""Raised when HTML parsing fails unexpectedly."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, source: str, detail: str = ""):
|
|
53
|
+
self.source = source
|
|
54
|
+
msg = f"Failed to parse data from {source}"
|
|
55
|
+
if detail:
|
|
56
|
+
msg += f": {detail}"
|
|
57
|
+
super().__init__(msg)
|