Jitendra12421 commited on
Commit
9773fb7
·
verified ·
1 Parent(s): 5bb1bb4

Upload 42 files

Browse files
Files changed (2) hide show
  1. __pycache__/kotak_neo.cpython-311.pyc +0 -0
  2. kotak_neo.py +225 -106
__pycache__/kotak_neo.cpython-311.pyc CHANGED
Binary files a/__pycache__/kotak_neo.cpython-311.pyc and b/__pycache__/kotak_neo.cpython-311.pyc differ
 
kotak_neo.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import os
4
  import threading
 
5
  from datetime import datetime, timezone
6
  from typing import Any
7
  from urllib.parse import quote
@@ -16,6 +17,7 @@ QUOTE_PATH_TEMPLATE = "script-details/1.0/quotes/neosymbol/{neo_symbols}/{quote_
16
  TOTP_LOGIN_PATH = "login/1.0/tradeApiLogin"
17
  TOTP_VALIDATE_PATH = "login/1.0/tradeApiValidate"
18
  DEFAULT_TIMEOUT_SECONDS = 20
 
19
 
20
 
21
  class KotakNeoError(Exception):
@@ -53,6 +55,21 @@ def _first_number(*values: Any) -> float | None:
53
 
54
  def _first_text(*values: Any) -> str | None:
55
  for value in values:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  if value not in (None, "", "--", "NA", "na", "-"):
57
  return str(value)
58
  return None
@@ -199,103 +216,133 @@ class KotakNeoManager:
199
  raise KotakNeoConfigError("Kotak Neo environment variables are incomplete.")
200
 
201
  with self._lock:
202
- self._ensure_authenticated_locked()
203
-
204
- holdings_raw = self._safe_account_call_locked(
205
- "holdings",
206
- lambda: self._request_trading_api_locked("portfolio/v1/holdings"),
207
- default={"data": []},
208
- )
209
- positions_raw = self._safe_account_call_locked(
210
- "positions",
211
- lambda: self._request_trading_api_locked("quick/user/positions"),
212
- default={"data": []},
213
- )
214
- trades_raw = self._safe_account_call_locked(
215
- "trades",
216
- lambda: self._request_trading_api_locked("quick/user/trades"),
217
- default={"data": []},
218
- )
219
- orders_raw = self._safe_account_call_locked(
220
- "orders",
221
- lambda: self._request_trading_api_locked("quick/user/orders"),
222
- default={"data": []},
223
- )
224
- limits_raw = self._safe_account_call_locked(
225
- "limits",
226
- lambda: self._post_trading_api_locked(
227
- "quick/user/limits",
228
- payload={"seg": "ALL", "exch": "ALL", "prod": "ALL"},
229
- content_type="application/x-www-form-urlencoded",
230
- ),
231
- default={},
232
- )
233
-
234
- holdings = _extract_items(holdings_raw)
235
- positions = _extract_items(positions_raw)
236
- trades = sorted(_extract_items(trades_raw), key=_sort_key, reverse=True)
237
- orders = sorted(_extract_items(orders_raw), key=_sort_key, reverse=True)
238
-
239
- quotes = self._safe_account_call_locked(
240
- "quotes",
241
- lambda: self._fetch_quotes_locked(self._instrument_tokens_for_quotes(holdings, positions)),
242
- default={"data": []},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  )
244
- quote_map = self._build_quote_map(quotes)
245
-
246
- normalized_holdings = [self._normalize_holding(item, quote_map) for item in holdings]
247
- normalized_positions = [self._normalize_position(item, quote_map) for item in positions]
248
-
249
- holdings_market_value = sum(item["market_value"] or 0.0 for item in normalized_holdings)
250
- holdings_cost = sum(item["cost_value"] or 0.0 for item in normalized_holdings)
251
- holdings_pnl = sum(item["pnl"] or 0.0 for item in normalized_holdings)
252
- positions_pnl = sum(item["pnl"] or 0.0 for item in normalized_positions)
253
-
254
- limits_summary = {
255
- "net": _first_number(limits_raw.get("Net")) if isinstance(limits_raw, dict) else None,
256
- "margin_used": _first_number(limits_raw.get("MarginUsed")) if isinstance(limits_raw, dict) else None,
257
- "collateral_value": _first_number(limits_raw.get("CollateralValue")) if isinstance(limits_raw, dict) else None,
258
- "cash_unrealized_mtm": _first_number(limits_raw.get("CashUnRlsMtomPrsnt")) if isinstance(limits_raw, dict) else None,
259
- "cash_realized_mtm": _first_number(limits_raw.get("CashRlsMtomPrsnt")) if isinstance(limits_raw, dict) else None,
260
- }
 
 
 
 
261
 
