kitecli 0.2.5__tar.gz → 0.2.9__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 (30) hide show
  1. {kitecli-0.2.5 → kitecli-0.2.9}/PKG-INFO +1 -1
  2. {kitecli-0.2.5 → kitecli-0.2.9}/cli/api_client.py +4 -2
  3. {kitecli-0.2.5 → kitecli-0.2.9}/cli/kite_manager.py +23 -9
  4. {kitecli-0.2.5 → kitecli-0.2.9}/cli/kotak_manager.py +63 -34
  5. {kitecli-0.2.5 → kitecli-0.2.9}/cli/live_session.py +234 -23
  6. {kitecli-0.2.5 → kitecli-0.2.9}/cli/main.py +1 -0
  7. {kitecli-0.2.5 → kitecli-0.2.9}/kitecli.egg-info/PKG-INFO +1 -1
  8. {kitecli-0.2.5 → kitecli-0.2.9}/pyproject.toml +1 -1
  9. {kitecli-0.2.5 → kitecli-0.2.9}/README.md +0 -0
  10. {kitecli-0.2.5 → kitecli-0.2.9}/cli/__init__.py +0 -0
  11. {kitecli-0.2.5 → kitecli-0.2.9}/cli/advisor.py +0 -0
  12. {kitecli-0.2.5 → kitecli-0.2.9}/cli/base_manager.py +0 -0
  13. {kitecli-0.2.5 → kitecli-0.2.9}/cli/config.py +0 -0
  14. {kitecli-0.2.5 → kitecli-0.2.9}/cli/display.py +0 -0
  15. {kitecli-0.2.5 → kitecli-0.2.9}/cli/executor.py +0 -0
  16. {kitecli-0.2.5 → kitecli-0.2.9}/cli/nli.py +0 -0
  17. {kitecli-0.2.5 → kitecli-0.2.9}/cli/parser.py +0 -0
  18. {kitecli-0.2.5 → kitecli-0.2.9}/cli/recorder.py +0 -0
  19. {kitecli-0.2.5 → kitecli-0.2.9}/cli/telegram_bot.py +0 -0
  20. {kitecli-0.2.5 → kitecli-0.2.9}/kitecli.egg-info/SOURCES.txt +0 -0
  21. {kitecli-0.2.5 → kitecli-0.2.9}/kitecli.egg-info/dependency_links.txt +0 -0
  22. {kitecli-0.2.5 → kitecli-0.2.9}/kitecli.egg-info/entry_points.txt +0 -0
  23. {kitecli-0.2.5 → kitecli-0.2.9}/kitecli.egg-info/requires.txt +0 -0
  24. {kitecli-0.2.5 → kitecli-0.2.9}/kitecli.egg-info/top_level.txt +0 -0
  25. {kitecli-0.2.5 → kitecli-0.2.9}/setup.cfg +0 -0
  26. {kitecli-0.2.5 → kitecli-0.2.9}/tests/test_multi_broker.py +0 -0
  27. {kitecli-0.2.5 → kitecli-0.2.9}/tests/test_nli.py +0 -0
  28. {kitecli-0.2.5 → kitecli-0.2.9}/tests/test_parser.py +0 -0
  29. {kitecli-0.2.5 → kitecli-0.2.9}/tests/test_telegram.py +0 -0
  30. {kitecli-0.2.5 → kitecli-0.2.9}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.5
3
+ Version: 0.2.9
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -141,11 +141,13 @@ class KCLIClient:
141
141
  is_valid = False
142
142
  if mgr.is_authenticated(account_key):
143
143
  try:
144
- mgr._clients[account_key].limits()
144
+ resp = mgr._clients[account_key].limits()
145
+ from cli.kotak_manager import _check_neo_error
146
+ _check_neo_error(resp)
145
147
  is_valid = True
146
148
  except Exception as exc:
147
149
  msg = str(exc).lower()
148
- is_auth_error = any(x in msg for x in ["100008", "unauthorized", "invalid token", "session expired", "session has been closed", "session closed"])
150
+ is_auth_error = any(x in msg for x in ["100008", "100022", "1037", "unauthorized", "invalid token", "invalid session", "session expired", "session has been closed", "session closed"])
149
151
  if is_auth_error:
150
152
  logger.info("Kotak validation failed (expired/invalid) for %s: %s", name, exc)
151
153
  mgr._authenticated[account_key] = False
@@ -1039,22 +1039,36 @@ class KiteAccountManager(BaseBrokerManager):
1039
1039
  return {}
1040
1040
 
1041
1041
  def get_market_indices(self) -> dict[str, Any]:
1042
- # 1. Try Kite first
1042
+ # 1. Try Kite ohlc first for last_price and yesterday's close
1043
1043
  for api_key in self.get_all_api_keys():
