nsepython 2.97__py3-none-any.whl → 2.98__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,84 +9,279 @@ 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
87
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
88
255
 
89
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
269
+
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
 
@@ -102,17 +297,10 @@ def running_status():
102
297
 
103
298
  #Getting FNO Symboles
104
299
  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
300
  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
-
301
+ nselist = indices.copy()
302
+ for x in range(len(positions['data'])):
303
+ nselist.append(positions['data'][x]['symbol'])
116
304
  return nselist
117
305
 
118
306
  def nsesymbolpurify(symbol):
@@ -121,15 +309,46 @@ def nsesymbolpurify(symbol):
121
309
 
122
310
  def nse_optionchain_scrapper(symbol):
123
311
  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)
312
+ # Using getSymbolDerivativesData as it provides all expiries and strikes in one go
313
+ url = f'https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolDerivativesData&symbol={symbol}'
314
+ payload = nsefetch(url)
315
+
316
+ # Transformation to match the "data" structure expected by pcr and other functions
317
+ if payload and 'data' in payload:
318
+ new_data = []
319
+ # Group by strikePrice and expiryDate to create a combined CE/PE structure if possible,
320
+ # or just provide the raw list if the consumers can handle it.
321
+ # The current pcr() handles a list of entries where each has CE/PE keys OR is the entry itself.
322
+
323
+ # Actually, let's restructure it to be more compatible with the expected 'data' format:
324
+ # a list of dictionaries, each having 'strikePrice', 'expiryDate', 'CE', 'PE'.
325
+ combined = {}
326
+ for entry in payload['data']:
327
+ sp = entry.get('strikePrice')
328
+ ed = entry.get('expiryDate')
329
+ ot = entry.get('optionType')
330
+ if not sp or not ed or ot == 'XX': continue
331
+
332
+ key = (sp, ed)
333
+ if key not in combined:
334
+ combined[key] = {'strikePrice': sp, 'expiryDate': ed, 'CE': None, 'PE': None}
335
+
336
+ combined[key][ot] = entry
337
+
338
+ payload['data'] = list(combined.values())
339
+
128
340
  return payload
129
341
 
130
342
 
131
343
  def oi_chain_builder(symbol,expiry="latest",oi_mode="full"):
132
344
 
345
+ if expiry == "latest":
346
+ dates = expiry_list(symbol, type="list")
347
+ if dates:
348
+ expiry = dates[0]
349
+ else:
350
+ return pd.DataFrame(), 0.0, ""
351
+
133
352
  payload = nse_optionchain_scrapper(symbol)
134
353
 
135
354
  if(oi_mode=='compact'):
@@ -138,109 +357,329 @@ def oi_chain_builder(symbol,expiry="latest",oi_mode="full"):
138
357
  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
358
  oi_data = pd.DataFrame(columns = col_names)
140
359
 
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']
360
+ # We will populate these dynamically
361
+ rows_list = []
362
+
363
+ if 'expiryDates' not in payload:
364
+ # Fallback for new API structure
365
+ if(expiry=="latest"):
366
+ expiry = expiry_list(symbol, type="list")[0]
367
+ data_list = payload['data']
368
+ else:
369
+ # Legacy structure support
370
+ if(expiry=="latest"):
371
+ expiry = payload['records']['expiryDates'][0]
372
+ data_list = payload['records']['data']
198
373
 
374
+ for m in range(len(data_list)):
375
+ current_expiry_str = data_list[m].get('expiryDates') or data_list[m].get('expiryDate')
376
+ try:
377
+ # Convert both to date objects for robust comparison
378
+ if "-" in current_expiry_str:
379
+ parts = current_expiry_str.split("-")
380
+ if parts[1].isdigit(): fmt = "%d-%m-%Y"
381
+ else: fmt = "%d-%b-%Y"
382
+ curr_date = datetime.datetime.strptime(current_expiry_str, fmt).date()
383
+
384
+ parts_exp = expiry.split("-")
385
+ if parts_exp[1].isdigit(): fmt_exp = "%d-%m-%Y"
386
+ else: fmt_exp = "%d-%b-%Y"
387
+ exp_date = datetime.datetime.strptime(expiry, fmt_exp).date()
388
+ match = (curr_date == exp_date)
389
+ else:
390
+ match = (current_expiry_str == expiry)
391
+ except:
392
+ match = (current_expiry_str == expiry)
393
+
394
+ if match:
395
+ oi_row = {col: 0 for col in col_names}
396
+ oi_row['Strike Price'] = data_list[m]['strikePrice']
397
+
398
+ for side in ['CE', 'PE']:
399
+ prefix = f"{'CALLS' if side == 'CE' else 'PUTS'}_"
400
+ if side in data_list[m] and data_list[m][side] is not None:
401
+ d = data_list[m][side]
402
+ oi_row[prefix + 'OI'] = d.get('openInterest', 0)
403
+ oi_row[prefix + 'Chng in OI'] = d.get('changeinOpenInterest', 0)
404
+ oi_row[prefix + 'Volume'] = d.get('totalTradedVolume', 0)
405
+ oi_row[prefix + 'IV'] = d.get('impliedVolatility', 0)
406
+ oi_row[prefix + 'LTP'] = d.get('lastPrice', 0)
407
+ oi_row[prefix + 'Net Chng'] = d.get('change', 0)
408
+
409
+ if oi_mode == 'full':
410
+ # New API key mapping
411
+ oi_row[prefix + 'Bid Qty'] = d.get('buyQuantity1', d.get('bidQty', 0))
412
+ oi_row[prefix + 'Bid Price'] = d.get('buyPrice1', d.get('bidprice', 0))
413
+ oi_row[prefix + 'Ask Price'] = d.get('sellPrice1', d.get('askPrice', 0))
414
+ oi_row[prefix + 'Ask Qty'] = d.get('sellQuantity1', d.get('askQty', 0))
415
+ oi_row[prefix + 'Chart'] = 0
416
+
417
+ rows_list.append(oi_row)
418
+
419
+ oi_data = pd.DataFrame(rows_list)
420
+ timestamp = payload.get('timestamp', payload.get('records', {}).get('timestamp', ''))
421
+ underlyingValue = payload.get('underlyingValue', payload.get('records', {}).get('underlyingValue', 0))
422
+
423
+ # github.com/aeron7/nsepython issue #80: the current getSymbolDerivativesData
424
+ # payload carries no top-level (or 'records') underlyingValue at all -- it
425
+ # only lives inside each individual CE/PE leaf record. Dig it out of there
426
+ # if the top-level lookup above came back empty.
427
+ if not underlyingValue and data_list:
428
+ for entry in data_list:
429
+ for side in ('CE', 'PE'):
430
+ leaf = entry.get(side)
431
+ if leaf and leaf.get('underlyingValue'):
432
+ underlyingValue = leaf['underlyingValue']
433
+ break
434
+ if underlyingValue:
435
+ break
436
+
437
+ oi_data['time_stamp'] = timestamp
438
+ return oi_data, float(underlyingValue or 0), timestamp
439
+
440
+
441
+ def nse_quote_derivatives(symbol):
442
+ symbol = nsesymbolpurify(symbol)
443
+ if symbol.upper() in fnolist():
444
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolDerivativesData&symbol='+symbol)
445
+ return payload
446
+ else:
447
+ return {"error": f"{symbol} is not in derivatives list."}
199
448
 
