kitecli 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {kitecli-0.4.0 → kitecli-0.5.0}/PKG-INFO +2 -1
  2. {kitecli-0.4.0 → kitecli-0.5.0}/cli/api_client.py +4 -0
  3. {kitecli-0.4.0 → kitecli-0.5.0}/cli/base_manager.py +4 -0
  4. {kitecli-0.4.0 → kitecli-0.5.0}/cli/greeks.py +212 -38
  5. {kitecli-0.4.0 → kitecli-0.5.0}/cli/kite_manager.py +140 -40
  6. {kitecli-0.4.0 → kitecli-0.5.0}/cli/live_session.py +1320 -63
  7. kitecli-0.5.0/cli/parser.py +571 -0
  8. {kitecli-0.4.0 → kitecli-0.5.0}/kitecli.egg-info/PKG-INFO +2 -1
  9. {kitecli-0.4.0 → kitecli-0.5.0}/kitecli.egg-info/SOURCES.txt +1 -0
  10. {kitecli-0.4.0 → kitecli-0.5.0}/kitecli.egg-info/requires.txt +1 -0
  11. {kitecli-0.4.0 → kitecli-0.5.0}/pyproject.toml +2 -1
  12. kitecli-0.5.0/tests/test_option_chain.py +574 -0
  13. {kitecli-0.4.0 → kitecli-0.5.0}/tests/test_ui.py +120 -5
  14. kitecli-0.4.0/cli/parser.py +0 -511
  15. {kitecli-0.4.0 → kitecli-0.5.0}/README.md +0 -0
  16. {kitecli-0.4.0 → kitecli-0.5.0}/cli/__init__.py +0 -0
  17. {kitecli-0.4.0 → kitecli-0.5.0}/cli/advisor.py +0 -0
  18. {kitecli-0.4.0 → kitecli-0.5.0}/cli/charges.py +0 -0
  19. {kitecli-0.4.0 → kitecli-0.5.0}/cli/config.py +0 -0
  20. {kitecli-0.4.0 → kitecli-0.5.0}/cli/display.py +0 -0
  21. {kitecli-0.4.0 → kitecli-0.5.0}/cli/executor.py +0 -0
  22. {kitecli-0.4.0 → kitecli-0.5.0}/cli/indicators.py +0 -0
  23. {kitecli-0.4.0 → kitecli-0.5.0}/cli/kotak_manager.py +0 -0
  24. {kitecli-0.4.0 → kitecli-0.5.0}/cli/main.py +0 -0
  25. {kitecli-0.4.0 → kitecli-0.5.0}/cli/nli.py +0 -0
  26. {kitecli-0.4.0 → kitecli-0.5.0}/cli/recorder.py +0 -0
  27. {kitecli-0.4.0 → kitecli-0.5.0}/cli/telegram_bot.py +0 -0
  28. {kitecli-0.4.0 → kitecli-0.5.0}/kitecli.egg-info/dependency_links.txt +0 -0
  29. {kitecli-0.4.0 → kitecli-0.5.0}/kitecli.egg-info/entry_points.txt +0 -0
  30. {kitecli-0.4.0 → kitecli-0.5.0}/kitecli.egg-info/top_level.txt +0 -0
  31. {kitecli-0.4.0 → kitecli-0.5.0}/setup.cfg +0 -0
  32. {kitecli-0.4.0 → kitecli-0.5.0}/tests/test_command_matrix.py +0 -0
  33. {kitecli-0.4.0 → kitecli-0.5.0}/tests/test_conditional_orders.py +0 -0
  34. {kitecli-0.4.0 → kitecli-0.5.0}/tests/test_multi_broker.py +0 -0
  35. {kitecli-0.4.0 → kitecli-0.5.0}/tests/test_nli.py +0 -0
  36. {kitecli-0.4.0 → kitecli-0.5.0}/tests/test_parser.py +0 -0
  37. {kitecli-0.4.0 → kitecli-0.5.0}/tests/test_scheduled_orders.py +0 -0
  38. {kitecli-0.4.0 → kitecli-0.5.0}/tests/test_telegram.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -14,6 +14,7 @@ Requires-Dist: kiteconnect>=5.0.0
14
14
  Requires-Dist: pyotp>=2.9.0
15
15
  Requires-Dist: requests>=2.31.0
