kitecli 0.2.0__tar.gz → 0.2.1__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 (31) hide show
  1. {kitecli-0.2.0 → kitecli-0.2.1}/PKG-INFO +1 -1
  2. {kitecli-0.2.0 → kitecli-0.2.1}/cli/display.py +0 -6
  3. {kitecli-0.2.0 → kitecli-0.2.1}/cli/executor.py +1 -1
  4. {kitecli-0.2.0 → kitecli-0.2.1}/cli/kotak_manager.py +209 -33
  5. {kitecli-0.2.0 → kitecli-0.2.1}/cli/live_session.py +165 -31
  6. {kitecli-0.2.0 → kitecli-0.2.1}/cli/telegram_bot.py +63 -25
  7. {kitecli-0.2.0 → kitecli-0.2.1}/kitecli.egg-info/PKG-INFO +1 -1
  8. {kitecli-0.2.0 → kitecli-0.2.1}/pyproject.toml +1 -1
  9. {kitecli-0.2.0 → kitecli-0.2.1}/tests/test_multi_broker.py +67 -0
  10. {kitecli-0.2.0 → kitecli-0.2.1}/tests/test_telegram.py +62 -0
  11. kitecli-0.2.1/tests/test_ui.py +98 -0
  12. kitecli-0.2.0/tests/test_ui.py +0 -74
  13. {kitecli-0.2.0 → kitecli-0.2.1}/README.md +0 -0
  14. {kitecli-0.2.0 → kitecli-0.2.1}/cli/__init__.py +0 -0
  15. {kitecli-0.2.0 → kitecli-0.2.1}/cli/advisor.py +0 -0
  16. {kitecli-0.2.0 → kitecli-0.2.1}/cli/api_client.py +0 -0
  17. {kitecli-0.2.0 → kitecli-0.2.1}/cli/base_manager.py +0 -0
  18. {kitecli-0.2.0 → kitecli-0.2.1}/cli/config.py +0 -0
  19. {kitecli-0.2.0 → kitecli-0.2.1}/cli/kite_manager.py +0 -0
  20. {kitecli-0.2.0 → kitecli-0.2.1}/cli/main.py +0 -0
  21. {kitecli-0.2.0 → kitecli-0.2.1}/cli/nli.py +0 -0
  22. {kitecli-0.2.0 → kitecli-0.2.1}/cli/parser.py +0 -0
  23. {kitecli-0.2.0 → kitecli-0.2.1}/cli/recorder.py +0 -0
  24. {kitecli-0.2.0 → kitecli-0.2.1}/kitecli.egg-info/SOURCES.txt +0 -0
  25. {kitecli-0.2.0 → kitecli-0.2.1}/kitecli.egg-info/dependency_links.txt +0 -0
  26. {kitecli-0.2.0 → kitecli-0.2.1}/kitecli.egg-info/entry_points.txt +0 -0
  27. {kitecli-0.2.0 → kitecli-0.2.1}/kitecli.egg-info/requires.txt +0 -0
  28. {kitecli-0.2.0 → kitecli-0.2.1}/kitecli.egg-info/top_level.txt +0 -0
  29. {kitecli-0.2.0 → kitecli-0.2.1}/setup.cfg +0 -0
  30. {kitecli-0.2.0 → kitecli-0.2.1}/tests/test_nli.py +0 -0
  31. {kitecli-0.2.0 → kitecli-0.2.1}/tests/test_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -117,11 +117,9 @@ def display_positions(accounts_data: list[dict]) -> None:
117
117
  table.add_column("Avg Price", justify="right")
118
118
  table.add_column("LTP", justify="right")
119
119
  table.add_column("P&L", justify="right")
120
- table.add_column("P&L %", justify="right")
121
120
 
122
121
  for pos in positions:
123
122
  pnl = float(pos.get("pnl", 0))
124
- pnl_pct = float(pos.get("pnl_pct", 0))
125
123
  style = _pnl_style(pnl)
126
124
 
127
125
  table.add_row(
@@ -130,7 +128,6 @@ def display_positions(accounts_data: list[dict]) -> None:
130
128
  _format_currency(float(pos.get("average_price", 0))),
131
129
  _format_currency(float(pos.get("last_price", 0))),
132
130
  Text(_format_currency(pnl), style=style),
133
- Text(_format_pnl_pct(pnl_pct), style=style),
134
131
  )
135
132
 
136
133
  panel = Panel(
@@ -233,11 +230,9 @@ def render_positions_to_string(accounts_data: list[dict], width: int = 80, show_
233
230
  table.add_column("Avg Price", justify="right")
234
231
  table.add_column("LTP", justify="right")
235
232
  table.add_column("P&L", justify="right")
236
- table.add_column("P&L %", justify="right")
237
233
 
238
234
  for pos in positions:
239
235
  pnl = float(pos.get("pnl", 0))
240
- pnl_pct = float(pos.get("pnl_pct", 0))
241
236
  style = _pnl_style(pnl)
242
237
 
243
238
  symbol = str(pos.get("tradingsymbol", ""))
@@ -260,7 +255,6 @@ def render_positions_to_string(accounts_data: list[dict], width: int = 80, show_
260
255
  _format_currency(float(pos.get("average_price", 0))),
261
256
  _format_currency(float(pos.get("last_price", 0))),
262
257
  Text(_format_currency(pnl), style=style),
263
- Text(_format_pnl_pct(pnl_pct), style=style),
264
258
  )
265
259
 
266
260
 
@@ -201,7 +201,7 @@ async def execute_command(cmd: Any, context: ExecutionContext) -> dict:
201
201
 
202
202
  res = context.client.exit_positions(
203
203
  api_keys=api_keys,
204
- symbol=symbol,
204
+ tradingsymbol=symbol,
205
205
  price=cmd.price
206
206
  )
207
207
 
@@ -39,6 +39,26 @@ def _patched_thread_init(self, *args, **kwargs):
39
39
  _orig_thread_init(self, *args, **kwargs)
40
40
  _threading.Thread.__init__ = _patched_thread_init
41
41
 
42
+ import builtins as _builtins
43
+ _orig_print = _builtins.print
44
+ _sdk_logger = logging.getLogger("kotak_sdk_print")
45
+
46
+ def _safe_print(*args, **kwargs):
47
+ try:
48
+ import inspect
49
+ frame = inspect.currentframe().f_back
50
+ if frame:
51
+ mod_name = frame.f_globals.get("__name__", "")
52
+ if mod_name and ("neo_api_client" in mod_name or "neo_api" in mod_name):
53
+ # Suppress SDK print pollution and redirect to debug logs
54
+ _sdk_logger.debug("SDK print suppressed: %s", " ".join(str(a) for a in args))
55
+ return
56
+ except Exception:
57
+ pass
58
+ _orig_print(*args, **kwargs)
59
+
60
+ _builtins.print = _safe_print
61
+
42
62
  from cli.base_manager import BaseBrokerManager
43
63
 
44
64
  logger = logging.getLogger(__name__)
@@ -65,7 +85,7 @@ def _check_neo_error(resp: Any) -> None:
65
85
 
66
86
  # 2. Backend server-level errors
67
87
  stat = str(resp.get("stat", "")).upper()
68
- if stat in ("NOT_OK", "FAIL", "ERROR"):
88
+ if stat in ("NOT_OK", "FAIL", "ERROR") or "ERROR" in stat:
69
89
  err_msg = resp.get("errMsg") or resp.get("desc") or resp.get("message") or "Unknown Kotak API error"
70
90
  st_code = resp.get("stCode") or resp.get("stcode")
71
91
  # If the error is simply 'No Data' (empty order book or positions), do not raise an exception
@@ -209,37 +229,91 @@ class KotakAccountManager(BaseBrokerManager):
209
229
 
210
230
  client = NeoAPI(environment="prod", consumer_key=consumer_key)
211
231
 
212
- # Inject proxy into the SDK's REST client so every HTTP call
213
- # (login, order placement, positions, etc.) routes through the proxy.
214
- if proxy:
215
- import requests as _req
216
- import json as _json
217
- import re as _re
218
- from six.moves.urllib.parse import urlencode as _urlencode
219
- _proxies = {"http": proxy, "https": proxy}
220
- _orig_request = client.api_client.rest_client.request
221
-
222
- def _proxied_request(method, url, query_params=None, headers=None, body=None):
223
- method = method.upper()
224
- headers = headers or {}
225
- if 'Content-Type' not in headers:
226
- headers['Content-Type'] = 'application/json'
232
+ import requests as _req
233
+ import json as _json
234
+ import re as _re
235
+ from six.moves.urllib.parse import urlencode as _urlencode
236
+ _proxies = {"http": proxy, "https": proxy} if proxy else None
237
+ _orig_request = client.api_client.rest_client.request
238
+
239
+ def _proxied_request(method, url, query_params=None, headers=None, body=None):
240
+ method = method.upper()
241
+ headers = headers or {}
242
+ if 'Content-Type' not in headers:
243
+ headers['Content-Type'] = 'application/json'
244
+
245
+ u = url
246
+ if query_params:
247
+ u_with_params = u
248
+ if '?' not in u:
249
+ u_with_params += '?' + _urlencode(query_params)
250
+ else:
251
+ pass
252
+ else:
253
+ u_with_params = u
254
+
255
+ def _do_req(h):
256
+ req_kwargs = {}
257
+ if _proxies:
258
+ req_kwargs["proxies"] = _proxies
259
+
260
+ logger.debug("Kotak REST [%s] requesting: %s", method, u_with_params)
261
+ logger.debug("Kotak REST headers: %s", {k: v[:15] + '...' if len(str(v)) > 15 else v for k, v in h.items()})
262
+
227
263
  if method in ['POST', 'PUT', 'PATCH', 'DELETE']:
228
- if query_params:
229
- url += '?' + _urlencode(query_params)
230
- if _re.search('json', headers['Content-Type'], _re.IGNORECASE):
264
+ if _re.search('json', h['Content-Type'], _re.IGNORECASE):
231
265
  request_body = _json.dumps(body) if body is not None else None
232
- return _req.post(url=url, headers=headers, data=request_body, proxies=_proxies)
233
- elif _re.search('x-www-form-urlencoded', headers['Content-Type'], _re.IGNORECASE):
266
+ return _req.post(url=u_with_params, headers=h, data=request_body, **req_kwargs)
267
+ elif _re.search('x-www-form-urlencoded', h['Content-Type'], _re.IGNORECASE):
234
268
  request_body = {"jData": _json.dumps(body)} if body is not None else {}
235
- return _req.post(url=url, headers=headers, data=request_body, proxies=_proxies)
269
+ return _req.post(url=u_with_params, headers=h, data=request_body, **req_kwargs)
236
270
  elif method == 'GET':
237
- if query_params:
238
- url += '?' + _urlencode(query_params)
239
- return _req.get(url=url, headers=headers, proxies=_proxies)
240
- return _orig_request(method, url, query_params=query_params, headers=headers, body=body)
271
+ return _req.get(url=u_with_params, headers=h, **req_kwargs)
272
+ return _orig_request(method, url, query_params=query_params, headers=h, body=body)
273
+
274
+ resp = _do_req(headers)
275
+ logger.info("Kotak REST %s %s → status %s", method, u_with_params, resp.status_code)
276
+ try:
277
+ logger.debug("Kotak REST response body: %s", resp.json())
278
+ except Exception:
279
+ logger.debug("Kotak REST response body (raw): %s", resp.text[:200])
241
280
 
242
- client.api_client.rest_client.request = _proxied_request
281
+ # Check for IP mismatch error (stCode 1037) or Session expired (stCode 100008)
282
+ try:
283
+ resp_json = resp.json()
284
+ if isinstance(resp_json, dict):
285
+ st_code = resp_json.get("stCode")
286
+ err_msg = str(resp_json.get("errMsg", "")).lower()
287
+ if (
288
+ st_code == 1037 or
289
+ st_code == 100008 or
290
+ "session ip" in err_msg or
291
+ "unauthorized" in err_msg
292
+ ):
293
+ logger.info("Kotak session invalid (stCode=%s, msg='%s') for account '%s'. Triggering auto-login...", st_code, resp_json.get("errMsg"), name)
294
+ if self.auto_login(consumer_key):
295
+ # Update headers with new credentials
296
+ headers["Sid"] = client.configuration.edit_sid
297
+ headers["Auth"] = client.configuration.edit_token
298
+ if isinstance(query_params, dict):
299
+ if "sId" in query_params:
300
+ query_params["sId"] = client.configuration.serverId
301
+ if "sid" in query_params:
302
+ query_params["sid"] = client.configuration.edit_sid
303
+ logger.info("Retrying request after successful auto-login...")
304
+ resp = _do_req(headers)
305
+ logger.info("Kotak REST retry %s %s → status %s", method, u_with_params, resp.status_code)
306
+ try:
307
+ logger.debug("Kotak REST retry response body: %s", resp.json())
308
+ except Exception:
309
+ logger.debug("Kotak REST retry response body (raw): %s", resp.text[:200])
310
+ except Exception as e:
311
+ logger.debug("Failed to check response for session validity: %s", e)
312
+
313
+ return resp
314
+
315
+ client.api_client.rest_client.request = _proxied_request
316
+ if proxy:
243
317
  logger.info("Kotak account '%s': HTTP proxy enabled (%s…)", name, proxy[:20])
244
318
 
245
319
  # Also monkeypatch websocket-client (used by Kotak Neo WebSocket) to route over the proxy.
@@ -302,11 +376,18 @@ class KotakAccountManager(BaseBrokerManager):
302
376
  )
303
377
  except Exception as exc:
304
378
  logger.info(
305
- "Saved Kotak token for '%s' (key=%s…) expired: %s",
379
+ "Saved Kotak token for '%s' (key=%s…) expired: %s. Will attempt auto-login.",
306
380
  name, consumer_key[:8], exc,
307
381
  )
308
382
  self._authenticated[consumer_key] = False
309
383
 
384
+ 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)
390
+
310
391
  return "" # no login URL for Kotak
311
392
 
312
393
  def auto_login(self, account_key: str, **_kwargs) -> bool:
@@ -675,11 +756,22 @@ class KotakAccountManager(BaseBrokerManager):
675
756
  }
676
757
  kotak_transaction_type = transaction_type_map.get(transaction_type, transaction_type)
677
758
 
759
+ # Resolve option lot size to align split legs
760
+ from cli.api_client import _kite_manager
761
+ lot_size = _kite_manager.get_nfo_lot_sizes().get(tradingsymbol, 1)
762
+
678
763
  KOTAK_FREEZE_LIMIT = 1800
764
+ if lot_size > 1:
765
+ freeze_limit = (KOTAK_FREEZE_LIMIT // lot_size) * lot_size
766
+ if freeze_limit == 0:
767
+ freeze_limit = lot_size
768
+ else:
769
+ freeze_limit = KOTAK_FREEZE_LIMIT
770
+
679
771
  legs: list[int] = []
680
772
  remaining = quantity
681
773
  while remaining > 0:
682
- leg_qty = min(remaining, KOTAK_FREEZE_LIMIT)
774
+ leg_qty = min(remaining, freeze_limit)
683
775
  legs.append(leg_qty)
684
776
  remaining -= leg_qty
685
777
 
@@ -856,10 +948,12 @@ class KotakTicker:
856
948
  MODE_QUOTE = "quote"
857
949
  MODE_FULL = "full"
858
950
 
859
- def __init__(self, api_key: str, access_token: str | None, client: Any) -> None:
951
+ def __init__(self, api_key: str, access_token: str | None, client: Any, reconnect: bool = True, reconnect_max_tries: int = 50) -> None:
860
952
  self.api_key = api_key
861
953
  self.access_token = access_token
862
954
  self.client = client
955
+ self.reconnect = reconnect
956
+ self.reconnect_max_tries = reconnect_max_tries
863
957
 
864
958
  self.on_connect = None
865
959
  self.on_ticks = None
@@ -867,8 +961,14 @@ class KotakTicker:
867
961
  self.on_close = None
868
962
  self.on_error = None
869
963
 
964
+ self._reconnect_attempt = 0
965
+ self._stop_reconnect = False
966
+ self._reconnect_timer = None
967
+ self._reconnect_lock = _threading.Lock()
968
+
870
969
  def connect(self, **kwargs) -> None:
871
970
  """Register callbacks and connect the Kotak Neo WebSocket threads."""
971
+ self._stop_reconnect = False
872
972
  self.client.on_open = self._on_open
873
973
  self.client.on_message = self._on_message
874
974
  self.client.on_error = self._on_error
@@ -881,10 +981,17 @@ class KotakTicker:
881
981
  except Exception as e:
882
982
  logger.error("Failed to connect Kotak Neo order feed: %s", e)
883
983
  if self.on_error:
884
- self.on_error(self, 0, str(e))
984
+ error_str = str(e).lower()
985
+ code = 403 if ("unauthorized" in error_str or "auth" in error_str or "session" in error_str or "token" in error_str) else 0
986
+ self.on_error(self, code, str(e))
885
987
 
886
988
  def close(self) -> None:
887
989
  """Disconnect and clean up Kotak Neo WebSocket connections."""
990
+ self._stop_reconnect = True
991
+ with self._reconnect_lock:
992
+ if self._reconnect_timer:
993
+ self._reconnect_timer.cancel()
994
+ self._reconnect_timer = None
888
995
  logger.info("Closing Kotak Neo WebSocket (api_key=%s…)...", self.api_key[:8])
889
996
  neo_ws = getattr(self.client, "NeoWebSocket", None)
890
997
  if neo_ws:
@@ -917,6 +1024,7 @@ class KotakTicker:
917
1024
 
918
1025
  def _on_open(self, message: Any = None) -> None:
919
1026
  logger.info("Kotak Neo WebSocket connected: %s", message or "Session opened")
1027
+ self._reconnect_attempt = 0
920
1028
  if self.on_connect:
921
1029
  self.on_connect(self, None)
922
1030
 
@@ -924,11 +1032,76 @@ class KotakTicker:
924
1032
  logger.info("Kotak Neo WebSocket closed: %s", message or "Session closed")
925
1033
  if self.on_close:
926
1034
  self.on_close(self, 1000, str(message or "Closed"))
1035
+ if self.reconnect and not self._stop_reconnect:
1036
+ self._trigger_reconnect()
927
1037
 
928
1038
  def _on_error(self, error: Any) -> None:
929
- logger.error("Kotak Neo WebSocket error: %s", error)
1039
+ error_str = str(error).lower()
1040
+ code = 403 if ("unauthorized" in error_str or "auth" in error_str or "session" in error_str or "token" in error_str) else 0
1041
+ logger.error("Kotak Neo WebSocket error: %s (mapped code=%s)", error, code)
930
1042
  if self.on_error:
931
- self.on_error(self, 0, str(error))
1043
+ self.on_error(self, code, str(error))
1044
+
1045
+ # Trigger reconnection if it's not a permanent auth failure (403)
1046
+ if self.reconnect and not self._stop_reconnect and code != 403:
1047
+ self._trigger_reconnect()
1048
+
1049
+ def _trigger_reconnect(self) -> None:
1050
+ with self._reconnect_lock:
1051
+ if self._stop_reconnect:
1052
+ return
1053
+ if self._reconnect_timer:
1054
+ return
1055
+
1056
+ if self._reconnect_attempt >= self.reconnect_max_tries:
1057
+ logger.error("Kotak Neo WS: Max reconnect attempts (%s) reached. Stopping.", self.reconnect_max_tries)
1058
+ return
1059
+
1060
+ # Exponential backoff delay
1061
+ delay = min(2 ** (self._reconnect_attempt + 1), 60)
1062
+ self._reconnect_attempt += 1
1063
+ logger.info("Kotak Neo WS: Connection lost. Scheduling reconnect attempt %s/%s in %s seconds...",
1064
+ self._reconnect_attempt, self.reconnect_max_tries, delay)
1065
+
1066
+ self._reconnect_timer = _threading.Timer(delay, self._run_reconnect)
1067
+ self._reconnect_timer.daemon = True
1068
+ self._reconnect_timer.start()
1069
+
1070
+ def _run_reconnect(self) -> None:
1071
+ with self._reconnect_lock:
1072
+ self._reconnect_timer = None
1073
+ if self._stop_reconnect:
1074
+ return
1075
+
1076
+ logger.info("Kotak Neo WS: Attempting reconnection now...")
1077
+ try:
1078
+ # First, clean up sockets
1079
+ neo_ws = getattr(self.client, "NeoWebSocket", None)
1080
+ if neo_ws:
1081
+ if getattr(neo_ws, "hsWebsocket", None):
1082
+ try:
1083
+ neo_ws.hsWebsocket.close()
1084
+ except Exception:
1085
+ pass
1086
+ if getattr(neo_ws, "hsiWebsocket", None):
1087
+ try:
1088
+ neo_ws.hsiWebsocket.close()
1089
+ except Exception:
1090
+ pass
1091
+ neo_ws.is_hsw_open = 0
1092
+ neo_ws.is_hsi_open = 0
1093
+
1094
+ # Re-register open/message/error/close hooks on client in case they got cleared
1095
+ self.client.on_open = self._on_open
1096
+ self.client.on_message = self._on_message
1097
+ self.client.on_error = self._on_error
1098
+ self.client.on_close = self._on_close
1099
+
1100
+ # Reconnect by subscribing to orderfeed
1101
+ self.client.subscribe_to_orderfeed()
1102
+ except Exception as e:
1103
+ logger.error("Kotak Neo WS: Reconnection attempt failed: %s", e)
1104
+ self._trigger_reconnect()
932
1105
 
933
1106
  def _on_message(self, message: Any) -> None:
934
1107
  if not isinstance(message, dict):
@@ -1009,6 +1182,9 @@ class KotakTicker:
1009
1182
  "status_message": str(data.get("rejRsn", data.get("status_message", ""))),
1010
1183
  }
1011
1184
 
1185
+ if not norm_data["order_id"] and not norm_data["tradingsymbol"]:
1186
+ return
1187
+
1012
1188
  if self.on_order_update:
1013
1189
  self.on_order_update(self, norm_data)
1014
1190