200
449
  def nse_quote(symbol,section=""):
201
- #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
450
+ #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
202
451
  symbol = nsesymbolpurify(symbol)
203
452
 
204
453
  if(section==""):
205
- if any(x in symbol for x in fnolist()):
206
- payload = nsefetch('https://www.nseindia.com/api/quote-derivative?symbol='+symbol)
454
+ if any(x in symbol for x in indices):
455
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolDerivativesData&symbol='+symbol)
207
456
  else:
208
- payload = nsefetch('https://www.nseindia.com/api/quote-equity?symbol='+symbol)
457
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol)
209
458
  return payload
210
459
 
460
+ if(section=="trade_info"):
461
+ # The old /api/quote-equity?section=trade_info route is dead on the
462
+ # live site (confirmed HTTP 403, even through the fully-warmed
463
+ # curl_cffi session round 1 built). But every category of data the
464
+ # old endpoint used to return is already present, just reshuffled,
465
+ # inside the NEW working GetQuoteApi?functionName=getSymbolData
466
+ # response this function's section=="" branch already fetches --
467
+ # confirmed field-by-field against the real, documented old
468
+ # response shape (EquityTradeInfo: marketDeptOrderBook.{bid,ask,
469
+ # tradeInfo,valueAtRisk} + securityWiseDP), so this is a pure
470
+ # remap/slice of data already being fetched, not a new network call.
471
+ #
472
+ # Two small fidelity gaps versus the old route, both because the
473
+ # source data for them no longer exists anywhere in the new
474
+ # response (not a mapping oversight):
475
+ # - noBlockDeals/bulkBlockDeals: the new endpoint carries no
476
+ # block-deal info at all -> defaulted to True/[] (i.e. "no
477
+ # block deals known"), not derived from a live block-deal
478
+ # check. Use nse_blockdeal()/get_blockdeals() directly if you
479
+ # need real block-deal data.
480
+ # - securityWiseDP.seriesRemarks: no equivalent field exists in
481
+ # the new response -> always None, same as it is for most
482
+ # symbols on the old route anyway.
483
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol)
484
+ eq = payload['equityResponse'][0]
485
+ ob = eq.get('orderBook', {})
486
+ md = eq.get('metaData', {})
487
+ ti = eq.get('tradeInfo', {})
488
+ pi = eq.get('priceInfo', {})
489
+ si = eq.get('secInfo', {})
490
+
491
+ bid = [{"price": ob.get(f"buyPrice{i}"), "quantity": ob.get(f"buyQuantity{i}")} for i in range(1, 6)]
492
+ ask = [{"price": ob.get(f"sellPrice{i}"), "quantity": ob.get(f"sellQuantity{i}")} for i in range(1, 6)]
493
+
494
+ return {
495
+ "noBlockDeals": True,
496
+ "bulkBlockDeals": [],
497
+ "marketDeptOrderBook": {
498
+ "totalBuyQuantity": ob.get("totalBuyQuantity"),
499
+ "totalSellQuantity": ob.get("totalSellQuantity"),
500
+ "open": md.get("open"),
501
+ "bid": bid,
502
+ "ask": ask,
503
+ "tradeInfo": {
504
+ "totalTradedVolume": ti.get("totalTradedVolume"),
505
+ "totalTradedValue": ti.get("totalTradedValue"),
506
+ "totalMarketCap": ti.get("totalMarketCap"),
507
+ "ffmc": ti.get("ffmc"),
508
+ "impactCost": ti.get("impactCost"),
509
+ "cmDailyVolatility": pi.get("cmDailyVolatility"),
510
+ "cmAnnualVolatility": pi.get("cmAnnualVolatility"),
511
+ "marketLot": ti.get("marketLot"),
512
+ "activeSeries": ti.get("series"),
513
+ },
514
+ "valueAtRisk": {
515
+ "securityVar": si.get("securityvar"),
516
+ "indexVar": si.get("indexvar"),
517
+ "varMargin": si.get("varMargin"),
518
+ "extremeLossMargin": si.get("extremelossMargin"),
519
+ "adhocMargin": si.get("adhocMargin"),
520
+ "applicableMargin": si.get("applicableMargin"),
521
+ },
522
+ },
523
+ "securityWiseDP": {
524
+ "quantityTraded": ti.get("quantitytraded"),
525
+ "deliveryQuantity": ti.get("deliveryquantity"),
526
+ "deliveryToTradedQuantity": ti.get("deliveryToTradedQuantity"),
527
+ "seriesRemarks": None,
528
+ "secWiseDelPosDate": ti.get("secwisedelposdate"),
529
+ },
530
+ }
531
+
211
532
  if(section!=""):
533
+ # Any other section value (e.g. the old "preOpenMarket") still hits
534
+ # the dead /api/quote-equity&section= route -- not yet remapped to
535
+ # a working source. Only trade_info was confirmed+fixed this round.
212
536
  payload = nsefetch('https://www.nseindia.com/api/quote-equity?symbol='+symbol+'&section='+section)
213
537
  return payload
214
-
215
-
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'):
538
+ def nse_expirydetails(payload, i=0, symbol=None):
539
+ expiry_dates = []
540
+ if 'records' in payload:
541
+ expiry_dates = payload['records']['expiryDates']
542
+ elif 'expiryDates' in payload:
543
+ expiry_dates = payload['expiryDates']
544
+ elif 'data' in payload:
545
+ unique_dates = set()
546
+ for entry in payload['data']:
547
+ if 'expiryDate' in entry:
548
+ unique_dates.add(entry['expiryDate'])
549
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
550
+
551
+ # Filter future dates
552
+ future_expiry_dates = []
553
+ if expiry_dates:
554
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
555
+ 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"))
556
+
557
+ # Fallback to expiry_list if i is out of range and we can determine the symbol
558
+ if i >= len(future_expiry_dates):
559
+ if not symbol and 'data' in payload and len(payload['data']) > 0:
560
+ # Try to extract symbol from payload data
561
+ first_entry = payload['data'][0]
562
+ symbol = first_entry.get('symbol')
563
+ if not symbol:
564
+ if 'CE' in first_entry and first_entry['CE']:
565
+ symbol = first_entry['CE'].get('underlying')
566
+ elif 'PE' in first_entry and first_entry['PE']:
567
+ symbol = first_entry['PE'].get('underlying')
568
+
569
+ if symbol:
570
+ dates = expiry_list(symbol, type="list")
571
+ if dates:
572
+ # Filter future dates from expiry_list as well
573
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in dates]
574
+ 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"))
575
+
576
+ if i >= len(future_expiry_dates):
577
+ return None, None
578
+
579
+ currentExpiry = future_expiry_dates[i]
580
+ currentExpiry_dt = datetime.datetime.strptime(currentExpiry, '%d-%b-%Y').date()
581
+ date_today = run_time.date()
582
+ dte = (currentExpiry_dt - date_today).days
583
+ return currentExpiry_dt, dte
584
+ def pcr(payload, inp=0):
229
585
  ce_oi = 0
230
586
  pe_oi = 0