1044
1044
  if self.is_authenticated(api_key):
1045
1045
  kite = self._clients.get(api_key)
1046
1046
  if kite:
1047
1047
  try:
1048
- data = kite.ltp(["NSE:NIFTY 50", "BSE:SENSEX", "NSE:INDIA VIX"])
1049
- nifty = data.get("NSE:NIFTY 50", {}).get("last_price")
1050
- sensex = data.get("BSE:SENSEX", {}).get("last_price")
1051
- vix = data.get("NSE:INDIA VIX", {}).get("last_price")
1052
- if nifty or sensex or vix:
1048
+ data = kite.ohlc(["NSE:NIFTY 50", "BSE:SENSEX", "NSE:INDIA VIX"])
1049
+
1050
+ def parse_item(item_data):
1051
+ if not isinstance(item_data, dict):
1052
+ return None, None
1053
+ last = item_data.get("last_price")
1054
+ ohlc = item_data.get("ohlc", {})
1055
+ close = ohlc.get("close") if isinstance(ohlc, dict) else None
1056
+ change = (last - close) if (last is not None and close is not None) else None
1057
+ return last, change
1058
+
1059
+ nifty_last, nifty_change = parse_item(data.get("NSE:NIFTY 50"))
1060
+ sensex_last, sensex_change = parse_item(data.get("BSE:SENSEX"))
1061
+ vix_last, vix_change = parse_item(data.get("NSE:INDIA VIX"))
1062
+
1063
+ if nifty_last or sensex_last or vix_last:
1053
1064
  return {
1054
1065
  "status": "success",
1055
- "nifty": nifty,
1056
- "sensex": sensex,
1057
- "vix": vix,
1066
+ "nifty": nifty_last,
1067
+ "nifty_change": nifty_change,
1068
+ "sensex": sensex_last,
1069
+ "sensex_change": sensex_change,
1070
+ "vix": vix_last,
1071
+ "vix_change": vix_change,
1058
1072
  }
1059
1073
  except Exception as exc:
1060
1074
  logger.warning("Kite indices fetch failed for api_key=%s…: %s", api_key[:8], exc)
@@ -137,7 +137,9 @@ class KotakAccountManager(BaseBrokerManager):
137
137
 
138
138
  def init_account(self, account_key: str, **credentials) -> str:
139
139
  """ABC entry point — delegates to init_account_kotak."""
140
- return self.init_account_kotak(consumer_key=account_key, **credentials)
140
+ creds = dict(credentials)
141
+ creds.pop("consumer_key", None)
142
+ return self.init_account_kotak(consumer_key=account_key, **creds)
141
143
 