262
- available_cash = None
263
- if limits_summary["net"] is not None and limits_summary["margin_used"] is not None:
264
- available_cash = limits_summary["net"] - limits_summary["margin_used"]
265
 
266
- current_capital = None
267
- if available_cash is not None:
268
- current_capital = available_cash + holdings_market_value
269
 
270
- return {
271
- "status": self.status(),
272
- "as_of": _utc_now_iso(),
273
- "summary": {
274
- "available_cash": available_cash,
275
- "current_capital": current_capital,
276
- "holdings_market_value": holdings_market_value,
277
- "holdings_cost_value": holdings_cost,
278
- "holdings_pnl": holdings_pnl,
279
- "positions_pnl": positions_pnl,
280
- "live_pnl": holdings_pnl + positions_pnl,
281
- "open_positions": sum(1 for item in normalized_positions if item["net_quantity"]),
282
- "holdings_count": len(normalized_holdings),
283
- "orders_count": len(orders),
284
- "trades_count": len(trades),
285
- },
286
- "limits_summary": limits_summary,
287
- "limits_raw": limits_raw,
288
- "holdings": normalized_holdings,
289
- "positions": normalized_positions,
290
- "trade_history": trades[:50],
291
- "order_book": orders[:50],
292
- "quotes": list(quote_map.values()),
293
- }
294
 
295
  def _ensure_authenticated_locked(self) -> None:
296
  if not self.edit_token or not self.edit_sid or not self.base_url:
297
  raise KotakNeoSessionRequired("Kotak Neo session is not authenticated.")
298
 
 
 
 
 
 
 
 
 
 
 
299
  def _post_session_api(self, path: str, headers: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]:
300
  response = requests.post(
301
  f"{SESSION_BASE_URL.rstrip('/')}/{path.lstrip('/')}",
@@ -308,16 +355,28 @@ class KotakNeoManager:
308
  return data
309
 
310
  def _request_trading_api_locked(self, path: str) -> dict[str, Any]:
311
- self._ensure_authenticated_locked()
 
 
 
 
 
 
 
 
 
 
 
 
312
  response = requests.get(
313
- f"{self.base_url.rstrip('/')}/{path.lstrip('/')}",
314
  headers={
315
- "Sid": self.edit_sid or "",
316
- "Auth": self.edit_token or "",
317
  "Accept": "application/json",
318
  },
319
- params={"sId": self.server_id or ""},
320
- timeout=DEFAULT_TIMEOUT_SECONDS,
321
  )
322
  data = self._decode_response(response)
323
  self._raise_for_error(response, data, session_sensitive=True)
@@ -330,15 +389,31 @@ class KotakNeoManager:
330
  *,
331
  content_type: str = "application/json",
332
  ) -> dict[str, Any]:
333
- self._ensure_authenticated_locked()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  headers = {
335
- "Sid": self.edit_sid or "",
336
- "Auth": self.edit_token or "",
337
  "Accept": "application/json",
338
  "Content-Type": content_type,
339
  }
340
- query_params = {"sId": self.server_id or ""}
341
- url = f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
342
 
343
  if content_type == "application/x-www-form-urlencoded":
344
  body = {"jData": json.dumps(payload)}
@@ -347,7 +422,7 @@ class KotakNeoManager:
347
  headers=headers,
348
  params=query_params,
349
  data=body,
350
- timeout=DEFAULT_TIMEOUT_SECONDS,
351
  )
352
  else:
353
  response = requests.post(
@@ -355,7 +430,7 @@ class KotakNeoManager:
355
  headers=headers,
356
  params=query_params,
357
  json=payload,
358
- timeout=DEFAULT_TIMEOUT_SECONDS,
359
  )
360
 
361
  data = self._decode_response(response)
@@ -363,6 +438,19 @@ class KotakNeoManager:
363
  return data
364
 
365
  def _fetch_quotes_locked(self, instrument_tokens: list[dict[str, str]]) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  if not instrument_tokens:
367
  return {"data": []}
368
 
@@ -371,13 +459,13 @@ class KotakNeoManager:
371
  )
372
  encoded_symbols = quote(neo_symbols, safe="")