231
- for i in payload['records']['data']:
232
- if i['expiryDate'] == payload['records']['expiryDates'][inp]:
587
+
588
+ # Identify the data and expiry dates based on structure
589
+ if 'records' in payload:
590
+ # Legacy structure
591
+ data_list = payload['records']['data']
592
+ expiry_dates = payload['records']['expiryDates']
593
+ elif 'data' in payload:
594
+ # New structure
595
+ data_list = payload['data']
596
+ # Extract unique sorted expiry dates from data
597
+ unique_dates = set()
598
+ for entry in data_list:
599
+ ed = entry.get('expiryDate') or entry.get('expiryDates')
600
+ if ed:
601
+ unique_dates.add(ed)
602
+ 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"))
603
+ else:
604
+ # If payload is empty or unknown, we can't proceed without fetching
605
+ # But we need a symbol. Try to get it from payload if possible.
606
+ return 0.0
607
+
608
+ if not expiry_dates or inp >= len(expiry_dates):
609
+ # Requested index is outside the current payload's scope.
610
+ # Check if we can fetch more data for this specific symbol.
611
+ symbol = payload.get('symbol') or payload.get('records', {}).get('symbol')
612
+ if not symbol and 'data' in payload and len(payload['data']) > 0:
613
+ first = payload['data'][0]
614
+ symbol = first.get('symbol') or (first.get('CE') and first['CE'].get('underlying'))
615
+
616
+ if symbol and inp > 0:
617
+ # Fetch all expiries to find the target one
618
+ all_expiries = expiry_list(symbol, type="list")
619
+ if inp < len(all_expiries):
620
+ target = all_expiries[inp]
621
+ # Fetch specific expiry data using getOptionChainData
622
+ url = f'https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getOptionChainData&symbol={nsesymbolpurify(symbol)}&params=expiryDate={target}'
623
+ new_payload = nsefetch(url)
624
+ if new_payload and 'data' in new_payload:
625
+ for entry in new_payload['data']:
626
+ ce_oi += entry.get('CE', {}).get('openInterest', 0) if entry.get('CE') else 0
627
+ pe_oi += entry.get('PE', {}).get('openInterest', 0) if entry.get('PE') else 0
628
+ if ce_oi > 0: return pe_oi / ce_oi
629
+ return 0.0
630
+
631
+ target_expiry = expiry_dates[inp]
632
+
633
+ found_data = False
634
+ for i in data_list:
635
+ curr_exp = i.get('expiryDate') or i.get('expiryDates')
636
+ if curr_exp == target_expiry:
637
+ found_data = True
233
638
  try:
234
- ce_oi += i['CE']['openInterest']
235
- pe_oi += i['PE']['openInterest']
236
- except KeyError:
639
+ if 'CE' in i and i['CE']:
640
+ ce_oi += i['CE'].get('openInterest', 0)
641
+ if 'PE' in i and i['PE']:
642
+ pe_oi += i['PE'].get('openInterest', 0)
643
+ except (KeyError, TypeError):
237
644
  pass
645
+
646
+ # If we didn't find any data for the target expiry in the payload,
647
+ # it means the payload was filtered (e.g. by the scrapper). Fetch it now.
648
+ if not found_data:
649
+ symbol = payload.get('symbol') or payload.get('records', {}).get('symbol')
650
+ if symbol:
651
+ url = f'https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getOptionChainData&symbol={nsesymbolpurify(symbol)}&params=expiryDate={target_expiry}'
652
+ new_payload = nsefetch(url)
653
+ if new_payload and 'data' in new_payload:
654
+ for entry in new_payload['data']:
655
+ ce_oi += entry.get('CE', {}).get('openInterest', 0) if entry.get('CE') else 0
656
+ pe_oi += entry.get('PE', {}).get('openInterest', 0) if entry.get('PE') else 0
657
+
658
+ if ce_oi == 0:
659
+ return 0.0
660
+
238
661
  return pe_oi / ce_oi
239
662
 
240
663
  #forum.unofficed.com/t/unable-to-find-nse-quote-meta-api/702/4
241
664
  #Refer https://forum.unofficed.com/t/changed-the-nse-quote-ltp-function/1276
242
665
  def nse_quote_ltp(symbol,expiryDate="latest",optionType="-",strikePrice=0):
243
- payload = nse_quote(symbol)
666
+ if(optionType!="-"):
667
+ payload = nse_quote_derivatives(symbol)
668
+ else:
669
+ if any(x in symbol for x in indices):
670
+ payload = nse_quote_derivatives(symbol)
671
+ else:
672
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol)
673
+
674
+ lastPrice = 0
675
+
676
+ if(optionType=="-"):
677
+ if 'equityResponse' in payload and len(payload['equityResponse']) > 0:
678
+ lastPrice = payload['equityResponse'][0]['orderBook']['lastPrice']
679
+ elif 'data' in payload and len(payload['data']) > 0:
680
+ # For indices, underlyingValue in derivative payload is the current index LTP
681
+ lastPrice = payload['data'][0].get('underlyingValue')
682
+ return lastPrice
244
683
 
245
684
  meta = "Options"
246
685
  if(optionType=="Fut"): meta = "Futures"
@@ -248,36 +687,68 @@ def nse_quote_ltp(symbol,expiryDate="latest",optionType="-",strikePrice=0):
248
687
  if(optionType=="CE"):optionType="Call"
249
688
 
250
689
  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]
690
+ i = 0 if expiryDate=="latest" else 1
691
+ expiry_dates = []
692
+
693
+ # Extract from new FNO payload structure
694
+ if 'data' in payload:
695
+ unique_dates = set()
696
+ for entry in payload['data']:
697
+ if 'expiryDate' in entry:
698
+ it = entry.get('instrumentType', '')
699
+ if (meta == "Futures" and "FUT" in it) or (meta == "Options" and "OPT" in it):
700
+ unique_dates.add(entry['expiryDate'])
701
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
702
+
703
+ # Filter future dates
704
+ future_expiry_dates = []
705
+ if expiry_dates:
706
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
707
+ 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"))
708
+
709
+ # Fallback to expiry_list
710
+ if i >= len(future_expiry_dates):
711
+ dates = expiry_list(symbol, type="list")
712
+ if dates:
713
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in dates]
714
+ 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"))
715
+
716
+ if i < len(future_expiry_dates):
717
+ expiryDate = future_expiry_dates[i]
262
718
 
263
719
 
264
720
  if(optionType!="-"):
265
- for i in payload['stocks']:
266
- if meta in i['metadata']['instrumentType']:
267
- #print(i['metadata'])
721
+ data_list = payload.get('data', [])
722
+ for i in data_list:
723
+ # Check instrument type in identifier or metadata if present
724
+ if meta == "Futures":
725
+ is_match = "FUT" in i.get('instrumentType', '')
726
+ else:
727
+ is_match = "OPT" in i.get('instrumentType', '')
728
+
729
+ if is_match:
268
730
  if(optionType=="Fut"):
269
- if(i['metadata']['expiryDate']==expiryDate):
270
- lastPrice = i['metadata']['lastPrice']
731
+ if(i.get('expiryDate')==expiryDate):
732
+ lastPrice = i.get('lastPrice')
733
+ break
271
734
 
272
735
  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']