142
144
  def init_account_kotak(
143
145
  self,
@@ -226,70 +228,82 @@ class KotakAccountManager(BaseBrokerManager):
226
228
  if 'Content-Type' not in headers:
227
229
  headers['Content-Type'] = 'application/json'
228
230
 
229
- u = url
230
- if query_params:
231
- u_with_params = u
232
- if '?' not in u:
233
- u_with_params += '?' + _urlencode(query_params)
231
+ def _do_req(h, max_proxy_retries: int = 2):
232
+ if query_params and isinstance(query_params, dict):
233
+ base_u = url.split('?')[0]
234
+ u_curr = base_u + '?' + _urlencode(query_params)
234
235
  else:
235
- pass
236
- else:
237
- u_with_params = u
236
+ u_curr = url
238
237
 
239
- def _do_req(h):
240
238
  req_kwargs = {}
241
239
  if _proxies:
242
- # Kotak Neo static IP whitelisting is only enforced on order APIs
243
- url_lower = u_with_params.lower()
244
- if "/order/" in url_lower and any(x in url_lower for x in ["/place", "/modify", "/cancel"]):
240
+ url_lower = u_curr.lower()
241
+ is_order_api = "/order/" in url_lower and any(x in url_lower for x in ["/place", "/modify", "/cancel"])
242
+ is_login_api = "/login/" in url_lower or "tradeapilogin" in url_lower or "tradeapivalidate" in url_lower
243
+ if is_order_api or is_login_api:
245
244
  req_kwargs["proxies"] = _proxies
246
245
 
247
- logger.debug("Kotak REST [%s] requesting: %s", method, u_with_params)
246
+ logger.debug("Kotak REST [%s] requesting: %s", method, u_curr)
248
247
  logger.debug("Kotak REST headers: %s", {k: v[:15] + '...' if len(str(v)) > 15 else v for k, v in h.items()})
249
248
 
250
- if method in ['POST', 'PUT', 'PATCH', 'DELETE']:
251
- if _re.search('json', h['Content-Type'], _re.IGNORECASE):
252
- request_body = _json.dumps(body) if body is not None else None
253
- return _req.post(url=u_with_params, headers=h, data=request_body, **req_kwargs)
254
- elif _re.search('x-www-form-urlencoded', h['Content-Type'], _re.IGNORECASE):
255
- request_body = {"jData": _json.dumps(body)} if body is not None else {}
256
- return _req.post(url=u_with_params, headers=h, data=request_body, **req_kwargs)
257
- elif method == 'GET':
258
- return _req.get(url=u_with_params, headers=h, **req_kwargs)
259
- return _orig_request(method, url, query_params=query_params, headers=h, body=body)
249
+ try:
250
+ if method in ['POST', 'PUT', 'PATCH', 'DELETE']:
251
+ if _re.search('json', h.get('Content-Type', ''), _re.IGNORECASE):
252
+ request_body = _json.dumps(body) if body is not None else None
253
+ return _req.post(url=u_curr, headers=h, data=request_body, **req_kwargs)
254
+ elif _re.search('x-www-form-urlencoded', h.get('Content-Type', ''), _re.IGNORECASE):
255
+ request_body = {"jData": _json.dumps(body)} if body is not None else {}
256
+ return _req.post(url=u_curr, headers=h, data=request_body, **req_kwargs)
257
+ elif method == 'GET':
258
+ return _req.get(url=u_curr, headers=h, **req_kwargs)
259
+ return _orig_request(method, url, query_params=query_params, headers=h, body=body)
260
+ except Exception as req_exc:
261
+ exc_str = str(req_exc).lower()
262
+ if max_proxy_retries > 0 and _proxies and ("proxy" in exc_str or "502" in exc_str or "tunnel" in exc_str or "connect" in exc_str):
263
+ import time
264
+ logger.warning("Kotak static proxy transient error (%s). Retrying via proxy in 1s...", req_exc)
265
+ time.sleep(1.0)
266
+ return _do_req(h, max_proxy_retries=max_proxy_retries - 1)
267
+ raise
260
268
 
261
269
  resp = _do_req(headers)
262
- logger.info("Kotak REST %s %s → status %s", method, u_with_params, resp.status_code)
270
+ logger.info("Kotak REST %s %s → status %s", method, url, resp.status_code)
263
271
  try:
264
272
  logger.debug("Kotak REST response body: %s", resp.json())
265
273
  except Exception:
266
274
  logger.debug("Kotak REST response body (raw): %s", resp.text[:200])
267
275
 
268
- # Check for IP mismatch error (stCode 1037) or Session expired (stCode 100008)
276
+ # Check for IP mismatch error (stCode 1037), Session expired (stCode 100008, 100022), or HTTP 401
269
277
  try:
270
- resp_json = resp.json()
278
+ resp_json = resp.json() if resp.text else {}
271
279
  if isinstance(resp_json, dict):
272
280
  st_code = resp_json.get("stCode")
273
281
  err_msg = str(resp_json.get("errMsg", "")).lower()
274
282
  if (
275
- st_code == 1037 or
276
- st_code == 100008 or
277
- "session ip" in err_msg or
278
- "unauthorized" in err_msg
283
+ resp.status_code == 401 or
284
+ st_code in (1037, 100008, 100022) or
285
+ "session ip" in err_msg or
286
+ "unauthorized" in err_msg or
287
+ "invalid session" in err_msg or
288
+ "invalid token" in err_msg
279
289
  ):
280
290
  logger.info("Kotak session invalid (stCode=%s, msg='%s') for account '%s'. Triggering auto-login...", st_code, resp_json.get("errMsg"), name)
281
291
  if self.auto_login(consumer_key):
282
292
  # Update headers with new credentials
293
+ new_sid = client.configuration.serverId or client.configuration.edit_sid
283
294
  headers["Sid"] = client.configuration.edit_sid
284
295
  headers["Auth"] = client.configuration.edit_token
296
+ if new_sid:
297
+ url = _re.sub(r'([?&]sId=)[^&]*', r'\g<1>' + str(new_sid), url)
298
+ url = _re.sub(r'([?&]sid=)[^&]*', r'\g<1>' + str(new_sid), url)
285
299
  if isinstance(query_params, dict):
286
300
  if "sId" in query_params:
287
- query_params["sId"] = client.configuration.serverId
301
+ query_params["sId"] = new_sid
288
302
  if "sid" in query_params:
289
- query_params["sid"] = client.configuration.edit_sid
303
+ query_params["sid"] = new_sid
290
304
  logger.info("Retrying request after successful auto-login...")
291
305
  resp = _do_req(headers)
292
- logger.info("Kotak REST retry %s %s → status %s", method, u_with_params, resp.status_code)
306
+ logger.info("Kotak REST retry %s %s → status %s", method, url, resp.status_code)
293
307
  try:
294
308
  logger.debug("Kotak REST retry response body: %s", resp.json())
295
309
  except Exception:
@@ -624,6 +638,18 @@ class KotakAccountManager(BaseBrokerManager):
624
638
  except ValueError:
625
639
  pending_quantity = 0
626
640
 
641
+ fld_qty_str = str(ord.get("fldQty", ord.get("filled_quantity", "0")))
642
+ try:
643
+ filled_quantity = int(fld_qty_str)
644
+ except ValueError:
645
+ filled_quantity = 0
646
+
647
+ avg_prc_str = str(ord.get("avgPrc", ord.get("average_price", "0")))
648
+ try:
649
+ average_price = float(avg_prc_str)
650
+ except ValueError:
651
+ average_price = 0.0
652
+
627
653
  price_str = str(ord.get("prc", ord.get("price", "0")))
628
654
  try:
629
655
  price = float(price_str)
@@ -635,8 +661,10 @@ class KotakAccountManager(BaseBrokerManager):
635
661
  "tradingsymbol": ord.get("trdSym", ord.get("tradingsymbol", "")),
636
662
  "transaction_type": str(ord.get("trnsTp", ord.get("transaction_type", ""))).upper(),
637
663
  "quantity": quantity,
664
+ "filled_quantity": filled_quantity,
638
665
  "pending_quantity": pending_quantity,
639
666
  "price": price,
667
+ "average_price": average_price,
640
668
  "order_type": str(ord.get("prcTp", ord.get("order_type", "MARKET"))).upper(),
641
669
  "status": status,
642
670
  "product": str(ord.get("prod", ord.get("product", "NRML"))).upper(),
@@ -655,6 +683,7 @@ class KotakAccountManager(BaseBrokerManager):
655
683
 
656
684
  try:
657
685
  resp = client.limits()
686
+ _check_neo_error(resp)
658
687
  if isinstance(resp, dict):
659
688
  data = resp.get("data", resp)
660
689
  net = data.get("Net", data.get("net"))
@@ -312,6 +312,7 @@ class KCLILiveSession:
312
312
  # NSE:NIFTY 50 -> 256265, BSE:SENSEX -> 265, NSE:INDIA VIX -> 264969
313
313
  self.index_tokens = {256265: "nifty", 265: "sensex", 264969: "vix"}
314
314
  self.index_values = {"nifty": None, "sensex": None, "vix": None}
315
+ self.index_close_prices = {}
315
316
 
316
317
  # Designated primary streaming account. Index and option-chain ticks are
317
318
  # subscribed only on this account's ticker (chosen in
@@ -1003,6 +1004,14 @@ class KCLILiveSession:
1003
1004
  " Advisor ",
1004
1005
  make_click_handler("advisor")
1005
1006
  ))