373
  response = requests.get(
374
- f"{self.base_url.rstrip('/')}/{QUOTE_PATH_TEMPLATE.format(neo_symbols=encoded_symbols, quote_type='all')}",
375
  headers={
376
- "Authorization": self.consumer_key or "",
377
  "Content-Type": "application/x-www-form-urlencoded",
378
  "Accept": "application/json",
379
  },
380
- timeout=DEFAULT_TIMEOUT_SECONDS,
381
  )
382
  data = self._decode_response(response)
383
  self._raise_for_error(response, data, session_sensitive=False)
@@ -394,7 +482,7 @@ class KotakNeoManager:
394
  return parsed
395
  return {"data": parsed}
396
 
397
- def _safe_account_call_locked(
398
  self,
399
  label: str,
400
  fn,
@@ -406,9 +494,40 @@ class KotakNeoManager:
406
  except KotakNeoSessionRequired:
407
  raise
408
  except Exception as exc:
409
- print(f"[kotak] {label} call failed: {exc}", flush=True)
 
410
  return default
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  def _raise_for_error(
413
  self,
414
  response: requests.Response,
 
2
 
3
  import os
4
  import threading
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
  from datetime import datetime, timezone
7
  from typing import Any
8
  from urllib.parse import quote
 
17
  TOTP_LOGIN_PATH = "login/1.0/tradeApiLogin"
18
  TOTP_VALIDATE_PATH = "login/1.0/tradeApiValidate"
19
  DEFAULT_TIMEOUT_SECONDS = 20
20
+ ACCOUNT_TIMEOUT_SECONDS = 7
21
 
22
 
23
  class KotakNeoError(Exception):
 
55
 
56
  def _first_text(*values: Any) -> str | None:
57
  for value in values:
58
+ if isinstance(value, dict):
59
+ nested = _first_text(
60
+ value.get("message"),
61
+ value.get("error"),
62
+ value.get("Error"),
63
+ value.get("emsg"),
64
+ value.get("detail"),
65
+ )
66
+ if nested:
67
+ return nested
68
+ if isinstance(value, list):
69
+ for item in value:
70
+ nested = _first_text(item)
71
+ if nested:
72
+ return nested
73
  if value not in (None, "", "--", "NA", "na", "-"):
74
  return str(value)
75
  return None
 
216
  raise KotakNeoConfigError("Kotak Neo environment variables are incomplete.")
217
 
218
  with self._lock:
219
+ context = self._context_locked()
220
+
221
+ account_calls = {
222
+ "holdings": lambda: self._request_trading_api_with_context(
223
+ context,
224
+ "portfolio/v1/holdings",
225
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
226
+ ),
227
+ "positions": lambda: self._request_trading_api_with_context(
228
+ context,
229
+ "quick/user/positions",
230
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
231
+ ),
232
+ "trades": lambda: self._request_trading_api_with_context(
233
+ context,
234
+ "quick/user/trades",
235
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
236
+ ),
237
+ "orders": lambda: self._request_trading_api_with_context(
238
+ context,
239
+ "quick/user/orders",
240
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
241
+ ),
242
+ "limits": lambda: self._post_trading_api_with_context(
243
+ context,
244
+ "quick/user/limits",
245
+ payload={"seg": "ALL", "exch": "ALL", "prod": "ALL"},
246
+ content_type="application/x-www-form-urlencoded",
247
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
248
+ ),
249
+ }
250
+ defaults = {
251
+ "holdings": {"data": []},
252
+ "positions": {"data": []},
253
+ "trades": {"data": []},
254
+ "orders": {"data": []},
255
+ "limits": {},
256
+ }
257
+ results: dict[str, dict[str, Any]] = dict(defaults)
258
+
259
+ with ThreadPoolExecutor(max_workers=5) as executor:
260
+ future_map = {executor.submit(fn): label for label, fn in account_calls.items()}
261
+ for future in as_completed(future_map):
262
+ label = future_map[future]
263
+ results[label] = self._resolve_account_future(label, future, default=defaults[label])
264
+
265
+ holdings = _extract_items(results["holdings"])
266
+ positions = _extract_items(results["positions"])
267
+ trades = sorted(_extract_items(results["trades"]), key=_sort_key, reverse=True)
268
+ orders = sorted(_extract_items(results["orders"]), key=_sort_key, reverse=True)
269
+
270
+ quotes = self._safe_account_call(
271
+ "quotes",
272
+ lambda: self._fetch_quotes_with_context(
273
+ context,
274
+ self._instrument_tokens_for_quotes(holdings, positions),
275
+ timeout=ACCOUNT_TIMEOUT_SECONDS,
276
  )
277
+ ,
278
+ default={"data": []},
279
+ )
280
+ quote_map = self._build_quote_map(quotes)
281
+
282
+ normalized_holdings = [self._normalize_holding(item, quote_map) for item in holdings]
283
+ normalized_positions = [self._normalize_position(item, quote_map) for item in positions]
284
+
285
+ holdings_market_value = sum(item["market_value"] or 0.0 for item in normalized_holdings)
286
+ holdings_cost = sum(item["cost_value"] or 0.0 for item in normalized_holdings)
287
+ holdings_pnl = sum(item["pnl"] or 0.0 for item in normalized_holdings)
288
+ positions_pnl = sum(item["pnl"] or 0.0 for item in normalized_positions)
289
+
290
+ limits_raw = results["limits"]
291
+ limits_summary = {
292
+ "net": _first_number(limits_raw.get("Net")) if isinstance(limits_raw, dict) else None,
293
+ "margin_used": _first_number(limits_raw.get("MarginUsed")) if isinstance(limits_raw, dict) else None,
294
+ "collateral_value": _first_number(limits_raw.get("CollateralValue")) if isinstance(limits_raw, dict) else None,
295
+ "cash_unrealized_mtm": _first_number(limits_raw.get("CashUnRlsMtomPrsnt")) if isinstance(limits_raw, dict) else None,
296
+ "cash_realized_mtm": _first_number(limits_raw.get("CashRlsMtomPrsnt")) if isinstance(limits_raw, dict) else None,
297
+ }
298
 
299
+ available_cash = None
300
+ if limits_summary["net"] is not None and limits_summary["margin_used"] is not None:
301
+ available_cash = limits_summary["net"] - limits_summary["margin_used"]
302
 
303
+ current_capital = None
304
+ if available_cash is not None:
305
+ current_capital = available_cash + holdings_market_value
306
 
307
+ return {
308
+ "status": self.status(),
309
+ "as_of": _utc_now_iso(),
310
+ "summary": {
311
+ "available_cash": available_cash,
312
+ "current_capital": current_capital,
313
+ "holdings_market_value": holdings_market_value,
314
+ "holdings_cost_value": holdings_cost,
315
+ "holdings_pnl": holdings_pnl,
316
+ "positions_pnl": positions_pnl,
317
+ "live_pnl": holdings_pnl + positions_pnl,
318
+ "open_positions": sum(1 for item in normalized_positions if item["net_quantity"]),
319
+ "holdings_count": len(normalized_holdings),
320
+ "orders_count": len(orders),
321
+ "trades_count": len(trades),
322
+ },
323
+ "limits_summary": limits_summary,
324
+ "limits_raw": limits_raw,
325
+ "holdings": normalized_holdings,
326
+ "positions": normalized_positions,
327
+ "trade_history": trades[:50],
328
+ "order_book": orders[:50],
329
+ "quotes": list(quote_map.values()),
330
+ }
331
 
332
  def _ensure_authenticated_locked(self) -> None:
333
  if not self.edit_token or not self.edit_sid or not self.base_url:
334
  raise KotakNeoSessionRequired("Kotak Neo session is not authenticated.")
335
 
336
+ def _context_locked(self) -> dict[str, str]:
337
+ self._ensure_authenticated_locked()
338
+ return {
339
+ "base_url": self.base_url or "",
340
+ "edit_sid": self.edit_sid or "",
341
+ "edit_token": self.edit_token or "",
342
+ "server_id": self.server_id or "",
343
+ "consumer_key": self.consumer_key or "",
344
+ }
345
+
346
  def _post_session_api(self, path: str, headers: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]:
347
  response = requests.post(
348
  f"{SESSION_BASE_URL.rstrip('/')}/{path.lstrip('/')}",
 
355
  return data
356
 
357
  def _request_trading_api_locked(self, path: str) -> dict[str, Any]:
358
+ return self._request_trading_api_with_context(
359
+ self._context_locked(),
360
+ path,
361
+ timeout=DEFAULT_TIMEOUT_SECONDS,
362
+ )
363
+
364
+ def _request_trading_api_with_context(
365
+ self,
366
+ context: dict[str, str],
367
+ path: str,
368
+ *,
369
+ timeout: int,
370
+ ) -> dict[str, Any]:
371
  response = requests.get(
372
+ f"{context['base_url'].rstrip('/')}/{path.lstrip('/')}",
373
  headers={
374
+ "Sid": context["edit_sid"],
375
+ "Auth": context["edit_token"],
376
  "Accept": "application/json",
377
  },
378
+ params={"sId": context["server_id"]},
379
+ timeout=timeout,
380
  )
381
  data = self._decode_response(response)
382
  self._raise_for_error(response, data, session_sensitive=True)
 
389
  *,
390
  content_type: str = "application/json",
391
  ) -> dict[str, Any]:
392
+ return self._post_trading_api_with_context(
393
+ self._context_locked(),
394
+ path,
395
+ payload,
396
+ content_type=content_type,
397
+ timeout=DEFAULT_TIMEOUT_SECONDS,
398
+ )
399
+
400
+ def _post_trading_api_with_context(
401
+ self,
402
+ context: dict[str, str],
403
+ path: str,
404
+ payload: dict[str, Any],
405
+ *,
406
+ content_type: str,
407
+ timeout: int,
408
+ ) -> dict[str, Any]:
409
  headers = {
410
+ "Sid": context["edit_sid"],
411
+ "Auth": context["edit_token"],
412
  "Accept": "application/json",
413
  "Content-Type": content_type,
414
  }
415
+ query_params = {"sId": context["server_id"]}
416
+ url = f"{context['base_url'].rstrip('/')}/{path.lstrip('/')}"
417
 
418
  if content_type == "application/x-www-form-urlencoded":
419
  body = {"jData": json.dumps(payload)}
 
422
  headers=headers,
423
  params=query_params,
424
  data=body,
425
+ timeout=timeout,
426
  )
427
  else:
428
  response = requests.post(
 
430
  headers=headers,
431
  params=query_params,
432
  json=payload,
433
+ timeout=timeout,
434
  )
435
 
436
  data = self._decode_response(response)
 
438
  return data
439
 
440
  def _fetch_quotes_locked(self, instrument_tokens: list[dict[str, str]]) -> dict[str, Any]:
441
+ return self._fetch_quotes_with_context(
442
+ self._context_locked(),
443
+ instrument_tokens,
444
+ timeout=DEFAULT_TIMEOUT_SECONDS,
445
+ )
446
+
447
+ def _fetch_quotes_with_context(
448
+ self,
449
+ context: dict[str, str],
450
+ instrument_tokens: list[dict[str, str]],
451
+ *,
452
+ timeout: int,
453
+ ) -> dict[str, Any]:
454
  if not instrument_tokens:
455
  return {"data": []}
456
 
 
459
  )