736
+ # Some APIs have optionType as 'PE'/'CE' or 'Put'/'Call'
737
+ p_opt_type = i.get('optionType')
738
+ if p_opt_type == "PE": p_opt_type = "Put"
739
+ if p_opt_type == "CE": p_opt_type = "Call"
740
+
741
+ if (i.get("expiryDate")==expiryDate):
742
+ if (p_opt_type==optionType):
743
+ # strikePrice in payload is often string with padding
744
+ try:
745
+ p_strike = float(str(i.get("strikePrice")).strip())
746
+ except:
747
+ p_strike = 0
748
+
749
+ if (p_strike==float(strikePrice)):
750
+ lastPrice = i.get('lastPrice')
751
+ break
281
752
 
282
753
  return lastPrice
283
754
 
@@ -292,45 +763,84 @@ def nse_quote_ltp(symbol,expiryDate="latest",optionType="-",strikePrice=0):
292
763
  # print(nse_quote_ltp("RELIANCE","next","PE",2300))
293
764
 
294
765
  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
766
+ if(optionType!="-"):
767
+ payload = nse_quote_derivatives(symbol)
768
+ else:
769
+ if any(x in symbol for x in indices):
770
+ payload = nse_quote_derivatives(symbol)
771
+ else:
772
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol)
298
773
 
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]
774
+ metadata = {}
305
775
 
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]
776
+ if(optionType=="-"):
777
+ if 'equityResponse' in payload and len(payload['equityResponse']) > 0:
778
+ metadata = payload['equityResponse'][0].get('metaData', {})
779
+ return metadata
311
780
 
312
781
  meta = "Options"
313
782
  if(optionType=="Fut"): meta = "Futures"
314
783
  if(optionType=="PE"):optionType="Put"
315
784
  if(optionType=="CE"):optionType="Call"
316
785
 
786
+ if(expiryDate=="latest") or (expiryDate=="next"):
787
+ i = 0 if expiryDate=="latest" else 1
788
+ expiry_dates = []
789
+ if 'data' in payload:
790
+ unique_dates = set()
791
+ for entry in payload['data']:
792
+ if 'expiryDate' in entry:
793
+ it = entry.get('instrumentType', '')
794
+ if (meta == "Futures" and "FUT" in it) or (meta == "Options" and "OPT" in it):
795
+ unique_dates.add(entry['expiryDate'])
796
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
797
+
798
+ future_expiry_dates = []
799
+ if expiry_dates:
800
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
801
+ 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"))
802
+
803
+ if i >= len(future_expiry_dates):
804
+ dates = expiry_list(symbol, type="list")
805
+ if dates:
806
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in dates]
807
+ 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"))
808
+
809
+ if i < len(future_expiry_dates):
810
+ expiryDate = future_expiry_dates[i]
811
+
812
+ # print(f"DEBUG: Calculated expiryDate={expiryDate}, meta={meta}, optionType={optionType}")
813
+
317
814
  if(optionType!="-"):
318
- for i in payload['stocks']:
319
- if meta in i['metadata']['instrumentType']:
320
- #print(i['metadata'])
815
+ data_list = payload.get('data', [])
816
+ # print(f"DEBUG: Searching in {len(data_list)} items")
817
+ for i in data_list:
818
+ if meta == "Futures":
819
+ is_match = "FUT" in i.get('instrumentType', '')
820
+ else:
821
+ is_match = "OPT" in i.get('instrumentType', '')
822
+
823
+ if is_match:
321
824
  if(optionType=="Fut"):
322
- if(i['metadata']['expiryDate']==expiryDate):
323
- metadata = i['metadata']
825
+ if(i.get('expiryDate')==expiryDate):
826
+ metadata = i
827
+ break
324
828
 
325
829
  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']
830
+ p_opt_type = i.get('optionType')
831
+ if p_opt_type == "PE": p_opt_type = "Put"
832
+ if p_opt_type == "CE": p_opt_type = "Call"
833
+
834
+ if (i.get("expiryDate")==expiryDate):
835
+ if (p_opt_type==optionType):
836
+ try:
837
+ p_strike = float(str(i.get("strikePrice")).strip())
838
+ except:
839
+ p_strike = 0
840
+
841
+ if (p_strike==float(strikePrice)):
842
+ metadata = i
843
+ break
334
844
 
335
845
  return metadata
336
846
 
@@ -355,8 +865,20 @@ def nse_eq(symbol):
355
865
  payload = nsefetch('https://www.nseindia.com/api/quote-derivative?symbol='+symbol)
356
866
  except:
357
867
  pass
358
- except KeyError:
359
- print("Getting Error While Fetching.")
868
+ except (KeyError, NSEEndpointError):
869
+ # /api/quote-equity is retired on the live site (confirmed HTTP 403,
870
+ # Akamai/WAF "Access Denied" page, as of 2026) with no indication it
871
+ # is coming back. The newer NextApi GetQuoteApi endpoint carries the
872
+ # same underlying data (just in a different JSON shape - data lives
873
+ # under payload['equityResponse'][0] instead of payload['priceInfo']/
874
+ # payload['info']) so we fall back to that instead of returning {}.
875
+ logging.warning(
876
+ "nse_eq(%s): /api/quote-equity is retired; returning data from "
877
+ "the newer NextApi quote endpoint instead (see nse_quote() - the "
878
+ "JSON shape differs from the old quote-equity response).",
879
+ symbol,
880
+ )
881
+ payload = nse_quote(symbol)
360
882
  return payload
361
883
 
362
884
 
@@ -370,8 +892,18 @@ def nse_fno(symbol):
370
892
  payload = nsefetch('https://www.nseindia.com/api/quote-equity?symbol='+symbol)
371
893
  except KeyError:
372
894
  pass
373
- except KeyError:
374
- print("Getting Error While Fetching.")
895
+ except (KeyError, NSEEndpointError):
896
+ # /api/quote-derivative is likewise retired (confirmed HTTP 404 on
897
+ # the live site). getSymbolDerivativesData via nse_quote_derivatives()
898
+ # is the working replacement (different JSON shape: a flat 'data'
899
+ # list of per-strike CE/PE records instead of records/underlyingValue).
900
+ logging.warning(
901
+ "nse_fno(%s): /api/quote-derivative is retired; returning data "
902
+ "from the newer NextApi derivatives endpoint instead (see "
903
+ "nse_quote_derivatives() - the JSON shape differs).",
904
+ symbol,
905
+ )
906
+ payload = nse_quote_derivatives(symbol)
375
907
  return payload
376
908
 
377
909
  def quote_equity(symbol):
@@ -411,19 +943,31 @@ def nse_past_results(symbol):
411
943
  symbol = nsesymbolpurify(symbol)
412
944
  return nsefetch('https://www.nseindia.com/api/results-comparision?symbol='+symbol)
413
945
 
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
946
+ def expiry_list(symbol, type=""):
947
+ logging.info("Getting Expiry List of: " + symbol)
948
+ symbol = nsesymbolpurify(symbol)
949
+ url = f'https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getOptionChainDropdown&symbol={symbol}'
950
+ payload = nsefetch(url)
951
+
952
+ if not payload or 'expiryDates' not in payload:
953
+ return [] if type == "list" else pd.DataFrame()
421
954
 
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
955
+ expiry_dates = payload['expiryDates']
956
+
957
+ # Format dates from DD-MM-YYYY to DD-Mon-YYYY
958
+ formatted_dates = []
959
+ for d in expiry_dates:
960
+ try:
961
+ dt = datetime.datetime.strptime(d, "%d-%m-%Y")
962
+ formatted_dates.append(dt.strftime("%d-%b-%Y"))
963
+ except:
964
+ formatted_dates.append(d)
965
+
966
+ if type == "list":
967
+ return formatted_dates
968
+ else:
969
+ # If anything other than "list" is provided (like "df", "pandas", or default), return DataFrame
970
+ return pd.DataFrame({'Date': formatted_dates})
427
971
 