1007
+ fragments.append(("", " "))
1008
+
1009
+ # Tab 5: Help
1010
+ fragments.append((
1011
+ "class:tab.active" if self.info_mode == "help" else "class:tab.inactive",
1012
+ " Help ",
1013
+ make_click_handler("help")
1014
+ ))
1006
1015
 
1007
1016
  return fragments
1008
1017
 
@@ -1162,6 +1171,12 @@ class KCLILiveSession:
1162
1171
  async def _initial_fetch_and_connect(self) -> None:
1163
1172
  """Initial fetch of data and connect all WebSockets."""
1164
1173
  self.log_message("Initializing connections and fetching data...")
1174
+
1175
+ # Ensure accounts are validated & auto-logged in
1176
+ try:
1177
+ await self._run_api_call(self.client.init_accounts, self.accounts)
1178
+ except Exception as e:
1179
+ self.log_message(f"[#ff8700]Account init warning:[/#] {e}")
1165
1180
 
1166
1181
  # Fire off REST data fetch in the background so it doesn't block WebSockets
1167
1182
  if hasattr(self, "app") and self.app and self.app.loop:
@@ -1188,7 +1203,7 @@ class KCLILiveSession:
1188
1203
  )
1189
1204
 
1190
1205
  # Connect WebSockets for accounts that support it (Zerodha and Kotak).
1191
- for idx, acct in enumerate(self.accounts):
1206
+ for acct in self.accounts:
1192
1207
  api_key = acct.get("api_key")
1193
1208
  broker = acct.get("broker", "zerodha").lower()
1194
1209
  access_token = self.client.get_access_token(api_key)
@@ -1201,9 +1216,6 @@ class KCLILiveSession:
1201
1216
  )
1202
1217
  continue
1203
1218
 
1204
- if idx > 0:
1205
- await asyncio.sleep(0.2)
1206
-
1207
1219
  if api_key and (access_token or broker == "kotak"):
