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.
- {kitecli-0.2.1 → kitecli-0.2.3}/PKG-INFO +1 -1
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/kite_manager.py +55 -69
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/kotak_manager.py +82 -85
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/live_session.py +176 -114
- {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/PKG-INFO +1 -1
- {kitecli-0.2.1 → kitecli-0.2.3}/pyproject.toml +1 -1
- {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_ui.py +22 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/README.md +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/__init__.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/advisor.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/api_client.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/base_manager.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/config.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/display.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/executor.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/main.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/nli.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/parser.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/recorder.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/cli/telegram_bot.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/SOURCES.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/dependency_links.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/entry_points.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/requires.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/kitecli.egg-info/top_level.txt +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/setup.cfg +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_multi_broker.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_nli.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_parser.py +0 -0
- {kitecli-0.2.1 → kitecli-0.2.3}/tests/test_telegram.py +0 -0
|
@@ -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.
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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.
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
994
|
-
|
|
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
|
-
#
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
1099
|
+
is_bad = True
|
|
1100
|
+
logs.append(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
|
|
1043
1101
|
elif status == "expired":
|
|
1044
|
-
|
|
1045
|
-
|
|
1102
|
+
is_bad = True
|
|
1103
|
+
logs.append(f"[#ff8700]⚠ Token expired:[/#] @{name} — re-login (run 'kcli init'). {detail}")
|
|
1046
1104
|
elif status == "forbidden":
|
|
1047
|
-
|
|
1048
|
-
|
|
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.
|
|
1095
|
-
except Exception as
|
|
1096
|
-
self.log_message(f"[#ff0000]Initial
|
|
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 ->
|
|
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
|
-
#
|
|
4241
|
+
# Ctrl+1/2/3/4 & F1-F4 — switch info pane content
|
|
4242
|
+
@kb.add("c-1")
|
|
4155
4243
|
@kb.add("f1")
|
|
4156
|
-
def
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
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="
|
|
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,
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kitecli"
|
|
7
|
-
version = "0.2.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|