428
972
 
429
973
  def nse_custom_function_secfno(symbol,attribute="lastPrice"):
@@ -442,8 +986,22 @@ def nse_marketStatus():
442
986
  return payload
443
987
 
444
988
  def nse_circular(mode="latest"):
989
+ # The old mode="latest" path (https://nseindia.com/api/latest-circular,
990
+ # no `www.`) is dead on the live site: it returns HTTP 200 but a bare
991
+ # {'error': True, 'status': 500} JSON body -- confirmed this is NOT an
992
+ # Akamai bot-challenge (no injected script, no 403/503), just NSE's own
993
+ # "this route doesn't exist" response. NSE renamed the circulars page
994
+ # itself from /resources/circulars to
995
+ # /resources/exchange-communication-circulars, and a Playwright network
996
+ # capture on that live page shows it calling
997
+ # https://www.nseindia.com/api/circulars?fromDate=DD-MM-YYYY&toDate=DD-MM-YYYY
998
+ # (with `www.`) -- the SAME URL this function's own mode!="latest"
999
+ # branch already used and which was independently confirmed live
1000
+ # (zero params defaults to NSE's own last-7-days/150-record window).
1001
+ # Fix: route "latest" to that same working endpoint too, instead of the
1002
+ # dead no-www path.
445
1003
  if(mode=="latest"):
446
- payload = nsefetch('https://nseindia.com/api/latest-circular')
1004
+ payload = nsefetch('https://www.nseindia.com/api/circulars')
447
1005
  else:
448
1006
  payload = nsefetch('https://www.nseindia.com/api/circulars')
449
1007
  return payload
@@ -465,21 +1023,30 @@ def nsetools_get_quote(symbol):
465
1023
  return payload['data'][m]
466
1024
 
467
1025
 
1026
+ def _nse_index_data():
1027
+ # iislliveblob.niftyindices.com is a dead host (confirmed live: NXDOMAIN,
1028
+ # twice). /api/allIndices on the main site carries the same live index
1029
+ # quotes (139 indices as of this writing, including pe/pb/dy per index).
1030
+ # Its per-row key is 'index' (e.g. "NIFTY 50"), not the old 'indexName' --
1031
+ # alias it so nse_get_index_list()/nse_get_index_quote() below (and any
1032
+ # external code doing the same lookup) keep working unchanged.
1033
+ payload = nsefetch("https://www.nseindia.com/api/allIndices")
1034
+ rows = payload.get("data", [])
1035
+ for row in rows:
1036
+ row.setdefault("indexName", row.get("index"))
1037
+ return rows
1038
+
1039
+
468
1040
  def nse_index():
469
- payload = nsefetch('https://iislliveblob.niftyindices.com/jsonfiles/LiveIndicesWatch.json')
470
- payload = pd.DataFrame(payload["data"])
471
- return payload
1041
+ return pd.DataFrame(_nse_index_data())
472
1042
 
473
1043
  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()
1044
+ return pd.DataFrame(_nse_index_data())["indexName"].tolist()
477
1045
 
478
1046
  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]
1047
+ for row in _nse_index_data():
1048
+ if row["indexName"] == index.upper():
1049
+ return row
483
1050
 
484
1051
  def nse_get_advances_declines(mode="pandas"):
485
1052
  try:
@@ -505,10 +1072,19 @@ def nse_get_top_gainers():
505
1072
  return df.head(5)
506
1073
 
507
1074
  def nse_get_fno_lot_sizes(symbol="all",mode="list"):
508
- url="https://archives.nseindia.com/content/fo/fo_mktlots.csv"
1075
+ # github.com/aeron7/nsepythonserver issue #4 ("lot sizes not working"):
1076
+ # two stacked bugs, confirmed live. (1) archives.nseindia.com silently
1077
+ # redirects this specific file to an unrelated PDF circular these days
1078
+ # (NSE's archives -> nsearchives host migration left a stale redirect on
1079
+ # just this path) -- nsearchives.nseindia.com/content/fo/fo_mktlots.csv
1080
+ # is the real, current location, confirmed live with the exact same CSV
1081
+ # shape. (2) plain `requests.get()` against nsearchives.nseindia.com
1082
+ # hangs to a read-timeout (confirmed live) -- it needs the same
1083
+ # curl_cffi TLS impersonation as the rest of the site now.
1084
+ url="https://nsearchives.nseindia.com/content/fo/fo_mktlots.csv"
509
1085
 
510
1086
  if(mode=="list"):
511
- s=requests.get(url).text
1087
+ s = _nse_fetch_csv_text(url)
512
1088
  res_dict = {}
513
1089
  for line in s.split('\n'):
514
1090
  if line != '' and re.search(',', line) and (line.casefold().find('symbol') == -1):
@@ -520,7 +1096,7 @@ def nse_get_fno_lot_sizes(symbol="all",mode="list"):
520
1096
  return res_dict[symbol.upper()]
521
1097
 
522
1098
  if(mode=="pandas"):
523
- payload = pd.read_csv(url)
1099
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(url)))
524
1100
  if(symbol=="all"):
525
1101
  return payload
526
1102
  else:
@@ -574,7 +1150,13 @@ def black_scholes_dexter(S0,X,t,σ="",r=10,q=0.0,td=365):
574
1150
 
575
1151
  def equity_history_virgin(symbol,series,start_date,end_date):
576
1152
  #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
1153
+ # NOTE: the original /api/historical/cm/equity route is retired on the
1154
+ # live site (confirmed HTTP 503 as of 2026, even via curl_cffi). NSE's
1155
+ # replacement is /api/historicalOR/cm/equity -- same query params, same
1156
+ # response shape (payload['data'] records with CH_TIMESTAMP/
1157
+ # CH_CLOSING_PRICE/etc), confirmed live, so this is a plain host-path
1158
+ # swap with no downstream parsing changes needed.
1159
+ url = 'https://www.nseindia.com/api/historicalOR/cm/equity?symbol=' + symbol + '&series=["' + series + '"]&from=' + start_date + '&to=' + end_date
578
1160
 
579
1161
  payload = nsefetch(url)
580
1162
  return pd.DataFrame.from_records(payload["data"])