460
  encoded_symbols = quote(neo_symbols, safe="")
461
  response = requests.get(
462
+ f"{context['base_url'].rstrip('/')}/{QUOTE_PATH_TEMPLATE.format(neo_symbols=encoded_symbols, quote_type='all')}",
463
  headers={
464
+ "Authorization": context["consumer_key"],
465
  "Content-Type": "application/x-www-form-urlencoded",
466
  "Accept": "application/json",
467
  },
468
+ timeout=timeout,
469
  )
470
  data = self._decode_response(response)
471
  self._raise_for_error(response, data, session_sensitive=False)
 
482
  return parsed
483
  return {"data": parsed}
484
 
485
+ def _safe_account_call(
486
  self,
487
  label: str,
488
  fn,
 
494
  except KotakNeoSessionRequired:
495
  raise
496
  except Exception as exc:
497
+ if not self._is_expected_empty_error(label, exc):
498
+ print(f"[kotak] {label} call failed: {exc}", flush=True)
499
  return default
500
 
501
+ def _resolve_account_future(self, label: str, future, *, default: dict[str, Any]) -> dict[str, Any]:
502
+ try:
503
+ return future.result()
504
+ except KotakNeoSessionRequired:
505
+ raise
506
+ except Exception as exc:
507
+ if not self._is_expected_empty_error(label, exc):
508
+ print(f"[kotak] {label} call failed: {exc}", flush=True)
509
+ return default
510
+
511
+ def _is_expected_empty_error(self, label: str, exc: Exception) -> bool:
512
+ text = str(exc).lower()
513
+ empty_markers = [
514
+ "no holdings found",
515
+ "no position",
516
+ "no positions",
517
+ "no trade",
518
+ "no trades",
519
+ "no order",
520
+ "no orders",
521
+ "no data found",
522
+ "no trade found",
523
+ "no order found",
524
+ ]
525
+ if any(marker in text for marker in empty_markers):
526
+ return True
527
+ if label in {"holdings", "positions", "trades", "orders"} and "424" in text:
528
+ return True
529
+ return False
530
+
531
  def _raise_for_error(
532
  self,
533
  response: requests.Response,