nsepython 2.98__tar.gz → 2.100__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nsepython
3
- Version: 2.98
3
+ Version: 2.100
4
4
  Summary: Python library for NSE India APIs
5
5
  Home-page: https://github.com/aeron7/nsepython
6
6
  Author: Aeron7
@@ -288,7 +288,18 @@ curl_headers = ''' -H "authority: beta.nseindia.com" -H "cache-control: max-age=
288
288
  run_time=datetime.datetime.now()
289
289
 
290
290
  #Constants
291
- 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']
292
303
 
293
304
  def running_status():
294
305
  start_now=datetime.datetime.now().replace(hour=9, minute=15, second=0, microsecond=0)
@@ -440,21 +451,47 @@ def oi_chain_builder(symbol,expiry="latest",oi_mode="full"):
440
451
 
441
452
  def nse_quote_derivatives(symbol):
442
453
  symbol = nsesymbolpurify(symbol)
443
- if symbol.upper() in fnolist():
444
- payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolDerivativesData&symbol='+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)
445
465
  return payload
446
466
  else:
447
467
  return {"error": f"{symbol} is not in derivatives list."}
448
468
 
449
469
  def nse_quote(symbol,section=""):
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
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.
451
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()
452
489
 
453
490
  if(section==""):
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)
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)
456
493
  else:
457
- payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol)
494
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol_u)
458
495
  return payload
459
496
 
460
497
  if(section=="trade_info"):
@@ -480,7 +517,14 @@ def nse_quote(symbol,section=""):
480
517
  # - securityWiseDP.seriesRemarks: no equivalent field exists in
481
518
  # the new response -> always None, same as it is for most
482
519
  # 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)
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
+ )
484
528
  eq = payload['equityResponse'][0]
485
529
  ob = eq.get('orderBook', {})
486
530
  md = eq.get('metaData', {})
@@ -529,12 +573,49 @@ def nse_quote(symbol,section=""):
529
573
  },
530
574
  }
531
575
 
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.
536
- payload = nsefetch('https://www.nseindia.com/api/quote-equity?symbol='+symbol+'&section='+section)
537
- return payload
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
+ )
538
619
  def nse_expirydetails(payload, i=0, symbol=None):
539
620
  expiry_dates = []
540
621
  if 'records' in payload:
@@ -581,17 +662,45 @@ def nse_expirydetails(payload, i=0, symbol=None):
581
662
  date_today = run_time.date()
582
663
  dte = (currentExpiry_dt - date_today).days
583
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
+
584
692
  def pcr(payload, inp=0):
585
693
  ce_oi = 0
586
694
  pe_oi = 0
587
-
695
+
588
696
  # Identify the data and expiry dates based on structure
589
697
  if 'records' in payload:
590
698
  # Legacy structure
591
699
  data_list = payload['records']['data']
592
700
  expiry_dates = payload['records']['expiryDates']
593
701
  elif 'data' in payload:
594
- # New structure
702
+ # New structure (covers BOTH the nested per-strike shape and the
703
+ # flat per-contract-leg shape -- see _pcr_entry_oi() above)
595
704
  data_list = payload['data']
596
705
  # Extract unique sorted expiry dates from data
597
706
  unique_dates = set()
@@ -601,9 +710,16 @@ def pcr(payload, inp=0):
601
710
  unique_dates.add(ed)
602
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"))
603
712
  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
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
+ )
607
723
 
608
724
  if not expiry_dates or inp >= len(expiry_dates):
609
725
  # Requested index is outside the current payload's scope.
@@ -611,8 +727,10 @@ def pcr(payload, inp=0):
611
727
  symbol = payload.get('symbol') or payload.get('records', {}).get('symbol')
612
728
  if not symbol and 'data' in payload and len(payload['data']) > 0:
613
729
  first = payload['data'][0]
614
- symbol = first.get('symbol') or (first.get('CE') and first['CE'].get('underlying'))
615
-
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
+
616
734
  if symbol and inp > 0:
617
735
  # Fetch all expiries to find the target one
618
736
  all_expiries = expiry_list(symbol, type="list")
@@ -629,17 +747,16 @@ def pcr(payload, inp=0):
629
747
  return 0.0
630
748
 
631
749
  target_expiry = expiry_dates[inp]
632
-
750
+
633
751
  found_data = False
634
752
  for i in data_list:
635
753
  curr_exp = i.get('expiryDate') or i.get('expiryDates')
636
754
  if curr_exp == target_expiry:
637
755
  found_data = True
638
756
  try:
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)
757
+ c, p = _pcr_entry_oi(i)
758
+ ce_oi += c
759
+ pe_oi += p
643
760
  except (KeyError, TypeError):
644
761
  pass
645
762
 
@@ -663,13 +780,17 @@ def pcr(payload, inp=0):
663
780
  #forum.unofficed.com/t/unable-to-find-nse-quote-meta-api/702/4
664
781
  #Refer https://forum.unofficed.com/t/changed-the-nse-quote-ltp-function/1276
665
782
  def nse_quote_ltp(symbol,expiryDate="latest",optionType="-",strikePrice=0):
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.
666
787
  if(optionType!="-"):
667
788
  payload = nse_quote_derivatives(symbol)
668
789
  else:
669
- if any(x in symbol for x in indices):
790
+ if any(x in symbol.upper() for x in indices):
670
791
  payload = nse_quote_derivatives(symbol)
671
792
  else:
672
- payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol)
793
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol.upper())
673
794
 
674
795
  lastPrice = 0
675
796
 
@@ -763,19 +884,36 @@ def nse_quote_ltp(symbol,expiryDate="latest",optionType="-",strikePrice=0):
763
884
  # print(nse_quote_ltp("RELIANCE","next","PE",2300))
764
885
 
765
886
  def nse_quote_meta(symbol,expiryDate="latest",optionType="-",strikePrice=0):
887
+ # Round 3 bug fix: case-sensitive index routing (see nse_quote_ltp()).
766
888
  if(optionType!="-"):
767
889
  payload = nse_quote_derivatives(symbol)
768
890
  else:
769
- if any(x in symbol for x in indices):
891
+ if any(x in symbol.upper() for x in indices):
770
892
  payload = nse_quote_derivatives(symbol)
771
893
  else:
772
- payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol)
894
+ payload = nsefetch('https://www.nseindia.com/api/NextApi/apiClient/GetQuoteApi?functionName=getSymbolData&marketType=N&series=EQ&symbol='+symbol.upper())
773
895
 
774
896
  metadata = {}
775
897
 
776
898
  if(optionType=="-"):
777
899
  if 'equityResponse' in payload and len(payload['equityResponse']) > 0:
778
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
+ }
779
917
  return metadata
780
918
 
781
919
  meta = "Options"
@@ -845,15 +983,113 @@ def nse_quote_meta(symbol,expiryDate="latest",optionType="-",strikePrice=0):
845
983
  return metadata
846
984
 
847
985
  def nse_optionchain_ltp(payload,strikePrice,optionType,inp=0,intent=""):
848
- expiry_dates = payload['records']['expiryDates']
849
- expiry_dates = [datetime.datetime.strptime(date, "%d-%b-%Y").date() for date in expiry_dates]
850
- expiry_dates = [date.strftime("%d-%b-%Y") for date in expiry_dates if date >= datetime.datetime.now().date()]
851
- expiryDate=expiry_dates[inp]
852
- for x in range(len(payload['records']['data'])):
853
- if((payload['records']['data'][x]['strikePrice']==strikePrice) & (payload['records']['data'][x]['expiryDate']==expiryDate)):
854
- if(intent==""): return payload['records']['data'][x][optionType]['lastPrice']
855
- if(intent=="sell"): return payload['records']['data'][x][optionType]['bidprice']
856
- 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
+ )
857
1093
 
858
1094
  def nse_eq(symbol):
859
1095
  symbol = nsesymbolpurify(symbol)
@@ -916,10 +1152,22 @@ def option_chain(symbol):
916
1152
  return nse_optionchain_scrapper(symbol)
917
1153
 
918
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.
919
1162
  if(type=="clearing"):
920
1163
  payload = nsefetch('https://www.nseindia.com/api/holiday-master?type=clearing')
921
- if(type=="trading"):
1164
+ elif(type=="trading"):
922
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
+ )
923
1171
  return payload
924
1172
 
925
1173
  def holiday_master(type="trading"):
@@ -1128,6 +1376,22 @@ def black_scholes_dexter(S0,X,t,σ="",r=10,q=0.0,td=365):
1128
1376
  S0,X,σ,r,q,t = float(S0),float(X),float(σ/100),float(r/100),float(q/100),float(t/td)
1129
1377
  #https://unofficed.com/black-scholes-model-options-calculator-google-sheet/
1130
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
+
1131
1395
  d1 = (math.log(S0/X)+(r-q+0.5*σ**2)*t)/(σ*math.sqrt(t))
1132
1396
  #stackoverflow.com/questions/34258537/python-typeerror-unsupported-operand-types-for-float-and-int
1133
1397
 
@@ -1304,7 +1568,6 @@ def derivative_history(symbol,start_date,end_date,instrumentType,expiry_date,str
1304
1568
 
1305
1569
 
1306
1570
  def expiry_history(symbol,start_date="",end_date="",type="options"):
1307
- if(end_date==""):end_date=end_date
1308
1571
  # Same retirement as derivative_history_virgin()/equity_history_virgin()
1309
1572
  # above -- /api/historical/* is gone, /api/historicalOR/* is the working
1310
1573
  # replacement with an identical response shape.
@@ -1313,6 +1576,7 @@ def expiry_history(symbol,start_date="",end_date="",type="options"):
1313
1576
 
1314
1577
  #print(payload)
1315
1578
 
1579
+ payload_data = None
1316
1580
  for key, value in payload['expiryDatesByInstrument'].items():
1317
1581
  if type.lower() == "options" and "OPT" in key:
1318
1582
  payload_data = payload['expiryDatesByInstrument'][key]
@@ -1320,7 +1584,20 @@ def expiry_history(symbol,start_date="",end_date="",type="options"):
1320
1584
  elif type.lower() == "futures" and "FUT" in key:
1321
1585
  payload_data = payload['expiryDatesByInstrument'][key]
1322
1586
  break
1323
-
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
+
1324
1601
  # Convert start_date and end_date to datetime objects
1325
1602
  start_date = datetime.datetime.strptime(start_date, "%d-%m-%Y")
1326
1603
  end_date = datetime.datetime.strptime(end_date, "%d-%m-%Y")
@@ -1329,7 +1606,7 @@ def expiry_history(symbol,start_date="",end_date="",type="options"):
1329
1606
  filtered_date_payload = []
1330
1607
 
1331
1608
  # Initialize a flag to check if the first date after end_date has been added
1332
- added_after_end_date = False
1609
+ added_after_end_date = False
1333
1610
 
1334
1611
  # Iterate through date_payload and filter dates within the range
1335
1612
  for date_str in payload_data:
@@ -1339,7 +1616,7 @@ def expiry_history(symbol,start_date="",end_date="",type="options"):
1339
1616
  elif date_obj > end_date and not added_after_end_date:
1340
1617
  filtered_date_payload.append(date_str)
1341
1618
  added_after_end_date = True
1342
-
1619
+
1343
1620
  return filtered_date_payload
1344
1621
 
1345
1622
  # # Nifty Indicies Site
@@ -1496,6 +1773,39 @@ def share_holding(symbol):
1496
1773
  return df
1497
1774
 
1498
1775
 
1776
+ def nse_annual_reports(symbol, year_from=None, year_to=None, index="equities"):
1777
+ """Forum feature request (forum.unofficed.com topic 1459): list a listed
1778
+ company's annual reports with direct PDF download links, optionally
1779
+ filtered to a from/to year range. Backed by
1780
+ `/api/annual-reports?index=equities&symbol=X`, confirmed live via
1781
+ curl_cffi -- NOT the Akamai-walled historical/* family, just a plain
1782
+ metadata+link listing, so this is reliable. `index` is almost always
1783
+ "equities" (NSE also recognises "debt", but that returns no rows for a
1784
+ pure-equity symbol like most NSE-listed companies). Each returned row's
1785
+ `fileName` column IS the direct downloadable PDF/zip URL on
1786
+ nsearchives.nseindia.com -- fetch it yourself (e.g. with `requests` or
1787
+ `curl`) if you want the actual file; this function returns the metadata
1788
+ + link, matching how every other *_history-style function in this
1789
+ library works, rather than silently downloading files to disk.
1790
+ `year_from`/`year_to` filter client-side on the report's `fromYr` (NSE's
1791
+ own API has no year-range parameter, it always returns the full
1792
+ available history -- commonly 15-20+ years for large-cap companies)."""
1793
+ symbol = nsesymbolpurify(symbol)
1794
+ payload = nsefetch(f"https://www.nseindia.com/api/annual-reports?index={index}&symbol={symbol}")
1795
+ rows = payload.get("data") or []
1796
+ df = pd.DataFrame.from_records(rows)
1797
+ if df.empty:
1798
+ return df
1799
+ if year_from is not None or year_to is not None:
1800
+ years = pd.to_numeric(df["fromYr"], errors="coerce")
1801
+ if year_from is not None:
1802
+ df = df[years >= int(year_from)]
1803
+ years = years[df.index]
1804
+ if year_to is not None:
1805
+ df = df[years <= int(year_to)]
1806
+ return df.reset_index(drop=True)
1807
+
1808
+
1499
1809
  #Request from subhash
1500
1810
  ## https://unofficed.com/how-to-find-the-beta-of-indian-stocks-using-python/
1501
1811
  def get_beta_df_maker(symbol,days):
@@ -1592,8 +1902,13 @@ def nse_preopen(key="NIFTY",type="pandas"):
1592
1902
 
1593
1903
  #By Avinash https://forum.unofficed.com/t/nsepython-documentation/376/102?u=dexter
1594
1904
  def nse_preopen_movers(key="FO",filter=1.5):
1905
+ # Round 3 bug fix: the body hardcoded the literal 1.5/-1.5 thresholds
1906
+ # instead of using the `filter` parameter at all -- any caller passing
1907
+ # a custom threshold (nse_preopen_movers(key="FO", filter=50)) got
1908
+ # silently ignored and always got the same 1.5% cutoff back, with no
1909
+ # error or warning.
1595
1910
  preOpen_gainer=nse_preopen(key)
1596
- return preOpen_gainer[preOpen_gainer['pChange'] >1.5],preOpen_gainer[preOpen_gainer['pChange'] <-1.5]
1911
+ return preOpen_gainer[preOpen_gainer['pChange'] >filter],preOpen_gainer[preOpen_gainer['pChange'] <-filter]
1597
1912
 
1598
1913
  # type = "securities"
1599
1914
  # type = "etf"
@@ -1673,8 +1988,20 @@ def nse_largedeals_historical(from_date, to_date, mode="bulk_deals"):
1673
1988
  #print(get_fao_participant_oi("04-06-2021"))
1674
1989
  def get_fao_participant_oi(date):
1675
1990
  date = date.replace("-","")
1676
- payload = pd.read_csv(io.StringIO(_nse_fetch_csv_text(
1677
- "https://archives.nseindia.com/content/nsccl/fao_participant_oi_"+date+".csv")))
1991
+ # Round 3 bug fix: this CSV has a title/caption row as line 1
1992
+ # ('""Participant wise Open Interest...""') with the REAL header on
1993
+ # line 2 -- reading it with no skiprows made pandas parse the caption
1994
+ # as the header and shift the real header row down into the data,
1995
+ # mislabeling every single column (confirmed live on every trading date
1996
+ # tested: columns came out as 'Unnamed: 2', 'Unnamed: 3', etc instead of
1997
+ # 'Future Index Long', 'Total Short Contracts', ...).
1998
+ text = _nse_fetch_csv_text(
1999
+ "https://archives.nseindia.com/content/nsccl/fao_participant_oi_"+date+".csv")
2000
+ payload = pd.read_csv(io.StringIO(text), skiprows=1)
2001
+ # NSE's own header row carries stray trailing whitespace on a couple of
2002
+ # columns (e.g. "Future Stock Short ") -- strip it so column
2003
+ # lookups by name work as documented.
2004
+ payload.columns = [c.strip() for c in payload.columns]
1678
2005
  return payload
1679
2006
 
1680
2007
  #https://forum.unofficed.com/t/how-to-check-if-the-market-is-open-today-or-not/1268/1
@@ -1756,3 +2083,653 @@ def security_wise_archive(from_date, to_date, symbol, series="ALL"):
1756
2083
  url = f"{base_url}?from={from_date}&to={to_date}&symbol={symbol.upper()}&type=priceVolumeDeliverable&series={series.upper()}"
1757
2084
  payload = nsefetch(url)
1758
2085
  return pd.DataFrame(payload['data'])
2086
+
2087
+
2088
+ # ---------------------------------------------------------------------------
2089
+ # NSE's official, no-auth MCP (Model Context Protocol) servers
2090
+ #
2091
+ # NSE India publishes its own free, no-API-key-required MCP servers
2092
+ # (https://www.nseindia.com/nse-mcp) -- two streamable-HTTP endpoints:
2093
+ #
2094
+ # "bhavcopy" -- https://mcp.nseindia.in/bhavcopy/cm/mcp
2095
+ # ("nse-bhavcopy-redis-mcp", 21 tools): historical/derived data --
2096
+ # stock & index history, valuations, corporate actions, comparisons,
2097
+ # moving averages, 52-week range, market mood/breadth, symbol search.
2098
+ #
2099
+ # "cmmkt" -- https://mcp.nseindia.in/cmmkt/mcp
2100
+ # ("cm-market-mcp", 15 tools): live cash-market data -- live quotes,
2101
+ # gainers/losers, live index values, equity/SME/bond/call-auction
2102
+ # stock lists.
2103
+ #
2104
+ # This is a genuinely different, independent path into NSE data from the
2105
+ # rest of this module: it is NSE's own hosted service, not a scrape of
2106
+ # nseindia.com through curl_cffi's Akamai-impersonation transport, so it is
2107
+ # unaffected by Akamai Bot Manager entirely and is often the more reliable
2108
+ # choice when it covers the data you need. It still reuses this module's
2109
+ # shared curl_cffi session (_get_nse_session()) purely for cheap connection
2110
+ # pooling / one consistent TLS-fingerprint story -- the MCP calls themselves
2111
+ # need no cookies, no warm-up, and no auth of any kind.
2112
+ #
2113
+ # Protocol notes (streamable-HTTP MCP, JSON-RPC 2.0):
2114
+ # - POST an "initialize" request first; the response carries a
2115
+ # "Mcp-Session-Id" (or "mcp-session-id") header that must be echoed back
2116
+ # as a header on every subsequent request for that session.
2117
+ # - A "notifications/initialized" notification should follow (no response
2118
+ # body expected) before calling any tool.
2119
+ # - "tools/call" responses come back either as plain JSON or as an
2120
+ # SSE-framed body (Content-Type: text/event-stream) shaped like
2121
+ # "event:message\ndata:{...}\n\n" -- both are handled below.
2122
+ # - The actual tool result is nested at result.content[0].text, which is
2123
+ # itself a JSON string in practice for every tool checked so far.
2124
+ # - A small number of tools (confirmed: nse_get_gainers / nse_get_losers
2125
+ # on the cmmkt server) currently come back with isError=false but an
2126
+ # inner {"error": "..."} payload -- a live bug on NSE's own server side
2127
+ # ("Failed to parse cached data: ArrayList cannot be cast to Map").
2128
+ # That is treated the same as any other failure here: raised as
2129
+ # NSEEndpointError rather than silently handed back as "data".
2130
+ # ---------------------------------------------------------------------------
2131
+
2132
+ _NSE_MCP_SERVERS = {
2133
+ "bhavcopy": "https://mcp.nseindia.in/bhavcopy/cm/mcp",
2134
+ "cmmkt": "https://mcp.nseindia.in/cmmkt/mcp",
2135
+ }
2136
+
2137
+ _NSE_MCP_CLIENT_VERSION = "2.98"
2138
+
2139
+ # Lightweight session-id cache, keyed by server URL, so repeated calls to the
2140
+ # same MCP server don't re-run the "initialize" handshake every time.
2141
+ _nse_mcp_session_cache = {}
2142
+
2143
+
2144
+ def _nse_mcp_parse_response(r):
2145
+ """Parse one MCP HTTP response body, which comes back as either plain
2146
+ JSON or an SSE-framed body (Content-Type: text/event-stream) shaped like
2147
+ "event:message\\ndata:{...}\\n\\n". Returns the decoded JSON-RPC envelope
2148
+ dict either way.
2149
+ """
2150
+ ctype = r.headers.get("content-type", "") or ""
2151
+ if "text/event-stream" in ctype:
2152
+ data_lines = [
2153
+ line[len("data:"):].strip()
2154
+ for line in r.text.splitlines()
2155
+ if line.startswith("data:")
2156
+ ]
2157
+ if not data_lines:
2158
+ raise NSEEndpointError("nse_mcp: empty SSE response body")
2159
+ try:
2160
+ return json.loads("".join(data_lines))
2161
+ except ValueError:
2162
+ raise NSEEndpointError("nse_mcp: malformed SSE JSON payload")
2163
+
2164
+ try:
2165
+ return r.json()
2166
+ except ValueError:
2167
+ # A server occasionally mislabels which framing it used -- scan for
2168
+ # "data:" lines regardless of the declared content-type before
2169
+ # giving up.
2170
+ data_lines = [
2171
+ line[len("data:"):].strip()
2172
+ for line in r.text.splitlines()
2173
+ if line.startswith("data:")
2174
+ ]
2175
+ if data_lines:
2176
+ try:
2177
+ return json.loads("".join(data_lines))
2178
+ except ValueError:
2179
+ pass
2180
+ raise NSEEndpointError(
2181
+ f"nse_mcp: non-JSON, non-SSE response body (content-type={ctype!r})"
2182
+ )
2183
+
2184
+
2185
+ def _nse_mcp_initialize(server_url):
2186
+ """Run the MCP "initialize" handshake (+ "notifications/initialized")
2187
+ against server_url and return the Mcp-Session-Id NSE's server hands
2188
+ back (or "" if the server doesn't issue one). Retries up to 3 times --
2189
+ the bhavcopy endpoint has been observed to 502 on a cold first request.
2190
+ """
2191
+ session = _get_nse_session()
2192
+ mcp_headers = {
2193
+ "Content-Type": "application/json",
2194
+ "Accept": "application/json, text/event-stream",
2195
+ }
2196
+ init_body = {
2197
+ "jsonrpc": "2.0",
2198
+ "id": 1,
2199
+ "method": "initialize",
2200
+ "params": {
2201
+ "protocolVersion": "2024-11-05",
2202
+ "capabilities": {},
2203
+ "clientInfo": {"name": "nsepython", "version": _NSE_MCP_CLIENT_VERSION},
2204
+ },
2205
+ }
2206
+
2207
+ last_exc = None
2208
+ for attempt in range(3):
2209
+ try:
2210
+ r = session.post(server_url, headers=mcp_headers, json=init_body, timeout=30)
2211
+ except Exception as e:
2212
+ last_exc = NSEEndpointError(f"nse_mcp initialize: request failed for {server_url}: {e}")
2213
+ continue
2214
+
2215
+ if r.status_code == 200:
2216
+ session_id = r.headers.get("mcp-session-id") or r.headers.get("Mcp-Session-Id") or ""
2217
+ notify_headers = dict(mcp_headers)
2218
+ if session_id:
2219
+ notify_headers["Mcp-Session-Id"] = session_id
2220
+ try:
2221
+ session.post(
2222
+ server_url, headers=notify_headers,
2223
+ json={"jsonrpc": "2.0", "method": "notifications/initialized"},
2224
+ timeout=15,
2225
+ )
2226
+ except Exception:
2227
+ pass # fire-and-forget notification; failure here is harmless
2228
+ return session_id
2229
+
2230
+ last_exc = NSEEndpointError(
2231
+ f"nse_mcp initialize: HTTP {r.status_code} for {server_url}"
2232
+ )
2233
+
2234
+ raise last_exc or NSEEndpointError(f"nse_mcp initialize: failed for {server_url}")
2235
+
2236
+
2237
+ def _nse_mcp_get_session_id(server_url, force_new=False):
2238
+ """Return a cached Mcp-Session-Id for server_url, initializing (and
2239
+ caching) one if there isn't one yet or force_new is requested.
2240
+ """
2241
+ if not force_new and server_url in _nse_mcp_session_cache:
2242
+ return _nse_mcp_session_cache[server_url]
2243
+ session_id = _nse_mcp_initialize(server_url)
2244
+ _nse_mcp_session_cache[server_url] = session_id
2245
+ return session_id
2246
+
2247
+
2248
+ def _nse_mcp_call(server_url, tool_name, arguments=None):
2249
+ """Call one tool on an NSE-official MCP server and return its parsed
2250
+ result payload.
2251
+
2252
+ Handles the initialize/session-id handshake (with a small cache keyed
2253
+ by server_url so repeat calls don't re-initialize every time), both
2254
+ plain-JSON and SSE response framing, and the nested
2255
+ result.content[0].text tool-result convention (itself JSON-encoded for
2256
+ every tool checked so far). Raises NSEEndpointError -- never returns
2257
+ `{}` -- on any transport failure, JSON-RPC error, MCP tool-level error,
2258
+ or an application-level {"error": ...} payload the tool itself reports.
2259
+ """
2260
+ arguments = arguments or {}
2261
+ session = _get_nse_session()
2262
+ mcp_headers = {
2263
+ "Content-Type": "application/json",
2264
+ "Accept": "application/json, text/event-stream",
2265
+ }
2266
+
2267
+ data = None
2268
+ last_exc = None
2269
+ for attempt in (1, 2):
2270
+ try:
2271
+ session_id = _nse_mcp_get_session_id(server_url, force_new=(attempt == 2))
2272
+ except NSEEndpointError as e:
2273
+ last_exc = e
2274
+ continue
2275
+
2276
+ call_headers = dict(mcp_headers)
2277
+ if session_id:
2278
+ call_headers["Mcp-Session-Id"] = session_id
2279
+
2280
+ body = {
2281
+ "jsonrpc": "2.0",
2282
+ "id": 3,
2283
+ "method": "tools/call",
2284
+ "params": {"name": tool_name, "arguments": arguments},
2285
+ }
2286
+ try:
2287
+ r = session.post(server_url, headers=call_headers, json=body, timeout=30)
2288
+ except Exception as e:
2289
+ last_exc = NSEEndpointError(f"nse_mcp_call({tool_name}): request failed: {e}")
2290
+ continue
2291
+
2292
+ if r.status_code in (401, 403, 404, 409, 502, 503) and attempt == 1:
2293
+ # Could be a stale/expired session id, or the transient 502 seen
2294
+ # on the bhavcopy endpoint's first request -- drop the cached
2295
+ # session and retry once with a fresh initialize.
2296
+ last_exc = NSEEndpointError(
2297
+ f"nse_mcp_call({tool_name}): HTTP {r.status_code} from {server_url}"
2298
+ )
2299
+ _nse_mcp_session_cache.pop(server_url, None)
2300
+ continue
2301
+
2302
+ if r.status_code != 200:
2303
+ raise NSEEndpointError(
2304
+ f"nse_mcp_call({tool_name}): HTTP {r.status_code} from {server_url}"
2305
+ )
2306
+
2307
+ data = _nse_mcp_parse_response(r)
2308
+ last_exc = None
2309
+ break
2310
+
2311
+ if data is None:
2312
+ raise last_exc or NSEEndpointError(
2313
+ f"nse_mcp_call({tool_name}): failed against {server_url}"
2314
+ )
2315
+
2316
+ if data.get("error"):
2317
+ raise NSEEndpointError(
2318
+ f"nse_mcp_call({tool_name}): JSON-RPC error: {data['error']}"
2319
+ )
2320
+
2321
+ result = data.get("result") or {}
2322
+ content = result.get("content") or []
2323
+ if not content:
2324
+ raise NSEEndpointError(
2325
+ f"nse_mcp_call({tool_name}): empty/missing content in response: {result}"
2326
+ )
2327
+
2328
+ text = content[0].get("text", "")
2329
+ try:
2330
+ payload = json.loads(text)
2331
+ except (ValueError, TypeError):
2332
+ payload = text # plain text/markdown tool result -- hand it back as-is
2333
+
2334
+ if result.get("isError"):
2335
+ raise NSEEndpointError(
2336
+ f"nse_mcp_call({tool_name}): tool reported an error: {payload}"
2337
+ )
2338
+
2339
+ if isinstance(payload, dict) and "error" in payload:
2340
+ # Seen live on nse_get_gainers/nse_get_losers: isError=false but an
2341
+ # inner application-level error from NSE's own server. Don't hand
2342
+ # this back as if it were usable data.
2343
+ raise NSEEndpointError(
2344
+ f"nse_mcp_call({tool_name}): NSE's MCP server reported an application "
2345
+ f"error for this call: {payload['error']}"
2346
+ )
2347
+
2348
+ return payload
2349
+
2350
+
2351
+ def nse_mcp_call(server, tool_name, **kwargs):
2352
+ """Call ANY tool on NSE's own official, no-auth MCP servers by name --
2353
+ a generic escape hatch for a tool this module doesn't have a named
2354
+ wrapper for (yet), or any new tool NSE adds to either server in future.
2355
+
2356
+ `server` is "bhavcopy" (historical/derived data) or "cmmkt" (live
2357
+ market data). `kwargs` become the tool's `arguments` object, passed
2358
+ straight through to NSE's MCP endpoint -- see nse_mcp_list_tools() for
2359
+ each tool's name, description and accepted arguments.
2360
+
2361
+ Backed by NSE's own official, no-auth MCP server, not the
2362
+ Akamai-affected nseindia.com scrape path the rest of this module uses --
2363
+ a notably more reliable route when it covers the data you need.
2364
+ """
2365
+ server_url = _NSE_MCP_SERVERS.get(server)
2366
+ if server_url is None:
2367
+ raise NSEEndpointError(
2368
+ f"nse_mcp_call: unknown server {server!r}, expected 'bhavcopy' or 'cmmkt'"
2369
+ )
2370
+ return _nse_mcp_call(server_url, tool_name, kwargs)
2371
+
2372
+
2373
+ def nse_mcp_list_tools(server=""):
2374
+ """Return NSE's own live tools/list response -- name, description and
2375
+ full inputSchema -- for one MCP server ("bhavcopy" or "cmmkt"), or both
2376
+ (as a dict keyed by server name) when `server` is omitted/empty.
2377
+
2378
+ Always asks the server live rather than returning a hardcoded copy, so
2379
+ this stays accurate if/when NSE changes either server's toolset.
2380
+ """
2381
+ if server:
2382
+ if server not in _NSE_MCP_SERVERS:
2383
+ raise NSEEndpointError(
2384
+ f"nse_mcp_list_tools: unknown server {server!r}, expected 'bhavcopy' or 'cmmkt'"
2385
+ )
2386
+ servers = {server: _NSE_MCP_SERVERS[server]}
2387
+ else:
2388
+ servers = _NSE_MCP_SERVERS
2389
+
2390
+ session = _get_nse_session()
2391
+ out = {}
2392
+ for name, url in servers.items():
2393
+ session_id = _nse_mcp_get_session_id(url)
2394
+ mcp_headers = {
2395
+ "Content-Type": "application/json",
2396
+ "Accept": "application/json, text/event-stream",
2397
+ }
2398
+ if session_id:
2399
+ mcp_headers["Mcp-Session-Id"] = session_id
2400
+ body = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
2401
+ r = session.post(url, headers=mcp_headers, json=body, timeout=30)
2402
+ if r.status_code != 200:
2403
+ raise NSEEndpointError(f"nse_mcp_list_tools({name}): HTTP {r.status_code}")
2404
+ data = _nse_mcp_parse_response(r)
2405
+ if data.get("error"):
2406
+ raise NSEEndpointError(f"nse_mcp_list_tools({name}): JSON-RPC error: {data['error']}")
2407
+ out[name] = (data.get("result") or {}).get("tools", [])
2408
+
2409
+ return out[server] if server else out
2410
+
2411
+
2412
+ def _nse_mcp_records(payload, key):
2413
+ """Return payload[key] (a list of record-dicts) as a DataFrame, or an
2414
+ empty DataFrame if the key is absent -- the same "list field on a dict
2415
+ payload becomes a DataFrame" convention used throughout this file.
2416
+ """
2417
+ rows = payload.get(key) if isinstance(payload, dict) else None
2418
+ return pd.DataFrame(rows if rows else [])
2419
+
2420
+
2421
+ # ---------------------------------------------------------------------------
2422
+ # Named wrappers -- "bhavcopy" server (nse-bhavcopy-redis-mcp, 21 tools)
2423
+ # ---------------------------------------------------------------------------
2424
+
2425
+ def nse_mcp_get_top_by_volume(date="today", n=10, sort_by="volume"):
2426
+ """Get the top N most actively traded NSE stocks on a date, sorted by
2427
+ 'volume' (traded quantity) or 'value' (turnover in Rs). Backed by NSE's
2428
+ own official no-auth MCP server (bhavcopy)."""
2429
+ payload = nse_mcp_call("bhavcopy", "get_top_by_volume", date=date, n=n, sortBy=sort_by)
2430
+ return _nse_mcp_records(payload, "stocks")
2431
+
2432
+
2433
+ def nse_mcp_get_top_movers(date="today", n=10, direction="gain"):
2434
+ """Get the top N gaining ('gain') or losing ('loss') NSE stocks on a
2435
+ date, with OHLCV details. Backed by NSE's own official no-auth MCP
2436
+ server (bhavcopy)."""
2437
+ payload = nse_mcp_call("bhavcopy", "get_top_movers", date=date, n=n, direction=direction)
2438
+ return _nse_mcp_records(payload, "stocks")
2439
+
2440
+
2441
+ def nse_mcp_nse_lookup_symbol(query):
2442
+ """Look up NSE ticker symbols by partial name or keyword (ticker list
2443
+ only, no price data). Backed by NSE's own official no-auth MCP server
2444
+ (bhavcopy)."""
2445
+ payload = nse_mcp_call("bhavcopy", "nse_lookup_symbol", query=query)
2446
+ return payload.get("symbols", []) if isinstance(payload, dict) else payload
2447
+
2448
+
2449
+ def nse_mcp_get_market_mood(date="today"):
2450
+ """Get a factual read of NSE market mood for a day: India VIX level and
2451
+ trend, index/stock advance-decline breadth, and benchmark changes.
2452
+ Backed by NSE's own official no-auth MCP server (bhavcopy)."""
2453
+ return nse_mcp_call("bhavcopy", "get_market_mood", date=date)
2454
+
2455
+
2456
+ def nse_mcp_get_index_valuation(index_name, months=24, date="today"):
2457
+ """Get an NSE index's valuation ratios (P/E, P/B, dividend yield) and
2458
+ where today's value sits within its own recent range. Backed by NSE's
2459
+ own official no-auth MCP server (bhavcopy)."""
2460
+ return nse_mcp_call(
2461
+ "bhavcopy", "get_index_valuation", indexName=index_name, months=months, date=date
2462
+ )
2463
+
2464
+
2465
+ def nse_mcp_get_market_breadth(date="today"):
2466
+ """Get overall NSE market breadth for a trading date: advances,
2467
+ declines, unchanged, A/D ratio, total volume. Backed by NSE's own
2468
+ official no-auth MCP server (bhavcopy)."""
2469
+ return nse_mcp_call("bhavcopy", "get_market_breadth", date=date)
2470
+
2471
+
2472
+ def nse_mcp_get_corporate_actions(symbol, from_date="", to_date=""):
2473
+ """Fetch actual NSE corporate action events (splits/bonus/dividends/
2474
+ other) for a stock, with exact ex-dates and adjustment factors. Backed
2475
+ by NSE's own official no-auth MCP server (bhavcopy)."""
2476
+ payload = nse_mcp_call(
2477
+ "bhavcopy", "get_corporate_actions", symbol=symbol, fromDate=from_date, toDate=to_date
2478
+ )
2479
+ return _nse_mcp_records(payload, "actions")
2480
+
2481
+
2482
+ def nse_mcp_compare_indices(index_names, months=6, date="today"):
2483
+ """Compare 2 to 10 NSE indices side by side: return, annualised
2484
+ volatility, max drawdown and current valuation. Backed by NSE's own
2485
+ official no-auth MCP server (bhavcopy)."""
2486
+ payload = nse_mcp_call(
2487
+ "bhavcopy", "compare_indices", indexNames=index_names, months=months, date=date
2488
+ )
2489
+ return _nse_mcp_records(payload, "indices")
2490
+
2491
+
2492
+ def nse_mcp_get_index_movers(date="today", period="1D", n=10, scope="equity"):
2493
+ """Get the top gaining and top losing NSE indices for a day or period
2494
+ (1D/1W/1M/3M/6M/1Y) -- useful for sector/theme rotation. Backed by NSE's
2495
+ own official no-auth MCP server (bhavcopy). Returns the raw dict (both
2496
+ a 'gainers' and a 'losers' list) since the result isn't a single table."""
2497
+ return nse_mcp_call(
2498
+ "bhavcopy", "get_index_movers", date=date, period=period, n=n, scope=scope
2499
+ )
2500
+
2501
+
2502
+ def nse_mcp_get_ltp_by_date(symbol, date="today"):
2503
+ """Return the last traded (close) price for an NSE symbol on a date
2504
+ (previous trading day's price if the date is a non-trading day). Backed
2505
+ by NSE's own official no-auth MCP server (bhavcopy)."""
2506
+ return nse_mcp_call("bhavcopy", "get_ltp_by_date", symbol=symbol, date=date)
2507
+
2508
+
2509
+ def nse_mcp_get_bulk_quote(symbols):
2510
+ """Get the latest price snapshot (OHLC, prev close, % change, volume)
2511
+ for up to 50 NSE stocks in one call. Backed by NSE's own official
2512
+ no-auth MCP server (bhavcopy)."""
2513
+ payload = nse_mcp_call("bhavcopy", "get_bulk_quote", symbols=symbols)
2514
+ return _nse_mcp_records(payload, "quotes")
2515
+
2516
+
2517
+ def nse_mcp_get_volume_analysis(symbol, days=30):
2518
+ """Analyse trading volume trends for an NSE stock over N trading days:
2519
+ average/max/min volume, volume spike days, recent trend. Backed by
2520
+ NSE's own official no-auth MCP server (bhavcopy)."""
2521
+ return nse_mcp_call("bhavcopy", "get_volume_analysis", symbol=symbol, days=days)
2522
+
2523
+
2524
+ def nse_mcp_get_stock_history(symbol, months=3, end_date="today"):
2525
+ """Get daily OHLCV price history for an NSE stock (up to 3 months per
2526
+ call; chain calls using the response's next_end_date for longer
2527
+ periods). Backed by NSE's own official no-auth MCP server (bhavcopy)."""
2528
+ payload = nse_mcp_call(
2529
+ "bhavcopy", "get_stock_history", symbol=symbol, months=months, endDate=end_date
2530
+ )
2531
+ return _nse_mcp_records(payload, "data")
2532
+
2533
+
2534
+ def nse_mcp_get_index_snapshot(date="today", filter=""):
2535
+ """Get end-of-day values (OHLC, % change, turnover, P/E, P/B, dividend
2536
+ yield) for NSE indices on a date, optionally filtered by a name
2537
+ substring. Backed by NSE's own official no-auth MCP server (bhavcopy)."""
2538
+ payload = nse_mcp_call("bhavcopy", "get_index_snapshot", date=date, filter=filter)
2539
+ return _nse_mcp_records(payload, "indices")
2540
+
2541
+
2542
+ def nse_mcp_search_symbols(query):
2543
+ """Search for NSE stock symbols by company name or partial symbol,
2544
+ returning matches with latest close price and % change. Backed by
2545
+ NSE's own official no-auth MCP server (bhavcopy)."""
2546
+ payload = nse_mcp_call("bhavcopy", "search_symbols", query=query)
2547
+ return _nse_mcp_records(payload, "results")
2548
+
2549
+
2550
+ def nse_mcp_get_stock_vs_index(symbol, index_name="Nifty 50", months=12, date="today"):
2551
+ """Compare one NSE stock against a benchmark index over a period:
2552
+ return of each, outperformance, beta and correlation (stock return is
2553
+ already corporate-action adjusted). Backed by NSE's own official
2554
+ no-auth MCP server (bhavcopy)."""
2555
+ return nse_mcp_call(
2556
+ "bhavcopy", "get_stock_vs_index",
2557
+ symbol=symbol, indexName=index_name, months=months, date=date,
2558
+ )
2559
+
2560
+
2561
+ def nse_mcp_compare_stocks(symbols, months=6):
2562
+ """Compare up to 10 NSE stocks side by side over a period: % return
2563
+ (ranked best to worst) and max drawdown per stock. Backed by NSE's own
2564
+ official no-auth MCP server (bhavcopy)."""
2565
+ payload = nse_mcp_call("bhavcopy", "compare_stocks", symbols=symbols, months=months)
2566
+ return _nse_mcp_records(payload, "stocks")
2567
+
2568
+
2569
+ def nse_mcp_get_index_history(index_name, months=3, end_date="today"):
2570
+ """Get daily history (OHLC, % change, turnover, P/E, P/B, dividend
2571
+ yield) for an NSE index, up to 12 months per call; chain calls using
2572
+ next_end_date for longer periods. Backed by NSE's own official no-auth
2573
+ MCP server (bhavcopy)."""
2574
+ payload = nse_mcp_call(
2575
+ "bhavcopy", "get_index_history", indexName=index_name, months=months, endDate=end_date
2576
+ )
2577
+ return _nse_mcp_records(payload, "data")
2578
+
2579
+
2580
+ def nse_mcp_moving_average(symbol, days=20):
2581
+ """Calculate the simple moving average (SMA) of close prices for an
2582
+ NSE stock over the last N trading days. Backed by NSE's own official
2583
+ no-auth MCP server (bhavcopy)."""
2584
+ return nse_mcp_call("bhavcopy", "moving_average", symbol=symbol, days=days)
2585
+
2586
+
2587
+ def nse_mcp_get_52_week_high_low(symbol):
2588
+ """Get the 52-week high/low for an NSE stock, with dates and the
2589
+ current price's position within that range. Backed by NSE's own
2590
+ official no-auth MCP server (bhavcopy)."""
2591
+ return nse_mcp_call("bhavcopy", "get_52_week_high_low", symbol=symbol)
2592
+
2593
+
2594
+ def nse_mcp_get_index_performance(index_name, date="today"):
2595
+ """Get an NSE index's price performance: 1-day change plus 1W/1M/3M/6M/
2596
+ 1Y/2Y returns and 52-week high/low with distances. Backed by NSE's own
2597
+ official no-auth MCP server (bhavcopy)."""
2598
+ return nse_mcp_call("bhavcopy", "get_index_performance", indexName=index_name, date=date)
2599
+
2600
+
2601
+ # ---------------------------------------------------------------------------
2602
+ # Named wrappers -- "cmmkt" server (cm-market-mcp, 15 tools)
2603
+ # ---------------------------------------------------------------------------
2604
+
2605
+ def nse_mcp_cm_get_live_market_data(index="gainers"):
2606
+ """Get live NSE market data for 'gainers' or 'loosers' (NSE's own
2607
+ spelling), refreshed every 5 minutes. Backed by NSE's own official
2608
+ no-auth MCP server (cmmkt)."""
2609
+ return nse_mcp_call("cmmkt", "cm_get_live_market_data", index=index)
2610
+
2611
+
2612
+ def nse_mcp_cm_get_equity_stocks(limit=100, symbol_filter=""):
2613
+ """Get latest live data for NSE Capital Market EQUITY-segment stocks
2614
+ (series EQ/BE/BL/BT/IL/IQ), refreshed every minute. Backed by NSE's own
2615
+ official no-auth MCP server (cmmkt)."""
2616
+ payload = nse_mcp_call(
2617
+ "cmmkt", "cm_get_equity_stocks", limit=limit, symbolFilter=symbol_filter
2618
+ )
2619
+ return _nse_mcp_records(payload, "stocks")
2620
+
2621
+
2622
+ def nse_mcp_nse_get_losers(limit=10):
2623
+ """Get the top N NSE stocks by % loss, flattened across all indices and
2624
+ sorted ascending. Backed by NSE's own official no-auth MCP server
2625
+ (cmmkt). NOTE: as of this writing NSE's own server has a live bug on
2626
+ this specific tool (confirmed: an internal "ArrayList cannot be cast to
2627
+ Map" exception) -- this raises NSEEndpointError until NSE fixes it; use
2628
+ nse_mcp_nse_get_market_movers() for the same ranking in the meantime."""
2629
+ payload = nse_mcp_call("cmmkt", "nse_get_losers", limit=limit)
2630
+ return _nse_mcp_records(payload, "losers") if isinstance(payload, dict) else payload
2631
+
2632
+
2633
+ def nse_mcp_cm_get_call_auction_stocks(limit=100, symbol_filter=""):
2634
+ """Get latest live data for NSE Call Auction session stocks (series
2635
+ CA/CB), refreshed every minute. Backed by NSE's own official no-auth
2636
+ MCP server (cmmkt)."""
2637
+ payload = nse_mcp_call(
2638
+ "cmmkt", "cm_get_call_auction_stocks", limit=limit, symbolFilter=symbol_filter
2639
+ )
2640
+ return _nse_mcp_records(payload, "stocks")
2641
+
2642
+
2643
+ def nse_mcp_cm_get_bond_stocks(limit=100, symbol_filter=""):
2644
+ """Get latest live data for NSE BONDS/debt instrument series, refreshed
2645
+ every minute. Backed by NSE's own official no-auth MCP server (cmmkt)."""
2646
+ payload = nse_mcp_call(
2647
+ "cmmkt", "cm_get_bond_stocks", limit=limit, symbolFilter=symbol_filter
2648
+ )
2649
+ return _nse_mcp_records(payload, "stocks")
2650
+
2651
+
2652
+ def nse_mcp_cm_get_live_gainers():
2653
+ """Return raw NSE gainers data grouped by index segment (NIFTY,
2654
+ BANKNIFTY, NIFTYNEXT50, allSec, etc.) -- not sorted by % change; use
2655
+ nse_mcp_nse_get_market_movers() for a sorted ranking instead. Backed by
2656
+ NSE's own official no-auth MCP server (cmmkt)."""
2657
+ return nse_mcp_call("cmmkt", "cm_get_live_gainers")
2658
+
2659
+
2660
+ def nse_mcp_nse_get_gainers(limit=10):
2661
+ """Get the top N NSE stocks by % gain, flattened across all indices and
2662
+ sorted descending. Backed by NSE's own official no-auth MCP server
2663
+ (cmmkt). NOTE: as of this writing NSE's own server has a live bug on
2664
+ this specific tool (confirmed: an internal "ArrayList cannot be cast to
2665
+ Map" exception) -- this raises NSEEndpointError until NSE fixes it; use
2666
+ nse_mcp_nse_get_market_movers() for the same ranking in the meantime."""
2667
+ payload = nse_mcp_call("cmmkt", "nse_get_gainers", limit=limit)
2668
+ return _nse_mcp_records(payload, "gainers") if isinstance(payload, dict) else payload
2669
+
2670
+
2671
+ def nse_mcp_cm_get_data_status():
2672
+ """Check freshness of NSE live gainers/losers market data (last crawl
2673
+ time, crawl interval, Redis TTL). Backed by NSE's own official no-auth
2674
+ MCP server (cmmkt)."""
2675
+ return nse_mcp_call("cmmkt", "cm_get_data_status")
2676
+
2677
+
2678
+ def nse_mcp_cm_get_stock_quote(symbol):
2679
+ """Get the latest live quote for one NSE CM stock by exact symbol
2680
+ (works for equity, SME, bond or call-auction segments). Backed by
2681
+ NSE's own official no-auth MCP server (cmmkt)."""
2682
+ return nse_mcp_call("cmmkt", "cm_get_stock_quote", symbol=symbol)
2683
+
2684
+
2685
+ def nse_mcp_cm_get_index_quote(index_name):
2686
+ """Get the full live quote for one NSE index by exact name: last
2687
+ value, change, day's OHLC, 52-week range, and 1W/1M/1Y comparisons.
2688
+ Backed by NSE's own official no-auth MCP server (cmmkt)."""
2689
+ return nse_mcp_call("cmmkt", "cm_get_index_quote", indexName=index_name)
2690
+
2691
+
2692
+ def nse_mcp_cm_get_sme_stocks(limit=100, symbol_filter=""):
2693
+ """Get latest live data for NSE SME (Small & Medium Enterprises) stocks
2694
+ (series SM/ST), refreshed every minute. Backed by NSE's own official
2695
+ no-auth MCP server (cmmkt)."""
2696
+ payload = nse_mcp_call(
2697
+ "cmmkt", "cm_get_sme_stocks", limit=limit, symbolFilter=symbol_filter
2698
+ )
2699
+ return _nse_mcp_records(payload, "stocks")
2700
+
2701
+
2702
+ def nse_mcp_cm_get_live_losers():
2703
+ """Return raw NSE losers data grouped by index segment (NIFTY,
2704
+ BANKNIFTY, NIFTYNEXT50, allSec, etc.) -- not sorted by % change; use
2705
+ nse_mcp_nse_get_market_movers() for a sorted ranking instead. Backed by
2706
+ NSE's own official no-auth MCP server (cmmkt)."""
2707
+ return nse_mcp_call("cmmkt", "cm_get_live_losers")
2708
+
2709
+
2710
+ def nse_mcp_cm_get_live_indices(group="", name_filter=""):
2711
+ """Get the latest live values of NSE indices (last, previous close,
2712
+ change, day's OHLC) across six groups (derivatives/broad/sectoral/
2713
+ strategy/thematic/fixed_income), optionally filtered by group and/or a
2714
+ name substring. Backed by NSE's own official no-auth MCP server
2715
+ (cmmkt)."""
2716
+ return nse_mcp_call("cmmkt", "cm_get_live_indices", group=group, nameFilter=name_filter)
2717
+
2718
+
2719
+ def nse_mcp_nse_get_market_movers(index_name=None, limit=10):
2720
+ """Get the top N gainers and top N losers (sorted) from all NSE
2721
+ securities, or filtered to one of NIFTY/BANKNIFTY/NIFTYNEXT50. The
2722
+ PRIMARY tool for "top gainers/losers today" style questions. Backed by
2723
+ NSE's own official no-auth MCP server (cmmkt). Returns the raw dict
2724
+ (both a 'gainers' and a 'losers' list) since the result isn't a single
2725
+ table."""
2726
+ return nse_mcp_call(
2727
+ "cmmkt", "nse_get_market_movers", indexName=index_name or "", limit=limit
2728
+ )
2729
+
2730
+
2731
+ def nse_mcp_cm_get_allstocks_status():
2732
+ """Check freshness of NSE's all-stocks live data cache: last crawl
2733
+ time, availability, and segment-wise stock counts. Backed by NSE's own
2734
+ official no-auth MCP server (cmmkt)."""
2735
+ return nse_mcp_call("cmmkt", "cm_get_allstocks_status")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nsepython
3
- Version: 2.98
3
+ Version: 2.100
4
4
  Summary: Python library for NSE India APIs
5
5
  Home-page: https://github.com/aeron7/nsepython
6
6
  Author: Aeron7
File without changes
File without changes
File without changes
File without changes