@@ -654,7 +1236,10 @@ def derivative_history_virgin(symbol,start_date,end_date,instrumentType,expiry_d
654
1236
  strikePrice = "%.2f" % strikePrice
655
1237
  strikePrice = str(strikePrice)
656
1238
 
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+""
1239
+ # /api/historical/fo/derivatives is retired (HTTP 503 live); the
1240
+ # confirmed-working replacement is /api/historicalOR/fo/derivatives with
1241
+ # the same query params and response shape.
1242
+ 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
1243
  payload = nsefetch(nsefetch_url)
659
1244
  logging.info(nsefetch_url)
660
1245
  logging.info(payload)
@@ -720,7 +1305,10 @@ def derivative_history(symbol,start_date,end_date,instrumentType,expiry_date,str
720
1305
 
721
1306
  def expiry_history(symbol,start_date="",end_date="",type="options"):
722
1307
  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+""
1308
+ # Same retirement as derivative_history_virgin()/equity_history_virgin()
1309
+ # above -- /api/historical/* is gone, /api/historicalOR/* is the working
1310
+ # replacement with an identical response shape.
1311
+ nsefetch_url = "https://www.nseindia.com/api/historicalOR/fo/derivatives/meta?&from="+start_date+"&to="+end_date+"&symbol="+symbol+""
724
1312
  payload = nsefetch(nsefetch_url)
725
1313
 
726
1314
  #print(payload)
@@ -755,58 +1343,159 @@ def expiry_history(symbol,start_date="",end_date="",type="options"):
755
1343
  return filtered_date_payload
756
1344
 
757
1345
  # # Nifty Indicies Site
1346
+ #
1347
+ # niftyindices.com is a completely separate host/site from nseindia.com (no
1348
+ # Akamai Bot Manager symptoms observed here) -- but it was fully redesigned
1349
+ # onto a different CMS at some point: the old ASP.NET WebMethods under
1350
+ # `niftyindices.com/Backpage.aspx/*` (returning `{"d": "<json string>"}`) are
1351
+ # gone, and POSTing to them now just returns the site's homepage HTML, which
1352
+ # is exactly github.com/aeron7/nsepython issue #78's
1353
+ # `JSONDecodeError: Expecting value: line 1 column 2 (char 1)`.
1354
+ #
1355
+ # The working replacement (confirmed live) is `www.niftyindices.com/BackPage/*`
1356
+ # (note: `www.` + `BackPage` not `Backpage.aspx`), which wants a short session
1357
+ # warm-up first (visiting the historical-data report page) and returns a
1358
+ # direct JSON array rather than the old `{"d": "..."}` wrapper.
758
1359
 
759
1360
  niftyindices_headers = {
760
- 'Connection': 'keep-alive',
761
- 'sec-ch-ua': '" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"',
762
1361
  'Accept': 'application/json, text/javascript, */*; q=0.01',
763
- 'DNT': '1',
1362
+ 'Accept-Language': 'en-US,en;q=0.9,hi;q=0.8',
1363
+ 'Content-Type': 'application/json; charset=UTF-8',
1364
+ 'Origin': 'https://www.niftyindices.com',
1365
+ 'Referer': 'https://www.niftyindices.com/reports/historical-data',
1366
+ '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
1367
  'X-Requested-With': 'XMLHttpRequest',
1368
+ 'sec-ch-ua': '"Not;A=Brand";v="8", "Chromium";v="130", "Google Chrome";v="130"',
765
1369
  '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',
1370
+ 'sec-ch-ua-platform': '"Windows"',
774
1371
  }
775
1372
 
776
- def index_history(symbol,start_date,end_date):
1373
+ _niftyindices_session = None
1374
+ _niftyindices_warmed = False
1375
+
1376
+
1377
+ def _get_niftyindices_session():
1378
+ global _niftyindices_session, _niftyindices_warmed
1379
+ if _niftyindices_session is None:
1380
+ _niftyindices_session = requests.Session()
1381
+ if not _niftyindices_warmed:
1382
+ try:
1383
+ _niftyindices_session.get(
1384
+ "https://www.niftyindices.com/reports/historical-data",
1385
+ headers=niftyindices_headers, timeout=15,
1386
+ )
1387
+ _niftyindices_warmed = True
1388
+ except Exception as e:
1389
+ logging.warning("niftyindices.com session warm-up failed/partial: %s", e)
1390
+ return _niftyindices_session
1391
+
1392
+
1393
+ def _niftyindices_fetch(endpoint, symbol, start_date, end_date):
1394
+ session = _get_niftyindices_session()
777
1395
  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
1396
+ response = session.post(
1397
+ f"https://www.niftyindices.com/BackPage/{endpoint}",
1398
+ headers=niftyindices_headers, json=data, timeout=20,
1399
+ )
1400
+ text = response.text.strip()
1401
+ if text.startswith('<!DOCTYPE') or text.startswith('<html') or text == "":
1402
+ raise NSEEndpointError(
1403
+ f"niftyindices.com/BackPage/{endpoint} returned HTML/empty instead of JSON "
1404
+ f"(HTTP {response.status_code}) -- the site may be down or have changed again."
1405
+ )
1406
+ try:
1407
+ payload = response.json()
1408
+ except ValueError:
1409
+ raise NSEEndpointError(
1410
+ f"niftyindices.com/BackPage/{endpoint}: non-JSON body (HTTP {response.status_code})"
1411
+ )
1412
+ # Old API wrapped the payload as {"d": "<json string>"}; the new one
1413
+ # returns the array directly. Support both so this keeps working if
1414
+ # niftyindices.com ever reverts/mixes the two shapes.
1415
+ if isinstance(payload, dict) and "d" in payload:
1416
+ payload = json.loads(payload["d"])
1417
+ return pd.DataFrame.from_records(payload)
1418
+
1419
+
1420
+ def index_history(symbol,start_date,end_date):
1421
+ return _niftyindices_fetch("getHistoricaldatatabletoString", symbol, start_date, end_date)
782
1422
 
783
1423
  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
1424
+ return _niftyindices_fetch("getpepbHistoricaldataDBtoString", symbol, start_date, end_date)
789
1425
 
790
1426
  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
1427
+ return _niftyindices_fetch("getTotalReturnIndexString", symbol, start_date, end_date)
796
1428
 
797
1429
  def get_bhavcopy(date):
798
1430
  date = date.replace("-","")
799
- payload=pd.read_csv("https://archives.nseindia.com/products/content/sec_bhavdata_full_"+date+".csv")
1431
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1432
+ "https://archives.nseindia.com/products/content/sec_bhavdata_full_"+date+".csv")))
800
1433
  return payload
801
1434
 
802
1435
  def get_bulkdeals():
803
- payload=pd.read_csv("https://archives.nseindia.com/content/equities/bulk.csv")
1436
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1437
+ "https://archives.nseindia.com/content/equities/bulk.csv")))
804
1438
  return payload
805
1439
 
806
1440
  def get_blockdeals():
807
- payload=pd.read_csv("https://archives.nseindia.com/content/equities/block.csv")
1441
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1442
+ "https://archives.nseindia.com/content/equities/block.csv")))
808
1443
  return payload
809
1444
 