1208
1220
  try:
1209
1221
  if broker == "kotak":
@@ -1318,7 +1330,9 @@ class KCLILiveSession:
1318
1330
  code == 403
1319
1331
  or "403" in reason_str
1320
1332
  or "auth_failed" in reason_str
1321
- or "token" in reason_str
1333
+ or "invalid token" in reason_str
1334
+ or "token expired" in reason_str
1335
+ or "unauthorized" in reason_str
1322
1336
  )
1323
1337
  if is_auth_failure:
1324
1338
  self.log_message(
@@ -1367,9 +1381,22 @@ class KCLILiveSession:
1367
1381
  if not token or ltp is None:
1368
1382
  continue
1369
1383
 
1370
- # Market index tick → update the header values.
1384
+ # Market index tick → update the header values & streaming change.
1371
1385
  if token in self.index_tokens:
1372
- self.index_values[self.index_tokens[token]] = ltp
1386
+ key = self.index_tokens[token]
1387
+ self.index_values[key] = ltp
1388
+
1389
+ # Extract close from tick OHLC if available, or use cached REST close price
1390
+ tick_close = None
1391
+ if isinstance(tick.get("ohlc"), dict):
1392
+ tick_close = tick["ohlc"].get("close")
1393
+ if tick_close:
1394
+ self.index_close_prices[key] = tick_close
1395
+
1396
+ close_val = self.index_close_prices.get(key)
1397
+ if close_val is not None:
1398
+ self.index_values[f"{key}_change"] = ltp - close_val
1399
+
1373
1400
  indices_updated = True
1374
1401
  continue
1375
1402
 
@@ -1447,18 +1474,82 @@ class KCLILiveSession:
1447
1474
  self.app.invalidate()
1448
1475
 
1449
1476
  def _render_indices_html(self) -> HTML:
1450
- """Build the formatted market-index header from the current values."""
1451
- def fmt(v):
1452
- return f"{v:,.2f}" if v else "N/A"
1453
-
1454
- nifty_str = fmt(self.index_values.get("nifty"))
1455
- sensex_str = fmt(self.index_values.get("sensex"))
1456
- vix_str = fmt(self.index_values.get("vix"))
1457
- return HTML(
1458
- f" <ansicyan><b>NIFTY 50:</b></ansicyan> <style fg='#ffffff'>{nifty_str}</style> "
1459
- f"│ <ansiyellow><b>SENSEX:</b></ansiyellow> <style fg='#ffffff'>{sensex_str}</style> "
1460
- f"│ <ansired><b>INDIA VIX:</b></ansired> <style fg='#ffffff'>{vix_str}</style>"
1461
- )
1477
+ """Build formatted market-index header tailored to the Right Pane width."""
1478
+ total_cols = 100
1479
+ if hasattr(self, "app") and self.app and hasattr(self.app, "output") and self.app.output:
1480
+ try:
1481
+ cols = self.app.output.get_size().columns
1482
+ if isinstance(cols, int):
1483
+ total_cols = cols
1484
+ except Exception:
1485
+ pass
1486
+
1487
+ # The indices bar is rendered inside the Right Pane, so compute right pane width
1488
+ lw = getattr(self, "left_pane_weight", 50)
1489
+ rw = getattr(self, "right_pane_weight", 50)
1490
+ pane_cols = int(total_cols * (rw / max(1, lw + rw)))
1491
+
1492
+ def fmt_item(val, chg, is_vix: bool = False, compact: bool = False):
1493
+ if val is None:
1494
+ return "<style fg='#ffffff'>N/A</style>"
1495
+ val_str = f"{val:,.2f}"
1496
+ if chg is None:
1497
+ return f"<style fg='#ffffff'>{val_str}</style>"
1498
+
1499
+ # For VIX: negative change (volatility drop) is green, positive (volatility spike) is red
1500
+ # For NIFTY/SENSEX: positive is green, negative is red
1501
+ if is_vix:
1502
+ color = "#00ff00" if chg <= 0 else "#ff5f5f"
1503
+ else:
1504
+ color = "#00ff00" if chg >= 0 else "#ff5f5f"
1505
+
1506
+ sign = "+" if chg >= 0 else ""
1507
+ if compact:
1508
+ # In tight right-pane spaces, round large changes to integers e.g. (+80) instead of (+80.50)
1509
+ chg_str = f"{sign}{int(round(chg)):,d}" if not is_vix else f"{sign}{chg:.2f}"
1510
+ else:
1511
+ chg_str = f"{sign}{chg:,.2f}"
1512
+ return f"<style fg='{color}'>{val_str} ({chg_str})</style>"
1513
+
1514
+ if pane_cols >= 75:
1515
+ # Full width: NIFTY 50: 24,234.85 (-99.80) │ SENSEX: 77,708.24 (-441.41) │ IV: 13.45 (+0.25)
1516
+ nifty_html = fmt_item(self.index_values.get("nifty"), self.index_values.get("nifty_change"))
1517
+ sensex_html = fmt_item(self.index_values.get("sensex"), self.index_values.get("sensex_change"))
1518
+ vix_html = fmt_item(self.index_values.get("vix"), self.index_values.get("vix_change"), is_vix=True)
1519
+ return HTML(
1520
+ f" <ansicyan><b>NIFTY 50:</b></ansicyan> {nifty_html} "
1521
+ f"│ <ansiyellow><b>SENSEX:</b></ansiyellow> {sensex_html} "
1522
+ f"│ <ansired><b>IV:</b></ansired> {vix_html}"
1523
+ )
1524
+ elif pane_cols >= 55:
1525
+ # Medium width: NIFTY: 24,234.85 (-100) │ SENSEX: 77,708.24 (-441) │ IV: 13.45 (+0.25)
1526
+ nifty_html = fmt_item(self.index_values.get("nifty"), self.index_values.get("nifty_change"), compact=True)
1527
+ sensex_html = fmt_item(self.index_values.get("sensex"), self.index_values.get("sensex_change"), compact=True)
1528
+ vix_html = fmt_item(self.index_values.get("vix"), self.index_values.get("vix_change"), is_vix=True, compact=True)
1529
+ return HTML(
1530
+ f"<ansicyan><b>NIFTY:</b></ansicyan> {nifty_html} "
1531
+ f"│ <ansiyellow><b>SENSEX:</b></ansiyellow> {sensex_html} "
1532
+ f"│ <ansired><b>IV:</b></ansired> {vix_html}"
1533
+ )
1534
+ else:
1535
+ # Ultra compact for narrow right pane (< 55 cols): N: 24,234.85 │ S: 77,708.24 │ IV: 13.45
1536
+ def fmt_val_only(val, chg, is_vix=False):
1537
+ if val is None:
1538
+ return "<style fg='#ffffff'>N/A</style>"
1539
+ val_str = f"{val:,.2f}"
1540
+ if chg is None:
1541
+ return f"<style fg='#ffffff'>{val_str}</style>"
1542
+ color = ("#00ff00" if chg <= 0 else "#ff5f5f") if is_vix else ("#00ff00" if chg >= 0 else "#ff5f5f")
1543
+ return f"<style fg='{color}'>{val_str}</style>"
1544
+
1545
+ nifty_html = fmt_val_only(self.index_values.get("nifty"), self.index_values.get("nifty_change"))
1546
+ sensex_html = fmt_val_only(self.index_values.get("sensex"), self.index_values.get("sensex_change"))
1547
+ vix_html = fmt_val_only(self.index_values.get("vix"), self.index_values.get("vix_change"), is_vix=True)
1548
+ return HTML(
1549
+ f"<ansicyan><b>N:</b></ansicyan> {nifty_html} "
1550
+ f"│ <ansiyellow><b>S:</b></ansiyellow> {sensex_html} "
1551
+ f"│ <ansired><b>IV:</b></ansired> {vix_html}"
1552
+ )
1462
1553
 
1463
1554
  def _get_active_ticker(self):
1464
1555
  """Get the primary ticker if it is connected, else fallback to any connected ticker."""
@@ -1719,9 +1810,20 @@ class KCLILiveSession:
1719
1810
  if self._ws_should_log("rest_err:indices", min_interval=30.0):
1720
1811
  self.log_message(f"[#ff8700]Indices fetch failed:[/#] {self._clean_error(str(indices_resp))}")
1721
1812
  elif isinstance(indices_resp, dict) and indices_resp.get("status") == "success":
1722
- self.index_values["nifty"] = indices_resp.get("nifty")
1723
- self.index_values["sensex"] = indices_resp.get("sensex")
1724
- self.index_values["vix"] = indices_resp.get("vix")
1813
+ self.index_values["nifty"] = indices_resp.get("nifty")
1814
+ self.index_values["nifty_change"] = indices_resp.get("nifty_change")
1815
+ self.index_values["sensex"] = indices_resp.get("sensex")
1816
+ self.index_values["sensex_change"] = indices_resp.get("sensex_change")
1817
+ self.index_values["vix"] = indices_resp.get("vix")
1818
+ self.index_values["vix_change"] = indices_resp.get("vix_change")
1819
+
1820
+ # Cache close prices (last - change) so live WebSocket ticks can dynamically recalculate change
1821
+ for key in ["nifty", "sensex", "vix"]:
1822
+ last_val = self.index_values.get(key)
1823
+ chg_val = self.index_values.get(f"{key}_change")
1824
+ if last_val is not None and chg_val is not None:
1825
+ self.index_close_prices[key] = last_val - chg_val
1826
+
1725
1827
  self.market_indices_control.text = self._render_indices_html()
1726
1828
  elif isinstance(indices_resp, dict):
1727
1829
  errors_this_cycle += 1
@@ -2320,6 +2422,44 @@ class KCLILiveSession:
2320
2422
  )
