nsepython 2.97__py3-none-any.whl → 2.99__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.
nsepython/rahu.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import os,sys
2
- # os.chdir(os.path.dirname(os.path.abspath(__file__)))
3
- # sys.path.insert(1, os.path.join(sys.path[0], '..'))
2
+ #os.chdir(os.path.dirname(os.path.abspath(__file__)))
3
+ #sys.path.insert(1, os.path.join(sys.path[0], '..'))
4
4
 
5
5
  import requests
6
6
  import pandas as pd
@@ -9,91 +9,297 @@ import random
9
9
  import datetime,time
10
10
  import logging
11
11
  import re
12
+ import io
12
13
  import urllib.parse
13
14
 
14
15
  mode ='local'
15
16
 
16
- if mode == "vpn":
17
- def nsefetch(payload: str):
18
- def encode(url: str) -> str:
19
- if "%26" in url or "%20" in url:
20
- return url
21
- return urllib.parse.quote(url, safe=":/?&=")
22
-
23
- def refresh_cookies():
24
- os.popen(f'curl -c cookies.txt "https://www.nseindia.com" {curl_headers}').read()
25
- os.popen(f'curl -b cookies.txt -c cookies.txt "https://www.nseindia.com/option-chain" {curl_headers}').read()
17
+ # ---------------------------------------------------------------------------
18
+ # Transport
19
+ #
20
+ # NSE's site is fronted by Akamai Bot Manager, which fingerprints the TLS/JA3
21
+ # handshake of the client. A plain `requests.Session()` (or a shelled-out
22
+ # plain `curl`) gets blocked outright (HTTP 403 on the homepage itself) --
23
+ # this is NOT "requests is blocked in India", it's a bot-detection block that
24
+ # has nothing to do with geography. curl_cffi is a requests-compatible
25
+ # Session that impersonates a real Chrome TLS fingerprint, which clears this
26
+ # wall while remaining a pure Python HTTP client (no shell-out, no browser).
27
+ #
28
+ # curl_cffi is therefore the one and only transport nsefetch() uses now. It
29
+ # is a hard dependency (see requirements.txt/setup.py) because it's what
30
+ # makes the large majority of this library's functions work at all against
31
+ # the live site today.
32
+ # ---------------------------------------------------------------------------
33
+
34
+ try:
35
+ from curl_cffi.requests import Session as _CurlSession
36
+ _CURL_CFFI_OK = True
37
+ except ImportError:
38
+ _CURL_CFFI_OK = False
39
+
40
+
41
+ class NSEEndpointError(Exception):
42
+ """Raised by nsefetch() when NSE's site cannot be reached, or responds
43
+ with something other than usable JSON (blocked, retired endpoint, rate
44
+ limited, server error, etc).
45
+
46
+ Older versions of this library silently swallowed these failures and
47
+ returned `{}`, which just pushed the problem one level down into a
48
+ confusing `KeyError`/`AttributeError` in whatever function called
49
+ nsefetch() (see github.com/aeron7/nsepython issues #74, #75, and
50
+ nsepythonserver #6). Raising a descriptive exception here instead makes
51
+ the real failure visible immediately instead of as a downstream KeyError.
52
+ """
53
+ pass
54
+
55
+
56
+ _nse_session = None
57
+ _nse_warmed = False
58
+
59
+
60
+ def _get_nse_session():
61
+ """Return the shared, warmed-up curl_cffi session used by nsefetch().
62
+
63
+ The warm-up (visiting the homepage, then the option-chain page) is what
64
+ gets NSE's Akamai Bot Manager to hand out the `nsit`/`_abck`/`ak_bmsc`/
65
+ `bm_sv` cookies that most JSON API calls expect to see on the request.
66
+ """
67
+ global _nse_session, _nse_warmed
68
+
69
+ if not _CURL_CFFI_OK:
70
+ raise ImportError(
71
+ "nsepython needs curl_cffi to talk to the real nseindia.com site. "
72
+ "A plain `requests` session (and plain `curl`) gets blocked by "
73
+ "NSE's Akamai Bot Manager purely on TLS fingerprint, regardless "
74
+ "of where you are. Install it with: pip install curl_cffi"
75
+ )
76
+
77
+ if _nse_session is None:
78
+ _nse_session = _CurlSession(impersonate="chrome124")
79
+
80
+ if not _nse_warmed:
81
+ try:
82
+ _nse_session.get("https://www.nseindia.com", headers=headers, timeout=20)
83
+ time.sleep(1.2)
84
+ _nse_session.get("https://www.nseindia.com/option-chain", headers=headers, timeout=20)
85
+ time.sleep(0.8)
86
+ _nse_warmed = True
87
+ except Exception as e:
88
+ logging.warning("NSE session warm-up failed/partial: %s", e)
89
+
90
+ return _nse_session
91
+
92
+
93
+ def _equity_stockindices_fallback(session, api_headers):
94
+ """`/api/equity-stockIndices?index=SECURITIES IN F%26O` -- the F&O
95
+ securities list used by fnolist()/nsetools_get_quote()/
96
+ nse_get_advances_declines()/nse_get_top_losers()/nse_get_top_gainers()/
97
+ nse_custom_function_secfno() -- is a retired route on the live site
98
+ (confirmed HTTP 404, NSE's own "Resource not found" page, with or
99
+ without a fully browser-solved Akamai cookie jar).
100
+
101
+ `/api/market-data-pre-open?key=FO` carries the same per-symbol pChange/
102
+ lastPrice/etc information for the F&O universe, so we transparently
103
+ rewrite the request to that endpoint and reshape its response back into
104
+ the old `{"data": [{"symbol":..., "pChange":..., ...}]}` shape every
105
+ existing caller above already expects -- they keep working unchanged.
106
+ """
107
+ r = session.get(
108
+ "https://www.nseindia.com/api/market-data-pre-open?key=FO",
109
+ headers=api_headers, timeout=30,
110
+ )
111
+ if r.status_code != 200:
112
+ raise NSEEndpointError(
113
+ f"equity-stockIndices fallback (market-data-pre-open) failed: HTTP {r.status_code}"
114
+ )
115
+ try:
116
+ raw = r.json()
117
+ except ValueError:
118
+ raise NSEEndpointError("equity-stockIndices fallback returned a non-JSON body")
119
+
120
+ reshaped = []
121
+ for item in raw.get("data", []):
122
+ m = item.get("metadata", {}) or {}
123
+ if not m.get("symbol"):
124
+ continue
125
+ reshaped.append({
126
+ "symbol": m.get("symbol", ""),
127
+ "pChange": m.get("pChange", 0),
128
+ "lastPrice": m.get("lastPrice", 0),
129
+ "change": m.get("change", 0),
130
+ "previousClose": m.get("previousClose", 0),
131
+ "yearHigh": m.get("yearHigh", 0),
132
+ "yearLow": m.get("yearLow", 0),
133
+ "totalTradedValue": m.get("totalTurnover", 0),
134
+ "totalTradedVolume": m.get("finalQuantity", 0),
135
+ })
136
+ return {"data": reshaped}
137
+
138
+
139
+ def nsefetch(payload: str):
140
+ """Fetch a nseindia.com JSON API URL through a warmed-up curl_cffi
141
+ session, retrying once with a fresh warm-up if the first attempt looks
142
+ blocked (stale/expired Akamai cookies), and raising NSEEndpointError
143
+ (instead of silently returning `{}`) if it still can't get real JSON
144
+ back. `mode` is kept only for backwards compatibility with older
145
+ versions of this file; both 'local' and 'vpn' use this same transport
146
+ now, since the previous mode='vpn' plain-curl/os.popen() implementation
147
+ was both broken against the current Akamai wall *and* a command-injection
148
+ risk (see github.com/aeron7/nsepython issue #73).
149
+ """
150
+ global _nse_warmed
151
+
152
+ session = _get_nse_session()
153
+ api_headers = dict(headers)
154
+ api_headers.update({
155
+ "Accept": "application/json, text/plain, */*",
156
+ "Referer": "https://www.nseindia.com/option-chain",
157
+ })
158
+
159
+ if "equity-stockIndices" in payload and "SECURITIES" in payload:
160
+ return _equity_stockindices_fallback(session, api_headers)
26
161
 
27
- if not os.path.exists("cookies.txt"):
28
- refresh_cookies()
162
+ try:
163
+ r = session.get(payload, headers=api_headers, timeout=30)
164
+ if r.status_code in (401, 403, 404, 429, 503):
165
+ # Could just be a stale/expired Akamai cookie jar -- re-warm once
166
+ # and retry before giving up.
167
+ _nse_warmed = False
168
+ session = _get_nse_session()
169
+ r = session.get(payload, headers=api_headers, timeout=30)
29
170
 
30
- encoded_url = encode(payload)
31
- cmd = f'curl -b cookies.txt "{encoded_url}" {curl_headers}'
32
- raw = os.popen(cmd).read()
171
+ if r.status_code != 200:
172
+ raise NSEEndpointError(f"nsefetch: HTTP {r.status_code} for {payload}")
33
173
 
34
174
  try:
35
- return json.loads(raw)
175
+ return r.json()
36
176
  except ValueError:
37
- refresh_cookies()
38
- raw = os.popen(cmd).read()
39
- try:
40
- return json.loads(raw)
41
- except ValueError:
42
- return {}
177
+ raise NSEEndpointError(
178
+ f"nsefetch: NSE returned a non-JSON body (length={len(r.text)}) for {payload}"
179
+ )
180
+ except NSEEndpointError:
181
+ raise
182
+ except Exception as e:
183
+ raise NSEEndpointError(f"nsefetch: request failed for {payload}: {e}")
43
184
 
44
- if(mode=='local'):
45
- def nsefetch(payload):
46
185
 
186
+ # ---------------------------------------------------------------------------
187
+ # Optional, lazily-imported Playwright cookie-harvest fallback.
188
+ #
189
+ # For most of the library, curl_cffi's TLS impersonation + the warm-up above
190
+ # is all that's needed -- it is NOT the same as "requests is blocked", and it
191
+ # is NOT, in practice, gated behind a real JS-solved Akamai sensor challenge
192
+ # for the endpoints this library actually calls today (verified live: a
193
+ # fully browser-solved cookie jar makes zero difference to the handful of
194
+ # genuinely-retired routes like /api/quote-equity or /api/equity-stockIndices
195
+ # -- they are simply dead/404, not JS-walled).
196
+ #
197
+ # This helper exists as a best-effort escape hatch for the rarer case where
198
+ # NSE *does* flip an endpoint to require a cookie only a real browser's JS
199
+ # engine can produce -- curl_cffi never executes JavaScript, so it cannot
200
+ # solve that kind of challenge itself. It is intentionally NOT imported at
201
+ # module load time and NOT wired automatically into nsefetch(): it is slow
202
+ # (it launches a real headless browser), and for the specific endpoints this
203
+ # library has found still blocked as of this writing (the historical
204
+ # bulk/block/short-deals and securityArchives routes), the block looks like a
205
+ # server-side 503/retirement rather than a missing-JS-cookie problem, so
206
+ # there's no evidence a browser visit would fix them either. Call
207
+ # nse_harvest_playwright_cookies() yourself, once, near the start of your
208
+ # script if you want to try it against an endpoint you believe is genuinely
209
+ # JS-walled; it injects the solved cookies into the same shared session
210
+ # nsefetch() uses for every call after that.
211
+ # ---------------------------------------------------------------------------
212
+
213
+ def nse_harvest_playwright_cookies(url="https://www.nseindia.com/option-chain", timeout_ms=45000):
214
+ """Launch a real headless Chromium (via Playwright), let it naturally
215
+ pass NSE's Akamai Bot Manager JS sensor challenge by visiting `url`, then
216
+ copy its solved cookie jar into the shared curl_cffi session nsefetch()
217
+ uses. Optional, best-effort, and NOT required for the vast majority of
218
+ this library's functions.
219
+
220
+ Requires: pip install playwright && playwright install chromium
221
+ """
222
+ try:
223
+ from playwright.sync_api import sync_playwright
224
+ except ImportError as e:
225
+ raise ImportError(
226
+ "nse_harvest_playwright_cookies() needs Playwright to drive a "
227
+ "real browser. Install it with: pip install playwright && "
228
+ "playwright install chromium"
229
+ ) from e
230
+
231
+ session = _get_nse_session()
232
+ harvested = {}
233
+ with sync_playwright() as p:
234
+ browser = p.chromium.launch(headless=True)
47
235
  try:
48
- s = requests.Session()
49
- s.get("https://www.nseindia.com", headers=headers, timeout=10)
50
- s.get("https://www.nseindia.com/option-chain", headers=headers, timeout=10)
51
- output = s.get(payload, headers=headers, timeout=10).json()
52
- except ValueError:
53
- output = {}
54
- return output
55
-
56
-
57
- # headers = {
58
- # 'Connection': 'keep-alive',
59
- # 'Cache-Control': 'max-age=0',
60
- # 'DNT': '1',
61
- # 'Upgrade-Insecure-Requests': '1',
62
- # 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36',
63
- # 'Sec-Fetch-User': '?1',
64
- # 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
65
- # 'Sec-Fetch-Site': 'none',
66
- # 'Sec-Fetch-Mode': 'navigate',
67
- # 'Accept-Encoding': 'gzip, deflate, br',
68
- # 'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
69
- # }
70
-
71
- #Rahul_Mittal's entry
72
- headers = {
73
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
74
- "accept-language": "en-US,en;q=0.9,en-IN;q=0.8,en-GB;q=0.7",
75
- "cache-control": "max-age=0",
76
- "priority": "u=0, i",
77
- "sec-ch-ua": '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"',
78
- "sec-ch-ua-mobile": "?0",
79
- "sec-ch-ua-platform": '"Windows"',
80
- "sec-fetch-dest": "document",
81
- "sec-fetch-mode": "navigate",
82
- "sec-fetch-site": "none",
83
- "sec-fetch-user": "?1",
84
- "upgrade-insecure-requests": "1",
85
- "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0"
86
- }
236
+ page = browser.new_page(user_agent=headers["User-Agent"])
237
+ page.goto("https://www.nseindia.com", timeout=timeout_ms)
238
+ page.wait_for_timeout(2000)
239
+ page.goto(url, timeout=timeout_ms)
240
+ page.wait_for_timeout(2000)
241
+ for c in page.context.cookies():
242
+ harvested[c["name"]] = c["value"]
243
+ finally:
244
+ browser.close()
245
+
246
+ for name, value in harvested.items():
247
+ try:
248
+ session.cookies.set(name, value, domain=".nseindia.com")
249
+ except Exception:
250
+ pass
251
+
252
+ global _nse_warmed
253
+ _nse_warmed = True # don't let the next nsefetch() stomp these with a plain re-warm
254
+ return harvested
255
+
87
256
 
257
+ def _nse_fetch_csv_text(url: str) -> str:
258
+ """Fetch a plain-text/CSV archive file through the shared curl_cffi
259
+ session (so these also benefit from the TLS-impersonation fix and don't
260
+ rely on plain `requests`/`pd.read_csv`'s bare urllib fetch, which
261
+ confirmed-live testing shows just hangs/times out against
262
+ nsearchives.nseindia.com, and is the less reliable of the two archive
263
+ hosts generally as NSE tightens Akamai enforcement over time)."""
264
+ session = _get_nse_session()
265
+ r = session.get(url, headers=headers, timeout=30)
266
+ if r.status_code != 200:
267
+ raise NSEEndpointError(f"nsefetch (csv): HTTP {r.status_code} for {url}")
268
+ return r.text
88
269
 
89
270
 
271
+ headers = {
272
+ 'Connection': 'keep-alive',
273
+ 'Cache-Control': 'max-age=0',
274
+ 'DNT': '1',
275
+ 'Upgrade-Insecure-Requests': '1',
276
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36',
277
+ 'Sec-Fetch-User': '?1',
278
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
279
+ 'Sec-Fetch-Site': 'none',
280
+ 'Sec-Fetch-Mode': 'navigate',
281
+ 'Accept-Encoding': 'gzip, deflate, br',
282
+ 'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
283
+ }
284
+
90
285
  #Curl headers
91
286
  curl_headers = ''' -H "authority: beta.nseindia.com" -H "cache-control: max-age=0" -H "dnt: 1" -H "upgrade-insecure-requests: 1" -H "user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36" -H "sec-fetch-user: ?1" -H "accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" -H "sec-fetch-site: none" -H "sec-fetch-mode: navigate" -H "accept-encoding: gzip, deflate, br" -H "accept-language: en-US,en;q=0.9,hi;q=0.8" --compressed'''
92
287
 
93
288
  run_time=datetime.datetime.now()
94
289
 
95
290
  #Constants
96
- indices = ['NIFTY','FINNIFTY','BANKNIFTY']
291
+ #
292
+ # Round 3: NSE has since added live F&O index-derivative products beyond
293
+ # the original 3 (confirmed live -- getSymbolDerivativesData&symbol=
294
+ # MIDCPNIFTY and &symbol=NIFTYNXT50 both return real, actively-traded
295
+ # CE/PE records right now, 2026-10). nse_quote()'s own `any(x in symbol
296
+ # for x in indices)` substring check already happened to work for these by
297
+ # accident (both names contain the substring "NIFTY"), but fnolist()'s
298
+ # exact-membership check (used by nse_quote_derivatives()) did not, which
299
+ # silently made nse_quote_ltp()/nse_quote_meta() return 0/{} for these
300
+ # symbols instead of real data. Listed explicitly here (not just relying on
301
+ # substring luck) so fnolist() membership works for them too.
302
+ indices = ['NIFTY','FINNIFTY','BANKNIFTY','MIDCPNIFTY','NIFTYNXT50']
97
303
 
98
304
  def running_status():
99
305
  start_now=datetime.datetime.now().replace(hour=9, minute=15, second=0, microsecond=0)
@@ -102,17 +308,10 @@ def running_status():
102
308
 
103
309
  #Getting FNO Symboles
104
310
  def fnolist():
105
- # df = pd.read_csv("https://www1.nseindia.com/content/fo/fo_mktlots.csv")
106
- # return [x.strip(' ') for x in df.drop(df.index[3]).iloc[:,1].to_list()]
107
-
108
311
  positions = nsefetch('https://www.nseindia.com/api/equity-stockIndices?index=SECURITIES%20IN%20F%26O')
109
-
110
- nselist=['NIFTY','NIFTYIT','BANKNIFTY']
111
-
112
- i=0
113
- for x in range(i, len(positions['data'])):
114
- nselist=nselist+[positions['data'][x]['symbol']]
115
-
312
+ nselist = indices.copy()
313
+ for x in range(len(positions['data'])):
314
+ nselist.append(positions['data'][x]['symbol'])
116
315
  return nselist