1445
+ def _nse_top_corp_info(symbol):
1446
+ """`/api/top-corp-info?symbol=X&market=equities` bundles a company's
1447
+ latest announcements, corporate actions (bonus/dividend/split/demerger),
1448
+ shareholding pattern history, financial results, and board meetings in
1449
+ one call -- confirmed live and working through curl_cffi+warm-up. This
1450
+ backs both dividend_timeline() and share_holding() below."""
1451
+ symbol = nsesymbolpurify(symbol)
1452
+ return nsefetch(f"https://www.nseindia.com/api/top-corp-info?symbol={symbol}&market=equities")
1453
+
1454
+
1455
+ def dividend_timeline(symbol):
1456
+ """github.com/aeron7/nsepython issue #75: documented on
1457
+ unofficed.com/nse-python/ but never actually implemented in the code
1458
+ (calling it raised `AttributeError: module 'nsepython' has no attribute
1459
+ 'dividend_timeline'`). Implemented here from `/api/top-corp-info`'s
1460
+ `corporate_actions` list, filtered down to the dividend-purpose entries
1461
+ (that list also contains bonuses/splits/demergers/etc, which this
1462
+ function intentionally excludes to match its name)."""
1463
+ data = _nse_top_corp_info(symbol)
1464
+ actions = (data.get("corporate_actions") or {}).get("data") or []
1465
+ dividends = [a for a in actions if "dividend" in (a.get("purpose") or "").lower()]
1466
+ return pd.DataFrame.from_records(dividends)
1467
+
1468
+
1469
+ def share_holding(symbol):
1470
+ """github.com/aeron7/nsepython issue #75: same situation as
1471
+ dividend_timeline() above -- documented but not implemented. Built from
1472
+ `/api/top-corp-info`'s `shareholdings_patterns` data, which is a dict
1473
+ keyed by filing date (e.g. "31-Mar-2026") whose value is a list of
1474
+ {"<category>": "<percent>"} rows (Promoter & Promoter Group / Public /
1475
+ Shares held by Employee Trusts / Total). Flattened here into one row per
1476
+ filing date with a column per category, newest filing first."""
1477
+ data = _nse_top_corp_info(symbol)
1478
+ by_date = (data.get("shareholdings_patterns") or {}).get("data") or {}
1479
+ rows = []
1480
+ for filing_date, categories in by_date.items():
1481
+ row = {"date": filing_date}
1482
+ for entry in categories:
1483
+ for k, v in entry.items():
1484
+ row[k.strip()] = v.strip() if isinstance(v, str) else v
1485
+ rows.append(row)
1486
+ df = pd.DataFrame.from_records(rows)
1487
+ if not df.empty and "date" in df.columns:
1488
+ try:
1489
+ df = df.sort_values(
1490
+ by="date",
1491
+ key=lambda s: pd.to_datetime(s, format="%d-%b-%Y"),
1492
+ ascending=False,
1493
+ ).reset_index(drop=True)
1494
+ except Exception:
1495
+ pass
1496
+ return df
1497
+
1498
+
810
1499
  #Request from subhash
811
1500
  ## https://unofficed.com/how-to-find-the-beta-of-indian-stocks-using-python/
812
1501
  def get_beta_df_maker(symbol,days):
@@ -841,11 +1530,32 @@ def getbeta(symbol,days=365,symbol2="NIFTY 50"):
841
1530
 
842
1531
  def get_beta(symbol,days=365,symbol2="NIFTY 50"):
843
1532
  #Default is 248 days. (Input of Subhash)
844
- df = get_beta_df_maker(symbol,days)
845
- df2 = get_beta_df_maker(symbol2,days)
1533
+ # github.com/aeron7/nsepython issue #75: this used to raise a raw
1534
+ # KeyError('data') because equity_history() silently returned {} on a
1535
+ # blocked/retired endpoint. nsefetch() now raises a descriptive
1536
+ # NSEEndpointError instead of swallowing the failure -- surface that
1537
+ # (plus any other unexpected shape problem) as a clear, named error
1538
+ # instead of a bare KeyError, per the issue reporter's own suggestion.
1539
+ try:
1540
+ df = get_beta_df_maker(symbol,days)
1541
+ df2 = get_beta_df_maker(symbol2,days)
1542
+ except NSEEndpointError:
1543
+ raise
1544
+ except Exception as e:
1545
+ raise NSEEndpointError(
1546
+ f"get_beta({symbol!r}, symbol2={symbol2!r}): could not build the "
1547
+ f"daily-change series needed for beta -- {e}"
1548
+ ) from e
846
1549
 
847
1550
  x=df["daily_change"].tolist()
848
1551
  y=df2["daily_change"].tolist()
1552
+
1553
+ if not x or not y:
1554
+ raise NSEEndpointError(
1555
+ f"get_beta({symbol!r}, symbol2={symbol2!r}): got no historical "
1556
+ f"price data back for the requested {days}-day window."
1557
+ )
1558
+
849
1559
  #stackoverflow.com/questions/42670055/is-there-any-better-way-to-calculate-the-covariance-of-two-lists-than-this
850
1560
  mean_x = sum(x) / len(x)
851
1561
  mean_y = sum(y) / len(y)
@@ -854,12 +1564,26 @@ def get_beta(symbol,days=365,symbol2="NIFTY 50"):
854
1564
  mean = sum(y) / len(y)
855
1565
  variance = sum((i - mean) ** 2 for i in y) / len(y)
856
1566
 
1567
+ if variance == 0:
1568
+ raise NSEEndpointError(
1569
+ f"get_beta({symbol!r}, symbol2={symbol2!r}): symbol2 had zero "
1570
+ f"price variance over this window, beta is undefined."
1571
+ )
1572
+
857
1573
  beta = covariance/variance
858
1574
  return round(beta,3)
859
1575
 
860
1576
  def nse_preopen(key="NIFTY",type="pandas"):
861
1577
  payload = nsefetch("https://www.nseindia.com/api/market-data-pre-open?key="+key+"")
862
1578
  if(type=="pandas"):
1579
+ # NSE's pre-open-market window for most `key` values (e.g. "NIFTY")
1580
+ # is only populated for a few minutes each morning; outside that
1581
+ # window `data` is a legitimate empty list ({"data": [], "msg": "No
1582
+ # Data Found"}), which used to raise a confusing KeyError('metadata')
1583
+ # trying to pull a column out of an empty DataFrame. Return an empty
1584
+ # DataFrame instead.
1585
+ if not payload.get('data'):
1586
+ return pd.DataFrame()
863
1587
  payload = pd.DataFrame(payload['data'])
864
1588
  payload = pd.json_normalize(payload['metadata'])
865
1589
  return payload
@@ -886,7 +1610,8 @@ def nse_most_active(type="securities",sort="value"):
886
1610
 
887
1611
  def nse_eq_symbols():
888
1612
  #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')
1613
+ eq_list_pd = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1614
+ 'https://archives.nseindia.com/content/equities/EQUITY_L.csv')))
890
1615
  return eq_list_pd['SYMBOL'].tolist()
891
1616
 
892
1617
  def nse_price_band_hitters(bandtype="both",view="AllSec"):
@@ -904,16 +1629,42 @@ def nse_largedeals(mode="bulk_deals"):
904
1629
  return pd.DataFrame(payload["SHORT_DEALS_DATA"])
905
1630
  if(mode=="block_deals"):
906
1631
  return pd.DataFrame(payload["BLOCK_DEALS_DATA"])
907
-
1632
+
908
1633
  def nse_largedeals_historical(from_date, to_date, mode="bulk_deals"):