2321
2423
  return
2322
2424
 
2425
+ # Direct commands to switch info pane mode
2426
+ cmd_norm = cmd.strip().lower()
2427
+ if cmd_norm in ("pending", "p", "1", "/1", "/pending"):
2428
+ self.info_mode = "orders_pending"
2429
+ self._update_info_buffer()
2430
+ if hasattr(self, "app") and self.app:
2431
+ self.app.invalidate()
2432
+ self.log_message("Switched info pane to [bold]Pending Orders[/bold].")
2433
+ return
2434
+ elif cmd_norm in ("executed", "e", "ex", "2", "/2", "/executed"):
2435
+ self.info_mode = "orders_executed"
2436
+ self._update_info_buffer()
2437
+ if hasattr(self, "app") and self.app:
2438
+ self.app.invalidate()
2439
+ self.log_message("Switched info pane to [bold]Executed Orders[/bold].")
2440
+ return
2441
+ elif cmd_norm in ("oc", "optionchain", "chain", "3", "/3", "/oc"):
2442
+ self.info_mode = "oc"
2443
+ self._update_info_buffer()
2444
+ if hasattr(self, "app") and self.app:
2445
+ self.app.invalidate()
2446
+ self.log_message("Switched info pane to [bold]Option Chain[/bold].")
2447
+ return
2448
+ elif cmd_norm in ("advisor", "adv", "ai", "4", "/4", "/advisor"):
2449
+ self.info_mode = "advisor"
2450
+ self._update_info_buffer()
2451
+ if hasattr(self, "app") and self.app:
2452
+ self.app.invalidate()
2453
+ self.log_message("Switched info pane to [bold]AI Advisor[/bold].")
2454
+ return
2455
+ elif cmd_norm in ("help", "h", "5", "/5", "/help"):
2456
+ self.info_mode = "help"
2457
+ self._update_info_buffer()
2458
+ if hasattr(self, "app") and self.app:
2459
+ self.app.invalidate()
2460
+ self.log_message("Switched info pane to [bold]Help & Shortcuts[/bold].")
2461
+ return
2462
+
2323
2463
  # Attempt parsing via the unified parser
