nseindiapy 0.1.0__tar.gz

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.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.3
2
+ Name: nseindiapy
3
+ Version: 0.1.0
4
+ Summary: Python client for NSE Indices (niftyindices.com) — historical data, live market data, daily snapshots, reports, and holiday calendar.
5
+ Keywords: nse,nifty,india,stock market,indices,finance
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Intended Audience :: Financial and Insurance Industry
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Office/Business :: Financial
16
+ Requires-Dist: requests>=2.32
17
+ Requires-Dist: polars>=1.0
18
+ Requires-Dist: python-dateutil>=2.9
19
+ Requires-Dist: beautifulsoup4>=4.12
20
+ Requires-Dist: playwright>=1.40 ; extra == 'playwright'
21
+ Requires-Python: >=3.11
22
+ Project-URL: Homepage, https://skamalj.github.io/nseindiapy/
23
+ Project-URL: Repository, https://github.com/skamalj/nseindiapy
24
+ Project-URL: Documentation, https://skamalj.github.io/nseindiapy/
25
+ Provides-Extra: playwright
26
+ Description-Content-Type: text/markdown
27
+
28
+ # nseindiapy
29
+
30
+ Python client for **NSE Indices** ([niftyindices.com](https://www.niftyindices.com)) — historical OHLC, TRI, P/E-P/B, India VIX, live index snapshot, daily snapshots, monthly reports, and the trading holiday calendar. All tabular data returned as [Polars](https://pola.rs/) DataFrames.
31
+
32
+ 📖 **[Full documentation](https://skamalj.github.io/nseindiapy/)**
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install nseindiapy
38
+ # or
39
+ uv add nseindiapy
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ ```python
45
+ from datetime import date
46
+ from nseindiapy import NiftyIndicesClient
47
+
48
+ client = NiftyIndicesClient()
49
+
50
+ # Historical OHLC — auto-paginates for ranges > 365 days
51
+ df = client.historical.price_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
52
+
53
+ # Total Return Index
54
+ tri = client.historical.tri_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
55
+
56
+ # P/E, P/B, Dividend Yield
57
+ pepb = client.historical.pepb_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
58
+
59
+ # India VIX (from daily snapshot CSV)
60
+ vix = client.historical.vix_history(date(2024, 1, 1), date(2024, 12, 31))
61
+
62
+ # Live indices (131 indices, CDN, no auth)
63
+ live = client.live.live_indices()
64
+
65
+ # Daily closing snapshot (all indices)
66
+ snap = client.reports.daily_snapshot(date.today())
67
+
68
+ # Holiday calendar
69
+ holidays = client.holidays.get_holidays(2026)
70
+ trading = client.holidays.trading_days(date(2026, 1, 1), date(2026, 3, 31))
71
+ ```
72
+
73
+ ## Features
74
+
75
+ - **Single entry point** — `NiftyIndicesClient` with four sub-modules
76
+ - **Auto-pagination** — 365-day API limit handled transparently
77
+ - **Session seeding** — ASP.NET cookie seeded automatically; no manual setup needed
78
+ - **Polars DataFrames** — typed columns, `pl.Date` dates, `pl.Float64` numerics
79
+ - **No API key** — all endpoints are public
80
+
81
+ ## Links
82
+
83
+ - [Documentation](https://skamalj.github.io/nseindiapy/)
84
+ - [PyPI](https://pypi.org/project/nseindiapy/)
85
+ - [GitHub](https://github.com/skamalj/nseindiapy)
@@ -0,0 +1,58 @@
1
+ # nseindiapy
2
+
3
+ Python client for **NSE Indices** ([niftyindices.com](https://www.niftyindices.com)) — historical OHLC, TRI, P/E-P/B, India VIX, live index snapshot, daily snapshots, monthly reports, and the trading holiday calendar. All tabular data returned as [Polars](https://pola.rs/) DataFrames.
4
+
5
+ 📖 **[Full documentation](https://skamalj.github.io/nseindiapy/)**
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install nseindiapy
11
+ # or
12
+ uv add nseindiapy
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```python
18
+ from datetime import date
19
+ from nseindiapy import NiftyIndicesClient
20
+
21
+ client = NiftyIndicesClient()
22
+
23
+ # Historical OHLC — auto-paginates for ranges > 365 days
24
+ df = client.historical.price_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
25
+
26
+ # Total Return Index
27
+ tri = client.historical.tri_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
28
+
29
+ # P/E, P/B, Dividend Yield
30
+ pepb = client.historical.pepb_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
31
+
32
+ # India VIX (from daily snapshot CSV)
33
+ vix = client.historical.vix_history(date(2024, 1, 1), date(2024, 12, 31))
34
+
35
+ # Live indices (131 indices, CDN, no auth)
36
+ live = client.live.live_indices()
37
+
38
+ # Daily closing snapshot (all indices)
39
+ snap = client.reports.daily_snapshot(date.today())
40
+
41
+ # Holiday calendar
42
+ holidays = client.holidays.get_holidays(2026)
43
+ trading = client.holidays.trading_days(date(2026, 1, 1), date(2026, 3, 31))
44
+ ```
45
+
46
+ ## Features
47
+
48
+ - **Single entry point** — `NiftyIndicesClient` with four sub-modules
49
+ - **Auto-pagination** — 365-day API limit handled transparently
50
+ - **Session seeding** — ASP.NET cookie seeded automatically; no manual setup needed
51
+ - **Polars DataFrames** — typed columns, `pl.Date` dates, `pl.Float64` numerics
52
+ - **No API key** — all endpoints are public
53
+
54
+ ## Links
55
+
56
+ - [Documentation](https://skamalj.github.io/nseindiapy/)
57
+ - [PyPI](https://pypi.org/project/nseindiapy/)
58
+ - [GitHub](https://github.com/skamalj/nseindiapy)
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "nseindiapy"
3
+ version = "0.1.0"
4
+ description = "Python client for NSE Indices (niftyindices.com) — historical data, live market data, daily snapshots, reports, and holiday calendar."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ keywords = ["nse", "nifty", "india", "stock market", "indices", "finance"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Intended Audience :: Financial and Insurance Industry",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Office/Business :: Financial",
19
+ ]
20
+ dependencies = [
21
+ "requests>=2.32",
22
+ "polars>=1.0",
23
+ "python-dateutil>=2.9",
24
+ "beautifulsoup4>=4.12",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ playwright = ["playwright>=1.40"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://skamalj.github.io/nseindiapy/"
32
+ Repository = "https://github.com/skamalj/nseindiapy"
33
+ Documentation = "https://skamalj.github.io/nseindiapy/"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.11.15,<0.12.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "mkdocs-material>=9.5",
42
+ "mkdocstrings[python]>=0.25",
43
+ ]
@@ -0,0 +1,14 @@
1
+ """nseindia — Python client for NSE Indices (niftyindices.com)."""
2
+ from .client import NiftyIndicesClient
3
+ from .historical import HistoricalData
4
+ from .holidays import Holidays
5
+ from .live import LiveMarket
6
+ from .reports import Reports
7
+
8
+ __all__ = [
9
+ "NiftyIndicesClient",
10
+ "HistoricalData",
11
+ "LiveMarket",
12
+ "Reports",
13
+ "Holidays",
14
+ ]
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import requests
4
+
5
+ BASE_URL = "https://www.niftyindices.com"
6
+ BLOB_URL = "https://iislliveblob.niftyindices.com"
7
+
8
+ # X-Requested-With is the jQuery AJAX marker required by ASP.NET WebForms.
9
+ # Without it the POST endpoints may be blocked at WAF/proxy level.
10
+ _HEADERS = {
11
+ "Content-Type": "application/json; charset=utf-8",
12
+ "X-Requested-With": "XMLHttpRequest",
13
+ "Accept": "application/json, text/javascript, */*; q=0.01",
14
+ "Referer": "https://www.niftyindices.com/reports/historical-data",
15
+ "Origin": "https://www.niftyindices.com",
16
+ "User-Agent": (
17
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
18
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
19
+ "Chrome/125.0.0.0 Safari/537.36"
20
+ ),
21
+ }
22
+
23
+ _SEED_URL = BASE_URL + "/reports/historical-data"
24
+
25
+
26
+ def make_session(seed: bool = True) -> requests.Session:
27
+ """Create a requests.Session pre-configured for niftyindices.com.
28
+
29
+ Args:
30
+ seed: If ``True`` (default), performs a GET to the historical-data page
31
+ to establish an ASP.NET session cookie before any POST calls.
32
+ This bypasses proxy-level blocks that check for a valid session.
33
+ """
34
+ session = requests.Session()
35
+ session.headers.update(_HEADERS)
36
+ if seed:
37
+ try:
38
+ session.get(_SEED_URL, timeout=20)
39
+ except Exception:
40
+ pass # network errors handled at call site
41
+ return session
@@ -0,0 +1,60 @@
1
+ """NSE India session helper — establishes the cookie session required by nseindia.com APIs."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ import requests
6
+
7
+ _NSE_HOME = "https://www.nseindia.com"
8
+ _NSE_VIX_PAGE = "https://www.nseindia.com/market-data/india-vix"
9
+
10
+ _HEADERS = {
11
+ "User-Agent": (
12
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
13
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
14
+ "Chrome/124.0.0.0 Safari/537.36"
15
+ ),
16
+ "Accept": (
17
+ "text/html,application/xhtml+xml,application/xml;q=0.9,"
18
+ "image/avif,image/webp,image/apng,*/*;q=0.8"
19
+ ),
20
+ "Accept-Language": "en-US,en;q=0.9",
21
+ "Accept-Encoding": "gzip, deflate, br",
22
+ "Connection": "keep-alive",
23
+ "Upgrade-Insecure-Requests": "1",
24
+ }
25
+
26
+ _API_HEADERS = {
27
+ "Accept": "application/json, text/plain, */*",
28
+ "Referer": _NSE_VIX_PAGE,
29
+ "X-Requested-With": "XMLHttpRequest",
30
+ }
31
+
32
+
33
+ def make_nse_session() -> requests.Session:
34
+ """Return a requests.Session primed with NSE India cookies.
35
+
36
+ NSE India gates its JSON APIs behind a session cookie. This function:
37
+ 1. Visits the homepage to seed the initial cookie
38
+ 2. Visits the India VIX page to establish the correct Referer context
39
+ 3. Returns the session with API-specific headers set
40
+
41
+ Note: NSE India uses JavaScript-based bot detection. If the session cannot
42
+ be established (e.g. 503 from a restricted network), the VIX API will raise
43
+ an ``HTTPError``. In that case the data is unavailable from this environment.
44
+ """
45
+ session = requests.Session()
46
+ session.headers.update(_HEADERS)
47
+
48
+ try:
49
+ # Step 1: seed cookies from homepage
50
+ session.get(_NSE_HOME, timeout=20)
51
+ time.sleep(0.5)
52
+ # Step 2: visit VIX page for Referer context
53
+ session.get(_NSE_VIX_PAGE, timeout=20)
54
+ time.sleep(0.3)
55
+ except Exception:
56
+ pass # network issues handled at call site
57
+
58
+ # Switch to JSON-API headers for subsequent API calls
59
+ session.headers.update(_API_HEADERS)
60
+ return session
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import date, timedelta
5
+
6
+ import requests
7
+ from dateutil.relativedelta import relativedelta
8
+
9
+
10
+ def fmt_date(d: date) -> str:
11
+ return d.strftime("%d %b %Y")
12
+
13
+
14
+ def parse_response(resp: requests.Response) -> list[dict]:
15
+ outer = resp.json()
16
+ inner = outer.get("d", "[]")
17
+ if not isinstance(inner, str):
18
+ return inner
19
+ if inner.lower() in ("false", "[]", ""):
20
+ return []
21
+ return json.loads(inner)
22
+
23
+
24
+ def date_chunks(start: date, end: date, months: int = 12):
25
+ current = start
26
+ while current < end:
27
+ chunk_end = min(current + relativedelta(months=months) - timedelta(days=1), end)
28
+ yield current, chunk_end
29
+ current = chunk_end + timedelta(days=1)
@@ -0,0 +1,75 @@
1
+ """Top-level client for niftyindices.com."""
2
+ from __future__ import annotations
3
+
4
+ from ._http import make_session
5
+ from .historical import HistoricalData
6
+ from .holidays import Holidays
7
+ from .live import LiveMarket
8
+ from .reports import Reports
9
+
10
+
11
+ class NiftyIndicesClient:
12
+ """Unified client for niftyindices.com data.
13
+
14
+ All network calls share a single ``requests.Session`` with the required
15
+ headers pre-configured.
16
+
17
+ Sub-modules:
18
+ - ``client.historical`` — price OHLC, TRI, P/E-P/B, India VIX (via NSE India),
19
+ index type/category/name discovery
20
+ - ``client.live`` — live index snapshot (CDN, no auth required)
21
+ - ``client.reports`` — daily snapshots CSV, monthly PDFs/ZIPs, static resources
22
+ - ``client.holidays`` — holiday calendar (HTML scrape), trading day utilities
23
+
24
+ Example::
25
+
26
+ from datetime import date
27
+ from nseindia import NiftyIndicesClient
28
+
29
+ client = NiftyIndicesClient()
30
+
31
+ # Historical OHLC (auto-paginates > 365 days)
32
+ df = client.historical.price_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
33
+
34
+ # TRI and P/E-P/B
35
+ tri = client.historical.tri_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
36
+ pepb = client.historical.pepb_history("NIFTY 50", date(2024, 1, 1), date(2024, 12, 31))
37
+
38
+ # India VIX (fetched from NSE India, session cookie handled automatically)
39
+ vix = client.historical.vix_history(date(2024, 1, 1), date(2024, 12, 31))
40
+
41
+ # Index discovery
42
+ types = client.historical.list_index_types()
43
+ cats = client.historical.list_sub_categories("Equity")
44
+ names = client.historical.list_indices("Broad Market Indices")
45
+
46
+ # Live index snapshot (CDN, ~15-30s refresh during market hours)
47
+ live = client.live.live_indices()
48
+
49
+ # Daily closing CSV
50
+ snap = client.reports.daily_snapshot(date.today())
51
+
52
+ # Monthly reports
53
+ client.reports.download_monthly(2026, 5, dest_dir="./data")
54
+
55
+ # Holiday calendar
56
+ holidays = client.holidays.get_holidays(2026)
57
+ trading = client.holidays.trading_days(date(2026, 1, 1), date(2026, 3, 31))
58
+ """
59
+
60
+ def __init__(self) -> None:
61
+ self._session = make_session()
62
+ self.historical = HistoricalData(self._session)
63
+ self.live = LiveMarket(self._session)
64
+ self.reports = Reports(self._session)
65
+ self.holidays = Holidays(self._session)
66
+
67
+ def close(self) -> None:
68
+ """Close the underlying HTTP session."""
69
+ self._session.close()
70
+
71
+ def __enter__(self) -> "NiftyIndicesClient":
72
+ return self
73
+
74
+ def __exit__(self, *_: object) -> None:
75
+ self.close()
@@ -0,0 +1,299 @@
1
+ """Historical index data — price OHLC, TRI, P/E-P/B, India VIX."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import time
6
+ from datetime import date, timedelta
7
+
8
+ import polars as pl
9
+ import requests
10
+
11
+ from ._http import BASE_URL, BLOB_URL
12
+ from ._utils import date_chunks, fmt_date, parse_response
13
+
14
+ _ENDPOINT = BASE_URL + "/Backpage.aspx/{method}"
15
+ _SNAPSHOT_URL = BASE_URL + "/Daily_Snapshot/ind_close_all_{date}.csv"
16
+
17
+
18
+ class HistoricalData:
19
+ def __init__(self, session: requests.Session) -> None:
20
+ self._s = session
21
+
22
+ # ------------------------------------------------------------------
23
+ # Metadata
24
+ # ------------------------------------------------------------------
25
+
26
+ def get_index_mapping(self) -> list[dict]:
27
+ """Fetch the index name mapping table from the CDN.
28
+
29
+ The file is served with a UTF-8 BOM — decoded automatically.
30
+ """
31
+ resp = self._s.get(
32
+ f"{BLOB_URL}/assets/json/IndexMapping.json", timeout=30
33
+ )
34
+ resp.raise_for_status()
35
+ import json
36
+ # CDN serves the file with a UTF-8 BOM (EF BB BF prefix)
37
+ text = resp.content.decode("utf-8-sig")
38
+ return json.loads(text)
39
+
40
+ def list_index_types(self) -> list[str]:
41
+ """Return top-level index types: Equity, Fixed Income, Multi Asset."""
42
+ resp = self._s.post(
43
+ _ENDPOINT.format(method="gethistoricaltypedata1"),
44
+ json={"cinfo": {}},
45
+ timeout=30,
46
+ )
47
+ resp.raise_for_status()
48
+ return [r["indextype"] for r in parse_response(resp)]
49
+
50
+ def list_sub_categories(
51
+ self,
52
+ index_type: str = "Equity",
53
+ section: str = "Historical Index Data",
54
+ ) -> list[str]:
55
+ """List sub-categories for a given index type and section.
56
+
57
+ Args:
58
+ index_type: One of ``"Equity"``, ``"Fixed Income"``, ``"Multi Asset"``.
59
+ section: ``"Historical Index Data"`` or ``"Total Returns Index Values"``.
60
+ """
61
+ resp = self._s.post(
62
+ _ENDPOINT.format(method="gethistoricaltypeSubindexdata"),
63
+ json={"cinfo": {"indextype": index_type, "indexgroup": section}},
64
+ timeout=30,
65
+ )
66
+ resp.raise_for_status()
67
+ return [r["indextype"] for r in parse_response(resp)]
68
+
69
+ def list_indices(
70
+ self,
71
+ sub_category: str,
72
+ section: str = "Historical Index Data",
73
+ ) -> list[str]:
74
+ """List index names within a sub-category."""
75
+ resp = self._s.post(
76
+ _ENDPOINT.format(method="gethistoricaltypeindexdata"),
77
+ json={"cinfo": {"indextype": sub_category, "indexgroup": section}},
78
+ timeout=30,
79
+ )
80
+ resp.raise_for_status()
81
+ return [r["indextype"] for r in parse_response(resp)]
82
+
83
+ # ------------------------------------------------------------------
84
+ # Internal helpers
85
+ # ------------------------------------------------------------------
86
+
87
+ def _post_data(
88
+ self,
89
+ method: str,
90
+ index_name: str,
91
+ start: date,
92
+ end: date,
93
+ ) -> list[dict]:
94
+ payload = {
95
+ "cinfo": (
96
+ f"{{'name':'{index_name}',"
97
+ f"'startDate':'{fmt_date(start)}',"
98
+ f"'endDate':'{fmt_date(end)}',"
99
+ f"'indexName':'{index_name}'}}"
100
+ )
101
+ }
102
+ resp = self._s.post(
103
+ _ENDPOINT.format(method=method), json=payload, timeout=30
104
+ )
105
+ resp.raise_for_status()
106
+ return parse_response(resp)
107
+
108
+ def _paginated(
109
+ self,
110
+ method: str,
111
+ index_name: str,
112
+ start: date,
113
+ end: date,
114
+ delay: float = 0.5,
115
+ ) -> list[dict]:
116
+ rows: list[dict] = []
117
+ for chunk_start, chunk_end in date_chunks(start, end, months=12):
118
+ rows.extend(self._post_data(method, index_name, chunk_start, chunk_end))
119
+ if chunk_end < end:
120
+ time.sleep(delay)
121
+ return rows
122
+
123
+ # ------------------------------------------------------------------
124
+ # Public data methods
125
+ # ------------------------------------------------------------------
126
+
127
+ def price_history(
128
+ self, index_name: str, start: date, end: date
129
+ ) -> pl.DataFrame:
130
+ """Daily OHLC (Price Return Index) for any NSE index.
131
+
132
+ Paginates automatically for date ranges > 365 days.
133
+ Available from 1990. Date format is handled internally.
134
+ """
135
+ rows = self._paginated(
136
+ "getHistoricaldatatabletoString", index_name.upper(), start, end
137
+ )
138
+ if not rows:
139
+ return pl.DataFrame(schema={
140
+ "index_name": pl.Utf8, "date": pl.Date,
141
+ "open": pl.Float64, "high": pl.Float64,
142
+ "low": pl.Float64, "close": pl.Float64,
143
+ })
144
+ df = pl.DataFrame(rows)
145
+ # Normalise whichever index-name field the API returns
146
+ for src in ("INDEX_NAME", "Index Name"):
147
+ if src in df.columns and "index_name" not in df.columns:
148
+ df = df.rename({src: "index_name"})
149
+ return (
150
+ df.rename({
151
+ "HistoricalDate": "date",
152
+ "OPEN": "open",
153
+ "HIGH": "high",
154
+ "LOW": "low",
155
+ "CLOSE": "close",
156
+ })
157
+ .with_columns([
158
+ pl.col("date").str.to_date("%d %b %Y"),
159
+ pl.col("open").cast(pl.Float64),
160
+ pl.col("high").cast(pl.Float64),
161
+ pl.col("low").cast(pl.Float64),
162
+ pl.col("close").cast(pl.Float64),
163
+ ])
164
+ .select(["index_name", "date", "open", "high", "low", "close"])
165
+ .sort("date")
166
+ )
167
+
168
+ def tri_history(
169
+ self, index_name: str, start: date, end: date
170
+ ) -> pl.DataFrame:
171
+ """Daily Total Return Index (TRI) values.
172
+
173
+ Gross TRI includes dividends reinvested. Net TRI (ntr) applies
174
+ withholding tax and is only available for NIFTY 50, NIFTY MIDCAP 50,
175
+ and NIFTY 500. Available from 1995.
176
+ """
177
+ rows = self._paginated(
178
+ "getTotalReturnIndexString", index_name.upper(), start, end
179
+ )
180
+ if not rows:
181
+ return pl.DataFrame(schema={
182
+ "index_name": pl.Utf8, "date": pl.Date,
183
+ "tri": pl.Float64, "ntr": pl.Float64,
184
+ })
185
+ df = pl.DataFrame(rows)
186
+ rename_map: dict[str, str] = {}
187
+ for src, dst in [
188
+ ("Index Name", "index_name"),
189
+ ("Date", "date"),
190
+ ("TotalReturnsIndex", "tri"),
191
+ ("NTR_Value", "ntr"),
192
+ ]:
193
+ if src in df.columns:
194
+ rename_map[src] = dst
195
+ df = df.rename(rename_map)
196
+ casts = [pl.col("date").str.to_date("%d %b %Y"), pl.col("tri").cast(pl.Float64)]
197
+ if "ntr" in df.columns:
198
+ casts.append(
199
+ pl.when(pl.col("ntr").str.strip_chars() == "")
200
+ .then(pl.lit(None))
201
+ .otherwise(pl.col("ntr"))
202
+ .cast(pl.Float64)
203
+ .alias("ntr")
204
+ )
205
+ keep = [c for c in ["index_name", "date", "tri", "ntr"] if c in df.columns]
206
+ return df.with_columns(casts).select(keep).sort("date")
207
+
208
+ def pepb_history(
209
+ self, index_name: str, start: date, end: date
210
+ ) -> pl.DataFrame:
211
+ """Daily P/E, P/B and Dividend Yield ratios. Available from 1996."""
212
+ rows = self._paginated(
213
+ "getpepbHistoricaldataDBtoString", index_name.upper(), start, end
214
+ )
215
+ if not rows:
216
+ return pl.DataFrame(schema={
217
+ "index_name": pl.Utf8, "date": pl.Date,
218
+ "pe": pl.Float64, "pb": pl.Float64, "div_yield": pl.Float64,
219
+ })
220
+ df = pl.DataFrame(rows)
221
+ return (
222
+ df.rename({k: v for k, v in {
223
+ "Index Name": "index_name",
224
+ "DATE": "date",
225
+ "divYield": "div_yield",
226
+ }.items() if k in df.columns})
227
+ .with_columns([
228
+ pl.col("date").str.to_date("%d %b %Y"),
229
+ pl.col("pe").cast(pl.Float64),
230
+ pl.col("pb").cast(pl.Float64),
231
+ pl.col("div_yield").cast(pl.Float64),
232
+ ])
233
+ .select(["index_name", "date", "pe", "pb", "div_yield"])
234
+ .sort("date")
235
+ )
236
+
237
+ def vix_history(self, start: date, end: date) -> pl.DataFrame:
238
+ """Daily OHLC for India VIX, extracted from niftyindices.com daily snapshot CSVs.
239
+
240
+ The original ``Backpage.aspx/BindHistoricalIndiaVixData`` endpoint was
241
+ decommissioned. The Daily Snapshot CSV already contains India VIX as a
242
+ named row — no NSE India session or Playwright needed.
243
+
244
+ Verified live from ``ind_close_all_05062026.csv``::
245
+
246
+ India VIX,05-06-2026,15.885,16.37,13.46,15.79,-0.1,-.61,-,-,-,-,-
247
+
248
+ Columns returned: ``date``, ``open``, ``high``, ``low``, ``close``,
249
+ ``points_change``, ``change_pct``.
250
+
251
+ Non-trading days (weekends / holidays) are skipped automatically —
252
+ those dates have no CSV file (HTTP 404).
253
+ """
254
+ rows: list[dict] = []
255
+ dt = start
256
+ while dt <= end:
257
+ url = _SNAPSHOT_URL.format(date=dt.strftime("%d%m%Y"))
258
+ try:
259
+ resp = self._s.get(url, timeout=15)
260
+ if resp.status_code == 404:
261
+ dt += timedelta(days=1)
262
+ continue
263
+ resp.raise_for_status()
264
+ # Parse CSV; find "India VIX" row
265
+ df_snap = pl.read_csv(
266
+ io.BytesIO(resp.content),
267
+ encoding="utf8-lossy",
268
+ infer_schema_length=0,
269
+ )
270
+ df_snap = df_snap.rename({c: c.strip() for c in df_snap.columns})
271
+ vix_row = df_snap.filter(
272
+ pl.col("Index Name").str.strip_chars() == "India VIX"
273
+ )
274
+ if not vix_row.is_empty():
275
+ r = vix_row.row(0, named=True)
276
+ rows.append({
277
+ "date": dt,
278
+ "open": float(r.get("Open Index Value", "nan").replace(",", "") or "nan"),
279
+ "high": float(r.get("High Index Value", "nan").replace(",", "") or "nan"),
280
+ "low": float(r.get("Low Index Value", "nan").replace(",", "") or "nan"),
281
+ "close": float(r.get("Closing Index Value", "nan").replace(",", "") or "nan"),
282
+ "points_change": float((r.get("Points Change") or "nan").replace(",", "") or "nan"),
283
+ "change_pct": float((r.get("Change(%)") or "nan").replace(",", "") or "nan"),
284
+ })
285
+ except Exception:
286
+ pass
287
+ dt += timedelta(days=1)
288
+
289
+ if not rows:
290
+ return pl.DataFrame(schema={
291
+ "date": pl.Date,
292
+ "open": pl.Float64, "high": pl.Float64,
293
+ "low": pl.Float64, "close": pl.Float64,
294
+ "points_change": pl.Float64, "change_pct": pl.Float64,
295
+ })
296
+
297
+ return pl.DataFrame(rows).with_columns(
298
+ pl.col("date").cast(pl.Date)
299
+ ).sort("date")
@@ -0,0 +1,104 @@
1
+ """NSE trading holiday calendar.
2
+
3
+ Note: ``POST /Backpage.aspx/getAllHolidays`` does not exist (HTTP 500,
4
+ "Unknown web method"). Holiday data is scraped from the HTML page at
5
+ ``https://www.niftyindices.com/resources/holiday-calendar``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from datetime import date, timedelta
10
+
11
+ import polars as pl
12
+ import requests
13
+ from bs4 import BeautifulSoup
14
+
15
+ from ._http import BASE_URL
16
+
17
+ _CALENDAR_URL = BASE_URL + "/resources/holiday-calendar"
18
+
19
+
20
+ def _scrape_holidays(html: str) -> list[dict]:
21
+ """Parse holiday rows from the holiday-calendar HTML page.
22
+
23
+ Actual page columns (verified June 2026):
24
+ SR. NO. | Date (DD/MM/YYYY) | Day | Occasion
25
+ """
26
+ soup = BeautifulSoup(html, "html.parser")
27
+ rows = []
28
+ for tr in soup.find_all("tr"):
29
+ tds = tr.find_all("td")
30
+ if len(tds) == 4:
31
+ sr_no = tds[0].get_text(strip=True)
32
+ trading_date = tds[1].get_text(strip=True)
33
+ week_day = tds[2].get_text(strip=True)
34
+ description = tds[3].get_text(strip=True)
35
+ # Skip header rows — sr_no must be numeric
36
+ if sr_no.isdigit() and trading_date:
37
+ rows.append({
38
+ "sr_no": int(sr_no),
39
+ "date_str": trading_date,
40
+ "week_day": week_day,
41
+ "description": description,
42
+ })
43
+ return rows
44
+
45
+
46
+ class Holidays:
47
+ def __init__(self, session: requests.Session) -> None:
48
+ self._s = session
49
+
50
+ def get_holidays(self, year: int) -> pl.DataFrame:
51
+ """Fetch the NSE trading holiday list for *year* by scraping the HTML page.
52
+
53
+ Returns a DataFrame with columns:
54
+ ``sr_no`` (int), ``date`` (Date), ``week_day`` (str), ``description`` (str).
55
+ """
56
+ resp = self._s.get(_CALENDAR_URL, params={"year": str(year)}, timeout=20)
57
+ resp.raise_for_status()
58
+ raw_rows = _scrape_holidays(resp.text)
59
+
60
+ if not raw_rows:
61
+ return pl.DataFrame(schema={
62
+ "sr_no": pl.Int64,
63
+ "date": pl.Date,
64
+ "week_day": pl.Utf8,
65
+ "description": pl.Utf8,
66
+ })
67
+
68
+ df = pl.DataFrame(raw_rows)
69
+ # Date strings on the page are "DD/MM/YYYY" format e.g. "26/01/2026"
70
+ df = df.with_columns(
71
+ pl.col("date_str").str.to_date("%d/%m/%Y", strict=False).alias("date")
72
+ ).drop("date_str")
73
+
74
+ return df.drop_nulls(subset=["date"]).sort("date")
75
+
76
+ def holiday_dates(self, year: int) -> set[date]:
77
+ """Return a set of holiday ``date`` objects for *year*."""
78
+ df = self.get_holidays(year)
79
+ if df.is_empty() or "date" not in df.columns:
80
+ return set()
81
+ return {d for d in df["date"].to_list() if d is not None}
82
+
83
+ def trading_days(self, start: date, end: date) -> list[date]:
84
+ """Return all NSE trading days between *start* and *end* inclusive.
85
+
86
+ Fetches the holiday calendar for every calendar year spanned by the range.
87
+ """
88
+ holidays: set[date] = set()
89
+ for y in range(start.year, end.year + 1):
90
+ holidays |= self.holiday_dates(y)
91
+
92
+ days: list[date] = []
93
+ dt = start
94
+ while dt <= end:
95
+ if dt.weekday() < 5 and dt not in holidays:
96
+ days.append(dt)
97
+ dt += timedelta(days=1)
98
+ return days
99
+
100
+ def is_trading_day(self, dt: date) -> bool:
101
+ """Return ``True`` if *dt* is an NSE trading day."""
102
+ if dt.weekday() >= 5:
103
+ return False
104
+ return dt not in self.holiday_dates(dt.year)
@@ -0,0 +1,51 @@
1
+ """Live market data from niftyindices.com CDN.
2
+
3
+ Note on removed endpoints
4
+ --------------------------
5
+ The following Backpage.aspx methods were found to be non-existent (HTTP 500
6
+ "Unknown web method") during live testing in June 2026 — they are not
7
+ implemented:
8
+
9
+ * ``getEquityStockWatch`` — page is a redirect button to nseindia.com
10
+ * ``getIndexMovers`` — redirect to nseindia.com
11
+ * ``getETFList`` — redirect to nseindia.com
12
+ * ``getReturnProfileData`` — page is server-rendered; no backing API
13
+
14
+ Use ``client.historical.price_history()`` or ``client.historical.tri_history()``
15
+ to compute return profiles from raw data.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import requests
20
+ import polars as pl
21
+
22
+ from ._http import BLOB_URL
23
+
24
+
25
+ class LiveMarket:
26
+ def __init__(self, session: requests.Session) -> None:
27
+ self._s = session
28
+
29
+ def live_indices(self) -> pl.DataFrame:
30
+ """Snapshot of all live NSE index values from the CDN.
31
+
32
+ Endpoint: ``GET https://iislliveblob.niftyindices.com/jsonfiles/LiveIndicesWatch_new.json``
33
+
34
+ Refreshed every 15–30 s during market hours (09:15–15:30 IST, Mon–Fri).
35
+ Outside trading hours the previous session's close data is returned.
36
+
37
+ Actual response columns (verified June 2026):
38
+ ``indexName``, ``last``, ``percChange``, ``icChange``, ``open``,
39
+ ``high``, ``low``, ``previousClose``, ``timeVal``, ``yearHigh``,
40
+ ``yearLow``, ``indicativeClose``, ``isConstituents``, ``constituents``
41
+ """
42
+ resp = self._s.get(
43
+ f"{BLOB_URL}/jsonfiles/LiveIndicesWatch_new.json", timeout=10
44
+ )
45
+ resp.raise_for_status()
46
+ payload = resp.json()
47
+ # CDN returns {"data": [...]} wrapper
48
+ data: list[dict] = payload.get("data", payload) if isinstance(payload, dict) else payload
49
+ if not data:
50
+ return pl.DataFrame()
51
+ return pl.DataFrame(data)
File without changes
@@ -0,0 +1,197 @@
1
+ """Daily and monthly report downloads from niftyindices.com."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import os
6
+ from datetime import date, timedelta
7
+
8
+ import polars as pl
9
+ import requests
10
+
11
+ _BASE = "https://www.niftyindices.com"
12
+
13
+ _MONTH_UPPER = {
14
+ 1: "JAN", 2: "FEB", 3: "MAR", 4: "APR",
15
+ 5: "MAY", 6: "JUN", 7: "JUL", 8: "AUG",
16
+ 9: "SEP", 10: "OCT", 11: "NOV", 12: "DEC",
17
+ }
18
+ _MONTH_MIXED = {
19
+ 1: "January", 2: "February", 3: "March", 4: "April",
20
+ 5: "May", 6: "June", 7: "July", 8: "August",
21
+ 9: "September", 10: "October", 11: "November", 12: "December",
22
+ }
23
+
24
+
25
+ class Reports:
26
+ def __init__(self, session: requests.Session) -> None:
27
+ self._s = session
28
+
29
+ # ------------------------------------------------------------------
30
+ # Daily reports
31
+ # ------------------------------------------------------------------
32
+
33
+ def daily_snapshot(self, dt: date) -> pl.DataFrame | None:
34
+ """Download and parse the daily index snapshot CSV for *dt*.
35
+
36
+ Returns a Polars DataFrame or ``None`` if *dt* was a non-trading day
37
+ (HTTP 404).
38
+ """
39
+ url = (
40
+ f"{_BASE}/Daily_Snapshot/"
41
+ f"ind_close_all_{dt.strftime('%d%m%Y')}.csv"
42
+ )
43
+ resp = self._s.get(url, timeout=30)
44
+ if resp.status_code == 404:
45
+ return None
46
+ resp.raise_for_status()
47
+ return self._parse_snapshot(resp.content)
48
+
49
+ def daily_snapshot_bytes(self, dt: date) -> bytes | None:
50
+ """Return raw CSV bytes for the daily snapshot, or ``None`` for non-trading days."""
51
+ url = (
52
+ f"{_BASE}/Daily_Snapshot/"
53
+ f"ind_close_all_{dt.strftime('%d%m%Y')}.csv"
54
+ )
55
+ resp = self._s.get(url, timeout=30)
56
+ if resp.status_code == 404:
57
+ return None
58
+ resp.raise_for_status()
59
+ return resp.content
60
+
61
+ def daily_snapshot_range(
62
+ self, start: date, end: date
63
+ ) -> pl.DataFrame:
64
+ """Download and combine daily snapshots for all trading days in [start, end]."""
65
+ frames: list[pl.DataFrame] = []
66
+ dt = start
67
+ while dt <= end:
68
+ df = self.daily_snapshot(dt)
69
+ if df is not None:
70
+ frames.append(df)
71
+ dt += timedelta(days=1)
72
+ return pl.concat(frames) if frames else pl.DataFrame()
73
+
74
+ @staticmethod
75
+ def _parse_snapshot(content: bytes) -> pl.DataFrame:
76
+ df = pl.read_csv(
77
+ io.BytesIO(content),
78
+ encoding="utf8-lossy",
79
+ infer_schema_length=0,
80
+ )
81
+ df = df.rename({c: c.strip() for c in df.columns})
82
+ numeric_cols = [
83
+ "Open Index Value", "High Index Value", "Low Index Value",
84
+ "Closing Index Value", "Points Change", "Change(%)",
85
+ "Volume", "Turnover (Rs. Cr.)", "P/E", "P/B", "Div Yield",
86
+ ]
87
+ casts = []
88
+ for col in numeric_cols:
89
+ if col in df.columns:
90
+ casts.append(
91
+ pl.col(col)
92
+ .str.replace_all(",", "")
93
+ .str.strip_chars()
94
+ .cast(pl.Float64, strict=False)
95
+ .alias(col)
96
+ )
97
+ if "Index Date" in df.columns:
98
+ casts.append(
99
+ pl.col("Index Date")
100
+ .str.to_date("%d-%b-%Y", strict=False)
101
+ .alias("Index Date")
102
+ )
103
+ return df.with_columns(casts) if casts else df
104
+
105
+ # ------------------------------------------------------------------
106
+ # Monthly report URLs
107
+ # ------------------------------------------------------------------
108
+
109
+ def monthly_urls(self, year: int, month: int) -> dict[str, str]:
110
+ """Return all known monthly report URLs for *year/month*."""
111
+ mmm = _MONTH_UPPER[month]
112
+ mon = _MONTH_MIXED[month]
113
+ y = str(year)
114
+ return {
115
+ "index_dashboard_equity": (
116
+ f"{_BASE}/Index_Dashboard/Index_Dashboard_{mmm}{y}.pdf"
117
+ ),
118
+ "index_dashboard_fixed_income": (
119
+ f"{_BASE}/Index_Dashboard_Fixed_Income/"
120
+ f"Index_Dashboard_FixedIncome_{mmm}{y}.pdf"
121
+ ),
122
+ "market_cap_weightage": (
123
+ f"{_BASE}/Market_Cap_Weightage/"
124
+ f"Market_Cap_Weightage_{mon}{y}.zip"
125
+ ),
126
+ "impact_cost": (
127
+ f"{_BASE}/Impact_Cost/Impact_Cost_{mmm}{y}.pdf"
128
+ ),
129
+ "passive_insights": (
130
+ f"{_BASE}/Nifty_Passive_Insights/"
131
+ f"NiftyPassiveFundReport-{mon}{y}.pdf"
132
+ ),
133
+ }
134
+
135
+ def download_monthly(
136
+ self,
137
+ year: int,
138
+ month: int,
139
+ dest_dir: str = ".",
140
+ ) -> dict[str, str]:
141
+ """Download all monthly reports into *dest_dir*.
142
+
143
+ Returns a mapping of report name → saved file path (or an error
144
+ message if the file was not found).
145
+ """
146
+ os.makedirs(dest_dir, exist_ok=True)
147
+ urls = self.monthly_urls(year, month)
148
+ results: dict[str, str] = {}
149
+ for name, url in urls.items():
150
+ resp = self._s.get(url, timeout=60)
151
+ if resp.status_code == 404:
152
+ results[name] = f"NOT FOUND: {url}"
153
+ continue
154
+ resp.raise_for_status()
155
+ ext = url.rsplit(".", 1)[-1]
156
+ path = os.path.join(dest_dir, f"{name}_{year}_{month:02d}.{ext}")
157
+ with open(path, "wb") as f:
158
+ f.write(resp.content)
159
+ results[name] = path
160
+ return results
161
+
162
+ # ------------------------------------------------------------------
163
+ # Static resource downloads
164
+ # ------------------------------------------------------------------
165
+
166
+ _RESOURCES: dict[str, str] = {
167
+ "methodology_equity": (
168
+ f"{_BASE}/Methodology/Method_NIFTY_Equity_Indices.pdf"
169
+ ),
170
+ "methodology_fixed_income": (
171
+ f"{_BASE}/Methodology/Method_NIFTY_Fixed_Income_Indices.pdf"
172
+ ),
173
+ "tracking_error": (
174
+ f"{_BASE}/docs/default-source/tracking-error/trackingerror.pdf"
175
+ ),
176
+ "benchmark_codes": (
177
+ f"{_BASE}/BenchmarkCodes/Nifty_Indices_Benchmark_Codes.pdf"
178
+ ),
179
+ }
180
+
181
+ def resource_urls(self) -> dict[str, str]:
182
+ """Return URLs for static methodology and reference documents."""
183
+ return dict(self._RESOURCES)
184
+
185
+ def download_resource(self, name: str, dest_path: str) -> str:
186
+ """Download a named static resource PDF to *dest_path*."""
187
+ if name not in self._RESOURCES:
188
+ raise ValueError(
189
+ f"Unknown resource '{name}'. "
190
+ f"Available: {list(self._RESOURCES)}"
191
+ )
192
+ url = self._RESOURCES[name]
193
+ resp = self._s.get(url, timeout=60)
194
+ resp.raise_for_status()
195
+ with open(dest_path, "wb") as f:
196
+ f.write(resp.content)
197
+ return dest_path