117
316
 
118
317
  def nsesymbolpurify(symbol):
@@ -121,15 +320,46 @@ def nsesymbolpurify(symbol):
121
320
 
122
321
  def nse_optionchain_scrapper(symbol):
123
322
  symbol = nsesymbolpurify(symbol)
124
- if any(x in symbol for x in indices):
125
- payload = nsefetch('https://www.nseindia.com/api/option-chain-indices?symbol='+symbol)
126
- else:
127
- payload = nsefetch('https://www.nseindia.com/api/option-chain-equities?symbol='+symbol)
323
+ # Using getSymbolDerivativesData as it provides all expiries and strikes in one go
324
+ url = f'https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolDerivativesData&symbol={symbol}'
325
+ payload = nsefetch(url)
326
+
327
+ # Transformation to match the "data" structure expected by pcr and other functions
328
+ if payload and 'data' in payload:
329
+ new_data = []
330
+ # Group by strikePrice and expiryDate to create a combined CE/PE structure if possible,
331
+ # or just provide the raw list if the consumers can handle it.
332
+ # The current pcr() handles a list of entries where each has CE/PE keys OR is the entry itself.
333
+
334
+ # Actually, let's restructure it to be more compatible with the expected 'data' format:
335
+ # a list of dictionaries, each having 'strikePrice', 'expiryDate', 'CE', 'PE'.
336
+ combined = {}
337
+ for entry in payload['data']:
338
+ sp = entry.get('strikePrice')
339
+ ed = entry.get('expiryDate')
340
+ ot = entry.get('optionType')
341
+ if not sp or not ed or ot == 'XX': continue
342
+
343
+ key = (sp, ed)
344
+ if key not in combined:
345
+ combined[key] = {'strikePrice': sp, 'expiryDate': ed, 'CE': None, 'PE': None}
346
+
347
+ combined[key][ot] = entry
348
+
349
+ payload['data'] = list(combined.values())
350
+
128
351
  return payload
129
352
 
130
353
 
131
354
  def oi_chain_builder(symbol,expiry="latest",oi_mode="full"):
132
355
 
356
+ if expiry == "latest":
357
+ dates = expiry_list(symbol, type="list")
358
+ if dates:
359
+ expiry = dates[0]
360
+ else:
361
+ return pd.DataFrame(), 0.0, ""
362
+
133
363
  payload = nse_optionchain_scrapper(symbol)
134
364
 
135
365
  if(oi_mode=='compact'):
@@ -138,109 +368,439 @@ def oi_chain_builder(symbol,expiry="latest",oi_mode="full"):
138
368
  col_names = ['CALLS_Chart','CALLS_OI','CALLS_Chng in OI','CALLS_Volume','CALLS_IV','CALLS_LTP','CALLS_Net Chng','CALLS_Bid Qty','CALLS_Bid Price','CALLS_Ask Price','CALLS_Ask Qty','Strike Price','PUTS_Bid Qty','PUTS_Bid Price','PUTS_Ask Price','PUTS_Ask Qty','PUTS_Net Chng','PUTS_LTP','PUTS_IV','PUTS_Volume','PUTS_Chng in OI','PUTS_OI','PUTS_Chart']
139
369
  oi_data = pd.DataFrame(columns = col_names)
140
370
 
141
- #oi_row = {'CALLS_OI':0, 'CALLS_Chng in OI':0, 'CALLS_Volume':0, 'CALLS_IV':0, 'CALLS_LTP':0, 'CALLS_Net Chng':0, 'Strike Price':0, 'PUTS_OI':0, 'PUTS_Chng in OI':0, 'PUTS_Volume':0, 'PUTS_IV':0, 'PUTS_LTP':0, 'PUTS_Net Chng':0}
142
- oi_row = {'CALLS_OI':0, 'CALLS_Chng in OI':0, 'CALLS_Volume':0, 'CALLS_IV':0, 'CALLS_LTP':0, 'CALLS_Net Chng':0, 'CALLS_Bid Qty':0,'CALLS_Bid Price':0,'CALLS_Ask Price':0,'CALLS_Ask Qty':0,'Strike Price':0, 'PUTS_OI':0, 'PUTS_Chng in OI':0, 'PUTS_Volume':0, 'PUTS_IV':0, 'PUTS_LTP':0, 'PUTS_Net Chng':0,'PUTS_Bid Qty':0,'PUTS_Bid Price':0,'PUTS_Ask Price':0,'PUTS_Ask Qty':0}
143
- if(expiry=="latest"):
144
- expiry = payload['records']['expiryDates'][0]
145
- m=0
146
- for m in range(len(payload['records']['data'])):
147
- if(payload['records']['data'][m]['expiryDate']==expiry):
148
- if(1>0):
149
- try:
150
- oi_row['CALLS_OI']=payload['records']['data'][m]['CE']['openInterest']
151
- oi_row['CALLS_Chng in OI']=payload['records']['data'][m]['CE']['changeinOpenInterest']
152
- oi_row['CALLS_Volume']=payload['records']['data'][m]['CE']['totalTradedVolume']
153
- oi_row['CALLS_IV']=payload['records']['data'][m]['CE']['impliedVolatility']
154
- oi_row['CALLS_LTP']=payload['records']['data'][m]['CE']['lastPrice']
155
- oi_row['CALLS_Net Chng']=payload['records']['data'][m]['CE']['change']
156
- if(oi_mode=='full'):
157
- oi_row['CALLS_Bid Qty']=payload['records']['data'][m]['CE']['bidQty']
158
- oi_row['CALLS_Bid Price']=payload['records']['data'][m]['CE']['bidprice']
159
- oi_row['CALLS_Ask Price']=payload['records']['data'][m]['CE']['askPrice']
160
- oi_row['CALLS_Ask Qty']=payload['records']['data'][m]['CE']['askQty']
161
- except KeyError:
162
- oi_row['CALLS_OI'], oi_row['CALLS_Chng in OI'], oi_row['CALLS_Volume'], oi_row['CALLS_IV'], oi_row['CALLS_LTP'],oi_row['CALLS_Net Chng']=0,0,0,0,0,0
163
- if(oi_mode=='full'):
164
- oi_row['CALLS_Bid Qty'],oi_row['CALLS_Bid Price'],oi_row['CALLS_Ask Price'],oi_row['CALLS_Ask Qty']=0,0,0,0
165
- pass
166
-
167
- oi_row['Strike Price']=payload['records']['data'][m]['strikePrice']
168
-
169
- try:
170
- oi_row['PUTS_OI']=payload['records']['data'][m]['PE']['openInterest']
171
- oi_row['PUTS_Chng in OI']=payload['records']['data'][m]['PE']['changeinOpenInterest']
172
- oi_row['PUTS_Volume']=payload['records']['data'][m]['PE']['totalTradedVolume']
173
- oi_row['PUTS_IV']=payload['records']['data'][m]['PE']['impliedVolatility']
174
- oi_row['PUTS_LTP']=payload['records']['data'][m]['PE']['lastPrice']
175
- oi_row['PUTS_Net Chng']=payload['records']['data'][m]['PE']['change']
176
- if(oi_mode=='full'):
177
- oi_row['PUTS_Bid Qty']=payload['records']['data'][m]['PE']['bidQty']
178
- oi_row['PUTS_Bid Price']=payload['records']['data'][m]['PE']['bidprice']
179
- oi_row['PUTS_Ask Price']=payload['records']['data'][m]['PE']['askPrice']
180
- oi_row['PUTS_Ask Qty']=payload['records']['data'][m]['PE']['askQty']
181
- except KeyError:
182
- oi_row['PUTS_OI'], oi_row['PUTS_Chng in OI'], oi_row['PUTS_Volume'], oi_row['PUTS_IV'], oi_row['PUTS_LTP'],oi_row['PUTS_Net Chng']=0,0,0,0,0,0
183
- if(oi_mode=='full'):
184
- oi_row['PUTS_Bid Qty'],oi_row['PUTS_Bid Price'],oi_row['PUTS_Ask Price'],oi_row['PUTS_Ask Qty']=0,0,0,0
185
- else:
186
- logging.info(m)
187
-
188
- if(oi_mode=='full'):
189
- oi_row['CALLS_Chart'],oi_row['PUTS_Chart']=0,0
190
- #oi_data = oi_data.append(oi_row, ignore_index=True)
191
- #oi_data = pd.concat([oi_data, oi_row], ignore_index=True)
192
- oi_data = pd.concat([oi_data, pd.DataFrame([oi_row])], ignore_index=True)
193
-
194
-
195
-
196
- oi_data['time_stamp']=payload['records']['timestamp']
197
- return oi_data,float(payload['records']['underlyingValue']),payload['records']['timestamp']
371
+ # We will populate these dynamically
372
+ rows_list = []
373
+
374
+ if 'expiryDates' not in payload:
375
+ # Fallback for new API structure
376
+ if(expiry=="latest"):
377
+ expiry = expiry_list(symbol, type="list")[0]
378
+ data_list = payload['data']
379
+ else:
380
+ # Legacy structure support
381
+ if(expiry=="latest"):
382
+ expiry = payload['records']['expiryDates'][0]
383
+ data_list = payload['records']['data']
198
384
 
385
+ for m in range(len(data_list)):
386
+ current_expiry_str = data_list[m].get('expiryDates') or data_list[m].get('expiryDate')
387
+ try:
388
+ # Convert both to date objects for robust comparison
389
+ if "-" in current_expiry_str:
390
+ parts = current_expiry_str.split("-")
391
+ if parts[1].isdigit(): fmt = "%d-%m-%Y"
392
+ else: fmt = "%d-%b-%Y"
393
+ curr_date = datetime.datetime.strptime(current_expiry_str, fmt).date()
394
+
395
+ parts_exp = expiry.split("-")
396
+ if parts_exp[1].isdigit(): fmt_exp = "%d-%m-%Y"
397
+ else: fmt_exp = "%d-%b-%Y"
398
+ exp_date = datetime.datetime.strptime(expiry, fmt_exp).date()
399
+ match = (curr_date == exp_date)
400
+ else:
401
+ match = (current_expiry_str == expiry)
402
+ except:
403
+ match = (current_expiry_str == expiry)
404
+
405
+ if match:
406
+ oi_row = {col: 0 for col in col_names}
407
+ oi_row['Strike Price'] = data_list[m]['strikePrice']
408
+
409
+ for side in ['CE', 'PE']:
410
+ prefix = f"{'CALLS' if side == 'CE' else 'PUTS'}_"
411
+ if side in data_list[m] and data_list[m][side] is not None:
412
+ d = data_list[m][side]
413
+ oi_row[prefix + 'OI'] = d.get('openInterest', 0)
414
+ oi_row[prefix + 'Chng in OI'] = d.get('changeinOpenInterest', 0)
415
+ oi_row[prefix + 'Volume'] = d.get('totalTradedVolume', 0)
416
+ oi_row[prefix + 'IV'] = d.get('impliedVolatility', 0)
417
+ oi_row[prefix + 'LTP'] = d.get('lastPrice', 0)
418
+ oi_row[prefix + 'Net Chng'] = d.get('change', 0)
419
+
420
+ if oi_mode == 'full':
421
+ # New API key mapping
422
+ oi_row[prefix + 'Bid Qty'] = d.get('buyQuantity1', d.get('bidQty', 0))
423
+ oi_row[prefix + 'Bid Price'] = d.get('buyPrice1', d.get('bidprice', 0))
424
+ oi_row[prefix + 'Ask Price'] = d.get('sellPrice1', d.get('askPrice', 0))
425
+ oi_row[prefix + 'Ask Qty'] = d.get('sellQuantity1', d.get('askQty', 0))
426
+ oi_row[prefix + 'Chart'] = 0
427
+
428
+ rows_list.append(oi_row)
429
+
430
+ oi_data = pd.DataFrame(rows_list)
431
+ timestamp = payload.get('timestamp', payload.get('records', {}).get('timestamp', ''))
432
+ underlyingValue = payload.get('underlyingValue', payload.get('records', {}).get('underlyingValue', 0))
433
+
434
+ # github.com/aeron7/nsepython issue #80: the current getSymbolDerivativesData
435
+ # payload carries no top-level (or 'records') underlyingValue at all -- it
436
+ # only lives inside each individual CE/PE leaf record. Dig it out of there
437
+ # if the top-level lookup above came back empty.
438
+ if not underlyingValue and data_list:
439
+ for entry in data_list:
440
+ for side in ('CE', 'PE'):
441
+ leaf = entry.get(side)
442
+ if leaf and leaf.get('underlyingValue'):
443
+ underlyingValue = leaf['underlyingValue']
444
+ break
445
+ if underlyingValue:
446
+ break
447
+
448
+ oi_data['time_stamp'] = timestamp
449
+ return oi_data, float(underlyingValue or 0), timestamp
450
+
451
+
452
+ def nse_quote_derivatives(symbol):
453
+ symbol = nsesymbolpurify(symbol)
454
+ # Round 3 bug fix: the membership check below was correctly
455
+ # case-insensitive (symbol.upper() in fnolist()) but the URL built right
456
+ # after it used the original, un-uppercased `symbol` -- the live
457
+ # getSymbolDerivativesData endpoint is itself case-sensitive, so a
458
+ # lowercase/mixed-case symbol (e.g. "sbin", "banknifty") silently came
459
+ # back as {'data': [], 'timestamp': ''} (a plausible-looking empty
460
+ # response, not an error) instead of real data. Uppercase once and reuse
461
+ # it for both the check and the fetch.
462
+ symbol_u = symbol.upper()
463
+ if symbol_u in fnolist():
464
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolDerivativesData&symbol='+symbol_u)
465
+ return payload
466
+ else:
467
+ return {"error": f"{symbol} is not in derivatives list."}
199
468
 
200
469
  def nse_quote(symbol,section=""):
201
470
  #https://forum.unofficed.com/t/nsetools-get-quote-is-not-fetching-delivery-data-and-delivery-can-you-include-this-as-part-of-feature-request/1115/4
471
+ #
472
+ # section='' (default) already returns the FULL detail in one call --
473
+ # metaData/secInfo/priceInfo/orderBook/tradeInfo are all present together
474
+ # in that single payload. Only pass section= for a genuine sub-slice:
475
+ # 'trade_info' -- order-book depth / VaR margin slice
476
+ # 'preOpenMarket' -- the day's 09:00-09:08 IST pre-open auction ladder
477
+ # Round 3 research (checked against unofficed.com's own docs, the
478
+ # hi-imcodeman/stock-nse-india reference TS implementation, and this
479
+ # project's entire GitHub issue history) found no evidence NSE's old API
480
+ # ever accepted any OTHER section= value -- 'preOpenMarket'/'metadata'/
481
+ # 'industryInfo'/'info'/'priceInfo'/'securityInfo' were never alternate
482
+ # query values, just top-level keys inside the un-sectioned response
483
+ # section='' already returns.
202
484
  symbol = nsesymbolpurify(symbol)
485
+ # Round 3 bug fix: this substring check used to be case-sensitive, so a
486
+ # lowercase/mixed-case index name (e.g. "banknifty") fell through to the
487
+ # equity branch below and 404'd (banknifty isn't an equity symbol).
488
+ symbol_u = symbol.upper()
203
489
 
204
490
  if(section==""):
205
- if any(x in symbol for x in fnolist()):
206
- payload = nsefetch('https://www.nseindia.com/api/quote-derivative?symbol='+symbol)
491
+ if any(x in symbol_u for x in indices):
492
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolDerivativesData&symbol='+symbol_u)
207
493
  else:
208
- payload = nsefetch('https://www.nseindia.com/api/quote-equity?symbol='+symbol)
209
- return payload
210
-
211
- if(section!=""):
212
- payload = nsefetch('https://www.nseindia.com/api/quote-equity?symbol='+symbol+'&section='+section)
494
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol_u)
213
495
  return payload
214
496
 
497
+ if(section=="trade_info"):
498
+ # The old /api/quote-equity?section=trade_info route is dead on the
499
+ # live site (confirmed HTTP 403, even through the fully-warmed
500
+ # curl_cffi session round 1 built). But every category of data the
501
+ # old endpoint used to return is already present, just reshuffled,
502
+ # inside the NEW working GetQuoteApi?functionName=getSymbolData
503
+ # response this function's section=="" branch already fetches --
504
+ # confirmed field-by-field against the real, documented old
505
+ # response shape (EquityTradeInfo: marketDeptOrderBook.{bid,ask,
506
+ # tradeInfo,valueAtRisk} + securityWiseDP), so this is a pure
507
+ # remap/slice of data already being fetched, not a new network call.
508
+ #
509
+ # Two small fidelity gaps versus the old route, both because the
510
+ # source data for them no longer exists anywhere in the new
511
+ # response (not a mapping oversight):
512
+ # - noBlockDeals/bulkBlockDeals: the new endpoint carries no
513
+ # block-deal info at all -> defaulted to True/[] (i.e. "no
514
+ # block deals known"), not derived from a live block-deal
515
+ # check. Use nse_blockdeal()/get_blockdeals() directly if you
516
+ # need real block-deal data.
517
+ # - securityWiseDP.seriesRemarks: no equivalent field exists in
518
+ # the new response -> always None, same as it is for most
519
+ # symbols on the old route anyway.
520
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol_u)
521
+ if 'equityResponse' not in payload or not payload['equityResponse']:
522
+ raise NSEEndpointError(
523
+ f"nse_quote({symbol!r}, section='trade_info'): no "
524
+ f"equityResponse in payload -- this section is only "
525
+ f"meaningful for an equity symbol (not an index/derivative "
526
+ f"underlying)."
527
+ )
528
+ eq = payload['equityResponse'][0]
529
+ ob = eq.get('orderBook', {})
530
+ md = eq.get('metaData', {})
531
+ ti = eq.get('tradeInfo', {})
532
+ pi = eq.get('priceInfo', {})
533
+ si = eq.get('secInfo', {})
534
+
535
+ bid = [{"price": ob.get(f"buyPrice{i}"), "quantity": ob.get(f"buyQuantity{i}")} for i in range(1, 6)]
536
+ ask = [{"price": ob.get(f"sellPrice{i}"), "quantity": ob.get(f"sellQuantity{i}")} for i in range(1, 6)]
537
+
538
+ return {
539
+ "noBlockDeals": True,
540
+ "bulkBlockDeals": [],
541
+ "marketDeptOrderBook": {
542
+ "totalBuyQuantity": ob.get("totalBuyQuantity"),
543
+ "totalSellQuantity": ob.get("totalSellQuantity"),
544
+ "open": md.get("open"),
545
+ "bid": bid,
546
+ "ask": ask,
547
+ "tradeInfo": {
548
+ "totalTradedVolume": ti.get("totalTradedVolume"),
549
+ "totalTradedValue": ti.get("totalTradedValue"),
550
+ "totalMarketCap": ti.get("totalMarketCap"),
551
+ "ffmc": ti.get("ffmc"),
552
+ "impactCost": ti.get("impactCost"),
553
+ "cmDailyVolatility": pi.get("cmDailyVolatility"),
554
+ "cmAnnualVolatility": pi.get("cmAnnualVolatility"),
555
+ "marketLot": ti.get("marketLot"),
556
+ "activeSeries": ti.get("series"),
557
+ },
558
+ "valueAtRisk": {
559
+ "securityVar": si.get("securityvar"),
560
+ "indexVar": si.get("indexvar"),
561
+ "varMargin": si.get("varMargin"),
562
+ "extremeLossMargin": si.get("extremelossMargin"),
563
+ "adhocMargin": si.get("adhocMargin"),
564
+ "applicableMargin": si.get("applicableMargin"),
565
+ },
566
+ },
567
+ "securityWiseDP": {
568
+ "quantityTraded": ti.get("quantitytraded"),
569
+ "deliveryQuantity": ti.get("deliveryquantity"),
570
+ "deliveryToTradedQuantity": ti.get("deliveryToTradedQuantity"),
571
+ "seriesRemarks": None,
572
+ "secWiseDelPosDate": ti.get("secwisedelposdate"),
573
+ },
574
+ }
215
575
 