2324
2464
  try:
2325
2465
  parsed_cmds = parse_command_line(cmd)
@@ -3826,6 +3966,59 @@ class KCLILiveSession:
3826
3966
  lines.append("No pending orders across all accounts.")
3827
3967
  return "\n".join(lines)
3828
3968
 
3969
+ def _build_help_text(self) -> str:
3970
+ """Return formatted keyboard shortcuts & command cheat sheet."""
3971
+ return """=== KiteCLI Keyboard & Command Cheat Sheet ===
3972
+
3973
+ 1. TAB SWITCHING (RIGHT PANE)
3974
+ Type at prompt:
3975
+ p or pending or 1 -> Switch to Pending Orders tab
3976
+ e or executed or 2 -> Switch to Executed Orders tab
3977
+ oc or chain or 3 -> Switch to Option Chain tab
3978
+ adv or advisor or 4-> Switch to AI Advisor tab
3979
+ h or help or 5 -> Switch to this Help tab
3980
+
3981
+ Universal Hotkeys:
3982
+ Ctrl + P -> Pending Orders
3983
+ Ctrl + E -> Executed Orders
3984
+ Ctrl + O -> Option Chain
3985
+ Ctrl + G -> AI Advisor
3986
+ F5 / Esc+5 -> Help
3987
+
3988
+ Function Keys / Alt:
3989
+ F1 / F2 / F3 / F4 / F5
3990
+ Option+1 / 2 / 3 / 4 / 5 (or Esc then 1..5)
3991
+ Mouse Click: Click directly on any tab title in header bar
3992
+
3993
+ 2. ORDER PLACEMENT & TRADING COMMANDS
3994
+ Buy / Sell Orders:
3995
+ b <symbol> <qty> [price] -> Buy limit/market (e.g. b NIFTY26JUL22500PE 100 1.40)
3996
+ s <symbol> <qty> [price] -> Sell limit/market (e.g. s BANKNIFTY26JUL50000CE 15@2.50)
3997
+ b 1 100 -> Buy 100 qty of active position #1
3998
+ s 2 -> Sell/exit all qty of active position #2
3999
+
4000
+ Exiting Positions:
4001
+ exit <symbol|id> [price] -> Exit position by symbol or table ID (e.g. exit 1, exit 2 1.50)
4002
+ exit all [price] -> Exit all active positions (e.g. exit all)
4003
+
4004
+ Modifying & Cancelling:
4005
+ modify <order_id> [qty] [price] -> Modify open order
4006
+ cancel <order_id> -> Cancel pending order
4007
+ cancel all -> Cancel all open/pending orders
4008
+
4009
+ 3. SELECTION & SELECTION CLEARING
4010
+ Selection:
4011
+ Click on symbol/account row -> Target specific position/account
4012
+ Esc -> Clear active selection / reset account filter
4013
+
4014
+ 4. GLOBAL NAVIGATION & CONTROL
4015
+ Tab -> Cycle focus (Input box -> Positions -> Right Pane)
4016
+ Ctrl + R -> Search command history
4017
+ Ctrl + Left -> Narrow left pane split width
4018
+ Ctrl + Right -> Widen left pane split width
4019
+ Ctrl + C -> Exit KiteCLI
4020
+ """
4021
+
3829
4022
  def _update_info_buffer(self, reset_scroll: bool = True) -> None:
3830
4023
  """Push current info_mode text into the info_buffer.
3831
4024
 
@@ -3846,6 +4039,8 @@ class KCLILiveSession:
3846
4039
  except Exception as exc:
3847
4040
  self._last_advisor_text = f"=== Tuesday Expiry Advisor ===\n\nFailed to plan: {exc}"
3848
4041
  text = self._last_advisor_text
4042
+ elif self.info_mode == "help":
4043
+ text = self._build_help_text()
3849
4044
  else:
3850
4045
  text = self._last_oc_text
3851
4046
  if reset_scroll:
@@ -4217,8 +4412,10 @@ class KCLILiveSession:
4217
4412
  nxt = self.input_field.control
4218
4413
  event.app.layout.focus(nxt)
4219
4414
 
4220
- # Ctrl+1/2/3/4 & F1-F4 — switch info pane content
4415
+ # Ctrl+1/2/3/4, Ctrl+P/E/O/G, Alt/Esc+1/2/3/4, F1-F4 — switch info pane content
4221
4416
  @kb.add("c-1")
4417
+ @kb.add("c-p")
4418
+ @kb.add("escape", "1")
4222
4419
  @kb.add("f1")
4223
4420
  def _c1(event):
4224
4421
  self.info_mode = "orders_pending"
@@ -4226,6 +4423,8 @@ class KCLILiveSession:
4226
4423
  event.app.invalidate()
4227
4424
 
4228
4425
  @kb.add("c-2")
4426
+ @kb.add("c-e")
4427
+ @kb.add("escape", "2")
4229
4428
  @kb.add("f2")
4230
4429
  def _c2(event):
4231
4430
  self.info_mode = "orders_executed"
@@ -4233,6 +4432,8 @@ class KCLILiveSession:
4233
4432
  event.app.invalidate()
4234
4433
 
4235
4434
  @kb.add("c-3")
4435
+ @kb.add("c-o")
4436
+ @kb.add("escape", "3")
4236
4437
  @kb.add("f3")
4237
4438
  def _c3(event):
4238
4439
  self.info_mode = "oc"
@@ -4240,12 +4441,22 @@ class KCLILiveSession:
4240
4441
  event.app.invalidate()
4241
4442
 
4242
4443
  @kb.add("c-4")
4444
+ @kb.add("c-g")
4445
+ @kb.add("escape", "4")
4243
4446
  @kb.add("f4")
4244
4447
  def _c4(event):
4245
4448
  self.info_mode = "advisor"
4246
4449
  self._update_info_buffer()
4247
4450
  event.app.invalidate()
4248
4451
 
4452
+ @kb.add("c-5")
4453
+ @kb.add("escape", "5")
4454
+ @kb.add("f5")
4455
+ def _c5(event):
4456
+ self.info_mode = "help"
4457
+ self._update_info_buffer()
4458
+ event.app.invalidate()
4459
+
4249
4460
  # Ctrl+Left/Right — adjust left/right pane split
4250
4461
  @kb.add("c-left")
4251
4462
  def _narrow_left(event):
@@ -206,6 +206,7 @@ def live() -> None:
206
206
  display_error("No accounts configured. Edit your config file to add accounts.")
207
207
  raise typer.Exit(code=1)
208
208
  client = _build_client(config)
209
+ client.init_accounts(accounts)
209
210
 
210
211
  # Launch interactive session
211
212
  session = KCLILiveSession(client, accounts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.5
3
+ Version: 0.2.9
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kitecli"
7
- version = "0.2.5"
7
+ version = "0.2.9"
8
8
  description = "KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes