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