216
- def nse_expirydetails(payload,i=0): #Can make problem. Use nse_expirydetails_by_symbol()
217
-
218
- expiry_dates = payload['records']['expiryDates']
219
- expiry_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
220
- expiry_dates = [date.strftime("%d-%b-%Y") for date in expiry_dates if date >= datetime.datetime.now().date()]
221
- currentExpiry=expiry_dates[i]
222
- currentExpiry = datetime.datetime.strptime(currentExpiry,'%d-%b-%Y').date() # converting json datetime to alice datetime
223
- date_today = run_time.strftime('%Y-%m-%d') # required to remove hh:mm:ss
224
- date_today = datetime.datetime.strptime(date_today,'%Y-%m-%d').date()
225
- dte = (currentExpiry - date_today).days
226
- return currentExpiry,dte
227
-
228
- def pcr(payload,inp='0'):
576
+ if(section=="preOpenMarket"):
577
+ # Round 3: NSE's old /api/quote-equity&section=preOpenMarket route
578
+ # is dead (confirmed HTTP 403, same wall as every other section
579
+ # value below), but a genuinely live, working replacement exists:
580
+ # /api/market-data-pre-open?key=ALL returns ALL ~2200 symbols' real
581
+ # pre-open order-book ladders in one shot. Filter it down to the
582
+ # requested symbol instead of fabricating anything.
583
+ #
584
+ # Caveat (documented, not disguised): this is literally the
585
+ # 09:00-09:08 IST pre-open auction snapshot, not continuous/live
586
+ # intraday data -- checked well after market open it will look
587
+ # "stale" because it reflects that morning's last pre-open auction.
588
+ # That is the real, live content of this feed, not a bug.
589
+ payload = nsefetch('https://www.nseindia.com/api/market-data-pre-open?key=ALL')
590
+ for entry in payload.get('data', []):
591
+ if entry.get('metadata', {}).get('symbol') == symbol_u:
592
+ return entry['detail']['preOpenMarket']
593
+ raise NSEEndpointError(
594
+ f"nse_quote({symbol!r}, section='preOpenMarket'): {symbol_u} was "
595
+ f"not found in today's pre-open-market list -- either it isn't a "
596
+ f"pre-open-eligible series, or today's pre-open session hasn't "
597
+ f"run/populated yet."
598
+ )
599
+
600
+ # Round 3: every other section value (e.g. the old 'metadata'/
601
+ # 'industryInfo'/'info'/'priceInfo'/'securityInfo') used to fall through
602
+ # here and hit the dead /api/quote-equity&section=X route -- a ~5s
603
+ # double-retry ending in a misleading HTTP 403, for a route that was
604
+ # never real in the first place. Checked against unofficed.com's own
605
+ # docs, the hi-imcodeman/stock-nse-india reference implementation, and
606
+ # this project's full GitHub issue history: NSE's API never accepted
607
+ # any section value beyond 'trade_info' -- those other names are just
608
+ # top-level keys inside the un-sectioned response, already returned in
609
+ # full by nse_quote(symbol) (section=""). Raise immediately and clearly
610
+ # instead of a slow, confusing network round-trip to a route that was
611
+ # never real.
612
+ raise ValueError(
613
+ f"nse_quote: unsupported section={section!r}; only '' (full quote), "
614
+ f"'trade_info', and 'preOpenMarket' are supported -- NSE's old "
615
+ f"quote-equity API never had other section values. section='' "
616
+ f"already returns the full detail (metaData/secInfo/priceInfo/"
617
+ f"orderBook/tradeInfo all together)."
618
+ )
619
+ def nse_expirydetails(payload, i=0, symbol=None):
620
+ expiry_dates = []
621
+ if 'records' in payload:
622
+ expiry_dates = payload['records']['expiryDates']
623
+ elif 'expiryDates' in payload:
624
+ expiry_dates = payload['expiryDates']
625
+ elif 'data' in payload:
626
+ unique_dates = set()
627
+ for entry in payload['data']:
628
+ if 'expiryDate' in entry:
629
+ unique_dates.add(entry['expiryDate'])
630
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
631
+
632
+ # Filter future dates
633
+ future_expiry_dates = []
634
+ if expiry_dates:
635
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
636
+ future_expiry_dates = sorted([date.strftime("%d-%b-%Y") for date in temp_dates if date >= datetime.datetime.now().date()], key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
637
+
638
+ # Fallback to expiry_list if i is out of range and we can determine the symbol
639
+ if i >= len(future_expiry_dates):
640
+ if not symbol and 'data' in payload and len(payload['data']) > 0:
641
+ # Try to extract symbol from payload data
642
+ first_entry = payload['data'][0]
643
+ symbol = first_entry.get('symbol')
644
+ if not symbol:
645
+ if 'CE' in first_entry and first_entry['CE']:
646
+ symbol = first_entry['CE'].get('underlying')
647
+ elif 'PE' in first_entry and first_entry['PE']:
648
+ symbol = first_entry['PE'].get('underlying')
649
+
650
+ if symbol:
651
+ dates = expiry_list(symbol, type="list")
652
+ if dates:
653
+ # Filter future dates from expiry_list as well
654
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in dates]
655
+ future_expiry_dates = sorted([date.strftime("%d-%b-%Y") for date in temp_dates if date >= datetime.datetime.now().date()], key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
656
+
657
+ if i >= len(future_expiry_dates):
658
+ return None, None
659
+
660
+ currentExpiry = future_expiry_dates[i]
661
+ currentExpiry_dt = datetime.datetime.strptime(currentExpiry, '%d-%b-%Y').date()
662
+ date_today = run_time.date()
663
+ dte = (currentExpiry_dt - date_today).days
664
+ return currentExpiry_dt, dte
665
+
666
+ def _pcr_entry_oi(entry):
667
+ """Round 3 fix: pcr() must accept BOTH option-chain shapes this library
668
+ can hand it --
669
+ - the NESTED per-strike shape nse_optionchain_scrapper()/option_chain()
670
+ return: {'strikePrice','expiryDate','CE':{...},'PE':{...}}
671
+ - the FLAT per-contract-leg shape nse_quote_derivatives()/nse_quote()
672
+ (for derivatives) actually return now: each entry IS one leg
673
+ directly, with optionType=='CE'/'PE' and openInterest at the TOP
674
+ LEVEL -- there is no nested entry['CE']/entry['PE'] in this shape at
675
+ all.
676
+ Before this fix, pcr()'s aggregation loop only ever read entry['CE']/
677
+ entry['PE'], so feeding it the flat shape matched the target expiry
678
+ (found_data=True) but silently added 0 to both ce_oi/pe_oi every time,
679
+ returning a plausible-looking-but-wrong pcr of 0.0 instead of raising.
680
+ Returns (ce_oi_contribution, pe_oi_contribution) for one entry.
681
+ """
682
+ if ('CE' in entry) or ('PE' in entry):
683
+ ce = entry['CE'].get('openInterest', 0) or 0 if entry.get('CE') else 0
684
+ pe = entry['PE'].get('openInterest', 0) or 0 if entry.get('PE') else 0
685
+ return ce, pe
686
+ if entry.get('optionType') == 'CE':
687
+ return entry.get('openInterest', 0) or 0, 0
688
+ if entry.get('optionType') == 'PE':
689
+ return 0, entry.get('openInterest', 0) or 0
690
+ return 0, 0
691
+
692
+ def pcr(payload, inp=0):
229
693
  ce_oi = 0
230
694
  pe_oi = 0
231
- for i in payload['records']['data']:
232
- if i['expiryDate'] == payload['records']['expiryDates'][inp]:
695
+
696
+ # Identify the data and expiry dates based on structure
697
+ if 'records' in payload:
698
+ # Legacy structure
699
+ data_list = payload['records']['data']
700
+ expiry_dates = payload['records']['expiryDates']
701
+ elif 'data' in payload:
702
+ # New structure (covers BOTH the nested per-strike shape and the
703
+ # flat per-contract-leg shape -- see _pcr_entry_oi() above)
704
+ data_list = payload['data']
705
+ # Extract unique sorted expiry dates from data
706
+ unique_dates = set()
707
+ for entry in data_list:
708
+ ed = entry.get('expiryDate') or entry.get('expiryDates')
709
+ if ed:
710
+ unique_dates.add(ed)
711
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%m-%Y") if "-" in x and x.split("-")[1].isdigit() else datetime.datetime.strptime(x, "%d-%b-%Y"))
712
+ else:
713
+ # Round 3: a payload with neither 'records' nor 'data' isn't a
714
+ # recognizable option-chain/derivatives shape at all -- returning
715
+ # 0.0 here used to silently look like "zero put/call OI" instead of
716
+ # "this isn't option-chain data". Raise clearly instead.
717
+ raise NSEEndpointError(
718
+ "pcr(): payload has neither 'records' nor 'data' -- pass the "
719
+ "output of option_chain()/nse_optionchain_scrapper(), "
720
+ "nse_quote_derivatives(), or nse_quote() for a derivatives "
721
+ "symbol."
722
+ )
723
+
724
+ if not expiry_dates or inp >= len(expiry_dates):
725
+ # Requested index is outside the current payload's scope.
726
+ # Check if we can fetch more data for this specific symbol.
727
+ symbol = payload.get('symbol') or payload.get('records', {}).get('symbol')
728
+ if not symbol and 'data' in payload and len(payload['data']) > 0:
729
+ first = payload['data'][0]
730
+ symbol = (first.get('symbol') or first.get('underlying')
731
+ or (first.get('CE') and first['CE'].get('underlying'))
732
+ or (first.get('PE') and first['PE'].get('underlying')))
733
+
734
+ if symbol and inp > 0:
735
+ # Fetch all expiries to find the target one
736
+ all_expiries = expiry_list(symbol, type="list")
737
+ if inp < len(all_expiries):
738
+ target = all_expiries[inp]
739
+ # Fetch specific expiry data using getOptionChainData
740
+ url = f'https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getOptionChainData&symbol={nsesymbolpurify(symbol)}&params=expiryDate={target}'
741
+ new_payload = nsefetch(url)
742
+ if new_payload and 'data' in new_payload:
743
+ for entry in new_payload['data']:
744
+ ce_oi += entry.get('CE', {}).get('openInterest', 0) if entry.get('CE') else 0
745
+ pe_oi += entry.get('PE', {}).get('openInterest', 0) if entry.get('PE') else 0
746
+ if ce_oi > 0: return pe_oi / ce_oi
747
+ return 0.0
748
+
749
+ target_expiry = expiry_dates[inp]
750
+
751
+ found_data = False
752
+ for i in data_list:
753
+ curr_exp = i.get('expiryDate') or i.get('expiryDates')
754
+ if curr_exp == target_expiry:
755
+ found_data = True
233
756
  try:
234
- ce_oi += i['CE']['openInterest']
235
- pe_oi += i['PE']['openInterest']
236
- except KeyError:
757
+ c, p = _pcr_entry_oi(i)
758
+ ce_oi += c
759
+ pe_oi += p
760
+ except (KeyError, TypeError):
237
761
  pass
762
+
763
+ # If we didn't find any data for the target expiry in the payload,
764
+ # it means the payload was filtered (e.g. by the scrapper). Fetch it now.
765
+ if not found_data:
766
+ symbol = payload.get('symbol') or payload.get('records', {}).get('symbol')
767
+ if symbol:
768
+ url = f'https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getOptionChainData&symbol={nsesymbolpurify(symbol)}&params=expiryDate={target_expiry}'
769
+ new_payload = nsefetch(url)
770
+ if new_payload and 'data' in new_payload:
771
+ for entry in new_payload['data']:
772
+ ce_oi += entry.get('CE', {}).get('openInterest', 0) if entry.get('CE') else 0
773
+ pe_oi += entry.get('PE', {}).get('openInterest', 0) if entry.get('PE') else 0
774
+
775
+ if ce_oi == 0:
776
+ return 0.0
777
+
238
778
  return pe_oi / ce_oi
239
779
 
240
780
  #forum.unofficed.com/t/unable-to-find-nse-quote-meta-api/702/4
241
781
  #Refer https://forum.unofficed.com/t/changed-the-nse-quote-ltp-function/1276
242
782
  def nse_quote_ltp(symbol,expiryDate="latest",optionType="-",strikePrice=0):
243
- payload = nse_quote(symbol)
783
+ # Round 3 bug fix: this index-routing check was case-sensitive, so e.g.
784
+ # nse_quote_ltp("banknifty") (no optionType) missed the indices branch,
785
+ # fell through to the equity getSymbolData endpoint, and 404'd. Checking
786
+ # against symbol.upper() routes it correctly regardless of case.
787
+ if(optionType!="-"):
788
+ payload = nse_quote_derivatives(symbol)
789
+ else:
790
+ if any(x in symbol.upper() for x in indices):
791
+ payload = nse_quote_derivatives(symbol)
792
+ else:
793
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol.upper())
794
+
795
+ lastPrice = 0
796
+
797
+ if(optionType=="-"):
798
+ if 'equityResponse' in payload and len(payload['equityResponse']) > 0:
799
+ lastPrice = payload['equityResponse'][0]['orderBook']['lastPrice']
800
+ elif 'data' in payload and len(payload['data']) > 0:
801
+ # For indices, underlyingValue in derivative payload is the current index LTP
802
+ lastPrice = payload['data'][0].get('underlyingValue')
803
+ return lastPrice
244
804
 
245
805
  meta = "Options"
246
806
  if(optionType=="Fut"): meta = "Futures"
@@ -248,36 +808,68 @@ def nse_quote_ltp(symbol,expiryDate="latest",optionType="-",strikePrice=0):
248
808
  if(optionType=="CE"):optionType="Call"
249
809
 
250
810
  if(expiryDate=="latest") or (expiryDate=="next"):
251
-
252
- if(meta=="Futures"):
253
- selected_key = next((key for key in payload["expiryDatesByInstrument"] if "futures" in key.lower()), None)
254
- if(meta=="Options"):
255
- selected_key = next((key for key in payload["expiryDatesByInstrument"] if "options" in key.lower()), None)
256
-
257
- expiry_dates=payload["expiryDatesByInstrument"][selected_key]
258
- expiry_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
259
- expiry_dates = [date.strftime("%d-%b-%Y") for date in expiry_dates if date >= datetime.datetime.now().date()]
260
- if(expiryDate=="latest"): expiryDate=expiry_dates[0]
261
- if(expiryDate=="next"): expiryDate=expiry_dates[1]
811
+ i = 0 if expiryDate=="latest" else 1
812
+ expiry_dates = []
813
+
814
+ # Extract from new FNO payload structure
815
+ if 'data' in payload:
816
+ unique_dates = set()
817
+ for entry in payload['data']:
818
+ if 'expiryDate' in entry:
819
+ it = entry.get('instrumentType', '')
820
+ if (meta == "Futures" and "FUT" in it) or (meta == "Options" and "OPT" in it):
821
+ unique_dates.add(entry['expiryDate'])
822
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
823
+
824
+ # Filter future dates
825
+ future_expiry_dates = []
826
+ if expiry_dates:
827
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
828
+ future_expiry_dates = sorted([date.strftime("%d-%b-%Y") for date in temp_dates if date >= datetime.datetime.now().date()], key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
829
+
830
+ # Fallback to expiry_list
831
+ if i >= len(future_expiry_dates):
832
+ dates = expiry_list(symbol, type="list")
833
+ if dates:
834
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in dates]
835
+ future_expiry_dates = sorted([date.strftime("%d-%b-%Y") for date in temp_dates if date >= datetime.datetime.now().date()], key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
836
+
837
+ if i < len(future_expiry_dates):
838
+ expiryDate = future_expiry_dates[i]
262
839
 
263
840
 
264
841
  if(optionType!="-"):
265
- for i in payload['stocks']:
266
- if meta in i['metadata']['instrumentType']:
267
- #print(i['metadata'])
842
+ data_list = payload.get('data', [])
843
+ for i in data_list:
844
+ # Check instrument type in identifier or metadata if present
845
+ if meta == "Futures":
846
+ is_match = "FUT" in i.get('instrumentType', '')
847
+ else:
848
+ is_match = "OPT" in i.get('instrumentType', '')
849
+
850
+ if is_match:
268
851
  if(optionType=="Fut"):
269
- if(i['metadata']['expiryDate']==expiryDate):
270
- lastPrice = i['metadata']['lastPrice']
852
+ if(i.get('expiryDate')==expiryDate):
853
+ lastPrice = i.get('lastPrice')
854
+ break
271
855
 
272
856
  if((optionType=="Put")or(optionType=="Call")):
273
- if (i['metadata']["expiryDate"]==expiryDate):
274
- if (i['metadata']["optionType"]==optionType):
275
- if (i['metadata']["strikePrice"]==strikePrice):
276
- #print(i['metadata'])
277
- lastPrice = i['metadata']['lastPrice']
278
-
279
- if(optionType=="-"):
280
- lastPrice = payload['underlyingValue']
857
+ # Some APIs have optionType as 'PE'/'CE' or 'Put'/'Call'
858
+ p_opt_type = i.get('optionType')
859
+ if p_opt_type == "PE": p_opt_type = "Put"
860
+ if p_opt_type == "CE": p_opt_type = "Call"
861
+
862
+ if (i.get("expiryDate")==expiryDate):
863
+ if (p_opt_type==optionType):
864
+ # strikePrice in payload is often string with padding
865
+ try:
866
+ p_strike = float(str(i.get("strikePrice")).strip())
867
+ except:
868
+ p_strike = 0
869
+
870
+ if (p_strike==float(strikePrice)):
871
+ lastPrice = i.get('lastPrice')
872
+ break
281
873
 
282
874
  return lastPrice
283
875
 
@@ -292,58 +884,212 @@ def nse_quote_ltp(symbol,expiryDate="latest",optionType="-",strikePrice=0):
292
884
  # print(nse_quote_ltp("RELIANCE","next","PE",2300))
293
885
 
294
886
  def nse_quote_meta(symbol,expiryDate="latest",optionType="-",strikePrice=0):
295
- payload = nse_quote(symbol)
296
- #https://stackoverflow.com/questions/7961363/removing-duplicates-in-lists
297
- #https://stackoverflow.com/questions/19199984/sort-a-list-in-python
887
+ # Round 3 bug fix: case-sensitive index routing (see nse_quote_ltp()).
888
+ if(optionType!="-"):
889
+ payload = nse_quote_derivatives(symbol)
890
+ else:
891
+ if any(x in symbol.upper() for x in indices):
892
+ payload = nse_quote_derivatives(symbol)
893
+ else:
894
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol.upper())
298
895
 
