kitecli 0.2.4__tar.gz → 0.2.6__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.4 → kitecli-0.2.6}/PKG-INFO +1 -1
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/api_client.py +48 -1
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/config.py +48 -1
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/kite_manager.py +1 -23
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/kotak_manager.py +44 -51
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/live_session.py +127 -27
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/main.py +1 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/PKG-INFO +1 -1
- {kitecli-0.2.4 → kitecli-0.2.6}/pyproject.toml +1 -1
- {kitecli-0.2.4 → kitecli-0.2.6}/README.md +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/__init__.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/advisor.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/base_manager.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/display.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/executor.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/nli.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/parser.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/recorder.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/cli/telegram_bot.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/SOURCES.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/dependency_links.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/entry_points.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/requires.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/kitecli.egg-info/top_level.txt +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/setup.cfg +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_multi_broker.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_nli.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_parser.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_telegram.py +0 -0
- {kitecli-0.2.4 → kitecli-0.2.6}/tests/test_ui.py +0 -0
|
@@ -7,8 +7,12 @@ identical to the previous single-broker version so that cli/main.py and
|
|
|
7
7
|
cli/live_session.py require zero changes.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import logging
|
|
10
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
11
12
|
from cli.kite_manager import KiteAccountManager
|
|
13
|
+
from cli.config import remove_session
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
class KCLIClientError(Exception):
|
|
@@ -132,8 +136,29 @@ class KCLIClient:
|
|
|
132
136
|
)
|
|
133
137
|
_account_manager_map[account_key] = mgr
|
|
134
138
|
acct.setdefault("api_key", account_key)
|
|
139
|
+
|
|
140
|
+
# Validation: verify token with a REST query (limits)
|
|
141
|
+
is_valid = False
|
|
142
|
+
if mgr.is_authenticated(account_key):
|
|
143
|
+
try:
|
|
144
|
+
resp = mgr._clients[account_key].limits()
|
|
145
|
+
from cli.kotak_manager import _check_neo_error
|
|
146
|
+
_check_neo_error(resp)
|
|
147
|
+
is_valid = True
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
msg = str(exc).lower()
|
|
150
|
+
is_auth_error = any(x in msg for x in ["100008", "100022", "1037", "unauthorized", "invalid token", "invalid session", "session expired", "session has been closed", "session closed"])
|
|
151
|
+
if is_auth_error:
|
|
152
|
+
logger.info("Kotak validation failed (expired/invalid) for %s: %s", name, exc)
|
|
153
|
+
mgr._authenticated[account_key] = False
|
|
154
|
+
remove_session(f"kotak:{account_key}")
|
|
155
|
+
else:
|
|
156
|
+
# Network/timeout error: fallback to assuming token is valid
|
|
157
|
+
logger.warning("Kotak validation query failed due to network/other issue. Assuming valid. Error: %s", exc)
|
|
158
|
+
is_valid = True
|
|
159
|
+
|
|
135
160
|
# Attempt auto-login for Kotak
|
|
136
|
-
if not
|
|
161
|
+
if not is_valid:
|
|
137
162
|
success = mgr.auto_login(account_key)
|
|
138
163
|
return {
|
|
139
164
|
"name": name, "api_key": account_key,
|
|
@@ -158,7 +183,29 @@ class KCLIClient:
|
|
|
158
183
|
)
|
|
159
184
|
_account_manager_map[api_key] = _kite_manager
|
|
160
185
|
|
|
186
|
+
# Validation: verify token with a REST query (profile)
|
|
187
|
+
is_valid = False
|
|
161
188
|
if _kite_manager.is_authenticated(api_key):
|
|
189
|
+
try:
|
|
190
|
+
_kite_manager._clients[api_key].profile()
|
|
191
|
+
is_valid = True
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
try:
|
|
194
|
+
from kiteconnect.exceptions import TokenException, PermissionException
|
|
195
|
+
except Exception:
|
|
196
|
+
TokenException = PermissionException = ()
|
|
197
|
+
msg = str(exc).lower()
|
|
198
|
+
is_auth_error = isinstance(exc, (TokenException, PermissionException)) or any(x in msg for x in ["403", "401", "unauthorized", "token", "session"])
|
|
199
|
+
if is_auth_error:
|
|
200
|
+
logger.info("Zerodha validation failed (expired/invalid) for %s: %s", name, exc)
|
|
201
|
+
_kite_manager._authenticated[api_key] = False
|
|
202
|
+
remove_session(api_key)
|
|
203
|
+
else:
|
|
204
|
+
# Network/timeout error: fallback to assuming token is valid
|
|
205
|
+
logger.warning("Zerodha validation query failed due to network/other issue. Assuming valid. Error: %s", exc)
|
|
206
|
+
is_valid = True
|
|
207
|
+
|
|
208
|
+
if is_valid:
|
|
162
209
|
return {
|
|
163
210
|
"name": name, "api_key": api_key,
|
|
164
211
|
"login_url": login_url, "auto_logged_in": True,
|
|
@@ -6,12 +6,19 @@ stored at ~/.kcli/config.yaml.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
9
|
+
from typing import Optional, Any, Dict
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
10
13
|
|
|
11
14
|
import yaml
|
|
12
15
|
|
|
13
16
|
CONFIG_DIR = Path.home() / ".kcli"
|
|
14
17
|
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
18
|
+
SESSIONS_FILE = CONFIG_DIR / "sessions.json"
|
|
19
|
+
|
|
20
|
+
sessions_lock = threading.RLock()
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
15
22
|
|
|
16
23
|
DEFAULT_CONFIG = {
|
|
17
24
|
"accounts": [
|
|
@@ -94,3 +101,43 @@ def create_default_config() -> dict:
|
|
|
94
101
|
"""
|
|
95
102
|
save_config(DEFAULT_CONFIG)
|
|
96
103
|
return DEFAULT_CONFIG
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_sessions() -> Dict[str, Any]:
|
|
107
|
+
"""Load the shared sessions from ~/.kcli/sessions.json thread-safely."""
|
|
108
|
+
with sessions_lock:
|
|
109
|
+
if not SESSIONS_FILE.exists():
|
|
110
|
+
return {}
|
|
111
|
+
try:
|
|
112
|
+
with open(SESSIONS_FILE, "r") as f:
|
|
113
|
+
return json.load(f)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
logger.error("Failed to load sessions: %s", exc)
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save_session(key: str, value: Any) -> None:
|
|
120
|
+
"""Save a session value thread-safely to ~/.kcli/sessions.json."""
|
|
121
|
+
with sessions_lock:
|
|
122
|
+
sessions = load_sessions()
|
|
123
|
+
sessions[key] = value
|
|
124
|
+
try:
|
|
125
|
+
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
with open(SESSIONS_FILE, "w") as f:
|
|
127
|
+
json.dump(sessions, f, indent=2)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
logger.error("Failed to save session for %s: %s", key, exc)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def remove_session(key: str) -> None:
|
|
133
|
+
"""Remove a session key thread-safely from ~/.kcli/sessions.json."""
|
|
134
|
+
with sessions_lock:
|
|
135
|
+
sessions = load_sessions()
|
|
136
|
+
if key in sessions:
|
|
137
|
+
del sessions[key]
|
|
138
|
+
try:
|
|
139
|
+
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
with open(SESSIONS_FILE, "w") as f:
|
|
141
|
+
json.dump(sessions, f, indent=2)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
logger.error("Failed to remove session for %s: %s", key, exc)
|
|
@@ -12,29 +12,7 @@ from cli.base_manager import BaseBrokerManager
|
|
|
12
12
|
|
|
13
13
|
logger = logging.getLogger(__name__)
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _load_sessions() -> dict[str, str]:
|
|
19
|
-
if not SESSIONS_FILE.exists():
|
|
20
|
-
return {}
|
|
21
|
-
try:
|
|
22
|
-
with open(SESSIONS_FILE, "r") as f:
|
|
23
|
-
return json.load(f)
|
|
24
|
-
except Exception as exc:
|
|
25
|
-
logger.error("Failed to load sessions: %s", exc)
|
|
26
|
-
return {}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _save_session(api_key: str, access_token: str) -> None:
|
|
30
|
-
sessions = _load_sessions()
|
|
31
|
-
sessions[api_key] = access_token
|
|
32
|
-
try:
|
|
33
|
-
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
-
with open(SESSIONS_FILE, "w") as f:
|
|
35
|
-
json.dump(sessions, f, indent=2)
|
|
36
|
-
except Exception as exc:
|
|
37
|
-
logger.error("Failed to save session for %s: %s", api_key[:8], exc)
|
|
15
|
+
from cli.config import load_sessions as _load_sessions, save_session as _save_session
|
|
38
16
|
|
|
39
17
|
|
|
40
18
|
class KiteAccountManager(BaseBrokerManager):
|
|
@@ -98,26 +98,7 @@ def _check_neo_error(resp: Any) -> None:
|
|
|
98
98
|
|
|
99
99
|
# ── session persistence (shared file with Zerodha tokens) ─────────────────────
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
if not SESSIONS_FILE.exists():
|
|
103
|
-
return {}
|
|
104
|
-
try:
|
|
105
|
-
with open(SESSIONS_FILE, "r") as f:
|
|
106
|
-
return json.load(f)
|
|
107
|
-
except Exception as exc:
|
|
108
|
-
logger.error("Failed to load sessions: %s", exc)
|
|
109
|
-
return {}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _save_session(key: str, token: str) -> None:
|
|
113
|
-
sessions = _load_sessions()
|
|
114
|
-
sessions[key] = token
|
|
115
|
-
try:
|
|
116
|
-
SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
-
with open(SESSIONS_FILE, "w") as f:
|
|
118
|
-
json.dump(sessions, f, indent=2)
|
|
119
|
-
except Exception as exc:
|
|
120
|
-
logger.error("Failed to save session for %s: %s", key, exc)
|
|
101
|
+
from cli.config import load_sessions as _load_sessions, save_session as _save_session
|
|
121
102
|
|
|
122
103
|
|
|
123
104
|
# ── KotakAccountManager ────────────────────────────────────────────────────────
|
|
@@ -156,7 +137,9 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
156
137
|
|
|
157
138
|
def init_account(self, account_key: str, **credentials) -> str:
|
|
158
139
|
"""ABC entry point — delegates to init_account_kotak."""
|
|
159
|
-
|
|
140
|
+
creds = dict(credentials)
|
|
141
|
+
creds.pop("consumer_key", None)
|
|
142
|
+
return self.init_account_kotak(consumer_key=account_key, **creds)
|
|
160
143
|
|
|
161
144
|
def init_account_kotak(
|
|
162
145
|
self,
|
|
@@ -284,17 +267,19 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
284
267
|
except Exception:
|
|
285
268
|
logger.debug("Kotak REST response body (raw): %s", resp.text[:200])
|
|
286
269
|
|
|
287
|
-
# Check for IP mismatch error (stCode 1037)
|
|
270
|
+
# Check for IP mismatch error (stCode 1037), Session expired (stCode 100008, 100022), or HTTP 401
|
|
288
271
|
try:
|
|
289
|
-
resp_json = resp.json()
|
|
272
|
+
resp_json = resp.json() if resp.text else {}
|
|
290
273
|
if isinstance(resp_json, dict):
|
|
291
274
|
st_code = resp_json.get("stCode")
|
|
292
275
|
err_msg = str(resp_json.get("errMsg", "")).lower()
|
|
293
276
|
if (
|
|
294
|
-
|
|
295
|
-
st_code
|
|
296
|
-
"session ip" in err_msg or
|
|
297
|
-
"unauthorized" in err_msg
|
|
277
|
+
resp.status_code == 401 or
|
|
278
|
+
st_code in (1037, 100008, 100022) or
|
|
279
|
+
"session ip" in err_msg or
|
|
280
|
+
"unauthorized" in err_msg or
|
|
281
|
+
"invalid session" in err_msg or
|
|
282
|
+
"invalid token" in err_msg
|
|
298
283
|
):
|
|
299
284
|
logger.info("Kotak session invalid (stCode=%s, msg='%s') for account '%s'. Triggering auto-login...", st_code, resp_json.get("errMsg"), name)
|
|
300
285
|
if self.auto_login(consumer_key):
|
|
@@ -320,30 +305,7 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
320
305
|
|
|
321
306
|
client.api_client.rest_client.request = _proxied_request
|
|
322
307
|
if proxy:
|
|
323
|
-
logger.info("Kotak account '%s': HTTP proxy enabled (%s…)", name, proxy[:20])
|
|
324
|
-
|
|
325
|
-
# Also monkeypatch websocket-client (used by Kotak Neo WebSocket) to route over the proxy.
|
|
326
|
-
try:
|
|
327
|
-
import websocket as _ws_lib
|
|
328
|
-
from urllib.parse import urlparse as _urlparse
|
|
329
|
-
_parsed = _urlparse(proxy)
|
|
330
|
-
_proxy_host = _parsed.hostname
|
|
331
|
-
_proxy_port = _parsed.port
|
|
332
|
-
_proxy_auth = (_parsed.username, _parsed.password) if _parsed.username and _parsed.password else None
|
|
333
|
-
|
|
334
|
-
_orig_run_forever = _ws_lib.WebSocketApp.run_forever
|
|
335
|
-
def _proxied_run_forever(self, *args, **kwargs):
|
|
336
|
-
kwargs.setdefault("http_proxy_host", _proxy_host)
|
|
337
|
-
kwargs.setdefault("http_proxy_port", _proxy_port)
|
|
338
|
-
kwargs.setdefault("proxy_type", "http")
|
|
339
|
-
if _proxy_auth:
|
|
340
|
-
kwargs.setdefault("http_proxy_auth", _proxy_auth)
|
|
341
|
-
return _orig_run_forever(self, *args, **kwargs)
|
|
342
|
-
|
|
343
|
-
_ws_lib.WebSocketApp.run_forever = _proxied_run_forever
|
|
344
|
-
logger.info("Kotak account '%s': WebSocket proxy monkeypatch applied", name)
|
|
345
|
-
except Exception as e:
|
|
346
|
-
logger.error("Failed to apply WebSocket proxy patch for Kotak: %s", e)
|
|
308
|
+
logger.info("Kotak account '%s': HTTP proxy enabled for order APIs (%s…)", name, proxy[:20])
|
|
347
309
|
self._clients[consumer_key] = client
|
|
348
310
|
self._account_names[consumer_key] = name or consumer_key
|
|
349
311
|
self._authenticated[consumer_key] = False
|
|
@@ -697,6 +659,7 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
697
659
|
|
|
698
660
|
try:
|
|
699
661
|
resp = client.limits()
|
|
662
|
+
_check_neo_error(resp)
|
|
700
663
|
if isinstance(resp, dict):
|
|
701
664
|
data = resp.get("data", resp)
|
|
702
665
|
net = data.get("Net", data.get("net"))
|
|
@@ -936,6 +899,10 @@ class KotakAccountManager(BaseBrokerManager):
|
|
|
936
899
|
return KotakTicker(account_key, self.get_access_token(account_key), client)
|
|
937
900
|
|
|
938
901
|
|
|
902
|
+
def _dummy_callback(*args, **kwargs):
|
|
903
|
+
pass
|
|
904
|
+
|
|
905
|
+
|
|
939
906
|
# ── KotakTicker WebSocket Wrapper ─────────────────────────────────────────────
|
|
940
907
|
|
|
941
908
|
class KotakTicker:
|
|
@@ -990,6 +957,13 @@ class KotakTicker:
|
|
|
990
957
|
self._reconnect_timer.cancel()
|
|
991
958
|
self._reconnect_timer = None
|
|
992
959
|
logger.info("Closing Kotak Neo WebSocket (api_key=%s…)...", self.api_key[:8])
|
|
960
|
+
|
|
961
|
+
# Clear client callbacks to avoid recursive callback noise during close
|
|
962
|
+
self.client.on_open = _dummy_callback
|
|
963
|
+
self.client.on_message = _dummy_callback
|
|
964
|
+
self.client.on_error = _dummy_callback
|
|
965
|
+
self.client.on_close = _dummy_callback
|
|
966
|
+
|
|
993
967
|
neo_ws = getattr(self.client, "NeoWebSocket", None)
|
|
994
968
|
if neo_ws:
|
|
995
969
|
if getattr(neo_ws, "hsWebsocket", None):
|
|
@@ -1027,6 +1001,16 @@ class KotakTicker:
|
|
|
1027
1001
|
|
|
1028
1002
|
def _on_close(self, message: Any = None) -> None:
|
|
1029
1003
|
logger.info("Kotak Neo WebSocket closed: %s", message or "Session closed")
|
|
1004
|
+
msg_str = str(message or "").lower()
|
|
1005
|
+
is_auth_error = any(x in msg_str for x in ["session has been closed", "unauthorized", "invalid token"])
|
|
1006
|
+
|
|
1007
|
+
if is_auth_error:
|
|
1008
|
+
logger.error("Kotak Neo WebSocket closed due to session expiry/auth failure: %s (mapped code=403)", message)
|
|
1009
|
+
if self.on_error:
|
|
1010
|
+
# Map to 403 so live_session stops reconnecting and prompts user to re-init
|
|
1011
|
+
self.on_error(self, 403, str(message or "Session closed"))
|
|
1012
|
+
return
|
|
1013
|
+
|
|
1030
1014
|
if self.on_close:
|
|
1031
1015
|
self.on_close(self, 1000, str(message or "Closed"))
|
|
1032
1016
|
if self.reconnect and not self._stop_reconnect:
|
|
@@ -1072,6 +1056,10 @@ class KotakTicker:
|
|
|
1072
1056
|
|
|
1073
1057
|
logger.info("Kotak Neo WS: Attempting reconnection now...")
|
|
1074
1058
|
try:
|
|
1059
|
+
# Temporarily stub callbacks to avoid recursive on_error/on_close triggers during cleanup
|
|
1060
|
+
self.client.on_error = _dummy_callback
|
|
1061
|
+
self.client.on_close = _dummy_callback
|
|
1062
|
+
|
|
1075
1063
|
# First, clean up sockets
|
|
1076
1064
|
neo_ws = getattr(self.client, "NeoWebSocket", None)
|
|
1077
1065
|
if neo_ws:
|
|
@@ -1098,6 +1086,11 @@ class KotakTicker:
|
|
|
1098
1086
|
self.client.subscribe_to_orderfeed()
|
|
1099
1087
|
except Exception as e:
|
|
1100
1088
|
logger.error("Kotak Neo WS: Reconnection attempt failed: %s", e)
|
|
1089
|
+
# Re-register open/message/error/close hooks on client
|
|
1090
|
+
self.client.on_open = self._on_open
|
|
1091
|
+
self.client.on_message = self._on_message
|
|
1092
|
+
self.client.on_error = self._on_error
|
|
1093
|
+
self.client.on_close = self._on_close
|
|
1101
1094
|
self._trigger_reconnect()
|
|
1102
1095
|
|
|
1103
1096
|
def _on_message(self, message: Any) -> None:
|
|
@@ -1003,6 +1003,14 @@ class KCLILiveSession:
|
|
|
1003
1003
|
" Advisor ",
|
|
1004
1004
|
make_click_handler("advisor")
|
|
1005
1005
|
))
|
|
1006
|
+
fragments.append(("", " "))
|
|
1007
|
+
|
|
1008
|
+
# Tab 5: Help
|
|
1009
|
+
fragments.append((
|
|
1010
|
+
"class:tab.active" if self.info_mode == "help" else "class:tab.inactive",
|
|
1011
|
+
" Help ",
|
|
1012
|
+
make_click_handler("help")
|
|
1013
|
+
))
|
|
1006
1014
|
|
|
1007
1015
|
return fragments
|
|
1008
1016
|
|
|
@@ -1162,6 +1170,12 @@ class KCLILiveSession:
|
|
|
1162
1170
|
async def _initial_fetch_and_connect(self) -> None:
|
|
1163
1171
|
"""Initial fetch of data and connect all WebSockets."""
|
|
1164
1172
|
self.log_message("Initializing connections and fetching data...")
|
|
1173
|
+
|
|
1174
|
+
# Ensure accounts are validated & auto-logged in
|
|
1175
|
+
try:
|
|
1176
|
+
await self._run_api_call(self.client.init_accounts, self.accounts)
|
|
1177
|
+
except Exception as e:
|
|
1178
|
+
self.log_message(f"[#ff8700]Account init warning:[/#] {e}")
|
|
1165
1179
|
|
|
1166
1180
|
# Fire off REST data fetch in the background so it doesn't block WebSockets
|
|
1167
1181
|
if hasattr(self, "app") and self.app and self.app.loop:
|
|
@@ -1188,7 +1202,7 @@ class KCLILiveSession:
|
|
|
1188
1202
|
)
|
|
1189
1203
|
|
|
1190
1204
|
# Connect WebSockets for accounts that support it (Zerodha and Kotak).
|
|
1191
|
-
for
|
|
1205
|
+
for acct in self.accounts:
|
|
1192
1206
|
api_key = acct.get("api_key")
|
|
1193
1207
|
broker = acct.get("broker", "zerodha").lower()
|
|
1194
1208
|
access_token = self.client.get_access_token(api_key)
|
|
@@ -1201,9 +1215,6 @@ class KCLILiveSession:
|
|
|
1201
1215
|
)
|
|
1202
1216
|
continue
|
|
1203
1217
|
|
|
1204
|
-
if idx > 0:
|
|
1205
|
-
await asyncio.sleep(0.2)
|
|
1206
|
-
|
|
1207
1218
|
if api_key and (access_token or broker == "kotak"):
|
|
1208
1219
|
try:
|
|
1209
1220
|
if broker == "kotak":
|
|
@@ -1228,29 +1239,8 @@ class KCLILiveSession:
|
|
|
1228
1239
|
ticker.on_close = self._make_on_close(api_key)
|
|
1229
1240
|
ticker.on_error = self._make_on_error(api_key)
|
|
1230
1241
|
|
|
1231
|
-
#
|
|
1232
|
-
proxy_str = acct.get("proxy")
|
|
1233
|
-
proxy_dict = None
|
|
1234
|
-
if broker != "zerodha" and proxy_str:
|
|
1235
|
-
from urllib.parse import urlparse
|
|
1236
|
-
try:
|
|
1237
|
-
p_str = proxy_str if "://" in proxy_str else f"http://{proxy_str}"
|
|
1238
|
-
parsed = urlparse(p_str)
|
|
1239
|
-
if parsed.hostname and parsed.port:
|
|
1240
|
-
proxy_dict = {
|
|
1241
|
-
"host": parsed.hostname,
|
|
1242
|
-
"port": int(parsed.port),
|
|
1243
|
-
}
|
|
1244
|
-
if parsed.username and parsed.password:
|
|
1245
|
-
proxy_dict["username"] = parsed.username
|
|
1246
|
-
proxy_dict["password"] = parsed.password
|
|
1247
|
-
except Exception as p_err:
|
|
1248
|
-
self.log_message(f"[#ff8700]Failed to parse proxy for {acct.get('name')}:[/#] {p_err}")
|
|
1249
|
-
|
|
1250
|
-
# connect() only accepts threaded + proxy
|
|
1242
|
+
# connect() only accepts threaded
|
|
1251
1243
|
connect_kwargs = dict(threaded=True)
|
|
1252
|
-
if proxy_dict:
|
|
1253
|
-
connect_kwargs["proxy"] = proxy_dict
|
|
1254
1244
|
ticker.connect(**connect_kwargs)
|
|
1255
1245
|
self.tickers[api_key] = ticker
|
|
1256
1246
|
except Exception as exc:
|
|
@@ -2341,6 +2331,44 @@ class KCLILiveSession:
|
|
|
2341
2331
|
)
|
|
2342
2332
|
return
|
|
2343
2333
|
|
|
2334
|
+
# Direct commands to switch info pane mode
|
|
2335
|
+
cmd_norm = cmd.strip().lower()
|
|
2336
|
+
if cmd_norm in ("pending", "p", "1", "/1", "/pending"):
|
|
2337
|
+
self.info_mode = "orders_pending"
|
|
2338
|
+
self._update_info_buffer()
|
|
2339
|
+
if hasattr(self, "app") and self.app:
|
|
2340
|
+
self.app.invalidate()
|
|
2341
|
+
self.log_message("Switched info pane to [bold]Pending Orders[/bold].")
|
|
2342
|
+
return
|
|
2343
|
+
elif cmd_norm in ("executed", "e", "ex", "2", "/2", "/executed"):
|
|
2344
|
+
self.info_mode = "orders_executed"
|
|
2345
|
+
self._update_info_buffer()
|
|
2346
|
+
if hasattr(self, "app") and self.app:
|
|
2347
|
+
self.app.invalidate()
|
|
2348
|
+
self.log_message("Switched info pane to [bold]Executed Orders[/bold].")
|
|
2349
|
+
return
|
|
2350
|
+
elif cmd_norm in ("oc", "optionchain", "chain", "3", "/3", "/oc"):
|
|
2351
|
+
self.info_mode = "oc"
|
|
2352
|
+
self._update_info_buffer()
|
|
2353
|
+
if hasattr(self, "app") and self.app:
|
|
2354
|
+
self.app.invalidate()
|
|
2355
|
+
self.log_message("Switched info pane to [bold]Option Chain[/bold].")
|
|
2356
|
+
return
|
|
2357
|
+
elif cmd_norm in ("advisor", "adv", "ai", "4", "/4", "/advisor"):
|
|
2358
|
+
self.info_mode = "advisor"
|
|
2359
|
+
self._update_info_buffer()
|
|
2360
|
+
if hasattr(self, "app") and self.app:
|
|
2361
|
+
self.app.invalidate()
|
|
2362
|
+
self.log_message("Switched info pane to [bold]AI Advisor[/bold].")
|
|
2363
|
+
return
|
|
2364
|
+
elif cmd_norm in ("help", "h", "5", "/5", "/help"):
|
|
2365
|
+
self.info_mode = "help"
|
|
2366
|
+
self._update_info_buffer()
|
|
2367
|
+
if hasattr(self, "app") and self.app:
|
|
2368
|
+
self.app.invalidate()
|
|
2369
|
+
self.log_message("Switched info pane to [bold]Help & Shortcuts[/bold].")
|
|
2370
|
+
return
|
|
2371
|
+
|
|
2344
2372
|
# Attempt parsing via the unified parser
|
|
2345
2373
|
try:
|
|
2346
2374
|
parsed_cmds = parse_command_line(cmd)
|
|
@@ -3847,6 +3875,59 @@ class KCLILiveSession:
|
|
|
3847
3875
|
lines.append("No pending orders across all accounts.")
|
|
3848
3876
|
return "\n".join(lines)
|
|
3849
3877
|
|
|
3878
|
+
def _build_help_text(self) -> str:
|
|
3879
|
+
"""Return formatted keyboard shortcuts & command cheat sheet."""
|
|
3880
|
+
return """=== KiteCLI Keyboard & Command Cheat Sheet ===
|
|
3881
|
+
|
|
3882
|
+
1. TAB SWITCHING (RIGHT PANE)
|
|
3883
|
+
Type at prompt:
|
|
3884
|
+
p or pending or 1 -> Switch to Pending Orders tab
|
|
3885
|
+
e or executed or 2 -> Switch to Executed Orders tab
|
|
3886
|
+
oc or chain or 3 -> Switch to Option Chain tab
|
|
3887
|
+
adv or advisor or 4-> Switch to AI Advisor tab
|
|
3888
|
+
h or help or 5 -> Switch to this Help tab
|
|
3889
|
+
|
|
3890
|
+
Universal Hotkeys:
|
|
3891
|
+
Ctrl + P -> Pending Orders
|
|
3892
|
+
Ctrl + E -> Executed Orders
|
|
3893
|
+
Ctrl + O -> Option Chain
|
|
3894
|
+
Ctrl + G -> AI Advisor
|
|
3895
|
+
Ctrl + H -> Help
|
|
3896
|
+
|
|
3897
|
+
Function Keys / Alt:
|
|
3898
|
+
F1 / F2 / F3 / F4 / F5
|
|
3899
|
+
Option+1 / 2 / 3 / 4 / 5 (or Esc then 1..5)
|
|
3900
|
+
Mouse Click: Click directly on any tab title in header bar
|
|
3901
|
+
|
|
3902
|
+
2. ORDER PLACEMENT & TRADING COMMANDS
|
|
3903
|
+
Buy / Sell Orders:
|
|
3904
|
+
b <symbol> <qty> [price] -> Buy limit/market (e.g. b NIFTY26JUL22500PE 100 1.40)
|
|
3905
|
+
s <symbol> <qty> [price] -> Sell limit/market (e.g. s BANKNIFTY26JUL50000CE 15@2.50)
|
|
3906
|
+
b 1 100 -> Buy 100 qty of active position #1
|
|
3907
|
+
s 2 -> Sell/exit all qty of active position #2
|
|
3908
|
+
|
|
3909
|
+
Exiting Positions:
|
|
3910
|
+
exit <symbol|id> [price] -> Exit position by symbol or table ID (e.g. exit 1, exit 2 1.50)
|
|
3911
|
+
exit all [price] -> Exit all active positions (e.g. exit all)
|
|
3912
|
+
|
|
3913
|
+
Modifying & Cancelling:
|
|
3914
|
+
modify <order_id> [qty] [price] -> Modify open order
|
|
3915
|
+
cancel <order_id> -> Cancel pending order
|
|
3916
|
+
cancel all -> Cancel all open/pending orders
|
|
3917
|
+
|
|
3918
|
+
3. SELECTION & SELECTION CLEARING
|
|
3919
|
+
Selection:
|
|
3920
|
+
Click on symbol/account row -> Target specific position/account
|
|
3921
|
+
Esc -> Clear active selection / reset account filter
|
|
3922
|
+
|
|
3923
|
+
4. GLOBAL NAVIGATION & CONTROL
|
|
3924
|
+
Tab -> Cycle focus (Input box -> Positions -> Right Pane)
|
|
3925
|
+
Ctrl + R -> Search command history
|
|
3926
|
+
Ctrl + Left -> Narrow left pane split width
|
|
3927
|
+
Ctrl + Right -> Widen left pane split width
|
|
3928
|
+
Ctrl + C -> Exit KiteCLI
|
|
3929
|
+
"""
|
|
3930
|
+
|
|
3850
3931
|
def _update_info_buffer(self, reset_scroll: bool = True) -> None:
|
|
3851
3932
|
"""Push current info_mode text into the info_buffer.
|
|
3852
3933
|
|
|
@@ -3867,6 +3948,8 @@ class KCLILiveSession:
|
|
|
3867
3948
|
except Exception as exc:
|
|
3868
3949
|
self._last_advisor_text = f"=== Tuesday Expiry Advisor ===\n\nFailed to plan: {exc}"
|
|
3869
3950
|
text = self._last_advisor_text
|
|
3951
|
+
elif self.info_mode == "help":
|
|
3952
|
+
text = self._build_help_text()
|
|
3870
3953
|
else:
|
|
3871
3954
|
text = self._last_oc_text
|
|
3872
3955
|
if reset_scroll:
|
|
@@ -4238,8 +4321,10 @@ class KCLILiveSession:
|
|
|
4238
4321
|
nxt = self.input_field.control
|
|
4239
4322
|
event.app.layout.focus(nxt)
|
|
4240
4323
|
|
|
4241
|
-
# Ctrl+1/2/3/4
|
|
4324
|
+
# Ctrl+1/2/3/4, Ctrl+P/E/O/G, Alt/Esc+1/2/3/4, F1-F4 — switch info pane content
|
|
4242
4325
|
@kb.add("c-1")
|
|
4326
|
+
@kb.add("c-p")
|
|
4327
|
+
@kb.add("escape", "1")
|
|
4243
4328
|
@kb.add("f1")
|
|
4244
4329
|
def _c1(event):
|
|
4245
4330
|
self.info_mode = "orders_pending"
|
|
@@ -4247,6 +4332,8 @@ class KCLILiveSession:
|
|
|
4247
4332
|
event.app.invalidate()
|
|
4248
4333
|
|
|
4249
4334
|
@kb.add("c-2")
|
|
4335
|
+
@kb.add("c-e")
|
|
4336
|
+
@kb.add("escape", "2")
|
|
4250
4337
|
@kb.add("f2")
|
|
4251
4338
|
def _c2(event):
|
|
4252
4339
|
self.info_mode = "orders_executed"
|
|
@@ -4254,6 +4341,8 @@ class KCLILiveSession:
|
|
|
4254
4341
|
event.app.invalidate()
|
|
4255
4342
|
|
|
4256
4343
|
@kb.add("c-3")
|
|
4344
|
+
@kb.add("c-o")
|
|
4345
|
+
@kb.add("escape", "3")
|
|
4257
4346
|
@kb.add("f3")
|
|
4258
4347
|
def _c3(event):
|
|
4259
4348
|
self.info_mode = "oc"
|
|
@@ -4261,12 +4350,23 @@ class KCLILiveSession:
|
|
|
4261
4350
|
event.app.invalidate()
|
|
4262
4351
|
|
|
4263
4352
|
@kb.add("c-4")
|
|
4353
|
+
@kb.add("c-g")
|
|
4354
|
+
@kb.add("escape", "4")
|
|
4264
4355
|
@kb.add("f4")
|
|
4265
4356
|
def _c4(event):
|
|
4266
4357
|
self.info_mode = "advisor"
|
|
4267
4358
|
self._update_info_buffer()
|
|
4268
4359
|
event.app.invalidate()
|
|
4269
4360
|
|
|
4361
|
+
@kb.add("c-5")
|
|
4362
|
+
@kb.add("c-h")
|
|
4363
|
+
@kb.add("escape", "5")
|
|
4364
|
+
@kb.add("f5")
|
|
4365
|
+
def _c5(event):
|
|
4366
|
+
self.info_mode = "help"
|
|
4367
|
+
self._update_info_buffer()
|
|
4368
|
+
event.app.invalidate()
|
|
4369
|
+
|
|
4270
4370
|
# Ctrl+Left/Right — adjust left/right pane split
|
|
4271
4371
|
@kb.add("c-left")
|
|
4272
4372
|
def _narrow_left(event):
|
|
@@ -206,6 +206,7 @@ def live() -> None:
|
|
|
206
206
|
display_error("No accounts configured. Edit your config file to add accounts.")
|
|
207
207
|
raise typer.Exit(code=1)
|
|
208
208
|
client = _build_client(config)
|
|
209
|
+
client.init_accounts(accounts)
|
|
209
210
|
|
|
210
211
|
# Launch interactive session
|
|
211
212
|
session = KCLILiveSession(client, accounts)
|
|
@@ -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.6"
|
|
8
8
|
description = "KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|