1634
+ # The old /api/historical/{bulk-deals,short-selling,block-deals} family is
1635
+ # retired on the live site (confirmed HTTP 503 straight from NSE's origin
1636
+ # -- not an Akamai bot-challenge: the 503 body is a tiny generic Apache
1637
+ # ErrorDocument page returned with a consistent ~20-30ms *origin* timing
1638
+ # on every single attempt, with or without warm-up/referer variations,
1639
+ # which is the signature of a dead backend route rather than a solvable
1640
+ # JS sensor wall).
1641
+ #
1642
+ # Found the real, current replacement by driving NSE's own "Bulk Deals/
1643
+ # Block Deals/ Short Selling Archives" report page
1644
+ # (https://www.nseindia.com/report-detail/display-bulk-and-block-deals)
1645
+ # with Playwright and capturing what it actually calls when you click
1646
+ # Go: `/api/historicalOR/bulk-block-short-deals?optionType=<mode>&from=
1647
+ # ..&to=..` -- same host-prefix swap pattern as equity/derivatives above,
1648
+ # just a different path and param name (`optionType=`, not a path
1649
+ # segment), confirmed live for all three modes. Response shape is the
1650
+ # same `{"data": [...]}` the old endpoint returned, just with a different
1651
+ # (current) NSE column-name scheme:
1652
+ # bulk_deals/block_deals -> BD_DT_DATE, BD_DT_ORDER, BD_SYMBOL,
1653
+ # BD_SCRIP_NAME, BD_CLIENT_NAME, BD_BUY_SELL,
1654
+ # BD_QTY_TRD, BD_TP_WATP, BD_REMARKS
1655
+ # short_deals -> SS_DATE, SS_DATE_ORDER, SS_SYMBOL, SS_NAME,
1656
+ # SS_QTY
909
1657
  if mode == "bulk_deals":
910
- mode = "bulk-deals"
1658
+ option_type = "bulk_deals"
911
1659
  elif mode == "short_deals":
912
- mode = "short-selling"
1660
+ option_type = "short_selling"
913
1661
  elif mode == "block_deals":
914
- mode = "block-deals"
915
-
916
- url='https://www.nseindia.com/api/historical/' + mode + '?from=' + from_date + '&to=' + to_date
1662
+ option_type = "block_deals"
1663
+ else:
1664
+ option_type = mode
1665
+
1666
+ url = ('https://www.nseindia.com/api/historicalOR/bulk-block-short-deals'
1667
+ '?optionType=' + option_type + '&from=' + from_date + '&to=' + to_date)
917
1668
  logging.info("Fetching " + str(url))
918
1669
  payload = nsefetch(url)
919
1670
  return pd.DataFrame(payload["data"])
@@ -922,45 +1673,86 @@ def nse_largedeals_historical(from_date, to_date, mode="bulk_deals"):
922
1673
  #print(get_fao_participant_oi("04-06-2021"))
923
1674
  def get_fao_participant_oi(date):
924
1675
  date = date.replace("-","")
925
- payload=pd.read_csv("https://archives.nseindia.com/content/nsccl/fao_participant_oi_"+date+".csv")
1676
+ payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1677
+ "https://archives.nseindia.com/content/nsccl/fao_participant_oi_"+date+".csv")))
926
1678
  return payload
927
1679
 
928
1680
  #https://forum.unofficed.com/t/how-to-check-if-the-market-is-open-today-or-not/1268/1
929
1681
  def is_market_open(segment = "FO"): #COM,CD,CB,CMOT,COM,FO,IRD,MF,NDM,NTRP,SLBS
930
-
1682
+ # Bug fix: the previous version returned True/False based only on
1683
+ # holiday_json's *first* entry, so it almost always reported "open"
1684
+ # regardless of today's actual date (today is essentially never the
1685
+ # first holiday in the list). Scan the whole list for a match instead.
931
1686
  holiday_json = nse_holidays()[segment]
932
1687
 
933
1688
  # Get today's date in the format 'dd-Mon-yyyy'
934
1689
  today_date = datetime.date.today().strftime('%d-%b-%Y')
935
1690
 
936
- # Check if today's date is in the holiday_json
937
1691
  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']}")
1692
+ if holiday.get('tradingDate') == today_date:
1693
+ print(f"Market is closed today because of {holiday.get('description')}")
943
1694
  return False
944
1695
 
945
- def nse_expirydetails_by_symbol(symbol,meta ="Futures",i=0):
946
- payload = nse_quote(symbol)
947
-
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)
1696
+ print("FNO Market is open today. Have a Nice Trade!")
1697
+ return True
952
1698
 
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()}"
1699
+ def nse_expirydetails_by_symbol(symbol,meta ="Futures",i=0):
1700
+ payload = nse_quote_derivatives(symbol)
1701
+ expiry_dates = []
1702
+
1703
+ # Extract from new FNO payload structure
1704
+ if 'data' in payload:
1705
+ unique_dates = set()
1706
+ for entry in payload['data']:
1707
+ if 'expiryDate' in entry:
1708
+ # Filter by meta type if possible, though 'data' usually contains all
1709
+ # To be precise, we can check instrumentType
1710
+ it = entry.get('instrumentType', '')
1711
+ if (meta == "Futures" and "FUT" in it) or (meta == "Options" and "OPT" in it):
1712
+ unique_dates.add(entry['expiryDate'])
1713
+ expiry_dates = sorted(list(unique_dates), key=lambda x: datetime.datetime.strptime(x, "%d-%b-%Y"))
1714
+
1715
+ # Filter future dates
1716
+ future_expiry_dates = []
1717
+ if expiry_dates:
1718
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
1719
+ 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"))
1720
+
1721
+ # Fallback to expiry_list if i is out of range
1722
+ if i >= len(future_expiry_dates):
1723
+ dates = expiry_list(symbol, type="list")
1724
+ if dates:
1725
+ temp_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in dates]
1726
+ 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"))
1727
+
1728
+ if i >= len(future_expiry_dates):
1729
+ return None, None
1730
+
1731
+ currentExpiry = future_expiry_dates[i]
1732
+ currentExpiry_dt = datetime.datetime.strptime(currentExpiry, '%d-%b-%Y').date()
1733
+ date_today = run_time.date()
1734
+ dte = (currentExpiry_dt - date_today).days
1735
+ return currentExpiry_dt, dte
1736
+
1737
+ def security_wise_archive(from_date, to_date, symbol, series="ALL"):
1738
+ # The old /api/historical/securityArchives route is retired on the live
1739
+ # site (confirmed HTTP 503 straight from NSE's origin -- same dead-route
1740
+ # signature as nse_largedeals_historical() above, not a solvable Akamai
1741
+ # challenge: tiny generic Apache ErrorDocument body, consistent fast
1742
+ # origin timing on every attempt regardless of warm-up/referer).
1743
+ #
1744
+ # Found the real, current replacement by driving NSE's own "Security-wise
1745
+ # Archives (Equities)" report page
1746
+ # (https://www.nseindia.com/report-detail/eq_security) with Playwright
1747
+ # and capturing what it actually calls when you click Go:
1748
+ # `/api/historicalOR/generateSecurityWiseHistoricalData?from=..&to=..&
1749
+ # symbol=..&type=..&series=..` -- same host-prefix-swap family as
1750
+ # equity_history()/derivative_history() above, just a different path and
1751
+ # `type=` instead of `dataType=`. Confirmed live: response shape is the
1752
+ # same `{"data": [...]}` with the same CH_*/COP_DELIV_* column names the
1753
+ # old endpoint used (cross-checked against equity_history()'s numbers for
1754
+ # the same symbol/dates -- exact match).
1755
+ base_url = "https://www.nseindia.com/api/historicalOR/generateSecurityWiseHistoricalData"
1756
+ url = f"{base_url}?from={from_date}&to={to_date}&symbol={symbol.upper()}&type=priceVolumeDeliverable&series={series.upper()}"
965
1757
  payload = nsefetch(url)
966
- return pd.DataFrame(payload['data'])
1758
+ return pd.DataFrame(payload['data'])