kitecli 0.2.1__tar.gz → 0.2.2__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.1 → kitecli-0.2.2}/PKG-INFO +1 -1
  2. {kitecli-0.2.1 → kitecli-0.2.2}/cli/kite_manager.py +28 -65
  3. {kitecli-0.2.1 → kitecli-0.2.2}/cli/kotak_manager.py +82 -85
  4. {kitecli-0.2.1 → kitecli-0.2.2}/cli/live_session.py +175 -112
  5. {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/PKG-INFO +1 -1
  6. {kitecli-0.2.1 → kitecli-0.2.2}/pyproject.toml +1 -1
  7. {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_ui.py +22 -0
  8. {kitecli-0.2.1 → kitecli-0.2.2}/README.md +0 -0
  9. {kitecli-0.2.1 → kitecli-0.2.2}/cli/__init__.py +0 -0
  10. {kitecli-0.2.1 → kitecli-0.2.2}/cli/advisor.py +0 -0
  11. {kitecli-0.2.1 → kitecli-0.2.2}/cli/api_client.py +0 -0
  12. {kitecli-0.2.1 → kitecli-0.2.2}/cli/base_manager.py +0 -0
  13. {kitecli-0.2.1 → kitecli-0.2.2}/cli/config.py +0 -0
  14. {kitecli-0.2.1 → kitecli-0.2.2}/cli/display.py +0 -0
  15. {kitecli-0.2.1 → kitecli-0.2.2}/cli/executor.py +0 -0
  16. {kitecli-0.2.1 → kitecli-0.2.2}/cli/main.py +0 -0
  17. {kitecli-0.2.1 → kitecli-0.2.2}/cli/nli.py +0 -0
  18. {kitecli-0.2.1 → kitecli-0.2.2}/cli/parser.py +0 -0
  19. {kitecli-0.2.1 → kitecli-0.2.2}/cli/recorder.py +0 -0
  20. {kitecli-0.2.1 → kitecli-0.2.2}/cli/telegram_bot.py +0 -0
  21. {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/SOURCES.txt +0 -0
  22. {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/dependency_links.txt +0 -0
  23. {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/entry_points.txt +0 -0
  24. {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/requires.txt +0 -0
  25. {kitecli-0.2.1 → kitecli-0.2.2}/kitecli.egg-info/top_level.txt +0 -0
  26. {kitecli-0.2.1 → kitecli-0.2.2}/setup.cfg +0 -0
  27. {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_multi_broker.py +0 -0
  28. {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_nli.py +0 -0
  29. {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_parser.py +0 -0
  30. {kitecli-0.2.1 → kitecli-0.2.2}/tests/test_telegram.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -97,6 +97,14 @@ class KiteAccountManager(BaseBrokerManager):
97
97
  """Core account initialisation (used both directly and via ABC)."""
98
98
  proxies = {"http": proxy, "https": proxy} if proxy else None
99
99
  kite = KiteConnect(api_key=api_key, proxies=proxies)
100
+
101
+ # Increase connection pool size and timeouts to support slow proxies
102
+ from requests.adapters import HTTPAdapter
103
+ adapter = HTTPAdapter(pool_connections=100, pool_maxsize=100)
104
+ kite.reqsession.mount("https://", adapter)
105
+ kite.reqsession.mount("http://", adapter)
106
+ kite.timeout = 25
107
+
100
108
  self._clients[api_key] = kite
101
109
  self._api_secrets[api_key] = api_secret
102
110
  self._account_names[api_key] = name or api_key
@@ -106,18 +114,9 @@ class KiteAccountManager(BaseBrokerManager):
106
114
  sessions = _load_sessions()
107
115
  saved_token = sessions.get(api_key)
108
116
  if saved_token:
109
- logger.info("Found saved session token for '%s' (api_key=%s…). Verifying...", name, api_key[:8])
110
- try:
111
- kite.set_access_token(saved_token)
112
- kite.profile()
113
- self._authenticated[api_key] = True
114
- logger.info("Successfully restored valid session for '%s' (api_key=%s…)", name, api_key[:8])
115
- except Exception as exc:
116
- logger.info(
117
- "Saved session token for '%s' (api_key=%s…) is invalid or expired: %s",
118
- name, api_key[:8], exc,
119
- )
120
- self._authenticated[api_key] = False
117
+ logger.debug("Found saved session token for '%s' (api_key=%s…). Assuming valid.", name, api_key[:8])
118
+ kite.set_access_token(saved_token)
119
+ self._authenticated[api_key] = True
121
120
 
122
121
  login_url = f"https://kite.zerodha.com/connect/login?v=3&api_key={api_key}"
123
122
  logger.info("Initialized account '%s' (api_key=%s…)", name, api_key[:8])
@@ -374,6 +373,11 @@ class KiteAccountManager(BaseBrokerManager):
374
373
  # Build a requests.Session and apply the per-account proxy so that
375
374
  # login and 2FA calls also flow through the proxy (not just KiteConnect SDK calls).
376
375
  session = http_requests.Session()
376
+ from requests.adapters import HTTPAdapter
377
+ adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20)
378
+ session.mount("https://", adapter)
379
+ session.mount("http://", adapter)
380
+
377
381
  account_proxies = self._proxies.get(api_key, {})
378
382
  if account_proxies:
379
383
  session.proxies.update(account_proxies)
@@ -384,6 +388,7 @@ class KiteAccountManager(BaseBrokerManager):
384
388
  login_resp = session.post(
385
389
  "https://kite.zerodha.com/api/login",
386
390
  data={"user_id": user_id, "password": password},
391
+ timeout=25,
387
392
  )
388
393
  login_data = login_resp.json()
389
394
 
@@ -411,6 +416,7 @@ class KiteAccountManager(BaseBrokerManager):
411
416
  "user_id": user_id,
412
417
  "twofa_type": "totp",
413
418
  },
419
+ timeout=25,
414
420
  )
415
421
  twofa_data = twofa_resp.json()
416
422
 
@@ -434,19 +440,8 @@ class KiteAccountManager(BaseBrokerManager):
434
440
 
435
441
  for hop in range(1, 11):
436
442
  logger.info("Auto-login hop %d: %s", hop, current_url)
437
- try:
438
- resp = session.get(current_url, allow_redirects=False)
439
- except http_requests.exceptions.RequestException as exc:
440
- logger.warning("Network error/unreachable during redirect hop %d: %s", hop, exc)
441
- # Check if the url itself contained the token before network error
442
- parsed = urlparse(current_url)
443
- query_params = parse_qs(parsed.query)
444
- token = query_params.get("request_token", [None])[0]
445
- if token:
446
- request_token = token
447
- break
448
443
 
449
- # Check if current_url already contains the request_token
444
+ # Check if current_url already contains the request_token BEFORE requesting it
450
445
  parsed = urlparse(current_url)
451
446
  query_params = parse_qs(parsed.query)
452
447
  token = query_params.get("request_token", [None])[0]
@@ -454,6 +449,12 @@ class KiteAccountManager(BaseBrokerManager):
454
449
  request_token = token
455
450
  break
456
451
 
452
+ try:
453
+ resp = session.get(current_url, allow_redirects=False, timeout=25)
454
+ except http_requests.exceptions.RequestException as exc:
455
+ logger.warning("Network error/unreachable during redirect hop %d: %s", hop, exc)
456
+ break
457
+
457
458
  location = resp.headers.get("Location")
458
459
  if not location:
459
460
  break
@@ -465,21 +466,14 @@ class KiteAccountManager(BaseBrokerManager):
465
466
 
466
467
  current_url = location
467
468
 
468
- # Check if target redirect URL contains the request_token
469
- parsed = urlparse(current_url)
470
- query_params = parse_qs(parsed.query)
471
- token = query_params.get("request_token", [None])[0]
472
- if token:
473
- request_token = token
474
- break
475
-
476
469
  # If target URL is not a Zerodha domain (e.g. localhost or client app domain),
477
470
  # do not actually request it (it may not be reachable or could trigger external side-effects),
478
471
  # and we've already checked if it has the request_token.
479
472
  parsed_loc = urlparse(current_url)
480
473
  if parsed_loc.netloc and not parsed_loc.netloc.endswith(".zerodha.com") and "zerodha.com" not in parsed_loc.netloc:
481
474
  logger.info("Reached non-Zerodha redirect target: %s", current_url)
482
- break
475
+ # We will extract the token at the start of the next iteration
476
+ pass
483
477
 
484
478
  if not request_token:
485
479
  logger.error("Could not extract request_token from redirect chain for %s", user_id)
@@ -1064,38 +1058,7 @@ class KiteAccountManager(BaseBrokerManager):
1064
1058
  except Exception as exc:
1065
1059
  logger.warning("Kite indices fetch failed for api_key=%s…: %s", api_key[:8], exc)
1066
1060
 
1067
- # 2. Fallback to Yahoo Finance chart API (in parallel)
1068
- try:
1069
- logger.info("Fetching indices from Yahoo Finance fallback...")
1070
- import requests as http_requests
1071
- headers = {"User-Agent": "Mozilla/5.0"}
1072
- from concurrent.futures import ThreadPoolExecutor
1073
-
1074
- symbols = {
1075
- "nifty": "^NSEI",
1076
- "sensex": "^BSESN",
1077
- "vix": "^INDIAVIX",
1078
- }
1079
-
1080
- def fetch_symbol_price(item):
1081
- name, code = item
1082
- url = f"https://query1.finance.yahoo.com/v8/finance/chart/{code}?interval=1m&range=1d"
1083
- resp = http_requests.get(url, headers=headers, timeout=5)
1084
- price = resp.json()["chart"]["result"][0]["meta"]["regularMarketPrice"]
1085
- return name, float(price)
1086
-
1087
- with ThreadPoolExecutor(max_workers=3) as executor:
1088
- results = dict(executor.map(fetch_symbol_price, symbols.items()))
1089
-
1090
- return {
1091
- "status": "success",
1092
- "nifty": results["nifty"],
1093
- "sensex": results["sensex"],
1094
- "vix": results["vix"],
1095
- }
1096
- except Exception as exc:
1097
- logger.error("Yahoo Finance fallback failed: %s", exc)
1098
- return {"status": "error", "message": f"Kite call had insufficient permission, and Yahoo fallback failed: {exc}"}
1061
+ return {"status": "error", "message": "All authenticated Zerodha account indices fetches failed."}
1099
1062
 
1100
1063
  def get_ltp_and_tokens(self, api_key: str, symbols: list[str]) -> dict:
1101
1064
  """Fetch LTP and instrument tokens for the given symbols using Zerodha ltp()."""
@@ -148,6 +148,9 @@ class KotakAccountManager(BaseBrokerManager):
148
148
  self._authenticated: dict[str, bool] = {}
149
149
  # consumer_key → stored credentials for re-auth
150
150
  self._credentials: dict[str, dict] = {}
151
+ import threading as _threading
152
+ self._auth_lock = _threading.Lock()
153
+ self._last_login_time: dict[str, float] = {}
151
154
 
152
155
  # ── account lifecycle ──────────────────────────────────────────────────────
153
156
 
@@ -255,7 +258,10 @@ class KotakAccountManager(BaseBrokerManager):
255
258
  def _do_req(h):
256
259
  req_kwargs = {}
257
260
  if _proxies:
258
- req_kwargs["proxies"] = _proxies
261
+ # Kotak Neo static IP whitelisting is only enforced on order APIs
262
+ url_lower = u_with_params.lower()
263
+ if "/order/" in url_lower and any(x in url_lower for x in ["/place", "/modify", "/cancel"]):
264
+ req_kwargs["proxies"] = _proxies
259
265
 
260
266
  logger.debug("Kotak REST [%s] requesting: %s", method, u_with_params)
261
267
  logger.debug("Kotak REST headers: %s", {k: v[:15] + '...' if len(str(v)) > 15 else v for k, v in h.items()})
@@ -355,100 +361,91 @@ class KotakAccountManager(BaseBrokerManager):
355
361
  sessions = _load_sessions()
356
362
  saved_session = sessions.get(f"kotak:{consumer_key}")
357
363
  if saved_session and isinstance(saved_session, dict):
358
- logger.info(
359
- "Found saved Kotak session for '%s' (key=%s…). Verifying...",
360
- name, consumer_key[:8],
361
- )
362
- try:
363
- client.configuration.edit_token = saved_session.get("edit_token")
364
- client.configuration.edit_sid = saved_session.get("edit_sid")
365
- client.configuration.edit_rid = saved_session.get("edit_rid")
366
- client.configuration.serverId = saved_session.get("serverId")
367
- client.configuration.data_center = saved_session.get("data_center")
368
- client.configuration.base_url = saved_session.get("base_url")
369
-
370
- # Verify token liveness
371
- limits_resp = client.limits()
372
- _check_neo_error(limits_resp)
373
- self._authenticated[consumer_key] = True
374
- logger.info(
375
- "Restored valid Kotak session for '%s' (key=%s…)", name, consumer_key[:8]
376
- )
377
- except Exception as exc:
378
- logger.info(
379
- "Saved Kotak token for '%s' (key=%s…) expired: %s. Will attempt auto-login.",
380
- name, consumer_key[:8], exc,
381
- )
382
- self._authenticated[consumer_key] = False
364
+ logger.debug("Found saved Kotak session for '%s' (key=%s…). Assuming valid.", name, consumer_key[:8])
365
+ client.configuration.edit_token = saved_session.get("edit_token")
366
+ client.configuration.edit_sid = saved_session.get("edit_sid")
367
+ client.configuration.edit_rid = saved_session.get("edit_rid")
368
+ client.configuration.serverId = saved_session.get("serverId")
369
+ client.configuration.data_center = saved_session.get("data_center")
370
+ client.configuration.base_url = saved_session.get("base_url")
371
+
372
+ self._authenticated[consumer_key] = True
383
373
 
384
374
  if not self._authenticated.get(consumer_key):
385
- logger.info("No valid Kotak session found for '%s' (key=%s…). Attempting auto-login...", name, consumer_key[:8])
386
- try:
387
- self.auto_login(consumer_key)
388
- except Exception as e:
389
- logger.error("Auto-login failed for Kotak account '%s': %s", name, e)
375
+ logger.debug("No valid Kotak session found for '%s' (key=%s…).", name, consumer_key[:8])
390
376
 
391
377
  return "" # no login URL for Kotak
392
378
 
393
379
  def auto_login(self, account_key: str, **_kwargs) -> bool:
394
380
  """Perform TOTP-based auto-login for the Kotak account."""
395
- creds = self._credentials.get(account_key, {})
396
- totp_secret = creds.get("totp_secret", "")
397
- mobile_number = creds.get("mobile_number", "")
398
- mpin = creds.get("mpin", "")
399
- ucc = creds.get("ucc", "")
400
-
401
- if not (mobile_number and ucc and mpin and totp_secret):
402
- logger.warning(
403
- "Kotak auto-login skipped for key=%s…: incomplete credentials",
404
- account_key[:8],
405
- )
406
- return False
407
-
408
- client = self._clients.get(account_key)
409
- if not client:
410
- return False
381
+ import time
382
+ with self._auth_lock:
383
+ # Debounce: if we just logged in within the last 15 seconds, skip re-login.
384
+ # This prevents concurrent threads from hammering the login endpoint when
385
+ # multiple requests see a 100008 (session expired) error simultaneously.
386
+ last_login = self._last_login_time.get(account_key, 0.0)
387
+ if time.time() - last_login < 15.0:
388
+ logger.info("Kotak auto-login skipped for key=%s… (debounced)", account_key[:8])
389
+ return True
390
+
391
+ creds = self._credentials.get(account_key, {})
392
+ totp_secret = creds.get("totp_secret", "")
393
+ mobile_number = creds.get("mobile_number", "")
394
+ mpin = creds.get("mpin", "")
395
+ ucc = creds.get("ucc", "")
396
+
397
+ if not (mobile_number and ucc and mpin and totp_secret):
398
+ logger.warning(
399
+ "Kotak auto-login skipped for key=%s…: incomplete credentials",
400
+ account_key[:8],
401
+ )
402
+ return False
411
403
 
412
- try:
413
- totp_code = pyotp.TOTP(totp_secret).now()
414
- logger.info("Kotak login: sending login request for key=%s…", account_key[:8])
415
-
416
- # Step 1: Initiate login
417
- login_resp = client.totp_login(
418
- mobile_number=mobile_number,
419
- ucc=ucc,
420
- totp=totp_code,
421
- )
422
- logger.debug("Kotak login resp: %s", login_resp)
423
- if isinstance(login_resp, dict) and "Error" in login_resp:
424
- raise RuntimeError(f"TOTP login failed: {login_resp}")
425
-
426
- # Step 2: 2FA validation with MPIN
427
- session_resp = client.totp_validate(mpin=mpin)
428
- logger.debug("Kotak 2FA resp: %s", session_resp)
429
- if isinstance(session_resp, dict) and "Error" in session_resp:
430
- raise RuntimeError(f"MPIN validation failed: {session_resp}")
431
-
432
- # Persist token
433
- conf = client.configuration
434
- if getattr(conf, "edit_token", None):
435
- session_data = {
436
- "edit_token": conf.edit_token,
437
- "edit_sid": conf.edit_sid,
438
- "edit_rid": conf.edit_rid,
439
- "serverId": conf.serverId,
440
- "data_center": conf.data_center,
441
- "base_url": conf.base_url,
442
- }
443
- _save_session(f"kotak:{account_key}", session_data)
404
+ client = self._clients.get(account_key)
405
+ if not client:
406
+ return False
444
407
 
445
- self._authenticated[account_key] = True
446
- logger.info("Kotak auto-login successful for key=%s…", account_key[:8])
447
- return True
408
+ try:
409
+ totp_code = pyotp.TOTP(totp_secret).now()
410
+ logger.info("Kotak login: sending login request for key=%s…", account_key[:8])
411
+
412
+ # Step 1: Initiate login
413
+ login_resp = client.totp_login(
414
+ mobile_number=mobile_number,
415
+ ucc=ucc,
416
+ totp=totp_code,
417
+ )
418
+ logger.debug("Kotak login resp: %s", login_resp)
419
+ if isinstance(login_resp, dict) and "Error" in login_resp:
420
+ raise RuntimeError(f"TOTP login failed: {login_resp}")
421
+
422
+ # Step 2: 2FA validation with MPIN
423
+ session_resp = client.totp_validate(mpin=mpin)
424
+ logger.debug("Kotak 2FA resp: %s", session_resp)
425
+ if isinstance(session_resp, dict) and "Error" in session_resp:
426
+ raise RuntimeError(f"MPIN validation failed: {session_resp}")
427
+
428
+ # Persist token
429
+ conf = client.configuration
430
+ if getattr(conf, "edit_token", None):
431
+ session_data = {
432
+ "edit_token": conf.edit_token,
433
+ "edit_sid": conf.edit_sid,
434
+ "edit_rid": conf.edit_rid,
435
+ "serverId": conf.serverId,
436
+ "data_center": conf.data_center,
437
+ "base_url": conf.base_url,
438
+ }
439
+ _save_session(f"kotak:{account_key}", session_data)
440
+
441
+ self._authenticated[account_key] = True
442
+ self._last_login_time[account_key] = time.time()
443
+ logger.info("Kotak auto-login successful for key=%s…", account_key[:8])
444
+ return True
448
445
 
449
- except Exception as exc:
450
- logger.error(
451
- "Kotak auto-login failed for key=%s…: %s", account_key[:8], exc, exc_info=True
446
+ except Exception as exc:
447
+ logger.error(
448
+ "Kotak auto-login failed for key=%s…: %s", account_key[:8], exc, exc_info=True
452
449
  )
453
450
  self._authenticated[account_key] = False
454
451
  return False
@@ -386,6 +386,10 @@ class KCLILiveSession:
386
386
  "divider": "bg:#21262d fg:#30363d",
387
387
  "divider.dragging": "bg:#0969da fg:#ffffff bold",
388
388
  "market_indices": "bg:#161b22",
389
+ "tab_bar": "bg:#161b22",
390
+ "tab.active": "bg:#1f6feb fg:#ffffff bold",
391
+ "tab.inactive": "bg:#21262d fg:#8b949e",
392
+ "tab.separator": "fg:#30363d",
389
393
  # Quick action bar
390
394
  "quickaction": "bg:#161b22 fg:#8b949e",
391
395
  "quickaction.hint": "bg:#161b22 fg:#484f58 italic",
@@ -946,6 +950,62 @@ class KCLILiveSession:
946
950
 
947
951
  return [(status_style, status_label, _header_click_handler)]
948
952
 
953
+ def _get_tab_bar_text(self):
954
+ """Build clickable, styled text fragments for the info pane tabs."""
955
+ def make_click_handler(mode):
956
+ def handler(*args, **kwargs):
957
+ from prompt_toolkit.mouse_events import MouseEventType
958
+ mouse_event = None
959
+ if len(args) == 2:
960
+ mouse_event = args[1]
961
+ elif len(args) == 1:
962
+ mouse_event = args[0]
963
+
964
+ if mouse_event and mouse_event.event_type == MouseEventType.MOUSE_UP:
965
+ self.info_mode = mode
966
+ self._update_info_buffer()
967
+ if hasattr(self, "app") and self.app:
968
+ self.app.invalidate()
969
+ return handler
970
+
971
+ fragments = []
972
+
973
+ # Tiny spacing for visual margin
974
+ fragments.append(("", " "))
975
+
976
+ # Tab 1: Pending
977
+ fragments.append((
978
+ "class:tab.active" if self.info_mode == "orders_pending" else "class:tab.inactive",
979
+ " Pending ",
980
+ make_click_handler("orders_pending")
981
+ ))
982
+ fragments.append(("", " "))
983
+
984
+ # Tab 2: Executed
985
+ fragments.append((
986
+ "class:tab.active" if self.info_mode == "orders_executed" else "class:tab.inactive",
987
+ " Executed ",
988
+ make_click_handler("orders_executed")
989
+ ))
990
+ fragments.append(("", " "))
991
+
992
+ # Tab 3: OC (Option Chain)
993
+ fragments.append((
994
+ "class:tab.active" if self.info_mode == "oc" else "class:tab.inactive",
995
+ " OC ",
996
+ make_click_handler("oc")
997
+ ))
998
+ fragments.append(("", " "))
999
+
1000
+ # Tab 4: Advisor
1001
+ fragments.append((
1002
+ "class:tab.active" if self.info_mode == "advisor" else "class:tab.inactive",
1003
+ " Advisor ",
1004
+ make_click_handler("advisor")
1005
+ ))
1006
+
1007
+ return fragments
1008
+
949
1009
  async def reconnect_websockets(self) -> None:
950
1010
  """Close existing WebSocket connections and start fresh ones, updating the UI."""
951
1011
  self.log_message("[#58a6ff]Closing existing WebSocket connections...[/#]")
@@ -983,72 +1043,85 @@ class KCLILiveSession:
983
1043
  self.log_message("Running token diagnostics...")
984
1044
  statuses: dict[str, str] = {}
985
1045
  any_bad = False
986
- for acct in self.accounts:
1046
+
1047
+ async def diagnose_account(acct):
987
1048
  api_key = acct.get("api_key")
1049
+ name = acct.get("name", api_key)
988
1050
  if not api_key:
989
- continue
1051
+ return api_key, "no_token", name, "", acct, [], False
1052
+
1053
+ logs = []
1054
+ is_bad = False
1055
+ broker = acct.get("broker", "zerodha").lower()
1056
+
1057
+ # Fast path for Zerodha: try WebSocket auth first
1058
+ if broker == "zerodha":
1059
+ access_token = self.client.get_access_token(api_key)
1060
+ if not access_token:
1061
+ logs.append(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
1062
+ return api_key, "no_token", name, "no access token", acct, logs, True
1063
+
1064
+ proxy_str = acct.get("proxy")
1065
+ ws_status, ws_detail = await self._run_api_call(
1066
+ probe_ws_auth, api_key, access_token, proxy_str
1067
+ )
1068
+
1069
+ if ws_status == "ok":
1070
+ logs.append(f"[#00ff00]✓ Streaming OK:[/#] @{name}")
1071
+ return api_key, "valid", name, "", acct, logs, False
1072
+ elif ws_status == "proxy_blocked":
1073
+ logs.append(f"[#ff8700]⚠ Proxy blocked streaming:[/#] @{name} — proxy refused the WebSocket tunnel ({ws_detail}).")
1074
+ return api_key, "proxy_blocked", name, ws_detail, acct, logs, True
1075
+
1076
+ # If we are here, either it's Kotak, or Zerodha WS failed.
1077
+ # Fallback to the full REST check_token to find out exactly why.
990
1078
  try:
991
1079
  result = await self._run_api_call(self.client.check_token, api_key)
992
1080
  except Exception as exc:
993
- result = {"name": acct.get("name", api_key), "status": "error",
994
- "detail": str(exc)}
1081
+ result = {"name": name, "status": "error", "detail": str(exc)}
1082
+
995
1083
  status = result.get("status", "error")
996
- statuses[api_key] = status
997
- name = result.get("name", api_key)
998
1084
  detail = result.get("detail", "")
1085
+
999
1086
  if status == "valid":
1000
- broker = acct.get("broker", "zerodha").lower()
1001
1087
  if broker != "zerodha":
1002
- # Non-Zerodha accounts (e.g. Kotak) use their own WebSocket
1003
- # feed — do not probe ws.kite.trade with a non-Zerodha token.
1004
- self.log_message(f"[#00ff00]✓ Token OK:[/#] @{name}")
1005
- elif broker == "zerodha":
1006
- # REST works, but that does NOT guarantee streaming works: the
1007
- # WebSocket can still reject this token with 403 "Authentication
1008
- # failed" (per api_key/appe.g. no active streaming
1009
- # subscription). Probe the actual WS handshake so we only start
1010
- # tickers that will succeed and avoid a 403 reconnect storm.
1011
- access_token = self.client.get_access_token(api_key)
1012
- proxy_str = acct.get("proxy")
1013
- ws_status, ws_detail = await self._run_api_call(
1014
- probe_ws_auth, api_key, access_token, proxy_str
1088
+ logs.append(f"[#00ff00]✓ Token OK:[/#] @{name}")
1089
+ else:
1090
+ # Zerodha REST is valid, but we already know WS failed from the fast-path above!
1091
+ is_bad = True
1092
+ status = "stream_forbidden"
1093
+ logs.append(
1094
+ f"[#ff0000]✗ Streaming rejected:[/#] @{name}"
1095
+ f"WebSocket auth failed. REST works, so the "
1096
+ f"token is valid but this api_key lacks streaming access. "
1097
+ f"Check the app's subscription on the Kite Connect dashboard."
1015
1098
  )
1016
- if ws_status == "ok":
1017
- self.log_message(f"[#00ff00]✓ Streaming OK:[/#] @{name}")
1018
- elif ws_status == "auth_failed":
1019
- any_bad = True
1020
- statuses[api_key] = "stream_forbidden"
1021
- self.log_message(
1022
- f"[#ff0000]✗ Streaming rejected:[/#] @{name} — "
1023
- f"WebSocket auth failed ({ws_detail}). REST works, so the "
1024
- f"token is valid but this api_key lacks streaming access. "
1025
- f"Check the app's subscription on the Kite Connect dashboard."
1026
- )
1027
- elif ws_status == "proxy_blocked":
1028
- any_bad = True
1029
- statuses[api_key] = "proxy_blocked"
1030
- self.log_message(
1031
- f"[#ff8700]⚠ Proxy blocked streaming:[/#] @{name} — "
1032
- f"proxy refused the WebSocket tunnel ({ws_detail})."
1033
- )
1034
- else:
1035
- # Inconclusive probe — let the ticker try anyway.
1036
- self.log_message(
1037
- f"[#ff8700]⚠ Streaming check inconclusive:[/#] @{name} "
1038
- f"({ws_detail}). Will attempt to connect."
1039
- )
1040
1099
  elif status == "no_token":
1041
- any_bad = True
1042
- self.log_message(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
1100
+ is_bad = True
1101
+ logs.append(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
1043
1102
  elif status == "expired":
1044
- any_bad = True
1045
- self.log_message(f"[#ff8700]⚠ Token expired:[/#] @{name} — re-login (run 'kcli init'). {detail}")
1103
+ is_bad = True
1104
+ logs.append(f"[#ff8700]⚠ Token expired:[/#] @{name} — re-login (run 'kcli init'). {detail}")
1046
1105
  elif status == "forbidden":
1047
- any_bad = True
1048
- self.log_message(f"[#ff0000]✗ Forbidden:[/#] @{name} — token rejected / no streaming entitlement. {detail}")
1106
+ is_bad = True
1107
+ logs.append(f"[#ff0000]✗ Forbidden:[/#] @{name} — token rejected / no streaming entitlement. {detail}")
1049
1108
  else:
1109
+ is_bad = True
1110
+ logs.append(f"[#ff0000]✗ Token check failed:[/#] @{name} — {detail}")
1111
+
1112
+ return api_key, status, name, detail, acct, logs, is_bad
1113
+
1114
+ tasks = [diagnose_account(acct) for acct in self.accounts]
1115
+ results = await asyncio.gather(*tasks)
1116
+
1117
+ for api_key, status, name, detail, acct, logs, is_bad in results:
1118
+ if not api_key:
1119
+ continue
1120
+ statuses[api_key] = status
1121
+ for log_msg in logs:
1122
+ self.log_message(log_msg)
1123
+ if is_bad:
1050
1124
  any_bad = True
1051
- self.log_message(f"[#ff0000]✗ Token check failed:[/#] @{name} — {detail}")
1052
1125
 
1053
1126
  if any_bad:
1054
1127
  self.log_message(
@@ -1090,15 +1163,16 @@ class KCLILiveSession:
1090
1163
  async def _initial_fetch_and_connect(self) -> None:
1091
1164
  """Initial fetch of data and connect all WebSockets."""
1092
1165
  self.log_message("Initializing connections and fetching data...")
1166
+
1167
+ # Fire off REST data fetch in the background so it doesn't block WebSockets
1168
+ if hasattr(self, "app") and self.app and self.app.loop:
1169
+ asyncio.run_coroutine_threadsafe(self._trigger_immediate_refresh(), self.app.loop)
1170
+
1093
1171
  try:
1094
- await self._trigger_immediate_refresh()
1095
- except Exception as exc:
1096
- self.log_message(f"[#ff0000]Initial fetch failed:[/#] {exc}")
1097
-
1098
- # Diagnose token validity up front. The same api_key/access_token pair is
1099
- # used for the WebSocket ticker, so a REST 403/expired here explains the
1100
- # "1006 / 403 Forbidden" handshake failures.
1101
- token_statuses = await self._diagnose_tokens()
1172
+ token_statuses = await self._diagnose_tokens()
1173
+ except Exception as e:
1174
+ self.log_message(f"[#ff0000]Initial task failed:[/#] {e}")
1175
+ token_statuses = {}
1102
1176
 
1103
1177
  # Choose the primary streaming account (used for index + option-chain
1104
1178
  # streaming). Index/OC ticks are subscribed only on this account.
@@ -1115,7 +1189,7 @@ class KCLILiveSession:
1115
1189
  )
1116
1190
 
1117
1191
  # Connect WebSockets for accounts that support it (Zerodha and Kotak).
1118
- for acct in self.accounts:
1192
+ for idx, acct in enumerate(self.accounts):
1119
1193
  api_key = acct.get("api_key")
1120
1194
  broker = acct.get("broker", "zerodha").lower()
1121
1195
  access_token = self.client.get_access_token(api_key)
@@ -1128,6 +1202,9 @@ class KCLILiveSession:
1128
1202
  )
1129
1203
  continue
1130
1204
 
1205
+ if idx > 0:
1206
+ await asyncio.sleep(0.2)
1207
+
1131
1208
  if api_key and (access_token or broker == "kotak"):
1132
1209
  try:
1133
1210
  if broker == "kotak":
@@ -4021,6 +4098,18 @@ class KCLILiveSession:
4021
4098
  get_vertical_scroll=lambda win: self._info_control.buffer.document.cursor_position_row,
4022
4099
  )
4023
4100
 
4101
+ # Define tab bar control and window for the info pane
4102
+ self.tab_bar_control = FormattedTextControl(
4103
+ text=self._get_tab_bar_text,
4104
+ focusable=False,
4105
+ )
4106
+ self.tab_bar_window = Window(
4107
+ content=self.tab_bar_control,
4108
+ height=1,
4109
+ wrap_lines=False,
4110
+ style="class:tab_bar",
4111
+ )
4112
+
4024
4113
  def make_scroll_handler(window, control):
4025
4114
  original_handler = control.mouse_handler
4026
4115
  def scroll_mouse_handler(mouse_event):
@@ -4136,12 +4225,11 @@ class KCLILiveSession:
4136
4225
 
4137
4226
  @kb.add("tab")
4138
4227
  def _tab(event):
4139
- """Cycle focus: input -> positions -> logs -> info pane -> input."""
4228
+ """Cycle focus: input -> positions -> info pane -> input."""
4140
4229
  cur = event.app.layout.current_control
4141
4230
  cycle = [
4142
4231
  self.input_field.control,
4143
4232
  self.positions_control,
4144
- self._logs_control,
4145
4233
  self._info_control,
4146
4234
  ]
4147
4235
  try:
@@ -4151,26 +4239,34 @@ class KCLILiveSession:
4151
4239
  nxt = self.input_field.control
4152
4240
  event.app.layout.focus(nxt)
4153
4241
 
4154
- # F1/F2/F3 — switch info pane content
4242
+ # Ctrl+1/2/3/4 & F1-F4 — switch info pane content
4243
+ @kb.add("c-1")
4155
4244
  @kb.add("f1")
4156
- def _f1(event):
4245
+ def _c1(event):
4157
4246
  self.info_mode = "orders_pending"
4158
4247
  self._update_info_buffer()
4248
+ event.app.invalidate()
4159
4249
 
4250
+ @kb.add("c-2")
4160
4251
  @kb.add("f2")
4161
- def _f2(event):
4252
+ def _c2(event):
4162
4253
  self.info_mode = "orders_executed"
4163
4254
  self._update_info_buffer()
4255
+ event.app.invalidate()
4164
4256
 
4257
+ @kb.add("c-3")
4165
4258
  @kb.add("f3")
4166
- def _f3(event):
4259
+ def _c3(event):
4167
4260
  self.info_mode = "oc"
4168
4261
  self._update_info_buffer()
4262
+ event.app.invalidate()
4169
4263
 
4264
+ @kb.add("c-4")
4170
4265
  @kb.add("f4")
4171
- def _f4(event):
4266
+ def _c4(event):
4172
4267
  self.info_mode = "advisor"
4173
4268
  self._update_info_buffer()
4269
+ event.app.invalidate()
4174
4270
 
4175
4271
  # Ctrl+Left/Right — adjust left/right pane split
4176
4272
  @kb.add("c-left")
@@ -4183,17 +4279,6 @@ class KCLILiveSession:
4183
4279
  self.left_width_pct = min(80, self.left_width_pct + 5)
4184
4280
  event.app.invalidate()
4185
4281
 
4186
- # Ctrl+Up/Down — adjust log pane height within left half
4187
- @kb.add("c-up")
4188
- def _shrink_log(event):
4189
- self.log_height_lines = max(4, self.log_height_lines - 2)
4190
- event.app.invalidate()
4191
-
4192
- @kb.add("c-down")
4193
- def _grow_log(event):
4194
- self.log_height_lines = min(30, self.log_height_lines + 2)
4195
- event.app.invalidate()
4196
-
4197
4282
  # Build dividers
4198
4283
  self.vertical_divider = Window(
4199
4284
  content=FormattedTextControl(
@@ -4204,15 +4289,6 @@ class KCLILiveSession:
4204
4289
  style="class:divider",
4205
4290
  )
4206
4291
 
4207
- self.horizontal_divider = Window(
4208
- content=FormattedTextControl(
4209
- text="━" * 250,
4210
- focusable=False,
4211
- ),
4212
- height=1,
4213
- style="class:divider",
4214
- )
4215
-
4216
4292
  # Divider mouse handlers
4217
4293
  def v_divider_mouse_handler(mouse_event):
4218
4294
  from prompt_toolkit.mouse_events import MouseEventType
@@ -4221,15 +4297,7 @@ class KCLILiveSession:
4221
4297
  self.vertical_divider.style = "class:divider.dragging"
4222
4298
  self.app.invalidate()
4223
4299
 
4224
- def h_divider_mouse_handler(mouse_event):
4225
- from prompt_toolkit.mouse_events import MouseEventType
4226
- if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
4227
- self.dragging_horizontal = True
4228
- self.horizontal_divider.style = "class:divider.dragging"
4229
- self.app.invalidate()
4230
-
4231
4300
  self.vertical_divider.content.mouse_handler = v_divider_mouse_handler
4232
- self.horizontal_divider.content.mouse_handler = h_divider_mouse_handler
4233
4301
 
4234
4302
  # Monkey-patch MouseHandlers.__init__ to wrap with DragInterceptDict
4235
4303
  original_init = MouseHandlers.__init__
@@ -4240,32 +4308,27 @@ class KCLILiveSession:
4240
4308
 
4241
4309
  # ── Dynamic Layout ─────────────────────────────────────────
4242
4310
  # DynamicContainer rebuilds the body on each render using current
4243
- # self.left_width_pct and self.log_height_lines values.
4311
+ # self.left_width_pct value.
4244
4312
  def _build_body():
4245
4313
  self._update_positions_display()
4246
4314
  lw = self.left_width_pct # left weight (20-80)
4247
4315
  rw = 100 - lw # right weight
4248
4316
 
4249
4317
  return VSplit([
4250
- # ── LEFT half: Positions (scrollable) + Logs (scrollable) ──
4251
- HSplit([
4252
- Frame(
4253
- title="Active Positions (Live Ticks) [Tab: focus]",
4254
- body=self.positions_window,
4255
- style=self._get_frame_style(self.positions_window),
4256
- ),
4257
- self.horizontal_divider,
4258
- Frame(
4259
- title="Status Logs [Tab: focus] [Ctrl+↑↓: resize]",
4260
- body=self.logs_window,
4261
- style=self._get_frame_style(self.logs_window),
4262
- ),
4263
- ], width=D(weight=lw)),
4318
+ # ── LEFT half: Positions (scrollable) ──
4319
+ Frame(
4320
+ title="Active Positions (Live Ticks) [Tab: focus]",
4321
+ body=self.positions_window,
4322
+ style=self._get_frame_style(self.positions_window),
4323
+ width=D(weight=lw),
4324
+ ),
4264
4325
  self.vertical_divider,
4265
- # ── RIGHT half: Info Pane (scrollable) ──
4326
+ # ── RIGHT half: Info Pane with clickable tabs (scrollable) ──
4266
4327
  Frame(
4267
- title="[F1] Pending [F2] Executed [F3] Option Chain [F4] Advisor [Ctrl+←→: resize]",
4328
+ title="",
4268
4329
  body=HSplit([
4330
+ self.tab_bar_window,
4331
+ Window(height=1, char="─", style="class:divider"),
4269
4332
  Window(
4270
4333
  content=self.market_indices_control,
4271
4334
  height=1,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.1
3
+ Version: 0.2.2
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.1"
7
+ version = "0.2.2"
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"
@@ -94,5 +94,27 @@ class TestUIComponents(unittest.IsolatedAsyncioTestCase):
94
94
  mock_run_coroutine.assert_called_once()
95
95
  self.session.log_message.assert_any_call("Triggering manual WebSocket and REST reconnection...")
96
96
 
97
+ @patch("cli.live_session.KCLILiveSession._update_info_buffer")
98
+ def test_info_pane_tabs(self, mock_update_buffer):
99
+ self.session.info_mode = "orders_pending"
100
+ fragments = self.session._get_tab_bar_text()
101
+
102
+ self.assertEqual(fragments[1][0], "class:tab.active")
103
+ self.assertEqual(fragments[1][1], " Pending ")
104
+ self.assertEqual(fragments[3][0], "class:tab.inactive")
105
+ self.assertEqual(fragments[3][1], " Executed ")
106
+
107
+ click_handler_oc = fragments[5][2]
108
+
109
+ from prompt_toolkit.mouse_events import MouseEventType
110
+ mock_event = MagicMock()
111
+ mock_event.event_type = MouseEventType.MOUSE_UP
112
+
113
+ click_handler_oc(mock_event)
114
+
115
+ self.assertEqual(self.session.info_mode, "oc")
116
+ mock_update_buffer.assert_called_once()
117
+ self.session.app.invalidate.assert_called_once()
118
+
97
119
  if __name__ == "__main__":
98
120
  unittest.main()
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
File without changes