299
- #BankNIFTY and NIFTY has weekly options. Using this Jugaad which has primary base of assumption that Reliance will not step out of FNO.
300
- #forum.unofficed.com/t/unable-to-find-nse-quote-meta-api/702/4
301
- if((symbol in indices) and (optionType=="Fut")):
302
- dates = expiry_list("RELIANCE","list")
303
- if(expiryDate=="latest"): expiryDate=dates[0]
304
- if(expiryDate=="next"): expiryDate=dates[1]
896
+ metadata = {}
305
897
 
306
- if(expiryDate=="latest") or (expiryDate=="next"):
307
- dates=list(set((payload["expiryDates"])))
308
- dates.sort(key = lambda date: datetime.datetime.strptime(date, '%d-%b-%Y'))
309
- if(expiryDate=="latest"): expiryDate=dates[0]
310
- if(expiryDate=="next"): expiryDate=dates[1]
898
+ if(optionType=="-"):
899
+ if 'equityResponse' in payload and len(payload['equityResponse']) > 0:
900
+ metadata = payload['equityResponse'][0].get('metaData', {})
901
+ elif 'data' in payload and len(payload['data']) > 0:
902
+ # Round 3 fix (known gap #2): index/derivative underlyings go
903
+ # through nse_quote_derivatives()'s flat per-contract-leg shape,
904
+ # which has no 'equityResponse'/'metaData' at all -- this used to
905
+ # silently fall through to the {} default, making every index
906
+ # symbol look like "no data" instead of "wrong shape for this
907
+ # accessor". There is no equity-style open/high/low/close
908
+ # snapshot anywhere in this payload for an index (only
909
+ # underlyingValue/underlying/timestamp per leg), so we return the
910
+ # real fields that DO exist instead of fabricating the rest.
911
+ first = payload['data'][0]
912
+ metadata = {
913
+ "symbol": first.get('underlying') or symbol.upper(),
914
+ "underlyingValue": first.get('underlyingValue'),
915
+ "timestamp": payload.get('timestamp'),
916
+ }
917
+ return metadata
311
918
 
312
919
  meta = "Options"
313
920
  if(optionType=="Fut"): meta = "Futures"
314
921
  if(optionType=="PE"):optionType="Put"
315
922
  if(optionType=="CE"):optionType="Call"
316
923
 
924
+ if(expiryDate=="latest") or (expiryDate=="next"):
925
+ i = 0 if expiryDate=="latest" else 1
926
+ expiry_dates = []
927
+ if 'data' in payload:
928
+ unique_dates = set()
929
+ for entry in payload['data']:
930
+ if 'expiryDate' in entry:
931
+ it = entry.get('instrumentType', '')
932
+ if (meta == "Futures" and "FUT" in it) or (meta == "Options" and "OPT" in it):
933
+ unique_dates.add(entry['expiryDate'])
934
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
935
+
936
+ future_expiry_dates = []
937
+ if expiry_dates:
938
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
939
+ future_expiry_dates = sorted([date.strftime("%d-%b-%Y") for date in temp_dates if date >= datetime.datetime.now().date()], key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
940
+
941
+ if i >= len(future_expiry_dates):
942
+ dates = expiry_list(symbol, type="list")
943
+ if dates:
944
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in dates]
945
+ future_expiry_dates = sorted([date.strftime("%d-%b-%Y") for date in temp_dates if date >= datetime.datetime.now().date()], key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
946
+
947
+ if i < len(future_expiry_dates):
948
+ expiryDate = future_expiry_dates[i]
949
+
950
+ # print(f"DEBUG: Calculated expiryDate={expiryDate}, meta={meta}, optionType={optionType}")
951
+
317
952
  if(optionType!="-"):
318
- for i in payload['stocks']:
319
- if meta in i['metadata']['instrumentType']:
320
- #print(i['metadata'])
953
+ data_list = payload.get('data', [])
954
+ # print(f"DEBUG: Searching in {len(data_list)} items")
955
+ for i in data_list:
956
+ if meta == "Futures":
957
+ is_match = "FUT" in i.get('instrumentType', '')
958
+ else:
959
+ is_match = "OPT" in i.get('instrumentType', '')
960
+
961
+ if is_match:
321
962
  if(optionType=="Fut"):
322
- if(i['metadata']['expiryDate']==expiryDate):
323
- metadata = i['metadata']
963
+ if(i.get('expiryDate')==expiryDate):
964
+ metadata = i
965
+ break
324
966
 
325
967
  if((optionType=="Put")or(optionType=="Call")):
326
- if (i['metadata']["expiryDate"]==expiryDate):
327
- if (i['metadata']["optionType"]==optionType):
328
- if (i['metadata']["strikePrice"]==strikePrice):
329
- #print(i['metadata'])
330
- metadata = i['metadata']
331
-
332
- if(optionType=="-"):
333
- metadata = i['metadata']
968
+ p_opt_type = i.get('optionType')
969
+ if p_opt_type == "PE": p_opt_type = "Put"
970
+ if p_opt_type == "CE": p_opt_type = "Call"
971
+
972
+ if (i.get("expiryDate")==expiryDate):
973
+ if (p_opt_type==optionType):
974
+ try:
975
+ p_strike = float(str(i.get("strikePrice")).strip())
976
+ except:
977
+ p_strike = 0
978
+
979
+ if (p_strike==float(strikePrice)):
980
+ metadata = i
981
+ break
334
982
 
335
983
  return metadata
336
984
 
337
985
  def nse_optionchain_ltp(payload,strikePrice,optionType,inp=0,intent=""):
338
- expiry_dates = payload['records']['expiryDates']
339
- expiry_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
340
- expiry_dates = [date.strftime("%d-%b-%Y") for date in expiry_dates if date >= datetime.datetime.now().date()]
341
- expiryDate=expiry_dates[inp]
342
- for x in range(len(payload['records']['data'])):
343
- if((payload['records']['data'][x]['strikePrice']==strikePrice) & (payload['records']['data'][x]['expiryDate']==expiryDate)):
344
- if(intent==""): return payload['records']['data'][x][optionType]['lastPrice']
345
- if(intent=="sell"): return payload['records']['data'][x][optionType]['bidprice']
346
- if(intent=="buy"): return payload['records']['data'][x][optionType]['askPrice']
986
+ # Round 3 bug fix (new finding, highest severity found this round): this
987
+ # function unconditionally indexed payload['records'] -- the pre-rewrite
988
+ # legacy NSE shape. option_chain()/nse_optionchain_scrapper() (this
989
+ # library's OWN current option-chain source, since round 1) return
990
+ # {'data': [...], 'timestamp': ...} instead -- 'records' doesn't exist
991
+ # anywhere in the live code path any more, so this function could never
992
+ # succeed with real data produced by this library: every call crashed
993
+ # with KeyError('records'), unconditionally.
994
+ if 'records' in payload:
995
+ # Legacy shape, kept for any caller handing in an old-style cached
996
+ # payload captured before this library's rewrite.
997
+ expiry_dates = payload['records']['expiryDates']
998
+ expiry_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
999
+ expiry_dates = [date.strftime("%d-%b-%Y") for date in expiry_dates if date >= datetime.datetime.now().date()]
1000
+ if inp >= len(expiry_dates):
1001
+ raise NSEEndpointError(
1002
+ f"nse_optionchain_ltp(): requested expiry index {inp} is out "
1003
+ f"of range -- only {len(expiry_dates)} future expiries found."
1004
+ )
1005
+ expiryDate = expiry_dates[inp]
1006
+ for x in range(len(payload['records']['data'])):
1007
+ row = payload['records']['data'][x]
1008
+ if (row['strikePrice'] == strikePrice) and (row['expiryDate'] == expiryDate):
1009
+ leg = row[optionType]
1010
+ if(intent==""): return leg['lastPrice']
1011
+ if(intent=="sell"): return leg['bidprice']
1012
+ if(intent=="buy"): return leg['askPrice']
1013
+ return None
1014
+
1015
+ if 'data' in payload:
1016
+ # Current shape: option_chain()/nse_optionchain_scrapper()'s flat
1017
+ # 'data' list, each entry already grouped per-strike with 'CE'/'PE'
1018
+ # sub-dicts (see nse_optionchain_scrapper()'s combine step) --
1019
+ # strikePrice/expiryDate live on the outer entry, the price fields
1020
+ # live inside entry[optionType].
1021
+ data_list = payload['data']
1022
+
1023
+ def _parse_exp(d):
1024
+ try:
1025
+ if "-" in d and d.split("-")[1].isdigit():
1026
+ return datetime.datetime.strptime(d, "%d-%m-%Y").date()
1027
+ return datetime.datetime.strptime(d, "%d-%b-%Y").date()
1028
+ except Exception:
1029
+ return None
1030
+
1031
+ unique_dates = sorted(
1032
+ {e.get('expiryDate') for e in data_list if e.get('expiryDate')},
1033
+ key=lambda d: _parse_exp(d) or datetime.date.max,
1034
+ )
1035
+ today = datetime.datetime.now().date()
1036
+ future_dates = [d for d in unique_dates if (_parse_exp(d) or today) >= today]
1037
+ if inp >= len(future_dates):
1038
+ raise NSEEndpointError(
1039
+ f"nse_optionchain_ltp(): requested expiry index {inp} is out "
1040
+ f"of range -- only {len(future_dates)} future expiries found "
1041
+ f"in this payload."
1042
+ )
1043
+ expiryDate = future_dates[inp]
1044
+
1045
+ try:
1046
+ target_strike = float(str(strikePrice).strip())
1047
+ except Exception:
1048
+ target_strike = None
1049
+
1050
+ for entry in data_list:
1051
+ if entry.get('expiryDate') != expiryDate:
1052
+ continue
1053
+ try:
1054
+ entry_strike = float(str(entry.get('strikePrice')).strip())
1055
+ except Exception:
1056
+ continue
1057
+ if target_strike is not None and entry_strike != target_strike:
1058
+ continue
1059
+ if 'optionType' in entry and 'CE' not in entry and 'PE' not in entry:
1060
+ # Flat per-leg shape (nse_quote_derivatives()/nse_quote()'s
1061
+ # getSymbolDerivativesData output): each list entry IS one
1062
+ # CE or PE leg directly (entry['optionType'] == 'CE'/'PE',
1063
+ # price fields on the entry itself) rather than one entry
1064
+ # per strike holding both legs nested under entry['CE']/
1065
+ # entry['PE']. Round-3 bug (confirmed live, fixed here):
1066
+ # entry.get(optionType) always returned None for this shape
1067
+ # since there's no such nested key on a flat leg.
1068
+ if entry.get('optionType') != optionType:
1069
+ continue
1070
+ leg = entry
1071
+ else:
1072
+ leg = entry.get(optionType)
1073
+ if not leg:
1074
+ continue
1075
+ if intent == "":
1076
+ return leg.get('lastPrice')
1077
+ if intent == "sell":
1078
+ # The live getSymbolDerivativesData-backed payload carries
1079
+ # no bid/ask order-book fields at all (confirmed live) --
1080
+ # only the legacy 'records' shape had bidprice/askPrice.
1081
+ # This is a genuine data-availability gap, not a lookup
1082
+ # bug: returns None rather than guessing a price.
1083
+ return leg.get('buyPrice1', leg.get('bidprice'))
1084
+ if intent == "buy":
1085
+ return leg.get('sellPrice1', leg.get('askPrice'))
1086
+ return None
1087
+
1088
+ raise NSEEndpointError(
1089
+ "nse_optionchain_ltp(): payload has neither 'records' nor 'data' -- "
1090
+ "pass the output of option_chain()/nse_optionchain_scrapper() "
1091
+ "directly."
1092
+ )
347
1093
 
348
1094
  def nse_eq(symbol):
349
1095
  symbol = nsesymbolpurify(symbol)
@@ -355,8 +1101,20 @@ def nse_eq(symbol):
355
1101
  payload = nsefetch('https://www.nseindia.com/api/quote-derivative?symbol='+symbol)
356
1102
  except:
357
1103
  pass
358
- except KeyError:
359
- print("Getting Error While Fetching.")
1104
+ except (KeyError, NSEEndpointError):
1105
+ # /api/quote-equity is retired on the live site (confirmed HTTP 403,
1106
+ # Akamai/WAF "Access Denied" page, as of 2026) with no indication it
1107
+ # is coming back. The newer NextApi GetQuoteApi endpoint carries the
1108
+ # same underlying data (just in a different JSON shape - data lives
1109
+ # under payload['equityResponse'][0] instead of payload['priceInfo']/
1110
+ # payload['info']) so we fall back to that instead of returning {}.
1111
+ logging.warning(
1112
+ "nse_eq(%s): /api/quote-equity is retired; returning data from "
1113
+ "the newer NextApi quote endpoint instead (see nse_quote() - the "
1114
+ "JSON shape differs from the old quote-equity response).",
1115
+ symbol,
1116
+ )
1117
+ payload = nse_quote(symbol)
360
1118
  return payload
361
1119
 
362
1120
 
@@ -370,8 +1128,18 @@ def nse_fno(symbol):
370
1128
  payload = nsefetch('https://www.nseindia.com/api/quote-equity?symbol='+symbol)
371
1129
  except KeyError:
372
1130
  pass
373
- except KeyError:
374
- print("Getting Error While Fetching.")
1131
+ except (KeyError, NSEEndpointError):
1132
+ # /api/quote-derivative is likewise retired (confirmed HTTP 404 on
1133
+ # the live site). getSymbolDerivativesData via nse_quote_derivatives()
1134
+ # is the working replacement (different JSON shape: a flat 'data'
1135
+ # list of per-strike CE/PE records instead of records/underlyingValue).
1136
+ logging.warning(
1137
+ "nse_fno(%s): /api/quote-derivative is retired; returning data "
1138
+ "from the newer NextApi derivatives endpoint instead (see "
1139
+ "nse_quote_derivatives() - the JSON shape differs).",
1140
+ symbol,
1141
+ )
1142
+ payload = nse_quote_derivatives(symbol)
375
1143
  return payload
376
1144
 
377
1145
  def quote_equity(symbol):
@@ -384,10 +1152,22 @@ def option_chain(symbol):
384
1152
  return nse_optionchain_scrapper(symbol)
385
1153
 
386
1154
  def nse_holidays(type="trading"):
1155
+ # Round 3 bug fix: these were two independent `if`s with no `else`, so
1156
+ # any type other than exactly "trading"/"clearing" left `payload` never
1157
+ # assigned, and `return payload` blew up with an unrelated-looking
1158
+ # UnboundLocalError instead of a clear "invalid type" message. Confirmed
1159
+ # live that NSE's own /api/holiday-master endpoint only accepts these
1160
+ # two type values (anything else comes back HTTP 200 with a zero-length
1161
+ # body) -- so raise a clear, descriptive error for anything else.
387
1162
  if(type=="clearing"):
388
1163
  payload = nsefetch('https://www.nseindia.com/api/holiday-master?type=clearing')
389
- if(type=="trading"):
1164
+ elif(type=="trading"):
390
1165
  payload = nsefetch('https://www.nseindia.com/api/holiday-master?type=trading')
1166
+ else:
1167
+ raise ValueError(
1168
+ f"nse_holidays: invalid type={type!r} -- NSE's holiday-master "
1169
+ f"API only supports type='trading' or type='clearing'."
1170
+ )
391
1171
  return payload
392
1172
 
393
1173
  def holiday_master(type="trading"):
@@ -411,19 +1191,31 @@ def nse_past_results(symbol):
411
1191
  symbol = nsesymbolpurify(symbol)
412
1192
  return nsefetch('https://www.nseindia.com/api/results-comparision?symbol='+symbol)
413
1193
 
414
- def expiry_list(symbol,type="list"):
415
- logging.info("Getting Expiry List of: "+ symbol)
416
-
417
- if(type!="list"):
418
- payload = nse_optionchain_scrapper(symbol)
419
- payload = pd.DataFrame({'Date':payload['records']['expiryDates']})
420
- return payload
1194
+ def expiry_list(symbol, type=""):
1195
+ logging.info("Getting Expiry List of: " + symbol)
1196
+ symbol = nsesymbolpurify(symbol)
1197
+ url = f'https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getOptionChainDropdown&symbol={symbol}'
1198
+ payload = nsefetch(url)
1199
+
1200
+ if not payload or 'expiryDates' not in payload:
1201
+ return [] if type == "list" else pd.DataFrame()
421
1202
 
422
- if(type=="list"):
423
- payload = nse_quote(symbol)
424
- dates=list(set((payload["expiryDates"])))
425
- dates.sort(key = lambda date: datetime.datetime.strptime(date, '%d-%b-%Y'))
426
- return dates
1203
+ expiry_dates = payload['expiryDates']
1204
+
1205
+ # Format dates from DD-MM-YYYY to DD-Mon-YYYY
1206
+ formatted_dates = []
1207
+ for d in expiry_dates:
1208
+ try:
1209
+ dt = datetime.datetime.strptime(d, "%d-%m-%Y")
1210
+ formatted_dates.append(dt.strftime("%d-%b-%Y"))
1211
+ except:
1212
+ formatted_dates.append(d)
1213
+
1214
+ if type == "list":
1215
+ return formatted_dates
1216
+ else:
1217
+ # If anything other than "list" is provided (like "df", "pandas", or default), return DataFrame
1218
+ return pd.DataFrame({'Date': formatted_dates})
427
1219
 
428
1220
 
429
1221
  def nse_custom_function_secfno(symbol,attribute="lastPrice"):
@@ -442,8 +1234,22 @@ def nse_marketStatus():
442
1234
  return payload
443
1235
 
444
1236
  def nse_circular(mode="latest"):
1237
+ # The old mode="latest" path (https://nseindia.com/api/latest-circular,
1238
+ # no `www.`) is dead on the live site: it returns HTTP 200 but a bare
1239
+ # {'error': True, 'status': 500} JSON body -- confirmed this is NOT an
1240
+ # Akamai bot-challenge (no injected script, no 403/503), just NSE's own
1241
+ # "this route doesn't exist" response. NSE renamed the circulars page
1242
+ # itself from /resources/circulars to
1243
+ # /resources/exchange-communication-circulars, and a Playwright network
1244
+ # capture on that live page shows it calling
1245
+ # https://www.nseindia.com/api/circulars?fromDate=DD-MM-YYYY&toDate=DD-MM-YYYY
1246
+ # (with `www.`) -- the SAME URL this function's own mode!="latest"
1247
+ # branch already used and which was independently confirmed live
1248
+ # (zero params defaults to NSE's own last-7-days/150-record window).
1249
+ # Fix: route "latest" to that same working endpoint too, instead of the
1250
+ # dead no-www path.
445
1251
  if(mode=="latest"):
446
- payload = nsefetch('https://nseindia.com/api/latest-circular')
1252
+ payload = nsefetch('https://www.nseindia.com/api/circulars')
447
1253
  else:
448
1254
  payload = nsefetch('https://www.nseindia.com/api/circulars')
449
1255
  return payload
@@ -465,21 +1271,30 @@ def nsetools_get_quote(symbol):
465
1271
  return payload['data'][m]
466
1272
 
467
1273
 
1274
+ def _nse_index_data():
1275
+ # iislliveblob.niftyindices.com is a dead host (confirmed live: NXDOMAIN,
1276
+ # twice). /api/allIndices on the main site carries the same live index
1277
+ # quotes (139 indices as of this writing, including pe/pb/dy per index).
1278
+ # Its per-row key is 'index' (e.g. "NIFTY 50"), not the old 'indexName' --
1279
+ # alias it so nse_get_index_list()/nse_get_index_quote() below (and any
1280
+ # external code doing the same lookup) keep working unchanged.
1281
+ payload = nsefetch("https://www.nseindia.com/api/allIndices")
1282
+ rows = payload.get("data", [])
1283
+ for row in rows:
1284
+ row.setdefault("indexName", row.get("index"))
1285
+ return rows
1286
+
1287
+
468
1288
  def nse_index():
469
- payload = nsefetch('https://iislliveblob.niftyindices.com/jsonfiles/LiveIndicesWatch.json')
470
- payload = pd.DataFrame(payload["data"])
471
- return payload
1289
+ return pd.DataFrame(_nse_index_data())
472
1290
 
473
1291
  def nse_get_index_list():
474
- payload = nsefetch('https://iislliveblob.niftyindices.com/jsonfiles/LiveIndicesWatch.json')
475
- payload = pd.DataFrame(payload["data"])
476
- return payload["indexName"].tolist()
1292
+ return pd.DataFrame(_nse_index_data())["indexName"].tolist()
477
1293
 
478
1294
  def nse_get_index_quote(index):
479
- payload = nsefetch('https://iislliveblob.niftyindices.com/jsonfiles/LiveIndicesWatch.json')
480
- for m in range(len(payload['data'])):
481
- if(payload['data'][m]["indexName"] == index.upper()):
482
- return payload['data'][m]
1295
+ for row in _nse_index_data():
1296
+ if row["indexName"] == index.upper():
1297
+ return row
483
1298
 
484
1299
  def nse_get_advances_declines(mode="pandas"):
485
1300
  try:
@@ -505,10 +1320,19 @@ def nse_get_top_gainers():
505
1320
  return df.head(5)
506
1321
 
507
1322
  def nse_get_fno_lot_sizes(symbol="all",mode="list"):
508
- url="https://archives.nseindia.com/content/fo/fo_mktlots.csv"
1323
+ # github.com/aeron7/nsepythonserver issue #4 ("lot sizes not working"):
1324
+ # two stacked bugs, confirmed live. (1) archives.nseindia.com silently
1325
+ # redirects this specific file to an unrelated PDF circular these days
1326
+ # (NSE's archives -> nsearchives host migration left a stale redirect on
1327
+ # just this path) -- nsearchives.nseindia.com/content/fo/fo_mktlots.csv
1328
+ # is the real, current location, confirmed live with the exact same CSV
1329
+ # shape. (2) plain `requests.get()` against nsearchives.nseindia.com
1330
+ # hangs to a read-timeout (confirmed live) -- it needs the same
1331
+ # curl_cffi TLS impersonation as the rest of the site now.
1332
+ url="https://nsearchives.nseindia.com/content/fo/fo_mktlots.csv"
509
1333
 
510
1334
  if(mode=="list"):
511
- s=requests.get(url).text
1335
+ s = _nse_fetch_csv_text(url)
512
1336
  res_dict = {}
513
1337
  for line in s.split('\n'):
514
1338
  if line != '' and re.search(',', line) and (line.casefold().find('symbol') == -1):
@@ -520,7 +1344,7 @@ def nse_get_fno_lot_sizes(symbol="all",mode="list"):
520
1344
  return res_dict[symbol.upper()]
521
1345
 
522
1346
  if(mode=="pandas"):
523
- payload = pd.read_csv(url)
1347
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(url)))
524
1348
  if(symbol=="all"):
525
1349
  return payload
526
1350
  else:
@@ -552,6 +1376,22 @@ def black_scholes_dexter(S0,X,t,σ="",r=10,q=0.0,td=365):
552
1376
  S0,X,σ,r,q,t = float(S0),float(X),float(σ/100),float(r/100),float(q/100),float(t/td)
553
1377
  #https://unofficed.com/black-scholes-model-options-calculator-google-sheet/
554
1378
 
1379
+ # Round 3 bug fix: t=0 (an option literally expiring today, a completely
1380
+ # normal real-world input given NSE's weekly expiries) used to raise a
1381
+ # raw, uncaught ZeroDivisionError from sigma*sqrt(t) in d1's denominator.
1382
+ # This is a genuine math-domain limit of the Black-Scholes formula (it's
1383
+ # undefined at t=0), not an NSE-API issue -- so raise a clear, descriptive
1384
+ # error pointing the caller at intrinsic value instead of a bare
1385
+ # ZeroDivisionError.
1386
+ if t <= 0:
1387
+ raise ValueError(
1388
+ f"black_scholes_dexter: t={t*td:g} days to expiry must be > 0 -- "
1389
+ f"Black-Scholes delta/gamma/theta/vega are undefined at t=0 (an "
1390
+ f"option expiring today). Use intrinsic value "
1391
+ f"(max(S0-X,0) for a call / max(X-S0,0) for a put) directly "
1392
+ f"instead for a same-day expiry."
1393
+ )
1394
+
555
1395
  d1 = (math.log(S0/X)+(r-q+0.5*σ**2)*t)/(σ*math.sqrt(t))
556
1396
  #stackoverflow.com/questions/34258537/python-typeerror-unsupported-operand-types-for-float-and-int
557
1397
 
@@ -574,7 +1414,13 @@ def black_scholes_dexter(S0,X,t,σ="",r=10,q=0.0,td=365):
574
1414
 
575
1415
  def equity_history_virgin(symbol,series,start_date,end_date):
576
1416
  #url="https://www.nseindia.com/api/historical/cm/equity?symbol="+symbol+"&series=[%22"+series+"%22]&from="+str(start_date)+"&to="+str(end_date)+""
577
- url = 'https://www.nseindia.com/api/historical/cm/equity?symbol=' + symbol + '&series=["' + series + '"]&from=' + start_date + '&to=' + end_date
1417
+ # NOTE: the original /api/historical/cm/equity route is retired on the
1418
+ # live site (confirmed HTTP 503 as of 2026, even via curl_cffi). NSE's
1419
+ # replacement is /api/historicalOR/cm/equity -- same query params, same
1420
+ # response shape (payload['data'] records with CH_TIMESTAMP/
1421
+ # CH_CLOSING_PRICE/etc), confirmed live, so this is a plain host-path
1422
+ # swap with no downstream parsing changes needed.
1423
+ url = 'https://www.nseindia.com/api/historicalOR/cm/equity?symbol=' + symbol + '&series=["' + series + '"]&from=' + start_date + '&to=' + end_date
578
1424
 
579
1425
  payload = nsefetch(url)
580
1426
  return pd.DataFrame.from_records(payload["data"])
@@ -654,7 +1500,10 @@ def derivative_history_virgin(symbol,start_date,end_date,instrumentType,expiry_d
654
1500
  strikePrice = "%.2f" % strikePrice
655
1501
  strikePrice = str(strikePrice)
656
1502
 
657
- nsefetch_url = "https://www.nseindia.com/api/historical/fo/derivatives?&from="+str(start_date)+"&to="+str(end_date)+"&optionType="+optionType+"&strikePrice="+strikePrice+"&expiryDate="+expiry_date+"&instrumentType="+instrumentType+"&symbol="+symbol+""
1503
+ # /api/historical/fo/derivatives is retired (HTTP 503 live); the
1504
+ # confirmed-working replacement is /api/historicalOR/fo/derivatives with
1505
+ # the same query params and response shape.
1506
+ nsefetch_url = "https://www.nseindia.com/api/historicalOR/fo/derivatives?&from="+str(start_date)+"&to="+str(end_date)+"&optionType="+optionType+"&strikePrice="+strikePrice+"&expiryDate="+expiry_date+"&instrumentType="+instrumentType+"&symbol="+symbol+""
658
1507
  payload = nsefetch(nsefetch_url)
659
1508
  logging.info(nsefetch_url)
660
1509
  logging.info(payload)
@@ -719,12 +1568,15 @@ def derivative_history(symbol,start_date,end_date,instrumentType,expiry_date,str
719
1568
 
720
1569
 
721
1570
  def expiry_history(symbol,start_date="",end_date="",type="options"):
722
- if(end_date==""):end_date=end_date
723
- nsefetch_url = "https://www.nseindia.com/api/historical/fo/derivatives/meta?&from="+start_date+"&to="+end_date+"&symbol="+symbol+""
1571
+ # Same retirement as derivative_history_virgin()/equity_history_virgin()
1572
+ # above -- /api/historical/* is gone, /api/historicalOR/* is the working
1573
+ # replacement with an identical response shape.
1574
+ nsefetch_url = "https://www.nseindia.com/api/historicalOR/fo/derivatives/meta?&from="+start_date+"&to="+end_date+"&symbol="+symbol+""
724
1575
  payload = nsefetch(nsefetch_url)
725
1576
 
726
1577
  #print(payload)
727
1578
 
1579
+ payload_data = None
728
1580
  for key, value in payload['expiryDatesByInstrument'].items():
729
1581
  if type.lower() == "options" and "OPT" in key:
730
1582
  payload_data = payload['expiryDatesByInstrument'][key]
@@ -732,7 +1584,20 @@ def expiry_history(symbol,start_date="",end_date="",type="options"):
732
1584
  elif type.lower() == "futures" and "FUT" in key:
733
1585
  payload_data = payload['expiryDatesByInstrument'][key]
734
1586
  break
735
-
1587
+
1588
+ if payload_data is None:
1589
+ return []
1590
+
1591
+ # Round 3 bug fix: calling this with its own documented defaults (no
1592
+ # dates -- expiry_history("NIFTY")) used to crash unconditionally with
1593
+ # `ValueError: time data '' does not match format '%d-%m-%Y'`, because
1594
+ # start_date/end_date default to "" but got passed straight into
1595
+ # strptime with no blank-check. Confirmed live that the endpoint itself
1596
+ # already handles blank from/to by returning the full unfiltered expiry
1597
+ # list -- so short-circuit and return that directly instead of crashing.
1598
+ if start_date == "" or end_date == "":
1599
+ return payload_data
1600
+
736
1601
  # Convert start_date and end_date to datetime objects
737
1602
  start_date = datetime.datetime.strptime(start_date, "%d-%m-%Y")
738
1603
  end_date = datetime.datetime.strptime(end_date, "%d-%m-%Y")
@@ -741,7 +1606,7 @@ def expiry_history(symbol,start_date="",end_date="",type="options"):
741
1606
  filtered_date_payload = []
742
1607
 
743
1608
  # Initialize a flag to check if the first date after end_date has been added
744
- added_after_end_date = False
1609
+ added_after_end_date = False
745
1610
 
746
1611
  # Iterate through date_payload and filter dates within the range
747
1612
  for date_str in payload_data:
@@ -751,62 +1616,163 @@ def expiry_history(symbol,start_date="",end_date="",type="options"):
751
1616
  elif date_obj > end_date and not added_after_end_date:
752
1617
  filtered_date_payload.append(date_str)
753
1618
  added_after_end_date = True
754
-
1619
+
755
1620
  return filtered_date_payload
756
1621
 
757
1622
  # # Nifty Indicies Site
1623
+ #
1624
+ # niftyindices.com is a completely separate host/site from nseindia.com (no
1625
+ # Akamai Bot Manager symptoms observed here) -- but it was fully redesigned
1626
+ # onto a different CMS at some point: the old ASP.NET WebMethods under
1627
+ # `niftyindices.com/Backpage.aspx/*` (returning `{"d": "<json string>"}`) are
1628
+ # gone, and POSTing to them now just returns the site's homepage HTML, which
1629
+ # is exactly github.com/aeron7/nsepython issue #78's
1630
+ # `JSONDecodeError: Expecting value: line 1 column 2 (char 1)`.
1631
+ #
1632
+ # The working replacement (confirmed live) is `www.niftyindices.com/BackPage/*`
1633
+ # (note: `www.` + `BackPage` not `Backpage.aspx`), which wants a short session
1634
+ # warm-up first (visiting the historical-data report page) and returns a
1635
+ # direct JSON array rather than the old `{"d": "..."}` wrapper.
758
1636
 
759
1637
  niftyindices_headers = {
760
- 'Connection': 'keep-alive',
761
- 'sec-ch-ua': '" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"',
762
1638
  'Accept': 'application/json, text/javascript, */*; q=0.01',
763
- 'DNT': '1',
1639
+ 'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
1640
+ 'Content-Type': 'application/json; charset=UTF-8',
1641
+ 'Origin': 'https://www.niftyindices.com',
1642
+ 'Referer': 'https://www.niftyindices.com/reports/historical-data',
1643
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
764
1644
  'X-Requested-With': 'XMLHttpRequest',
1645
+ 'sec-ch-ua': '"Not;A=Brand";v="8", "Chromium";v="130", "Google Chrome";v="130"',
765
1646
  'sec-ch-ua-mobile': '?0',
766
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36',
767
- 'Content-Type': 'application/json; charset=UTF-8',
768
- 'Origin': 'https://niftyindices.com',
769
- 'Sec-Fetch-Site': 'same-origin',
770
- 'Sec-Fetch-Mode': 'cors',
771
- 'Sec-Fetch-Dest': 'empty',
772
- 'Referer': 'https://niftyindices.com/reports/historical-data',
773
- 'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
1647
+ 'sec-ch-ua-platform': '"Windows"',
774
1648
  }
775
1649
 
776
- def index_history(symbol,start_date,end_date):
1650
+ _niftyindices_session = None
1651
+ _niftyindices_warmed = False
1652
+
1653
+
1654
+ def _get_niftyindices_session():
1655
+ global _niftyindices_session, _niftyindices_warmed
1656
+ if _niftyindices_session is None:
1657
+ _niftyindices_session = requests.Session()
1658
+ if not _niftyindices_warmed:
1659
+ try:
1660
+ _niftyindices_session.get(
1661
+ "https://www.niftyindices.com/reports/historical-data",
1662
+ headers=niftyindices_headers, timeout=15,
1663
+ )
1664
+ _niftyindices_warmed = True
1665
+ except Exception as e:
1666
+ logging.warning("niftyindices.com session warm-up failed/partial: %s", e)
1667
+ return _niftyindices_session
1668
+
1669
+
1670
+ def _niftyindices_fetch(endpoint, symbol, start_date, end_date):
1671
+ session = _get_niftyindices_session()
777
1672
  data = {'cinfo': "{'name':'" + symbol + "','startDate':'" + start_date + "','endDate':'" + end_date + "','indexName':'" + symbol + "'}"}
778
- payload = requests.post('https://niftyindices.com/Backpage.aspx/getHistoricaldatatabletoString', headers=niftyindices_headers, json=data).json()
779
- payload = json.loads(payload["d"])
780
- payload=pd.DataFrame.from_records(payload)
781
- return payload
1673
+ response = session.post(
1674
+ f"https://www.niftyindices.com/BackPage/{endpoint}",
1675
+ headers=niftyindices_headers, json=data, timeout=20,
1676
+ )
1677
+ text = response.text.strip()
1678
+ if text.startswith('<!DOCTYPE') or text.startswith('<html') or text == "":
1679
+ raise NSEEndpointError(
1680
+ f"niftyindices.com/BackPage/{endpoint} returned HTML/empty instead of JSON "
1681
+ f"(HTTP {response.status_code}) -- the site may be down or have changed again."
1682
+ )
1683
+ try:
1684
+ payload = response.json()
1685
+ except ValueError:
1686
+ raise NSEEndpointError(
1687
+ f"niftyindices.com/BackPage/{endpoint}: non-JSON body (HTTP {response.status_code})"
1688
+ )
1689
+ # Old API wrapped the payload as {"d": "<json string>"}; the new one
1690
+ # returns the array directly. Support both so this keeps working if
1691
+ # niftyindices.com ever reverts/mixes the two shapes.
1692
+ if isinstance(payload, dict) and "d" in payload:
1693
+ payload = json.loads(payload["d"])
1694
+ return pd.DataFrame.from_records(payload)
1695
+
1696
+
1697
+ def index_history(symbol,start_date,end_date):
1698
+ return _niftyindices_fetch("getHistoricaldatatabletoString", symbol, start_date, end_date)
782
1699
 
783
1700
  def index_pe_pb_div(symbol,start_date,end_date):
784
- data = {'cinfo': "{'name':'" + symbol + "','startDate':'" + start_date + "','endDate':'" + end_date + "','indexName':'" + symbol + "'}"}
785
- payload = requests.post('https://niftyindices.com/Backpage.aspx/getpepbHistoricaldataDBtoString', headers=niftyindices_headers, json=data).json()
786
- payload = json.loads(payload["d"])
787
- payload=pd.DataFrame.from_records(payload)
788
- return payload
1701
+ return _niftyindices_fetch("getpepbHistoricaldataDBtoString", symbol, start_date, end_date)
789
1702
 
790
1703
  def index_total_returns(symbol,start_date,end_date):
791
- data = {'cinfo': "{'name':'" + symbol + "','startDate':'" + start_date + "','endDate':'" + end_date + "','indexName':'" + symbol + "'}"}
792
- payload = requests.post('https://niftyindices.com/Backpage.aspx/getTotalReturnIndexString', headers=niftyindices_headers, json=data).json()
793
- payload = json.loads(payload["d"])
794
- payload=pd.DataFrame.from_records(payload)
795
- return payload
1704
+ return _niftyindices_fetch("getTotalReturnIndexString", symbol, start_date, end_date)
796
1705
 
797
1706
  def get_bhavcopy(date):
798
1707
  date = date.replace("-","")
799
- payload=pd.read_csv("https://archives.nseindia.com/products/content/sec_bhavdata_full_"+date+".csv")
1708
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1709
+ "https://archives.nseindia.com/products/content/sec_bhavdata_full_"+date+".csv")))
800
1710
  return payload
801
1711
 
802
1712
  def get_bulkdeals():
803
- payload=pd.read_csv("https://archives.nseindia.com/content/equities/bulk.csv")
1713
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1714
+ "https://archives.nseindia.com/content/equities/bulk.csv")))
804
1715
  return payload
805
1716
 
806
1717
  def get_blockdeals():
807
- payload=pd.read_csv("https://archives.nseindia.com/content/equities/block.csv")
1718
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1719
+ "https://archives.nseindia.com/content/equities/block.csv")))
808
1720
  return payload
809
1721
 
1722
+ def _nse_top_corp_info(symbol):
1723
+ """`/api/top-corp-info?symbol=X&market=equities` bundles a company's
1724
+ latest announcements, corporate actions (bonus/dividend/split/demerger),
1725
+ shareholding pattern history, financial results, and board meetings in
1726
+ one call -- confirmed live and working through curl_cffi+warm-up. This
1727
+ backs both dividend_timeline() and share_holding() below."""
1728
+ symbol = nsesymbolpurify(symbol)
1729
+ return nsefetch(f"https://www.nseindia.com/api/top-corp-info?symbol={symbol}&market=equities")
1730
+
1731
+
1732
+ def dividend_timeline(symbol):
1733
+ """github.com/aeron7/nsepython issue #75: documented on
1734
+ unofficed.com/nse-python/ but never actually implemented in the code
1735
+ (calling it raised `AttributeError: module 'nsepython' has no attribute
1736
+ 'dividend_timeline'`). Implemented here from `/api/top-corp-info`'s
1737
+ `corporate_actions` list, filtered down to the dividend-purpose entries
1738
+ (that list also contains bonuses/splits/demergers/etc, which this
1739
+ function intentionally excludes to match its name)."""
1740
+ data = _nse_top_corp_info(symbol)
1741
+ actions = (data.get("corporate_actions") or {}).get("data") or []
1742
+ dividends = [a for a in actions if "dividend" in (a.get("purpose") or "").lower()]
1743
+ return pd.DataFrame.from_records(dividends)
1744
+
1745
+
1746
+ def share_holding(symbol):
1747
+ """github.com/aeron7/nsepython issue #75: same situation as
1748
+ dividend_timeline() above -- documented but not implemented. Built from
1749
+ `/api/top-corp-info`'s `shareholdings_patterns` data, which is a dict
1750
+ keyed by filing date (e.g. "31-Mar-2026") whose value is a list of
1751
+ {"<category>": "<percent>"} rows (Promoter & Promoter Group / Public /
1752
+ Shares held by Employee Trusts / Total). Flattened here into one row per
1753
+ filing date with a column per category, newest filing first."""
1754
+ data = _nse_top_corp_info(symbol)
1755
+ by_date = (data.get("shareholdings_patterns") or {}).get("data") or {}
1756
+ rows = []
1757
+ for filing_date, categories in by_date.items():
1758
+ row = {"date": filing_date}
1759
+ for entry in categories:
1760
+ for k, v in entry.items():
1761
+ row[k.strip()] = v.strip() if isinstance(v, str) else v
1762
+ rows.append(row)
1763
+ df = pd.DataFrame.from_records(rows)
1764
+ if not df.empty and "date" in df.columns:
1765
+ try:
1766
+ df = df.sort_values(
1767
+ by="date",
1768
+ key=lambda s: pd.to_datetime(s, format="%d-%b-%Y"),
1769
+ ascending=False,
1770
+ ).reset_index(drop=True)
1771
+ except Exception:
1772
+ pass
1773
+ return df
1774
+
1775
+
810
1776
  #Request from subhash
811
1777
  ## https://unofficed.com/how-to-find-the-beta-of-indian-stocks-using-python/
812
1778
  def get_beta_df_maker(symbol,days):
@@ -841,11 +1807,32 @@ def getbeta(symbol,days=365,symbol2="NIFTY 50"):
841
1807
 
842
1808
  def get_beta(symbol,days=365,symbol2="NIFTY 50"):
843
1809
  #Default is 248 days. (Input of Subhash)
844
- df = get_beta_df_maker(symbol,days)
845
- df2 = get_beta_df_maker(symbol2,days)
1810
+ # github.com/aeron7/nsepython issue #75: this used to raise a raw
1811
+ # KeyError('data') because equity_history() silently returned {} on a
1812
+ # blocked/retired endpoint. nsefetch() now raises a descriptive
1813
+ # NSEEndpointError instead of swallowing the failure -- surface that
1814
+ # (plus any other unexpected shape problem) as a clear, named error
1815
+ # instead of a bare KeyError, per the issue reporter's own suggestion.
1816
+ try:
1817
+ df = get_beta_df_maker(symbol,days)
1818
+ df2 = get_beta_df_maker(symbol2,days)
1819
+ except NSEEndpointError:
1820
+ raise
1821
+ except Exception as e:
1822
+ raise NSEEndpointError(
1823
+ f"get_beta({symbol!r}, symbol2={symbol2!r}): could not build the "
1824
+ f"daily-change series needed for beta -- {e}"
1825
+ ) from e
846
1826
 
847
1827
  x=df["daily_change"].tolist()
848
1828
  y=df2["daily_change"].tolist()
1829
+
1830
+ if not x or not y:
1831
+ raise NSEEndpointError(
1832
+ f"get_beta({symbol!r}, symbol2={symbol2!r}): got no historical "
1833
+ f"price data back for the requested {days}-day window."
1834
+ )
1835
+
849
1836
  #stackoverflow.com/questions/42670055/is-there-any-better-way-to-calculate-the-covariance-of-two-lists-than-this
850
1837
  mean_x = sum(x) / len(x)
851
1838
  mean_y = sum(y) / len(y)
@@ -854,12 +1841,26 @@ def get_beta(symbol,days=365,symbol2="NIFTY 50"):
854
1841
  mean = sum(y) / len(y)
855
1842
  variance = sum((i - mean) ** 2 for i in y) / len(y)
856
1843
 
1844
+ if variance == 0:
1845
+ raise NSEEndpointError(
1846
+ f"get_beta({symbol!r}, symbol2={symbol2!r}): symbol2 had zero "
1847
+ f"price variance over this window, beta is undefined."
1848
+ )
1849
+
857
1850
  beta = covariance/variance
858
1851
  return round(beta,3)
859
1852
 
860
1853
  def nse_preopen(key="NIFTY",type="pandas"):
861
1854
  payload = nsefetch("https://www.nseindia.com/api/market-data-pre-open?key="+key+"")
862
1855
  if(type=="pandas"):
1856
+ # NSE's pre-open-market window for most `key` values (e.g. "NIFTY")
1857
+ # is only populated for a few minutes each morning; outside that
1858
+ # window `data` is a legitimate empty list ({"data": [], "msg": "No
1859
+ # Data Found"}), which used to raise a confusing KeyError('metadata')
1860
+ # trying to pull a column out of an empty DataFrame. Return an empty
1861
+ # DataFrame instead.
1862
+ if not payload.get('data'):
1863
+ return pd.DataFrame()
863
1864
  payload = pd.DataFrame(payload['data'])
864
1865
  payload = pd.json_normalize(payload['metadata'])
865
1866
  return payload
@@ -868,8 +1869,13 @@ def nse_preopen(key="NIFTY",type="pandas"):
868
1869
 
869
1870
  #By Avinash https://forum.unofficed.com/t/nsepython-documentation/376/102?u=dexter
870
1871
  def nse_preopen_movers(key="FO",filter=1.5):
1872
+ # Round 3 bug fix: the body hardcoded the literal 1.5/-1.5 thresholds
1873
+ # instead of using the `filter` parameter at all -- any caller passing
1874
+ # a custom threshold (nse_preopen_movers(key="FO", filter=50)) got
1875
+ # silently ignored and always got the same 1.5% cutoff back, with no
1876
+ # error or warning.
871
1877
  preOpen_gainer=nse_preopen(key)
872
- return preOpen_gainer[preOpen_gainer['pChange'] >1.5],preOpen_gainer[preOpen_gainer['pChange'] <-1.5]
1878
+ return preOpen_gainer[preOpen_gainer['pChange'] >filter],preOpen_gainer[preOpen_gainer['pChange'] <-filter]
873
1879
 
874
1880
  # type = "securities"
875
1881
  # type = "etf"
@@ -886,7 +1892,8 @@ def nse_most_active(type="securities",sort="value"):
886
1892
 
887
1893
  def nse_eq_symbols():
888
1894
  #https://forum.unofficed.com/t/feature-request-stocklist-api/1073/11
889
- eq_list_pd = pd.read_csv('https://archives.nseindia.com/content/equities/EQUITY_L.csv')
1895
+ eq_list_pd = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1896
+ 'https://archives.nseindia.com/content/equities/EQUITY_L.csv')))
890
1897
  return eq_list_pd['SYMBOL'].tolist()
891
1898
 
892
1899
  def nse_price_band_hitters(bandtype="both",view="AllSec"):
@@ -904,16 +1911,42 @@ def nse_largedeals(mode="bulk_deals"):
904
1911
  return pd.DataFrame(payload["SHORT_DEALS_DATA"])
905
1912
  if(mode=="block_deals"):
906
1913
  return pd.DataFrame(payload["BLOCK_DEALS_DATA"])
907
-
1914
+
908
1915
  def nse_largedeals_historical(from_date, to_date, mode="bulk_deals"):
1916
+ # The old /api/historical/{bulk-deals,short-selling,block-deals} family is
1917
+ # retired on the live site (confirmed HTTP 503 straight from NSE's origin
1918
+ # -- not an Akamai bot-challenge: the 503 body is a tiny generic Apache
1919
+ # ErrorDocument page returned with a consistent ~20-30ms *origin* timing
1920
+ # on every single attempt, with or without warm-up/referer variations,
1921
+ # which is the signature of a dead backend route rather than a solvable
1922
+ # JS sensor wall).
1923
+ #
1924
+ # Found the real, current replacement by driving NSE's own "Bulk Deals/
1925
+ # Block Deals/ Short Selling Archives" report page
1926
+ # (https://www.nseindia.com/report-detail/display-bulk-and-block-deals)
1927
+ # with Playwright and capturing what it actually calls when you click
1928
+ # Go: `/api/historicalOR/bulk-block-short-deals?optionType=<mode>&from=
1929
+ # ..&to=..` -- same host-prefix swap pattern as equity/derivatives above,
1930
+ # just a different path and param name (`optionType=`, not a path
1931
+ # segment), confirmed live for all three modes. Response shape is the
1932
+ # same `{"data": [...]}` the old endpoint returned, just with a different
1933
+ # (current) NSE column-name scheme:
1934
+ # bulk_deals/block_deals -> BD_DT_DATE, BD_DT_ORDER, BD_SYMBOL,
1935
+ # BD_SCRIP_NAME, BD_CLIENT_NAME, BD_BUY_SELL,
1936
+ # BD_QTY_TRD, BD_TP_WATP, BD_REMARKS
1937
+ # short_deals -> SS_DATE, SS_DATE_ORDER, SS_SYMBOL, SS_NAME,
1938
+ # SS_QTY
909
1939
  if mode == "bulk_deals":
910
- mode = "bulk-deals"
1940
+ option_type = "bulk_deals"
911
1941
  elif mode == "short_deals":
912
- mode = "short-selling"
1942
+ option_type = "short_selling"
913
1943
  elif mode == "block_deals":
914
- mode = "block-deals"
915
-
916
- url='https://www.nseindia.com/api/historical/' + mode + '?from=' + from_date + '&to=' + to_date
1944
+ option_type = "block_deals"
1945
+ else:
1946
+ option_type = mode
1947
+
1948
+ url = ('https://www.nseindia.com/api/historicalOR/bulk-block-short-deals'
1949
+ '?optionType=' + option_type + '&from=' + from_date + '&to=' + to_date)
917
1950
  logging.info("Fetching " + str(url))
918
1951
  payload = nsefetch(url)
919
1952
  return pd.DataFrame(payload["data"])
@@ -922,45 +1955,748 @@ def nse_largedeals_historical(from_date, to_date, mode="bulk_deals"):
922
1955
  #print(get_fao_participant_oi("04-06-2021"))
923
1956
  def get_fao_participant_oi(date):
924
1957
  date = date.replace("-","")
925
- payload=pd.read_csv("https://archives.nseindia.com/content/nsccl/fao_participant_oi_"+date+".csv")
1958
+ # Round 3 bug fix: this CSV has a title/caption row as line 1
1959
+ # ('""Participant wise Open Interest...""') with the REAL header on
1960
+ # line 2 -- reading it with no skiprows made pandas parse the caption
1961
+ # as the header and shift the real header row down into the data,
1962
+ # mislabeling every single column (confirmed live on every trading date
1963
+ # tested: columns came out as 'Unnamed: 2', 'Unnamed: 3', etc instead of
1964
+ # 'Future Index Long', 'Total Short Contracts', ...).
1965
+ text = _nse_fetch_csv_text(
1966
+ "https://archives.nseindia.com/content/nsccl/fao_participant_oi_"+date+".csv")
1967
+ payload = pd.read_csv(io.StringIO(text), skiprows=1)
1968
+ # NSE's own header row carries stray trailing whitespace on a couple of
1969
+ # columns (e.g. "Future Stock Short ") -- strip it so column
1970
+ # lookups by name work as documented.
1971
+ payload.columns = [c.strip() for c in payload.columns]
926
1972
  return payload
927
1973
 
928
1974
  #https://forum.unofficed.com/t/how-to-check-if-the-market-is-open-today-or-not/1268/1
929
1975
  def is_market_open(segment = "FO"): #COM,CD,CB,CMOT,COM,FO,IRD,MF,NDM,NTRP,SLBS
930
-
1976
+ # Bug fix: the previous version returned True/False based only on
1977
+ # holiday_json's *first* entry, so it almost always reported "open"
1978
+ # regardless of today's actual date (today is essentially never the
1979
+ # first holiday in the list). Scan the whole list for a match instead.
931
1980
  holiday_json = nse_holidays()[segment]
932
1981
 
933
1982
  # Get today's date in the format 'dd-Mon-yyyy'
934
1983
  today_date = datetime.date.today().strftime('%d-%b-%Y')
935
1984
 
936
- # Check if today's date is in the holiday_json
937
1985
  for holiday in holiday_json:
938
- if holiday['tradingDate'] != today_date:
939
- print("FNO Market is open today. Have a Nice Trade!")
940
- return True
941
- if holiday['tradingDate'] == today_date:
942
- print(f"Market is closed today because of {holiday['description']}")
1986
+ if holiday.get('tradingDate') == today_date:
1987
+ print(f"Market is closed today because of {holiday.get('description')}")
943
1988
  return False
944
1989
 
1990
+ print("FNO Market is open today. Have a Nice Trade!")
1991
+ return True
1992
+
945
1993
  def nse_expirydetails_by_symbol(symbol,meta ="Futures",i=0):
