kitecli 0.2.1__tar.gz → 0.2.3__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.3}/PKG-INFO +1 -1
  2. {kitecli-0.2.1 → kitecli-0.2.3}/cli/kite_manager.py +55 -69
  3. {kitecli-0.2.1 → kitecli-0.2.3}/cli/kotak_manager.py +82 -85
  4. {kitecli-0.2.1 → kitecli-0.2.3}/cli/live_session.py +176 -114
  5. {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/PKG-INFO +1 -1
  6. {kitecli-0.2.1 → kitecli-0.2.3}/pyproject.toml +1 -1
  7. {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_ui.py +22 -0
  8. {kitecli-0.2.1 → kitecli-0.2.3}/README.md +0 -0
  9. {kitecli-0.2.1 → kitecli-0.2.3}/cli/__init__.py +0 -0
  10. {kitecli-0.2.1 → kitecli-0.2.3}/cli/advisor.py +0 -0
  11. {kitecli-0.2.1 → kitecli-0.2.3}/cli/api_client.py +0 -0
  12. {kitecli-0.2.1 → kitecli-0.2.3}/cli/base_manager.py +0 -0
  13. {kitecli-0.2.1 → kitecli-0.2.3}/cli/config.py +0 -0
  14. {kitecli-0.2.1 → kitecli-0.2.3}/cli/display.py +0 -0
  15. {kitecli-0.2.1 → kitecli-0.2.3}/cli/executor.py +0 -0
  16. {kitecli-0.2.1 → kitecli-0.2.3}/cli/main.py +0 -0
  17. {kitecli-0.2.1 → kitecli-0.2.3}/cli/nli.py +0 -0
  18. {kitecli-0.2.1 → kitecli-0.2.3}/cli/parser.py +0 -0
  19. {kitecli-0.2.1 → kitecli-0.2.3}/cli/recorder.py +0 -0
  20. {kitecli-0.2.1 → kitecli-0.2.3}/cli/telegram_bot.py +0 -0
  21. {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/SOURCES.txt +0 -0
  22. {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/dependency_links.txt +0 -0
  23. {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/entry_points.txt +0 -0
  24. {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/requires.txt +0 -0
  25. {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/top_level.txt +0 -0
  26. {kitecli-0.2.1 → kitecli-0.2.3}/setup.cfg +0 -0
  27. {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_multi_broker.py +0 -0
  28. {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_nli.py +0 -0
  29. {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_parser.py +0 -0
  30. {kitecli-0.2.1 → kitecli-0.2.3}/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.3
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -97,27 +97,50 @@ 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
103
111
  self._authenticated[api_key] = False
104
112
  self._proxies[api_key] = proxies or {}
105
113
 
114
+ # Monkeypatch reqsession.request to bypass proxy for all non-order endpoints
115
+ orig_request = kite.reqsession.request
116
+ def proxied_request(method, url, *args, **kwargs):
117
+ method_upper = method.upper()
118
+ url_lower = url.lower()
119
+ # Only apply proxies for order placement, modification, or cancellation
120
+ is_order_api = (
121
+ method_upper in ("POST", "PUT", "DELETE") and
122
+ ("/orders" in url_lower or "/gtt" in url_lower)
123
+ )
124
+ if not is_order_api:
125
+ # Remove proxies for both the individual request kwargs and session-level config
126
+ kwargs["proxies"] = {}
127
+ orig_proxies = kite.reqsession.proxies
128
+ kite.reqsession.proxies = {}
129
+ try:
130
+ return orig_request(method, url, *args, **kwargs)
131
+ finally:
132
+ kite.reqsession.proxies = orig_proxies
133
+ else:
134
+ return orig_request(method, url, *args, **kwargs)
135
+
136
+ kite.reqsession.request = proxied_request
137
+
106
138
  sessions = _load_sessions()
107
139
  saved_token = sessions.get(api_key)
108
140
  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
141
+ logger.debug("Found saved session token for '%s' (api_key=%s…). Assuming valid.", name, api_key[:8])
142
+ kite.set_access_token(saved_token)
143
+ self._authenticated[api_key] = True
121
144
 
122
145
  login_url = f"https://kite.zerodha.com/connect/login?v=3&api_key={api_key}"
123
146
  logger.info("Initialized account '%s' (api_key=%s…)", name, api_key[:8])
@@ -374,16 +397,21 @@ class KiteAccountManager(BaseBrokerManager):
374
397
  # Build a requests.Session and apply the per-account proxy so that
375
398
  # login and 2FA calls also flow through the proxy (not just KiteConnect SDK calls).
376
399
  session = http_requests.Session()
377
- account_proxies = self._proxies.get(api_key, {})
378
- if account_proxies:
379
- session.proxies.update(account_proxies)
380
- logger.info("auto_login: using proxy for %s (api_key=%s…)", user_id, api_key[:8])
400
+ from requests.adapters import HTTPAdapter
401
+ adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20)
402
+ session.mount("https://", adapter)
403
+ session.mount("http://", adapter)
404
+
405
+ # Zerodha login does not enforce whitelisting, direct is faster and more reliable
406
+ # session.proxies.update(account_proxies)
407
+ # logger.info("auto_login: using proxy for %s (api_key=%s…)", user_id, api_key[:8])
381
408
 
382
409
  try:
383
410
  # Step 1: Login with user_id + password
384
411
  login_resp = session.post(
385
412
  "https://kite.zerodha.com/api/login",
386
413
  data={"user_id": user_id, "password": password},
414
+ timeout=25,
387
415
  )
388
416
  login_data = login_resp.json()
389
417
 
@@ -411,6 +439,7 @@ class KiteAccountManager(BaseBrokerManager):
411
439
  "user_id": user_id,
412
440
  "twofa_type": "totp",
413
441
  },
442
+ timeout=25,
414
443
  )
415
444
  twofa_data = twofa_resp.json()
416
445
 
@@ -434,19 +463,8 @@ class KiteAccountManager(BaseBrokerManager):
434
463
 
435
464
  for hop in range(1, 11):
436
465
  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
466
 
449
- # Check if current_url already contains the request_token
467
+ # Check if current_url already contains the request_token BEFORE requesting it
450
468
  parsed = urlparse(current_url)
451
469
  query_params = parse_qs(parsed.query)
452
470
  token = query_params.get("request_token", [None])[0]
@@ -454,6 +472,12 @@ class KiteAccountManager(BaseBrokerManager):
454
472
  request_token = token
455
473
  break
456
474
 
475
+ try:
476
+ resp = session.get(current_url, allow_redirects=False, timeout=25)
477
+ except http_requests.exceptions.RequestException as exc:
478
+ logger.warning("Network error/unreachable during redirect hop %d: %s", hop, exc)
479
+ break
480
+
457
481
  location = resp.headers.get("Location")
458
482
  if not location:
459
483
  break
@@ -465,21 +489,14 @@ class KiteAccountManager(BaseBrokerManager):
465
489
 
466
490
  current_url = location
467
491
 
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
492
  # If target URL is not a Zerodha domain (e.g. localhost or client app domain),
477
493
  # do not actually request it (it may not be reachable or could trigger external side-effects),
478
494
  # and we've already checked if it has the request_token.
479
495
  parsed_loc = urlparse(current_url)
480
496
  if parsed_loc.netloc and not parsed_loc.netloc.endswith(".zerodha.com") and "zerodha.com" not in parsed_loc.netloc:
481
497
  logger.info("Reached non-Zerodha redirect target: %s", current_url)
482
- break
498
+ # We will extract the token at the start of the next iteration
499
+ pass
483
500
 
484
501
  if not request_token:
485
502
  logger.error("Could not extract request_token from redirect chain for %s", user_id)
@@ -1064,38 +1081,7 @@ class KiteAccountManager(BaseBrokerManager):
1064
1081
  except Exception as exc:
1065
1082
  logger.warning("Kite indices fetch failed for api_key=%s…: %s", api_key[:8], exc)
1066
1083
 
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}"}
1084
+ return {"status": "error", "message": "All authenticated Zerodha account indices fetches failed."}
1099
1085
 
1100
1086
  def get_ltp_and_tokens(self, api_key: str, symbols: list[str]) -> dict:
1101
1087
  """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,84 @@ 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
+ ws_status, ws_detail = await self._run_api_call(
1065
+ probe_ws_auth, api_key, access_token, None
1066
+ )
1067
+
1068
+ if ws_status == "ok":
1069
+ logs.append(f"[#00ff00]✓ Streaming OK:[/#] @{name}")
1070
+ return api_key, "valid", name, "", acct, logs, False
1071
+ elif ws_status == "proxy_blocked":
1072
+ logs.append(f"[#ff8700]⚠ Proxy blocked streaming:[/#] @{name} — proxy refused the WebSocket tunnel ({ws_detail}).")
1073
+ return api_key, "proxy_blocked", name, ws_detail, acct, logs, True
1074
+
1075
+ # If we are here, either it's Kotak, or Zerodha WS failed.
1076
+ # Fallback to the full REST check_token to find out exactly why.
990
1077
  try:
991
1078
  result = await self._run_api_call(self.client.check_token, api_key)
992
1079
  except Exception as exc:
993
- result = {"name": acct.get("name", api_key), "status": "error",
994
- "detail": str(exc)}
1080
+ result = {"name": name, "status": "error", "detail": str(exc)}
1081
+
995
1082
  status = result.get("status", "error")
996
- statuses[api_key] = status
997
- name = result.get("name", api_key)
998
1083
  detail = result.get("detail", "")
1084
+
999
1085
  if status == "valid":
1000
- broker = acct.get("broker", "zerodha").lower()
1001
1086
  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
1087
+ logs.append(f"[#00ff00]✓ Token OK:[/#] @{name}")
1088
+ else:
1089
+ # Zerodha REST is valid, but we already know WS failed from the fast-path above!
1090
+ is_bad = True
1091
+ status = "stream_forbidden"
1092
+ logs.append(
1093
+ f"[#ff0000]✗ Streaming rejected:[/#] @{name}"
1094
+ f"WebSocket auth failed. REST works, so the "
1095
+ f"token is valid but this api_key lacks streaming access. "
1096
+ f"Check the app's subscription on the Kite Connect dashboard."
1015
1097
  )
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
1098
  elif status == "no_token":
1041
- any_bad = True
1042
- self.log_message(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
1099
+ is_bad = True
1100
+ logs.append(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
1043
1101
  elif status == "expired":
1044
- any_bad = True
1045
- self.log_message(f"[#ff8700]⚠ Token expired:[/#] @{name} — re-login (run 'kcli init'). {detail}")
1102
+ is_bad = True
1103
+ logs.append(f"[#ff8700]⚠ Token expired:[/#] @{name} — re-login (run 'kcli init'). {detail}")
1046
1104
  elif status == "forbidden":
1047
- any_bad = True
1048
- self.log_message(f"[#ff0000]✗ Forbidden:[/#] @{name} — token rejected / no streaming entitlement. {detail}")
1105
+ is_bad = True
1106
+ logs.append(f"[#ff0000]✗ Forbidden:[/#] @{name} — token rejected / no streaming entitlement. {detail}")
1049
1107
  else:
1108
+ is_bad = True
1109
+ logs.append(f"[#ff0000]✗ Token check failed:[/#] @{name} — {detail}")
1110
+
1111
+ return api_key, status, name, detail, acct, logs, is_bad
1112
+
1113
+ tasks = [diagnose_account(acct) for acct in self.accounts]
1114
+ results = await asyncio.gather(*tasks)
1115
+
1116
+ for api_key, status, name, detail, acct, logs, is_bad in results:
1117
+ if not api_key:
1118
+ continue
1119
+ statuses[api_key] = status
1120
+ for log_msg in logs:
1121
+ self.log_message(log_msg)
1122
+ if is_bad:
1050
1123
  any_bad = True
1051
- self.log_message(f"[#ff0000]✗ Token check failed:[/#] @{name} — {detail}")
1052
1124
 
1053
1125
  if any_bad:
1054
1126
  self.log_message(
@@ -1090,15 +1162,16 @@ class KCLILiveSession:
1090
1162
  async def _initial_fetch_and_connect(self) -> None:
1091
1163
  """Initial fetch of data and connect all WebSockets."""
1092
1164
  self.log_message("Initializing connections and fetching data...")
1165
+
1166
+ # Fire off REST data fetch in the background so it doesn't block WebSockets
1167
+ if hasattr(self, "app") and self.app and self.app.loop:
1168
+ asyncio.run_coroutine_threadsafe(self._trigger_immediate_refresh(), self.app.loop)
1169
+
1093
1170
  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()
1171
+ token_statuses = await self._diagnose_tokens()
1172
+ except Exception as e:
1173
+ self.log_message(f"[#ff0000]Initial task failed:[/#] {e}")
1174
+ token_statuses = {}
1102
1175
 
1103
1176
  # Choose the primary streaming account (used for index + option-chain
1104
1177
  # streaming). Index/OC ticks are subscribed only on this account.
@@ -1115,7 +1188,7 @@ class KCLILiveSession:
1115
1188
  )
1116
1189
 
1117
1190
  # Connect WebSockets for accounts that support it (Zerodha and Kotak).
1118
- for acct in self.accounts:
1191
+ for idx, acct in enumerate(self.accounts):
1119
1192
  api_key = acct.get("api_key")
1120
1193
  broker = acct.get("broker", "zerodha").lower()
1121
1194
  access_token = self.client.get_access_token(api_key)
@@ -1128,6 +1201,9 @@ class KCLILiveSession:
1128
1201
  )
1129
1202
  continue
1130
1203
 
1204
+ if idx > 0:
1205
+ await asyncio.sleep(0.2)
1206
+
1131
1207
  if api_key and (access_token or broker == "kotak"):
1132
1208
  try:
1133
1209
  if broker == "kotak":
@@ -1152,10 +1228,10 @@ class KCLILiveSession:
1152
1228
  ticker.on_close = self._make_on_close(api_key)
1153
1229
  ticker.on_error = self._make_on_error(api_key)
1154
1230
 
1155
- # Parse proxy if configured for this account
1231
+ # Parse proxy if configured for this account (Zerodha WS does not require IP whitelisting, direct is faster)
1156
1232
  proxy_str = acct.get("proxy")
1157
1233
  proxy_dict = None
1158
- if proxy_str:
1234
+ if broker != "zerodha" and proxy_str:
1159
1235
  from urllib.parse import urlparse
1160
1236
  try:
1161
1237
  p_str = proxy_str if "://" in proxy_str else f"http://{proxy_str}"
@@ -4021,6 +4097,18 @@ class KCLILiveSession:
4021
4097
  get_vertical_scroll=lambda win: self._info_control.buffer.document.cursor_position_row,
4022
4098
  )
4023
4099
 
4100
+ # Define tab bar control and window for the info pane
4101
+ self.tab_bar_control = FormattedTextControl(
4102
+ text=self._get_tab_bar_text,
4103
+ focusable=False,
4104
+ )
4105
+ self.tab_bar_window = Window(
4106
+ content=self.tab_bar_control,
4107
+ height=1,
4108
+ wrap_lines=False,
4109
+ style="class:tab_bar",
4110
+ )
4111
+
4024
4112
  def make_scroll_handler(window, control):
4025
4113
  original_handler = control.mouse_handler
4026
4114
  def scroll_mouse_handler(mouse_event):
@@ -4136,12 +4224,11 @@ class KCLILiveSession:
4136
4224
 
4137
4225
  @kb.add("tab")
4138
4226
  def _tab(event):
4139
- """Cycle focus: input -> positions -> logs -> info pane -> input."""
4227
+ """Cycle focus: input -> positions -> info pane -> input."""
4140
4228
  cur = event.app.layout.current_control
4141
4229
  cycle = [
4142
4230
  self.input_field.control,
4143
4231
  self.positions_control,
4144
- self._logs_control,
4145
4232
  self._info_control,
4146
4233
  ]
4147
4234
  try:
@@ -4151,26 +4238,34 @@ class KCLILiveSession:
4151
4238
  nxt = self.input_field.control
4152
4239
  event.app.layout.focus(nxt)
4153
4240
 
4154
- # F1/F2/F3 — switch info pane content
4241
+ # Ctrl+1/2/3/4 & F1-F4 — switch info pane content
4242
+ @kb.add("c-1")
4155
4243
  @kb.add("f1")
4156
- def _f1(event):
4244
+ def _c1(event):
4157
4245
  self.info_mode = "orders_pending"
4158
4246
  self._update_info_buffer()
4247
+ event.app.invalidate()
4159
4248
 
4249
+ @kb.add("c-2")
4160
4250
  @kb.add("f2")
4161
- def _f2(event):
4251
+ def _c2(event):
4162
4252
  self.info_mode = "orders_executed"
4163
4253
  self._update_info_buffer()
4254
+ event.app.invalidate()
4164
4255
 
4256
+ @kb.add("c-3")
4165
4257
  @kb.add("f3")
4166
- def _f3(event):
4258
+ def _c3(event):
4167
4259
  self.info_mode = "oc"
4168
4260
  self._update_info_buffer()
4261
+ event.app.invalidate()
4169
4262
 
4263
+ @kb.add("c-4")
4170
4264
  @kb.add("f4")
4171
- def _f4(event):
4265
+ def _c4(event):
4172
4266
  self.info_mode = "advisor"
4173
4267
  self._update_info_buffer()
4268
+ event.app.invalidate()
4174
4269
 
4175
4270
  # Ctrl+Left/Right — adjust left/right pane split
4176
4271
  @kb.add("c-left")
@@ -4183,17 +4278,6 @@ class KCLILiveSession:
4183
4278
  self.left_width_pct = min(80, self.left_width_pct + 5)
4184
4279
  event.app.invalidate()
4185
4280
 
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
4281
  # Build dividers
4198
4282
  self.vertical_divider = Window(
4199
4283
  content=FormattedTextControl(
@@ -4204,15 +4288,6 @@ class KCLILiveSession:
4204
4288
  style="class:divider",
4205
4289
  )
4206
4290
 
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
4291
  # Divider mouse handlers
4217
4292
  def v_divider_mouse_handler(mouse_event):
4218
4293
  from prompt_toolkit.mouse_events import MouseEventType
@@ -4221,15 +4296,7 @@ class KCLILiveSession:
4221
4296
  self.vertical_divider.style = "class:divider.dragging"
4222
4297
  self.app.invalidate()
4223
4298
 
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
4299
  self.vertical_divider.content.mouse_handler = v_divider_mouse_handler
4232
- self.horizontal_divider.content.mouse_handler = h_divider_mouse_handler
4233
4300
 
4234
4301
  # Monkey-patch MouseHandlers.__init__ to wrap with DragInterceptDict
4235
4302
  original_init = MouseHandlers.__init__
@@ -4240,32 +4307,27 @@ class KCLILiveSession:
4240
4307
 
4241
4308
  # ── Dynamic Layout ─────────────────────────────────────────
4242
4309
  # DynamicContainer rebuilds the body on each render using current
4243
- # self.left_width_pct and self.log_height_lines values.
4310
+ # self.left_width_pct value.
4244
4311
  def _build_body():
4245
4312
  self._update_positions_display()
4246
4313
  lw = self.left_width_pct # left weight (20-80)
4247
4314
  rw = 100 - lw # right weight
4248
4315
 
4249
4316
  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)),
4317
+ # ── LEFT half: Positions (scrollable) ──
4318
+ Frame(
4319
+ title="Active Positions (Live Ticks) [Tab: focus]",
4320
+ body=self.positions_window,
4321
+ style=self._get_frame_style(self.positions_window),
4322
+ width=D(weight=lw),
4323
+ ),
4264
4324
  self.vertical_divider,
4265
- # ── RIGHT half: Info Pane (scrollable) ──
4325
+ # ── RIGHT half: Info Pane with clickable tabs (scrollable) ──
4266
4326
  Frame(
4267
- title="[F1] Pending [F2] Executed [F3] Option Chain [F4] Advisor [Ctrl+←→: resize]",
4327
+ title="",
4268
4328
  body=HSplit([
4329
+ self.tab_bar_window,
4330
+ Window(height=1, char="─", style="class:divider"),
4269
4331
  Window(
4270
4332
  content=self.market_indices_control,
4271
4333
  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.3
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.3"
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