16
16
  Requires-Dist: yfinance>=0.2.0
17
+ Requires-Dist: lark>=1.1.0
17
18
  Provides-Extra: server
18
19
  Requires-Dist: fastapi>=0.110.0; extra == "server"
19
20
  Requires-Dist: uvicorn[standard]>=0.27.0; extra == "server"
@@ -645,6 +645,10 @@ class KCLIClient:
645
645
  """Fetch NFO tradingsymbol → lot_size map (one-shot, cache at startup)."""
646
646
  return _kite_manager.get_nfo_lot_sizes()
647
647
 
648
+ def get_nfo_symbol_token_map(self) -> dict:
649
+ """Fetch NFO tradingsymbol → instrument_token map."""
650
+ return _kite_manager.get_nfo_symbol_token_map()
651
+
648
652
  def get_market_indices(self) -> dict:
649
653
  """Fetch live Nifty, Sensex, and India VIX."""
650
654
  return _kite_manager.get_market_indices()
@@ -144,6 +144,10 @@ class BaseBrokerManager(ABC):
144
144
  """
145
145
  return {}
146
146
 
147
+ def get_nfo_symbol_token_map(self) -> dict[str, int]:
148
+ """Return tradingsymbol → instrument_token map for NFO instruments. Default: empty dict."""
149
+ return {}
150
+
147
151
  def get_market_indices(self) -> dict[str, Any]:
148
152
  """Return Nifty / Sensex / India VIX snapshot. Default: not supported."""
149
153
  return {"status": "error", "message": "not supported by this broker"}
@@ -200,25 +200,38 @@ def format_strike_display(info: Dict[str, Any]) -> str:
200
200
 
201
201
 
202
202
  def implied_volatility(
203
- spot_price: float, strike_price: float, T: float, option_price: float, is_call: bool, r: float = 0.07
203
+ spot_price: float,
204
+ strike_price: float,
205
+ T: float,
206
+ option_price: float,
207
+ is_call: bool,
208
+ r: float = 0.0,
209
+ forward_price: float | None = None,
204
210
  ) -> float | None:
205
- """Solve for Implied Volatility (IV %) from option price using Newton-Raphson."""
206
- if option_price <= 0 or T <= 0:
211
+ """Solve for Implied Volatility (IV %) from option price using Black-76 Newton-Raphson."""
212
+ F = forward_price if (forward_price and forward_price > 0) else spot_price
213
+ if option_price <= 0 or T <= 0 or F <= 0 or strike_price <= 0:
214
+ return None
215
+
216
+ intrinsic = max(0.0, (F - strike_price) if is_call else (strike_price - F))
217
+ if option_price < intrinsic - 1e-4:
207
218
  return None
208
- sigma = 0.25
219
+
220
+ sigma = 0.20
221
+ df = math.exp(-r * T)
209
222
  for _ in range(100):
210
- d1 = (math.log(spot_price / strike_price) + (r + 0.5 * sigma**2) * T) / (sigma * math.sqrt(T))
223
+ d1 = (math.log(F / strike_price) + (0.5 * sigma**2) * T) / (sigma * math.sqrt(T))
211
224
  d2 = d1 - sigma * math.sqrt(T)
212
225
  if is_call:
213
- price = spot_price * _norm_cdf(d1) - strike_price * math.exp(-r * T) * _norm_cdf(d2)
226
+ price = df * (F * _norm_cdf(d1) - strike_price * _norm_cdf(d2))
214
227
  else:
215
- price = strike_price * math.exp(-r * T) * _norm_cdf(-d2) - spot_price * _norm_cdf(-d1)
228
+ price = df * (strike_price * _norm_cdf(-d2) - F * _norm_cdf(-d1))
216
229
 
217
230
  diff = price - option_price
218
231
  if abs(diff) < 1e-4:
219
232
  return sigma * 100.0
220
233
 
221
- vega = spot_price * math.sqrt(T) * _norm_pdf(d1)
234
+ vega = df * F * math.sqrt(T) * _norm_pdf(d1)
222
235
  if vega < 1e-8:
223
236
  break
224
237
  sigma -= diff / vega
@@ -251,39 +264,48 @@ def calculate_greeks(
251
264
  expiry_date: str | datetime | date,
252
265
  option_type: str,
253
266
  vix_pct: float = 15.0,
254
- risk_free_rate: float = 0.07,
267
+ risk_free_rate: float = 0.0,
255
268
  option_price: float | None = None,
269
+ forward_price: float | None = None,
256
270
  ) -> Dict[str, Optional[float]]:
257
- """Calculate Black-Scholes Option Greeks.
271
+ """Calculate Black-76 Option Greeks using Forward / Synthetic Future price.
258
272
 
259
273
  Args:
260
- spot_price: Current spot price of underlying index/stock (e.g. 24500.0)
261
- strike_price: Strike price of option (e.g. 24500.0)
274
+ spot_price: Current spot price of underlying index/stock (e.g. 24055.80)
275
+ strike_price: Strike price of option (e.g. 24000.0)
262
276
  expiry_date: Expiry date string (YYYY-MM-DD), date, or datetime object
263
277
  option_type: 'CE' / 'CALL' or 'PE' / 'PUT'
264
278
  vix_pct: Baseline Volatility percentage (e.g. 15.2 for 15.2% India VIX)
265
- risk_free_rate: Annual risk-free interest rate (default 7% = 0.07)
279
+ risk_free_rate: Annual risk-free interest rate (default 0.0 for Forward pricing)
266
280
  option_price: Optional option market price (LTP) to solve for exact IV.
281
+ forward_price: Optional Synthetic Forward price (F) from Put-Call Parity.
267
282
 
268
283
  Returns:
269
284
  Dict containing delta, gamma, theta, vega, and iv.
270
285
  """