946
- payload = nse_quote(symbol)
1994
+ payload = nse_quote_derivatives(symbol)
1995
+ expiry_dates = []
1996
+
1997
+ # Extract from new FNO payload structure
1998
+ if 'data' in payload:
1999
+ unique_dates = set()
2000
+ for entry in payload['data']:
2001
+ if 'expiryDate' in entry:
2002
+ # Filter by meta type if possible, though 'data' usually contains all
2003
+ # To be precise, we can check instrumentType
2004
+ it = entry.get('instrumentType', '')
2005
+ if (meta == "Futures" and "FUT" in it) or (meta == "Options" and "OPT" in it):
2006
+ unique_dates.add(entry['expiryDate'])
2007
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
2008
+
2009
+ # Filter future dates
2010
+ future_expiry_dates = []
2011
+ if expiry_dates:
2012
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
2013
+ future_expiry_dates = sorted([date.strftime("%d-%b-%Y") for date in temp_dates if date >= datetime.datetime.now().date()], key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
2014
+
2015
+ # Fallback to expiry_list if i is out of range
2016
+ if i >= len(future_expiry_dates):
2017
+ dates = expiry_list(symbol, type="list")
2018
+ if dates:
2019
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in dates]
2020
+ future_expiry_dates = sorted([date.strftime("%d-%b-%Y") for date in temp_dates if date >= datetime.datetime.now().date()], key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
2021
+
2022
+ if i >= len(future_expiry_dates):
2023
+ return None, None
2024
+
2025
+ currentExpiry = future_expiry_dates[i]
2026
+ currentExpiry_dt = datetime.datetime.strptime(currentExpiry, '%d-%b-%Y').date()
2027
+ date_today = run_time.date()
2028
+ dte = (currentExpiry_dt - date_today).days
2029
+ return currentExpiry_dt, dte
2030
+
2031
+ def security_wise_archive(from_date, to_date, symbol, series="ALL"):
2032
+ # The old /api/historical/securityArchives route is retired on the live
2033
+ # site (confirmed HTTP 503 straight from NSE's origin -- same dead-route
2034
+ # signature as nse_largedeals_historical() above, not a solvable Akamai
2035
+ # challenge: tiny generic Apache ErrorDocument body, consistent fast
2036
+ # origin timing on every attempt regardless of warm-up/referer).
2037
+ #
2038
+ # Found the real, current replacement by driving NSE's own "Security-wise
2039
+ # Archives (Equities)" report page
2040
+ # (https://www.nseindia.com/report-detail/eq_security) with Playwright
2041
+ # and capturing what it actually calls when you click Go:
2042
+ # `/api/historicalOR/generateSecurityWiseHistoricalData?from=..&to=..&
2043
+ # symbol=..&type=..&series=..` -- same host-prefix-swap family as
2044
+ # equity_history()/derivative_history() above, just a different path and
2045
+ # `type=` instead of `dataType=`. Confirmed live: response shape is the
2046
+ # same `{"data": [...]}` with the same CH_*/COP_DELIV_* column names the
2047
+ # old endpoint used (cross-checked against equity_history()'s numbers for
2048
+ # the same symbol/dates -- exact match).
2049
+ base_url = "https://www.nseindia.com/api/historicalOR/generateSecurityWiseHistoricalData"
2050
+ url = f"{base_url}?from={from_date}&to={to_date}&symbol={symbol.upper()}&type=priceVolumeDeliverable&series={series.upper()}"
2051
+ payload = nsefetch(url)
2052
+ return pd.DataFrame(payload['data'])
947
2053
 
948
- if(meta=="Futures"):
949
- selected_key = next((key for key in payload["expiryDatesByInstrument"] if "futures" in key.lower()), None)
950
- if(meta=="Options"):
951
- selected_key = next((key for key in payload["expiryDatesByInstrument"] if "options" in key.lower()), None)
952
2054
 
953
- expiry_dates=payload["expiryDatesByInstrument"][selected_key]
954
- expiry_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
955
- expiry_dates = [date.strftime("%d-%b-%Y") for date in expiry_dates if date >= datetime.datetime.now().date()]
956
-
957
- currentExpiry=expiry_dates[i]
958
- currentExpiry = datetime.datetime.strptime(currentExpiry,'%d-%b-%Y').date()
959
- dte = (currentExpiry - datetime.datetime.now().date()).days
960
- return currentExpiry,dte
961
-
962
- def security_wise_archive(from_date, to_date, symbol, series="ALL"):
963
- base_url = "https://www.nseindia.com/api/historical/securityArchives"
964
- url = f"{base_url}?from={from_date}&to={to_date}&symbol={symbol.upper()}&dataType=priceVolumeDeliverable&series={series.upper()}"
965
- payload = nsefetch(url)
966
- return pd.DataFrame(payload['data'])
2055
+ # ---------------------------------------------------------------------------
2056
+ # NSE's official, no-auth MCP (Model Context Protocol) servers
2057
+ #
2058
+ # NSE India publishes its own free, no-API-key-required MCP servers
2059
+ # (https://www.nseindia.com/nse-mcp) -- two streamable-HTTP endpoints:
2060
+ #
2061
+ # "bhavcopy" -- https://mcp.nseindia.in/bhavcopy/cm/mcp
2062
+ # ("nse-bhavcopy-redis-mcp", 21 tools): historical/derived data --
2063
+ # stock & index history, valuations, corporate actions, comparisons,
2064
+ # moving averages, 52-week range, market mood/breadth, symbol search.
2065
+ #
2066
+ # "cmmkt" -- https://mcp.nseindia.in/cmmkt/mcp
2067
+ # ("cm-market-mcp", 15 tools): live cash-market data -- live quotes,
2068
+ # gainers/losers, live index values, equity/SME/bond/call-auction
2069
+ # stock lists.
2070
+ #
2071
+ # This is a genuinely different, independent path into NSE data from the
2072
+ # rest of this module: it is NSE's own hosted service, not a scrape of
2073
+ # nseindia.com through curl_cffi's Akamai-impersonation transport, so it is
2074
+ # unaffected by Akamai Bot Manager entirely and is often the more reliable
2075
+ # choice when it covers the data you need. It still reuses this module's
2076
+ # shared curl_cffi session (_get_nse_session()) purely for cheap connection
2077
+ # pooling / one consistent TLS-fingerprint story -- the MCP calls themselves
2078
+ # need no cookies, no warm-up, and no auth of any kind.
2079
+ #
2080
+ # Protocol notes (streamable-HTTP MCP, JSON-RPC 2.0):
2081
+ # - POST an "initialize" request first; the response carries a
2082
+ # "Mcp-Session-Id" (or "mcp-session-id") header that must be echoed back
2083
+ # as a header on every subsequent request for that session.
2084
+ # - A "notifications/initialized" notification should follow (no response
2085
+ # body expected) before calling any tool.
2086
+ # - "tools/call" responses come back either as plain JSON or as an
2087
+ # SSE-framed body (Content-Type: text/event-stream) shaped like
2088
+ # "event:message\ndata:{...}\n\n" -- both are handled below.
2089
+ # - The actual tool result is nested at result.content[0].text, which is
2090
+ # itself a JSON string in practice for every tool checked so far.
2091
+ # - A small number of tools (confirmed: nse_get_gainers / nse_get_losers
2092
+ # on the cmmkt server) currently come back with isError=false but an
2093
+ # inner {"error": "..."} payload -- a live bug on NSE's own server side
2094
+ # ("Failed to parse cached data: ArrayList cannot be cast to Map").
2095
+ # That is treated the same as any other failure here: raised as
2096
+ # NSEEndpointError rather than silently handed back as "data".
2097
+ # ---------------------------------------------------------------------------
2098
+
2099
+ _NSE_MCP_SERVERS = {
2100
+ "bhavcopy": "https://mcp.nseindia.in/bhavcopy/cm/mcp",
2101
+ "cmmkt": "https://mcp.nseindia.in/cmmkt/mcp",
2102
+ }
2103
+
2104
+ _NSE_MCP_CLIENT_VERSION = "2.98"
2105
+
2106
+ # Lightweight session-id cache, keyed by server URL, so repeated calls to the
2107
+ # same MCP server don't re-run the "initialize" handshake every time.
2108
+ _nse_mcp_session_cache = {}
2109
+
2110
+
2111
+ def _nse_mcp_parse_response(r):
2112
+ """Parse one MCP HTTP response body, which comes back as either plain
2113
+ JSON or an SSE-framed body (Content-Type: text/event-stream) shaped like
2114
+ "event:message\\ndata:{...}\\n\\n". Returns the decoded JSON-RPC envelope
2115
+ dict either way.
2116
+ """
2117
+ ctype = r.headers.get("content-type", "") or ""
2118
+ if "text/event-stream" in ctype:
2119
+ data_lines = [
2120
+ line[len("data:"):].strip()
2121
+ for line in r.text.splitlines()
2122
+ if line.startswith("data:")
2123
+ ]
2124
+ if not data_lines:
2125
+ raise NSEEndpointError("nse_mcp: empty SSE response body")
2126
+ try:
2127
+ return json.loads("".join(data_lines))
2128
+ except ValueError:
2129
+ raise NSEEndpointError("nse_mcp: malformed SSE JSON payload")
2130
+
2131
+ try:
2132
+ return r.json()
2133
+ except ValueError:
2134
+ # A server occasionally mislabels which framing it used -- scan for
2135
+ # "data:" lines regardless of the declared content-type before
2136
+ # giving up.
2137
+ data_lines = [
2138
+ line[len("data:"):].strip()
2139
+ for line in r.text.splitlines()
2140
+ if line.startswith("data:")
2141
+ ]
2142
+ if data_lines:
2143
+ try:
2144
+ return json.loads("".join(data_lines))
2145
+ except ValueError:
2146
+ pass
2147
+ raise NSEEndpointError(
2148
+ f"nse_mcp: non-JSON, non-SSE response body (content-type={ctype!r})"
2149
+ )
2150
+
2151
+
2152
+ def _nse_mcp_initialize(server_url):
2153
+ """Run the MCP "initialize" handshake (+ "notifications/initialized")
2154
+ against server_url and return the Mcp-Session-Id NSE's server hands
2155
+ back (or "" if the server doesn't issue one). Retries up to 3 times --
2156
+ the bhavcopy endpoint has been observed to 502 on a cold first request.
2157
+ """
2158
+ session = _get_nse_session()
2159
+ mcp_headers = {
2160
+ "Content-Type": "application/json",
2161
+ "Accept": "application/json, text/event-stream",
2162
+ }
2163
+ init_body = {
2164
+ "jsonrpc": "2.0",
2165
+ "id": 1,
2166
+ "method": "initialize",
2167
+ "params": {
2168
+ "protocolVersion": "2024-11-05",
2169
+ "capabilities": {},
2170
+ "clientInfo": {"name": "nsepython", "version": _NSE_MCP_CLIENT_VERSION},
2171
+ },
2172
+ }
2173
+
2174
+ last_exc = None
2175
+ for attempt in range(3):
2176
+ try:
2177
+ r = session.post(server_url, headers=mcp_headers, json=init_body, timeout=30)
2178
+ except Exception as e:
2179
+ last_exc = NSEEndpointError(f"nse_mcp initialize: request failed for {server_url}: {e}")
2180
+ continue
2181
+
2182
+ if r.status_code == 200:
2183
+ session_id = r.headers.get("mcp-session-id") or r.headers.get("Mcp-Session-Id") or ""
2184
+ notify_headers = dict(mcp_headers)
2185
+ if session_id:
2186
+ notify_headers["Mcp-Session-Id"] = session_id
2187
+ try:
2188
+ session.post(
2189
+ server_url, headers=notify_headers,
2190
+ json={"jsonrpc": "2.0", "method": "notifications/initialized"},
2191
+ timeout=15,
2192
+ )
2193
+ except Exception:
2194
+ pass # fire-and-forget notification; failure here is harmless
2195
+ return session_id
2196
+
2197
+ last_exc = NSEEndpointError(
2198
+ f"nse_mcp initialize: HTTP {r.status_code} for {server_url}"
2199
+ )
2200
+
2201
+ raise last_exc or NSEEndpointError(f"nse_mcp initialize: failed for {server_url}")
2202
+
2203
+
2204
+ def _nse_mcp_get_session_id(server_url, force_new=False):
2205
+ """Return a cached Mcp-Session-Id for server_url, initializing (and
2206
+ caching) one if there isn't one yet or force_new is requested.
2207
+ """
2208
+ if not force_new and server_url in _nse_mcp_session_cache:
2209
+ return _nse_mcp_session_cache[server_url]
2210
+ session_id = _nse_mcp_initialize(server_url)
2211
+ _nse_mcp_session_cache[server_url] = session_id
2212
+ return session_id
2213
+
2214
+
2215
+ def _nse_mcp_call(server_url, tool_name, arguments=None):
2216
+ """Call one tool on an NSE-official MCP server and return its parsed
2217
+ result payload.
2218
+
2219
+ Handles the initialize/session-id handshake (with a small cache keyed
2220
+ by server_url so repeat calls don't re-initialize every time), both
2221
+ plain-JSON and SSE response framing, and the nested
2222
+ result.content[0].text tool-result convention (itself JSON-encoded for
2223
+ every tool checked so far). Raises NSEEndpointError -- never returns
2224
+ `{}` -- on any transport failure, JSON-RPC error, MCP tool-level error,
2225
+ or an application-level {"error": ...} payload the tool itself reports.
2226
+ """
2227
+ arguments = arguments or {}
2228
+ session = _get_nse_session()
2229
+ mcp_headers = {
2230
+ "Content-Type": "application/json",
2231
+ "Accept": "application/json, text/event-stream",
2232
+ }
2233
+
2234
+ data = None
2235
+ last_exc = None
2236
+ for attempt in (1, 2):
2237
+ try:
2238
+ session_id = _nse_mcp_get_session_id(server_url, force_new=(attempt == 2))
2239
+ except NSEEndpointError as e:
2240
+ last_exc = e
2241
+ continue
2242
+
2243
+ call_headers = dict(mcp_headers)
2244
+ if session_id:
2245
+ call_headers["Mcp-Session-Id"] = session_id
2246
+
2247
+ body = {
2248
+ "jsonrpc": "2.0",
2249
+ "id": 3,
2250
+ "method": "tools/call",
2251
+ "params": {"name": tool_name, "arguments": arguments},
2252
+ }
2253
+ try:
2254
+ r = session.post(server_url, headers=call_headers, json=body, timeout=30)
2255
+ except Exception as e:
2256
+ last_exc = NSEEndpointError(f"nse_mcp_call({tool_name}): request failed: {e}")
2257
+ continue
2258
+
2259
+ if r.status_code in (401, 403, 404, 409, 502, 503) and attempt == 1:
2260
+ # Could be a stale/expired session id, or the transient 502 seen
2261
+ # on the bhavcopy endpoint's first request -- drop the cached
2262
+ # session and retry once with a fresh initialize.
2263
+ last_exc = NSEEndpointError(
2264
+ f"nse_mcp_call({tool_name}): HTTP {r.status_code} from {server_url}"
2265
+ )
2266
+ _nse_mcp_session_cache.pop(server_url, None)
2267
+ continue
2268
+
2269
+ if r.status_code != 200:
2270
+ raise NSEEndpointError(
2271
+ f"nse_mcp_call({tool_name}): HTTP {r.status_code} from {server_url}"
2272
+ )
2273
+
2274
+ data = _nse_mcp_parse_response(r)
2275
+ last_exc = None
2276
+ break
2277
+
2278
+ if data is None:
2279
+ raise last_exc or NSEEndpointError(
2280
+ f"nse_mcp_call({tool_name}): failed against {server_url}"
2281
+ )
2282
+
2283
+ if data.get("error"):
2284
+ raise NSEEndpointError(
2285
+ f"nse_mcp_call({tool_name}): JSON-RPC error: {data['error']}"
2286
+ )
2287
+
2288
+ result = data.get("result") or {}
2289
+ content = result.get("content") or []
2290
+ if not content:
2291
+ raise NSEEndpointError(
2292
+ f"nse_mcp_call({tool_name}): empty/missing content in response: {result}"
2293
+ )
2294
+
2295
+ text = content[0].get("text", "")
2296
+ try:
2297
+ payload = json.loads(text)
2298
+ except (ValueError, TypeError):
2299
+ payload = text # plain text/markdown tool result -- hand it back as-is
2300
+
2301
+ if result.get("isError"):
2302
+ raise NSEEndpointError(
2303
+ f"nse_mcp_call({tool_name}): tool reported an error: {payload}"
2304
+ )
2305
+
2306
+ if isinstance(payload, dict) and "error" in payload:
2307
+ # Seen live on nse_get_gainers/nse_get_losers: isError=false but an
2308
+ # inner application-level error from NSE's own server. Don't hand
2309
+ # this back as if it were usable data.
2310
+ raise NSEEndpointError(
2311
+ f"nse_mcp_call({tool_name}): NSE's MCP server reported an application "
2312
+ f"error for this call: {payload['error']}"
2313
+ )
2314
+
2315
+ return payload
2316
+
2317
+
2318
+ def nse_mcp_call(server, tool_name, **kwargs):
2319
+ """Call ANY tool on NSE's own official, no-auth MCP servers by name --
2320
+ a generic escape hatch for a tool this module doesn't have a named
2321
+ wrapper for (yet), or any new tool NSE adds to either server in future.
2322
+
2323
+ `server` is "bhavcopy" (historical/derived data) or "cmmkt" (live
2324
+ market data). `kwargs` become the tool's `arguments` object, passed
2325
+ straight through to NSE's MCP endpoint -- see nse_mcp_list_tools() for
2326
+ each tool's name, description and accepted arguments.
2327
+
2328
+ Backed by NSE's own official, no-auth MCP server, not the
2329
+ Akamai-affected nseindia.com scrape path the rest of this module uses --
2330
+ a notably more reliable route when it covers the data you need.
2331
+ """
2332
+ server_url = _NSE_MCP_SERVERS.get(server)
2333
+ if server_url is None:
2334
+ raise NSEEndpointError(
2335
+ f"nse_mcp_call: unknown server {server!r}, expected 'bhavcopy' or 'cmmkt'"
2336
+ )
2337
+ return _nse_mcp_call(server_url, tool_name, kwargs)
2338
+
2339
+
2340
+ def nse_mcp_list_tools(server=""):
2341
+ """Return NSE's own live tools/list response -- name, description and
2342
+ full inputSchema -- for one MCP server ("bhavcopy" or "cmmkt"), or both
2343
+ (as a dict keyed by server name) when `server` is omitted/empty.
2344
+
2345
+ Always asks the server live rather than returning a hardcoded copy, so
2346
+ this stays accurate if/when NSE changes either server's toolset.
2347
+ """
2348
+ if server:
2349
+ if server not in _NSE_MCP_SERVERS:
2350
+ raise NSEEndpointError(
2351
+ f"nse_mcp_list_tools: unknown server {server!r}, expected 'bhavcopy' or 'cmmkt'"
2352
+ )
2353
+ servers = {server: _NSE_MCP_SERVERS[server]}
2354
+ else:
2355
+ servers = _NSE_MCP_SERVERS
2356
+
2357
+ session = _get_nse_session()
2358
+ out = {}
2359
+ for name, url in servers.items():
2360
+ session_id = _nse_mcp_get_session_id(url)
2361
+ mcp_headers = {
2362
+ "Content-Type": "application/json",
2363
+ "Accept": "application/json, text/event-stream",
2364
+ }
2365
+ if session_id:
2366
+ mcp_headers["Mcp-Session-Id"] = session_id
2367
+ body = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
2368
+ r = session.post(url, headers=mcp_headers, json=body, timeout=30)
2369
+ if r.status_code != 200:
2370
+ raise NSEEndpointError(f"nse_mcp_list_tools({name}): HTTP {r.status_code}")
2371
+ data = _nse_mcp_parse_response(r)
2372
+ if data.get("error"):
2373
+ raise NSEEndpointError(f"nse_mcp_list_tools({name}): JSON-RPC error: {data['error']}")
2374
+ out[name] = (data.get("result") or {}).get("tools", [])
2375
+
2376
+ return out[server] if server else out
2377
+
2378
+
2379
+ def _nse_mcp_records(payload, key):
2380
+ """Return payload[key] (a list of record-dicts) as a DataFrame, or an
2381
+ empty DataFrame if the key is absent -- the same "list field on a dict
2382
+ payload becomes a DataFrame" convention used throughout this file.
2383
+ """
2384
+ rows = payload.get(key) if isinstance(payload, dict) else None
2385
+ return pd.DataFrame(rows if rows else [])
2386
+
2387
+
2388
+ # ---------------------------------------------------------------------------
2389
+ # Named wrappers -- "bhavcopy" server (nse-bhavcopy-redis-mcp, 21 tools)
2390
+ # ---------------------------------------------------------------------------
2391
+
2392
+ def nse_mcp_get_top_by_volume(date="today", n=10, sort_by="volume"):
2393
+ """Get the top N most actively traded NSE stocks on a date, sorted by
2394
+ 'volume' (traded quantity) or 'value' (turnover in Rs). Backed by NSE's
2395
+ own official no-auth MCP server (bhavcopy)."""
2396
+ payload = nse_mcp_call("bhavcopy", "get_top_by_volume", date=date, n=n, sortBy=sort_by)
2397
+ return _nse_mcp_records(payload, "stocks")
2398
+
2399
+
2400
+ def nse_mcp_get_top_movers(date="today", n=10, direction="gain"):
2401
+ """Get the top N gaining ('gain') or losing ('loss') NSE stocks on a
2402
+ date, with OHLCV details. Backed by NSE's own official no-auth MCP
2403
+ server (bhavcopy)."""
2404
+ payload = nse_mcp_call("bhavcopy", "get_top_movers", date=date, n=n, direction=direction)
2405
+ return _nse_mcp_records(payload, "stocks")
2406
+
2407
+
2408
+ def nse_mcp_nse_lookup_symbol(query):
2409
+ """Look up NSE ticker symbols by partial name or keyword (ticker list
2410
+ only, no price data). Backed by NSE's own official no-auth MCP server
2411
+ (bhavcopy)."""
2412
+ payload = nse_mcp_call("bhavcopy", "nse_lookup_symbol", query=query)
2413
+ return payload.get("symbols", []) if isinstance(payload, dict) else payload
2414
+
2415
+
2416
+ def nse_mcp_get_market_mood(date="today"):
2417
+ """Get a factual read of NSE market mood for a day: India VIX level and
2418
+ trend, index/stock advance-decline breadth, and benchmark changes.
2419
+ Backed by NSE's own official no-auth MCP server (bhavcopy)."""
2420
+ return nse_mcp_call("bhavcopy", "get_market_mood", date=date)
2421
+
2422
+
2423
+ def nse_mcp_get_index_valuation(index_name, months=24, date="today"):
2424
+ """Get an NSE index's valuation ratios (P/E, P/B, dividend yield) and
2425
+ where today's value sits within its own recent range. Backed by NSE's
2426
+ own official no-auth MCP server (bhavcopy)."""
2427
+ return nse_mcp_call(
2428
+ "bhavcopy", "get_index_valuation", indexName=index_name, months=months, date=date
2429
+ )
2430
+
2431
+
2432
+ def nse_mcp_get_market_breadth(date="today"):
2433
+ """Get overall NSE market breadth for a trading date: advances,
2434
+ declines, unchanged, A/D ratio, total volume. Backed by NSE's own
2435
+ official no-auth MCP server (bhavcopy)."""
2436
+ return nse_mcp_call("bhavcopy", "get_market_breadth", date=date)
2437
+
2438
+
2439
+ def nse_mcp_get_corporate_actions(symbol, from_date="", to_date=""):
2440
+ """Fetch actual NSE corporate action events (splits/bonus/dividends/
2441
+ other) for a stock, with exact ex-dates and adjustment factors. Backed
2442
+ by NSE's own official no-auth MCP server (bhavcopy)."""
2443
+ payload = nse_mcp_call(
2444
+ "bhavcopy", "get_corporate_actions", symbol=symbol, fromDate=from_date, toDate=to_date
2445
+ )
2446
+ return _nse_mcp_records(payload, "actions")
2447
+
2448
+
2449
+ def nse_mcp_compare_indices(index_names, months=6, date="today"):
2450
+ """Compare 2 to 10 NSE indices side by side: return, annualised
2451
+ volatility, max drawdown and current valuation. Backed by NSE's own
2452
+ official no-auth MCP server (bhavcopy)."""
2453
+ payload = nse_mcp_call(
2454
+ "bhavcopy", "compare_indices", indexNames=index_names, months=months, date=date
2455
+ )
2456
+ return _nse_mcp_records(payload, "indices")
2457
+
2458
+
2459
+ def nse_mcp_get_index_movers(date="today", period="1D", n=10, scope="equity"):
2460
+ """Get the top gaining and top losing NSE indices for a day or period
2461
+ (1D/1W/1M/3M/6M/1Y) -- useful for sector/theme rotation. Backed by NSE's
2462
+ own official no-auth MCP server (bhavcopy). Returns the raw dict (both
2463
+ a 'gainers' and a 'losers' list) since the result isn't a single table."""
2464
+ return nse_mcp_call(
2465
+ "bhavcopy", "get_index_movers", date=date, period=period, n=n, scope=scope
2466
+ )
2467
+
2468
+
2469
+ def nse_mcp_get_ltp_by_date(symbol, date="today"):
2470
+ """Return the last traded (close) price for an NSE symbol on a date
2471
+ (previous trading day's price if the date is a non-trading day). Backed
2472
+ by NSE's own official no-auth MCP server (bhavcopy)."""
2473
+ return nse_mcp_call("bhavcopy", "get_ltp_by_date", symbol=symbol, date=date)
2474
+
2475
+
2476
+ def nse_mcp_get_bulk_quote(symbols):
2477
+ """Get the latest price snapshot (OHLC, prev close, % change, volume)
2478
+ for up to 50 NSE stocks in one call. Backed by NSE's own official
2479
+ no-auth MCP server (bhavcopy)."""
2480
+ payload = nse_mcp_call("bhavcopy", "get_bulk_quote", symbols=symbols)
2481
+ return _nse_mcp_records(payload, "quotes")
2482
+
2483
+
2484
+ def nse_mcp_get_volume_analysis(symbol, days=30):
2485
+ """Analyse trading volume trends for an NSE stock over N trading days:
2486
+ average/max/min volume, volume spike days, recent trend. Backed by
2487
+ NSE's own official no-auth MCP server (bhavcopy)."""
2488
+ return nse_mcp_call("bhavcopy", "get_volume_analysis", symbol=symbol, days=days)
2489
+
2490
+
2491
+ def nse_mcp_get_stock_history(symbol, months=3, end_date="today"):
2492
+ """Get daily OHLCV price history for an NSE stock (up to 3 months per
2493
+ call; chain calls using the response's next_end_date for longer
2494
+ periods). Backed by NSE's own official no-auth MCP server (bhavcopy)."""
2495
+ payload = nse_mcp_call(
2496
+ "bhavcopy", "get_stock_history", symbol=symbol, months=months, endDate=end_date
2497
+ )
2498
+ return _nse_mcp_records(payload, "data")
2499
+
2500
+
2501
+ def nse_mcp_get_index_snapshot(date="today", filter=""):
2502
+ """Get end-of-day values (OHLC, % change, turnover, P/E, P/B, dividend
2503
+ yield) for NSE indices on a date, optionally filtered by a name
2504
+ substring. Backed by NSE's own official no-auth MCP server (bhavcopy)."""
2505
+ payload = nse_mcp_call("bhavcopy", "get_index_snapshot", date=date, filter=filter)
2506
+ return _nse_mcp_records(payload, "indices")
2507
+
2508
+
2509
+ def nse_mcp_search_symbols(query):
2510
+ """Search for NSE stock symbols by company name or partial symbol,
2511
+ returning matches with latest close price and % change. Backed by
2512
+ NSE's own official no-auth MCP server (bhavcopy)."""
2513
+ payload = nse_mcp_call("bhavcopy", "search_symbols", query=query)
2514
+ return _nse_mcp_records(payload, "results")
2515
+
2516
+
2517
+ def nse_mcp_get_stock_vs_index(symbol, index_name="Nifty 50", months=12, date="today"):
2518
+ """Compare one NSE stock against a benchmark index over a period:
2519
+ return of each, outperformance, beta and correlation (stock return is
2520
+ already corporate-action adjusted). Backed by NSE's own official
2521
+ no-auth MCP server (bhavcopy)."""
2522
+ return nse_mcp_call(
2523
+ "bhavcopy", "get_stock_vs_index",
2524
+ symbol=symbol, indexName=index_name, months=months, date=date,
2525
+ )
2526
+
2527
+
2528
+ def nse_mcp_compare_stocks(symbols, months=6):
2529
+ """Compare up to 10 NSE stocks side by side over a period: % return
2530
+ (ranked best to worst) and max drawdown per stock. Backed by NSE's own
2531
+ official no-auth MCP server (bhavcopy)."""
2532
+ payload = nse_mcp_call("bhavcopy", "compare_stocks", symbols=symbols, months=months)
2533
+ return _nse_mcp_records(payload, "stocks")
2534
+
2535
+
2536
+ def nse_mcp_get_index_history(index_name, months=3, end_date="today"):
2537
+ """Get daily history (OHLC, % change, turnover, P/E, P/B, dividend
2538
+ yield) for an NSE index, up to 12 months per call; chain calls using
2539
+ next_end_date for longer periods. Backed by NSE's own official no-auth
2540
+ MCP server (bhavcopy)."""
2541
+ payload = nse_mcp_call(
2542
+ "bhavcopy", "get_index_history", indexName=index_name, months=months, endDate=end_date
2543
+ )
2544
+ return _nse_mcp_records(payload, "data")
2545
+
2546
+
2547
+ def nse_mcp_moving_average(symbol, days=20):
2548
+ """Calculate the simple moving average (SMA) of close prices for an
2549
+ NSE stock over the last N trading days. Backed by NSE's own official
2550
+ no-auth MCP server (bhavcopy)."""
2551
+ return nse_mcp_call("bhavcopy", "moving_average", symbol=symbol, days=days)
2552
+
2553
+
2554
+ def nse_mcp_get_52_week_high_low(symbol):
2555
+ """Get the 52-week high/low for an NSE stock, with dates and the
2556
+ current price's position within that range. Backed by NSE's own
2557
+ official no-auth MCP server (bhavcopy)."""
2558
+ return nse_mcp_call("bhavcopy", "get_52_week_high_low", symbol=symbol)
2559
+
2560
+
2561
+ def nse_mcp_get_index_performance(index_name, date="today"):
2562
+ """Get an NSE index's price performance: 1-day change plus 1W/1M/3M/6M/
2563
+ 1Y/2Y returns and 52-week high/low with distances. Backed by NSE's own
2564
+ official no-auth MCP server (bhavcopy)."""
2565
+ return nse_mcp_call("bhavcopy", "get_index_performance", indexName=index_name, date=date)
2566
+
2567
+
2568
+ # ---------------------------------------------------------------------------
2569
+ # Named wrappers -- "cmmkt" server (cm-market-mcp, 15 tools)
2570
+ # ---------------------------------------------------------------------------
2571
+
2572
+ def nse_mcp_cm_get_live_market_data(index="gainers"):
2573
+ """Get live NSE market data for 'gainers' or 'loosers' (NSE's own
2574
+ spelling), refreshed every 5 minutes. Backed by NSE's own official
2575
+ no-auth MCP server (cmmkt)."""
2576
+ return nse_mcp_call("cmmkt", "cm_get_live_market_data", index=index)
2577
+
2578
+
2579
+ def nse_mcp_cm_get_equity_stocks(limit=100, symbol_filter=""):
2580
+ """Get latest live data for NSE Capital Market EQUITY-segment stocks
2581
+ (series EQ/BE/BL/BT/IL/IQ), refreshed every minute. Backed by NSE's own
2582
+ official no-auth MCP server (cmmkt)."""
2583
+ payload = nse_mcp_call(
2584
+ "cmmkt", "cm_get_equity_stocks", limit=limit, symbolFilter=symbol_filter
2585
+ )
2586
+ return _nse_mcp_records(payload, "stocks")
2587
+
2588
+
2589
+ def nse_mcp_nse_get_losers(limit=10):
2590
+ """Get the top N NSE stocks by % loss, flattened across all indices and
2591
+ sorted ascending. Backed by NSE's own official no-auth MCP server
2592
+ (cmmkt). NOTE: as of this writing NSE's own server has a live bug on
2593
+ this specific tool (confirmed: an internal "ArrayList cannot be cast to
2594
+ Map" exception) -- this raises NSEEndpointError until NSE fixes it; use
2595
+ nse_mcp_nse_get_market_movers() for the same ranking in the meantime."""
2596
+ payload = nse_mcp_call("cmmkt", "nse_get_losers", limit=limit)
2597
+ return _nse_mcp_records(payload, "losers") if isinstance(payload, dict) else payload
2598
+
2599
+
2600
+ def nse_mcp_cm_get_call_auction_stocks(limit=100, symbol_filter=""):
2601
+ """Get latest live data for NSE Call Auction session stocks (series
2602
+ CA/CB), refreshed every minute. Backed by NSE's own official no-auth
2603
+ MCP server (cmmkt)."""
2604
+ payload = nse_mcp_call(
2605
+ "cmmkt", "cm_get_call_auction_stocks", limit=limit, symbolFilter=symbol_filter
2606
+ )
2607
+ return _nse_mcp_records(payload, "stocks")
2608
+
2609
+
2610
+ def nse_mcp_cm_get_bond_stocks(limit=100, symbol_filter=""):
2611
+ """Get latest live data for NSE BONDS/debt instrument series, refreshed
2612
+ every minute. Backed by NSE's own official no-auth MCP server (cmmkt)."""
2613
+ payload = nse_mcp_call(
2614
+ "cmmkt", "cm_get_bond_stocks", limit=limit, symbolFilter=symbol_filter
2615
+ )
2616
+ return _nse_mcp_records(payload, "stocks")
2617
+
2618
+
2619
+ def nse_mcp_cm_get_live_gainers():
2620
+ """Return raw NSE gainers data grouped by index segment (NIFTY,
2621
+ BANKNIFTY, NIFTYNEXT50, allSec, etc.) -- not sorted by % change; use
2622
+ nse_mcp_nse_get_market_movers() for a sorted ranking instead. Backed by
2623
+ NSE's own official no-auth MCP server (cmmkt)."""
2624
+ return nse_mcp_call("cmmkt", "cm_get_live_gainers")
2625
+
2626
+
2627
+ def nse_mcp_nse_get_gainers(limit=10):
2628
+ """Get the top N NSE stocks by % gain, flattened across all indices and
2629
+ sorted descending. Backed by NSE's own official no-auth MCP server
2630
+ (cmmkt). NOTE: as of this writing NSE's own server has a live bug on
2631
+ this specific tool (confirmed: an internal "ArrayList cannot be cast to
2632
+ Map" exception) -- this raises NSEEndpointError until NSE fixes it; use
2633
+ nse_mcp_nse_get_market_movers() for the same ranking in the meantime."""
2634
+ payload = nse_mcp_call("cmmkt", "nse_get_gainers", limit=limit)
2635
+ return _nse_mcp_records(payload, "gainers") if isinstance(payload, dict) else payload
2636
+
2637
+
2638
+ def nse_mcp_cm_get_data_status():
2639
+ """Check freshness of NSE live gainers/losers market data (last crawl
2640
+ time, crawl interval, Redis TTL). Backed by NSE's own official no-auth
2641
+ MCP server (cmmkt)."""
2642
+ return nse_mcp_call("cmmkt", "cm_get_data_status")
2643
+
2644
+
2645
+ def nse_mcp_cm_get_stock_quote(symbol):
2646
+ """Get the latest live quote for one NSE CM stock by exact symbol
2647
+ (works for equity, SME, bond or call-auction segments). Backed by
2648
+ NSE's own official no-auth MCP server (cmmkt)."""
2649
+ return nse_mcp_call("cmmkt", "cm_get_stock_quote", symbol=symbol)
2650
+
2651
+
2652
+ def nse_mcp_cm_get_index_quote(index_name):
2653
+ """Get the full live quote for one NSE index by exact name: last
2654
+ value, change, day's OHLC, 52-week range, and 1W/1M/1Y comparisons.
2655
+ Backed by NSE's own official no-auth MCP server (cmmkt)."""
2656
+ return nse_mcp_call("cmmkt", "cm_get_index_quote", indexName=index_name)
2657
+
2658
+
2659
+ def nse_mcp_cm_get_sme_stocks(limit=100, symbol_filter=""):
2660
+ """Get latest live data for NSE SME (Small & Medium Enterprises) stocks
2661
+ (series SM/ST), refreshed every minute. Backed by NSE's own official
2662
+ no-auth MCP server (cmmkt)."""
2663
+ payload = nse_mcp_call(
2664
+ "cmmkt", "cm_get_sme_stocks", limit=limit, symbolFilter=symbol_filter
2665
+ )
2666
+ return _nse_mcp_records(payload, "stocks")
2667
+
2668
+
2669
+ def nse_mcp_cm_get_live_losers():
2670
+ """Return raw NSE losers data grouped by index segment (NIFTY,
2671
+ BANKNIFTY, NIFTYNEXT50, allSec, etc.) -- not sorted by % change; use
2672
+ nse_mcp_nse_get_market_movers() for a sorted ranking instead. Backed by
2673
+ NSE's own official no-auth MCP server (cmmkt)."""
2674
+ return nse_mcp_call("cmmkt", "cm_get_live_losers")
2675
+
2676
+
2677
+ def nse_mcp_cm_get_live_indices(group="", name_filter=""):
2678
+ """Get the latest live values of NSE indices (last, previous close,
2679
+ change, day's OHLC) across six groups (derivatives/broad/sectoral/
2680
+ strategy/thematic/fixed_income), optionally filtered by group and/or a
2681
+ name substring. Backed by NSE's own official no-auth MCP server
2682
+ (cmmkt)."""
2683
+ return nse_mcp_call("cmmkt", "cm_get_live_indices", group=group, nameFilter=name_filter)
2684
+
2685
+
2686
+ def nse_mcp_nse_get_market_movers(index_name=None, limit=10):
2687
+ """Get the top N gainers and top N losers (sorted) from all NSE
2688
+ securities, or filtered to one of NIFTY/BANKNIFTY/NIFTYNEXT50. The
2689
+ PRIMARY tool for "top gainers/losers today" style questions. Backed by
2690
+ NSE's own official no-auth MCP server (cmmkt). Returns the raw dict
2691
+ (both a 'gainers' and a 'losers' list) since the result isn't a single
2692
+ table."""
2693
+ return nse_mcp_call(
2694
+ "cmmkt", "nse_get_market_movers", indexName=index_name or "", limit=limit
2695
+ )
2696
+
2697
+
2698
+ def nse_mcp_cm_get_allstocks_status():
2699
+ """Check freshness of NSE's all-stocks live data cache: last crawl
2700
+ time, availability, and segment-wise stock counts. Backed by NSE's own
2701
+ official no-auth MCP server (cmmkt)."""
2702
+ return nse_mcp_call("cmmkt", "cm_get_allstocks_status")