271
- if spot_price <= 0 or strike_price <= 0:
286
+ F = forward_price if (forward_price and forward_price > 0) else spot_price
287
+ if F <= 0 or strike_price <= 0:
272
288
  return {"delta": None, "gamma": None, "theta": None, "vega": None, "iv": None}
273
289
 
274
- # Calculate time to expiry in years
290
+ # Calculate time to expiry in years targeting 15:30 IST market close
291
+ from datetime import time as _time
275
292
  if isinstance(expiry_date, str):
276
293
  try:
277
- exp_dt = datetime.strptime(expiry_date, "%Y-%m-%d")
294
+ exp_d = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
278
295
  except ValueError:
279
- exp_dt = datetime.now() + timedelta(days=7)
280
- elif isinstance(expiry_date, date) and not isinstance(expiry_date, datetime):
281
- exp_dt = datetime.combine(expiry_date, datetime.min.time())
296
+ exp_d = date.today() + timedelta(days=7)
297
+ elif isinstance(expiry_date, datetime):
298
+ exp_d = expiry_date.date()
299
+ elif isinstance(expiry_date, date):
300
+ exp_d = expiry_date
282
301
  else:
283
- exp_dt = expiry_date
302
+ exp_d = date.today() + timedelta(days=7)
284
303
 
304
+ exp_dt = datetime.combine(exp_d, _time(15, 30))
285
305
  now = datetime.now()
286
- days_to_exp = max((exp_dt - now).total_seconds() / 86400.0, 0.25)
306
+ seconds = max((exp_dt - now).total_seconds(), 0.0)
307
+ # Minimum 0.05 days (~1.2 hours) for numerical stability on expiry day close
308
+ days_to_exp = max(seconds / 86400.0, 0.05)
287
309
  T = days_to_exp / 365.0
288
310
 
289
311
  opt_type_upper = option_type.upper()
@@ -291,44 +313,196 @@ def calculate_greeks(
291
313
 
292
314
  eff_vix = vix_pct
293
315
  if option_price and option_price > 0:
294
- solved_iv = implied_volatility(spot_price, strike_price, T, option_price, is_call, risk_free_rate)
316
+ solved_iv = implied_volatility(
317
+ spot_price=spot_price,
318
+ strike_price=strike_price,
319
+ T=T,
320
+ option_price=option_price,
321
+ is_call=is_call,
322
+ r=risk_free_rate,
323
+ forward_price=F,
324
+ )
295
325
  if not solved_iv or solved_iv <= 0:
296
326
  return {"delta": None, "gamma": None, "theta": None, "vega": None, "iv": None}
297
327
  eff_vix = solved_iv
298
328
 
299
329
  sigma = max(eff_vix / 100.0, 0.01)
330
+ df = math.exp(-risk_free_rate * T)
300
331
 
301
- # Black-Scholes d1 and d2 calculations
302
- d1 = (math.log(spot_price / strike_price) + (risk_free_rate + 0.5 * sigma**2) * T) / (sigma * math.sqrt(T))
332
+ # Black-76 d1 and d2 calculations
333
+ d1 = (math.log(F / strike_price) + 0.5 * sigma**2 * T) / (sigma * math.sqrt(T))
303
334
  d2 = d1 - sigma * math.sqrt(T)
304
335
 
305
- opt_type_upper = option_type.upper()
306
- is_call = "CE" in opt_type_upper or "CALL" in opt_type_upper
307
-
308
336
  # 1. Delta
309
337
  if is_call:
310
- delta = _norm_cdf(d1)
338
+ delta = df * _norm_cdf(d1)
311
339
  else:
312
- delta = _norm_cdf(d1) - 1.0
340
+ delta = df * (_norm_cdf(d1) - 1.0)
313
341
 
314
342
  # 2. Gamma
315
- gamma = _norm_pdf(d1) / (spot_price * sigma * math.sqrt(T))
343
+ gamma = (df * _norm_pdf(d1)) / (F * sigma * math.sqrt(T))
316
344
 
317
345
  # 3. Theta (daily decay)
318
- term1 = -(spot_price * _norm_pdf(d1) * sigma) / (2.0 * math.sqrt(T))
319
- if is_call:
320
- term2 = -risk_free_rate * strike_price * math.exp(-risk_free_rate * T) * _norm_cdf(d2)
321
- else:
322
- term2 = risk_free_rate * strike_price * math.exp(-risk_free_rate * T) * _norm_cdf(-d2)
323
- theta = (term1 + term2) / 365.0
346
+ theta = -(F * _norm_pdf(d1) * sigma) / (2.0 * math.sqrt(T) * 365.0)
324
347
 
325
348
  # 4. Vega (per 1% change in volatility)
326
- vega = (spot_price * math.sqrt(T) * _norm_pdf(d1)) / 100.0
349
+ vega = (df * F * math.sqrt(T) * _norm_pdf(d1)) / 100.0
327
350
 
328
351
  return {
329
- "delta": round(delta, 4),
352
+ "delta": round(delta, 2),
330
353
  "gamma": round(gamma, 6),
331
354
  "theta": round(theta, 2),
332
355
  "vega": round(vega, 2),
333
356
  "iv": round(eff_vix, 2),
334
357
  }
358
+
359
+
360
+ def enrich_option_chain_greeks(
361
+ strikes: list[dict],
362
+ spot_price: float,
363
+ expiry_date: str | datetime | date,
364
+ vix_pct: float = 15.0,
365
+ risk_free_rate: float = 0.0,
366
+ ) -> Dict[str, Any]:
367
+ """Calculate Greeks for every strike in the option chain using Black-76 forward model.
368
+
369
+ Derives the Synthetic Forward Price (F) from Put-Call parity across liquid pairs.
370
+
371
+ Returns a summary dict with:
372
+ - 'atm_strike': float
373
+ - 'atm_straddle': float | None
374
+ - 'pcr': float
375
+ - 'max_pain': float
376
+ - 'total_ce_oi': int
377
+ - 'total_pe_oi': int
378
+ - 'forward_price': float
379
+ """
380
+ if not strikes or spot_price <= 0:
381
+ return {
382
+ "atm_strike": 0.0,
383
+ "atm_straddle": None,
384
+ "pcr": 0.0,
385
+ "max_pain": 0.0,
386
+ "total_ce_oi": 0,
387
+ "total_pe_oi": 0,
388
+ "forward_price": 0.0,
389
+ }
390
+
391
+ # 1. Derive Forward Price (F) from liquid strike pairs via Put-Call Parity:
392
+ # F_i = K_i + CE_ltp_i - PE_ltp_i
393
+ fwd_estimates = []
394
+ for s in strikes:
395
+ k = float(s.get("strike", 0.0))
396
+ ce = s.get("ce_ltp")
397
+ pe = s.get("pe_ltp")
398
+ if ce is not None and pe is not None and ce > 0 and pe > 0:
399
+ if spot_price <= 0 or abs(k - spot_price) / spot_price <= 0.05:
400
+ fwd_estimates.append(k + ce - pe)
401
+
402
+ if fwd_estimates:
403
+ fwd_estimates.sort()
404
+ mid = len(fwd_estimates) // 2
405
+ forward_price = (
406
+ fwd_estimates[mid]
407
+ if len(fwd_estimates) % 2 == 1
408
+ else (fwd_estimates[mid - 1] + fwd_estimates[mid]) / 2.0
409
+ )
410
+ else:
411
+ forward_price = spot_price
412
+
413
+ total_ce_oi = 0
414
+ total_pe_oi = 0
415
+ atm_strike = None
416
+ min_fwd_diff = float("inf")
417
+ atm_straddle = None
418
+
419
+ for s in strikes:
420
+ strike = float(s.get("strike", 0.0))
421
+ ce_ltp = s.get("ce_ltp")
422
+ pe_ltp = s.get("pe_ltp")
423
+ ce_oi = int(s.get("ce_oi") or 0)
424
+ pe_oi = int(s.get("pe_oi") or 0)
425
+ total_ce_oi += ce_oi
426
+ total_pe_oi += pe_oi
427
+
428
+ # Find ATM strike (closest strike to Forward Price)
429
+ diff = abs(strike - forward_price)
430
+ if diff < min_fwd_diff:
431
+ min_fwd_diff = diff
432
+ atm_strike = strike
433
+ if ce_ltp is not None and pe_ltp is not None:
434
+ atm_straddle = round(ce_ltp + pe_ltp, 2)
435
+ else:
436
+ atm_straddle = None
437
+
438
+ # Calculate CE Greeks
439
+ if ce_ltp is not None and ce_ltp > 0:
440
+ ce_g = calculate_greeks(
441
+ spot_price=spot_price,
442
+ strike_price=strike,
443
+ expiry_date=expiry_date,
444
+ option_type="CE",
445
+ vix_pct=vix_pct,
446
+ risk_free_rate=risk_free_rate,
447
+ option_price=ce_ltp,
448
+ forward_price=forward_price,
449
+ )
450
+ s["ce_delta"] = ce_g.get("delta")
451
+ s["ce_iv"] = ce_g.get("iv")
452
+ s["ce_theta"] = ce_g.get("theta")
453
+ else:
454
+ s["ce_delta"] = None
455
+ s["ce_iv"] = None
456
+ s["ce_theta"] = None
457
+
458
+ # Calculate PE Greeks
459
+ if pe_ltp is not None and pe_ltp > 0:
460
+ pe_g = calculate_greeks(
461
+ spot_price=spot_price,
462
+ strike_price=strike,
463
+ expiry_date=expiry_date,
464
+ option_type="PE",
465
+ vix_pct=vix_pct,
466
+ risk_free_rate=risk_free_rate,
467
+ option_price=pe_ltp,
468
+ forward_price=forward_price,
469
+ )
470
+ s["pe_delta"] = pe_g.get("delta")
471
+ s["pe_iv"] = pe_g.get("iv")
472
+ s["pe_theta"] = pe_g.get("theta")
473
+ else:
474
+ s["pe_delta"] = None
475
+ s["pe_iv"] = None
476
+ s["pe_theta"] = None
477
+
478
+ # Calculate PCR
479
+ pcr = round(total_pe_oi / total_ce_oi, 2) if total_ce_oi > 0 else 0.0
480
+
481
+ # Calculate Max Pain
482
+ max_pain = atm_strike or 0.0
483
+ min_total_payout = float("inf")
484
+ for target in strikes:
485
+ k_target = float(target.get("strike", 0.0))
486
+ total_payout = 0.0
487
+ for s in strikes:
488
+ k = float(s.get("strike", 0.0))
489
+ ce_oi = int(s.get("ce_oi") or 0)
490
+ pe_oi = int(s.get("pe_oi") or 0)
491
+ if k_target > k:
492
+ total_payout += (k_target - k) * ce_oi
493
+ elif k_target < k:
494
+ total_payout += (k - k_target) * pe_oi
495
+ if total_payout < min_total_payout:
496
+ min_total_payout = total_payout
497
+ max_pain = k_target
498
+
499
+ return {
500
+ "atm_strike": atm_strike,
501
+ "atm_straddle": atm_straddle,
502
+ "pcr": pcr,
503
+ "max_pain": max_pain,
504
+ "total_ce_oi": total_ce_oi,
505
+ "total_pe_oi": total_pe_oi,
506
+ "forward_price": round(forward_price, 2),
507
+ }
508
+
@@ -68,6 +68,7 @@ class KiteAccountManager(BaseBrokerManager):
68
68
  self._authenticated: dict[str, bool] = {}
69
69
  self._proxies: dict[str, dict] = {} # per-account proxy dicts for requests.Session
70
70
  self._nfo_lot_size_cache: dict[str, int] = {} # fetched once, reused across calls
71
+ self._nfo_symbol_token_cache: dict[str, int] = {} # tradingsymbol -> instrument_token map
71
72
 
72
73
  # ── public init helpers (named with _by_key suffix to avoid ABC clash) ────
73
74
 
@@ -858,39 +859,89 @@ class KiteAccountManager(BaseBrokerManager):
858
859
  "message": "No active expiry dates found.",
859
860
  }
860
861
 
862
+ # Identify monthly expiries (last expiry for each year-month)
863
+ month_last_expiry = {}
864
+ for exp in all_expiries:
865
+ month_key = (exp.year, exp.month)
866
+ month_last_expiry[month_key] = exp
867
+ monthly_expiries = set(month_last_expiry.values())
868
+ sorted_monthly = sorted(monthly_expiries)
869
+
861
870
  # Build available_expiries labels
862
871
  available_expiries = []
863
872
  for i, exp in enumerate(all_expiries):
873
+ is_monthly = exp in monthly_expiries
874
+ month_name = exp.strftime("%b")
875
+
876
+ # Weekly designation
864
877
  if i == 0:
865
- label = "Current Week"
878
+ week_desc = "Current Week"
866
879
  elif i == 1:
867
- label = "Next Week"
880
+ week_desc = "Next Week"
881
+ elif i == 2:
882
+ week_desc = "Far Week"
868
883
  else:
869
- label = f"Week +{i}"
870
- available_expiries.append({"expiry": exp.isoformat(), "week_label": label})
884
+ week_desc = f"Week +{i}"
885
+
886
+ # Monthly designation
887
+ month_desc = ""
888
+ if is_monthly:
889
+ m_idx = sorted_monthly.index(exp)
890
+ if m_idx == 0:
891
+ month_desc = f"Current Month ({month_name})"
892
+ elif m_idx == 1:
893
+ month_desc = f"Next Month ({month_name})"
894
+ elif m_idx == 2:
895
+ month_desc = f"Far Month ({month_name})"
896
+ else:
897
+ month_desc = f"{month_name} Monthly"
898
+
899
+ label = f"{month_desc}" if is_monthly and i >= 2 else f"{week_desc} ({month_name} Monthly)" if is_monthly else week_desc
900
+
901
+ available_expiries.append({
902
+ "expiry": exp.isoformat(),
903
+ "week_label": label,
904
+ "week_desc": week_desc,
905
+ "month_desc": month_desc,
906
+ "is_monthly": is_monthly,
907
+ })
871
908
 
872
909
  # Resolve target expiry
873
910
  if expiry_date:
874
- try:
875
- target_expiry = datetime.date.fromisoformat(expiry_date)
876
- except ValueError:
877
- return {
878
- "underlying": underlying,
879
- "expiry": "",
880
- "strikes": [],
881
- "available_expiries": available_expiries,
882
- "status": "error",
883
- "message": f"Invalid expiry_date format '{expiry_date}'. Use YYYY-MM-DD.",
884
- }
885
- if target_expiry not in all_expiries:
886
- return {
887
- "underlying": underlying,
888
- "expiry": expiry_date,
889
- "strikes": [],
890
- "available_expiries": available_expiries,
891
- "status": "error",
892
- "message": f"Expiry date '{expiry_date}' not found for {underlying}.",
893
- }
911
+ exp_date_lower = expiry_date.strip().lower()
912
+ if exp_date_lower in ("monthly", "month", "m", "current month", "current monthly"):
913
+ target_expiry = sorted_monthly[0] if sorted_monthly else all_expiries[0]
914
+ elif exp_date_lower in ("next monthly", "next month", "next_month", "month 1", "monthly 1"):
915
+ target_expiry = sorted_monthly[1] if len(sorted_monthly) > 1 else (sorted_monthly[0] if sorted_monthly else all_expiries[-1])
916
+ elif exp_date_lower in ("far monthly", "far month", "far_month", "month 2", "monthly 2"):
917
+ target_expiry = sorted_monthly[2] if len(sorted_monthly) > 2 else (sorted_monthly[-1] if sorted_monthly else all_expiries[-1])
918
+ elif exp_date_lower in ("current week", "current"):
919
+ target_expiry = all_expiries[0]
920
+ elif exp_date_lower in ("next week", "next"):
921
+ target_expiry = all_expiries[1] if len(all_expiries) > 1 else all_expiries[0]
922
+ elif exp_date_lower in ("far week", "far"):
923
+ target_expiry = all_expiries[2] if len(all_expiries) > 2 else all_expiries[-1]
924
+ else:
925
+ try:
926
+ target_expiry = datetime.date.fromisoformat(expiry_date)
927
+ except ValueError:
928
+ return {
929
+ "underlying": underlying,
930
+ "expiry": "",
931
+ "strikes": [],
932
+ "available_expiries": available_expiries,
933
+ "status": "error",
934
+ "message": f"Invalid expiry_date format '{expiry_date}'. Use 'monthly', 'next month', 'week <N>', or YYYY-MM-DD.",
935
+ }
936
+ if target_expiry not in all_expiries:
937
+ return {
938
+ "underlying": underlying,
939
+ "expiry": expiry_date,
940
+ "strikes": [],
941
+ "available_expiries": available_expiries,
942
+ "status": "error",
943
+ "message": f"Expiry date '{expiry_date}' not found for {underlying}.",
944
+ }
894
945
  else:
895
946
  idx = min(expiry_week, len(all_expiries) - 1)
896
947
  target_expiry = all_expiries[idx]
@@ -917,47 +968,84 @@ class KiteAccountManager(BaseBrokerManager):
917
968
 
918
969
  strikes = sorted(strike_map.values(), key=lambda x: x["strike"])
919
970
 
920
- # Batch-fetch LTP for all option symbols in one call
921
- all_symbols = []
971
+ # Map spot underlying symbol
972
+ index_spot_map = {
973
+ "NIFTY": "NSE:NIFTY 50",
974
+ "BANKNIFTY": "NSE:NIFTY BANK",
975
+ "FINNIFTY": "NSE:NIFTY FIN SERVICE",
976
+ "MIDCPNIFTY": "NSE:NIFTY MID SELECT",
977
+ "SENSEX": "BSE:SENSEX",
978
+ "BANKEX": "BSE:BANKEX",
979
+ }
980
+ spot_symbol = index_spot_map.get(underlying, f"NSE:{underlying}")
981
+
982
+ # Batch-fetch Quote (LTP, OI, Volume) for all option symbols + Spot Index
983
+ all_symbols = [spot_symbol]
922
984
  for s in strikes:
923
985
  if s.get("ce_symbol"):
924
986
  all_symbols.append(f"NFO:{s['ce_symbol']}")
925
987
  if s.get("pe_symbol"):
926
988
  all_symbols.append(f"NFO:{s['pe_symbol']}")
927
989
 
928
- ltp_data: dict = {}
990
+ quote_data: dict = {}
929
991
  if all_symbols:
930
992
  try:
931
- # kite.ltp() accepts up to 500 symbols at once
993
+ # kite.quote() accepts up to 500 symbols at once
932
994
  for i in range(0, len(all_symbols), 500):
933
995
  chunk = all_symbols[i : i + 500]
934
- ltp_data.update(kite.ltp(chunk))
996
+ quote_data.update(kite.quote(chunk))
935
997
  except Exception as exc:
936
- logger.warning("Failed to fetch LTP for option chain: %s", exc)
998
+ logger.warning("kite.quote failed for option chain, falling back to kite.ltp: %s", exc)
999
+ try:
1000
+ for i in range(0, len(all_symbols), 500):
1001
+ chunk = all_symbols[i : i + 500]
1002
+ quote_data.update(kite.ltp(chunk))
1003
+ except Exception as exc2:
1004
+ logger.warning("kite.ltp also failed for option chain: %s", exc2)
937
1005
 
938
- # Enrich each strike entry with live LTP and instrument tokens. The
939
- # tokens let the live TUI subscribe to these options on the WebSocket so
940
- # the option-chain LTPs can stream rather than being a one-shot snapshot.
1006
+ spot_price = 0.0
1007
+ if spot_symbol in quote_data:
1008
+ spot_price = float(quote_data[spot_symbol].get("last_price") or 0.0)
1009
+
1010
+ # Enrich each strike entry with live LTP, instrument tokens, OI, and volume
941
1011
  for s in strikes:
942
1012
  ce_key = f"NFO:{s.get('ce_symbol', '')}"
943
1013
  pe_key = f"NFO:{s.get('pe_symbol', '')}"
944
- if ce_key in ltp_data:
945
- s["ce_ltp"] = ltp_data[ce_key].get("last_price")
946
- s["ce_token"] = ltp_data[ce_key].get("instrument_token")
947
- if pe_key in ltp_data:
948
- s["pe_ltp"] = ltp_data[pe_key].get("last_price")
949
- s["pe_token"] = ltp_data[pe_key].get("instrument_token")
1014
+ if ce_key in quote_data:
1015
+ q = quote_data[ce_key]
1016
+ s["ce_ltp"] = q.get("last_price")
1017
+ s["ce_token"] = q.get("instrument_token")
1018
+ s["ce_oi"] = q.get("oi") or 0
1019
+ s["ce_volume"] = q.get("volume") or 0
1020
+ if pe_key in quote_data:
1021
+ q = quote_data[pe_key]
1022
+ s["pe_ltp"] = q.get("last_price")
1023
+ s["pe_token"] = q.get("instrument_token")
1024
+ s["pe_oi"] = q.get("oi") or 0
1025
+ s["pe_volume"] = q.get("volume") or 0
1026
+
1027
+ # Enrich with Greeks (Delta, IV, Theta, ATM Straddle, PCR, Max Pain)
1028
+ from cli.greeks import enrich_option_chain_greeks
1029
+ summary = enrich_option_chain_greeks(
1030
+ strikes=strikes,
1031
+ spot_price=spot_price,
1032
+ expiry_date=target_expiry,
1033
+ )
950
1034
 
951
1035
  logger.info(
952
- "Option chain: %s, expiry=%s, strikes=%d",
1036
+ "Option chain: %s (Spot: %.2f), expiry=%s, strikes=%d, PCR=%.2f",
953
1037
  underlying,
1038
+ spot_price,
954
1039
  target_expiry.isoformat(),
955
1040
  len(strikes),
1041
+ summary.get("pcr", 0.0),
956
1042
  )
957
1043
 
958
1044
  return {
959
1045
  "underlying": underlying,
960
1046
  "expiry": target_expiry.isoformat(),
1047
+ "spot_price": spot_price,
1048
+ "summary": summary,
961
1049
  "strikes": strikes,
962
1050
  "available_expiries": available_expiries,
963
1051
  "status": "success",
@@ -1135,12 +1223,24 @@ class KiteAccountManager(BaseBrokerManager):
1135
1223
  for inst in instruments
1136
1224
  if inst.get("tradingsymbol")
1137
1225
  }
1226
+ self._nfo_symbol_token_cache = {
1227
+ inst["tradingsymbol"]: int(inst["instrument_token"])
1228
+ for inst in instruments
1229
+ if inst.get("tradingsymbol") and inst.get("instrument_token")
1230
+ }
1138
1231
  return self._nfo_lot_size_cache
1139
1232
  except Exception as exc:
1140
1233
  logger.warning("get_nfo_lot_sizes failed: %s", exc)
1141
1234
  return {}
1142
1235
  return {}
1143
1236
 
1237
+ def get_nfo_symbol_token_map(self) -> dict[str, int]:
1238
+ """Return tradingsymbol → instrument_token map for NFO instruments."""
1239
+ if self._nfo_symbol_token_cache:
1240
+ return self._nfo_symbol_token_cache
1241
+ self.get_nfo_lot_sizes()
1242
+ return self._nfo_symbol_token_cache
1243
+
1144
1244
  def get_market_indices(self) -> dict[str, Any]:
1145
1245
  # 1. Try Kite ohlc first for last_price and yesterday's close
1146
1246
  for api_key in self.get_all_api_keys():