kitecli 0.1.0__py3-none-any.whl

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.
cli/kite_manager.py ADDED
@@ -0,0 +1,1042 @@
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Any
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ import pyotp
8
+ import requests as http_requests
9
+ from kiteconnect import KiteConnect
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ SESSIONS_FILE = Path.home() / ".kcli" / "sessions.json"
14
+
15
+
16
+ def _load_sessions() -> dict[str, str]:
17
+ if not SESSIONS_FILE.exists():
18
+ return {}
19
+ try:
20
+ with open(SESSIONS_FILE, "r") as f:
21
+ return json.load(f)
22
+ except Exception as exc:
23
+ logger.error("Failed to load sessions: %s", exc)
24
+ return {}
25
+
26
+
27
+ def _save_session(api_key: str, access_token: str) -> None:
28
+ sessions = _load_sessions()
29
+ sessions[api_key] = access_token
30
+ try:
31
+ SESSIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
32
+ with open(SESSIONS_FILE, "w") as f:
33
+ json.dump(sessions, f, indent=2)
34
+ except Exception as exc:
35
+ logger.error("Failed to save session for %s: %s", api_key[:8], exc)
36
+
37
+
38
+ class KiteAccountManager:
39
+ """Manages multiple KiteConnect instances keyed by api_key.
40
+
41
+ Stores KiteConnect client objects, api_secrets, account names,
42
+ and tracks authentication state for each registered account.
43
+ """
44
+
45
+ def __init__(self) -> None:
46
+ self._clients: dict[str, KiteConnect] = {}
47
+ self._api_secrets: dict[str, str] = {}
48
+ self._account_names: dict[str, str] = {}
49
+ self._authenticated: dict[str, bool] = {}
50
+ self._proxies: dict[str, dict] = {} # per-account proxy dicts for requests.Session
51
+ self._nfo_lot_size_cache: dict[str, int] = {} # fetched once, reused across calls
52
+
53
+ def init_account(self, api_key: str, api_secret: str, name: str = "", proxy: str = None) -> str:
54
+ """Initialize a KiteConnect instance and return the login URL.
55
+
56
+ Args:
57
+ api_key: The Kite Connect API key.
58
+ api_secret: The Kite Connect API secret.
59
+ name: A human-readable name for the account.
60
+ proxy: Optional HTTP/HTTPS proxy string for routing requests.
61
+
62
+ Returns:
63
+ The Kite login URL for user authorization.
64
+ """
65
+ proxies = {"http": proxy, "https": proxy} if proxy else None
66
+ kite = KiteConnect(api_key=api_key, proxies=proxies)
67
+ self._clients[api_key] = kite
68
+ self._api_secrets[api_key] = api_secret
69
+ self._account_names[api_key] = name or api_key
70
+ self._authenticated[api_key] = False
71
+ # Store proxy dict so auto_login can also route via proxy
72
+ self._proxies[api_key] = proxies or {}
73
+
74
+ # Try to restore session from saved token
75
+ sessions = _load_sessions()
76
+ saved_token = sessions.get(api_key)
77
+ if saved_token:
78
+ logger.info("Found saved session token for '%s' (api_key=%s…). Verifying...", name, api_key[:8])
79
+ try:
80
+ kite.set_access_token(saved_token)
81
+ # Verify token by calling profile
82
+ kite.profile()
83
+ self._authenticated[api_key] = True
84
+ logger.info("Successfully restored valid session for '%s' (api_key=%s…)", name, api_key[:8])
85
+ except Exception as exc:
86
+ logger.info(
87
+ "Saved session token for '%s' (api_key=%s…) is invalid or expired: %s",
88
+ name,
89
+ api_key[:8],
90
+ exc,
91
+ )
92
+ self._authenticated[api_key] = False
93
+
94
+ login_url = f"https://kite.zerodha.com/connect/login?v=3&api_key={api_key}"
95
+ logger.info("Initialized account '%s' (api_key=%s…)", name, api_key[:8])
96
+ return login_url
97
+
98
+ def complete_login(self, api_key: str, request_token: str) -> bool:
99
+ """Complete the OAuth login flow by generating a session.
100
+
101
+ Args:
102
+ api_key: The Kite Connect API key.
103
+ request_token: The request token received from the OAuth callback.
104
+
105
+ Returns:
106
+ True if login succeeded, False otherwise.
107
+ """
108
+ kite = self._clients.get(api_key)
109
+ api_secret = self._api_secrets.get(api_key)
110
+
111
+ if not kite or not api_secret:
112
+ logger.error("Account not found for api_key=%s…", api_key[:8])
113
+ return False
114
+
115
+ try:
116
+ data = kite.generate_session(request_token, api_secret=api_secret)
117
+ access_token = data["access_token"]
118
+ kite.set_access_token(access_token)
119
+ self._authenticated[api_key] = True
120
+ _save_session(api_key, access_token)
121
+ logger.info(
122
+ "Login successful for account '%s' (api_key=%s…)",
123
+ self._account_names.get(api_key, api_key),
124
+ api_key[:8],
125
+ )
126
+ return True
127
+ except Exception as exc:
128
+ logger.error(
129
+ "Login failed for account '%s' (api_key=%s…): %s",
130
+ self._account_names.get(api_key, api_key),
131
+ api_key[:8],
132
+ exc,
133
+ exc_info=True,
134
+ )
135
+ self._authenticated[api_key] = False
136
+ return False
137
+
138
+ def get_positions(self, api_key: str) -> list[dict[str, Any]]:
139
+ """Fetch open positions for an account.
140
+
141
+ Only positions with a non-zero quantity are returned.
142
+
143
+ Args:
144
+ api_key: The Kite Connect API key.
145
+
146
+ Returns:
147
+ A list of position dicts with keys matching the Position model.
148
+
149
+ Raises:
150
+ ValueError: If the account is not found.
151
+ RuntimeError: If the Kite API call fails.
152
+ """
153
+ kite = self._clients.get(api_key)
154
+ if not kite:
155
+ raise ValueError(f"Account not found for api_key={api_key[:8]}…")
156
+
157
+ try:
158
+ positions_data = kite.positions()
159
+ # Combine day and net positions; net is the primary view
160
+ all_positions = positions_data.get("net", [])
161
+
162
+ ret_positions = []
163
+ for pos in all_positions:
164
+ qty = pos.get("quantity", 0)
165
+ avg_price = pos.get("average_price", 0.0)
166
+ pnl = pos.get("pnl", 0.0)
167
+ pnl_pct = (pnl / (avg_price * abs(qty))) * 100 if avg_price > 0 and qty != 0 else 0.0
168
+
169
+ ret_positions.append(
170
+ {
171
+ "tradingsymbol": pos.get("tradingsymbol", ""),
172
+ "quantity": qty,
173
+ "average_price": avg_price,
174
+ "last_price": pos.get("last_price", 0.0),
175
+ "pnl": pnl,
176
+ "realised": pos.get("realised", 0.0),
177
+ "unrealised": pos.get("unrealised", 0.0),
178
+ "pnl_pct": pnl_pct,
179
+ "product": pos.get("product", ""),
180
+ "exchange": pos.get("exchange", ""),
181
+ "instrument_token": pos.get("instrument_token"),
182
+ }
183
+ )
184
+
185
+ logger.info(
186
+ "Fetched %d net positions for api_key=%s…",
187
+ len(ret_positions),
188
+ api_key[:8],
189
+ )
190
+ return ret_positions
191
+ except Exception as exc:
192
+ logger.error(
193
+ "Failed to fetch positions for api_key=%s…", api_key[:8]
194
+ )
195
+ raise RuntimeError(f"Failed to fetch positions: {exc}") from exc
196
+
197
+ def is_authenticated(self, api_key: str) -> bool:
198
+ """Check if the given account has completed authentication.
199
+
200
+ Args:
201
+ api_key: The Kite Connect API key.
202
+
203
+ Returns:
204
+ True if the account is authenticated, False otherwise.
205
+ """
206
+ return self._authenticated.get(api_key, False)
207
+
208
+ def get_account_info(self, api_key: str) -> dict[str, Any]:
209
+ """Get summary info for a registered account.
210
+
211
+ Args:
212
+ api_key: The Kite Connect API key.
213
+
214
+ Returns:
215
+ A dict with name, api_key, and authenticated status.
216
+ """
217
+ return {
218
+ "name": self._account_names.get(api_key, api_key),
219
+ "api_key": api_key,
220
+ "authenticated": self.is_authenticated(api_key),
221
+ }
222
+
223
+ def get_access_token(self, api_key: str) -> str | None:
224
+ """Get the access token for an authenticated account.
225
+
226
+ Args:
227
+ api_key: The Kite Connect API key.
228
+
229
+ Returns:
230
+ The access token string, or None if not authenticated/found.
231
+ """
232
+ kite = self._clients.get(api_key)
233
+ if kite:
234
+ return getattr(kite, "access_token", None)
235
+ return None
236
+
237
+ def check_token(self, api_key: str) -> dict[str, Any]:
238
+ """Validate the stored access token for an account against the REST API.
239
+
240
+ Calls ``kite.profile()`` and classifies the outcome so callers can tell
241
+ the difference between a valid token, an expired/invalid token, and other
242
+ failures (network/proxy). This is the same credential pair used for the
243
+ WebSocket ticker, so it explains 403 handshake failures.
244
+
245
+ Returns a dict with keys:
246
+ name: account display name
247
+ api_key: the api_key
248
+ status: one of "valid", "no_token", "expired", "forbidden", "error"
249
+ detail: human-readable message
250
+ """
251
+ name = self._account_names.get(api_key, api_key)
252
+ kite = self._clients.get(api_key)
253
+ if kite is None:
254
+ return {"name": name, "api_key": api_key, "status": "error",
255
+ "detail": "account not registered"}
256
+
257
+ token = getattr(kite, "access_token", None)
258
+ if not token:
259
+ return {"name": name, "api_key": api_key, "status": "no_token",
260
+ "detail": "no access token (login required)"}
261
+
262
+ try:
263
+ from kiteconnect.exceptions import TokenException, PermissionException
264
+ except Exception:
265
+ TokenException = PermissionException = ()
266
+
267
+ try:
268
+ kite.profile()
269
+ return {"name": name, "api_key": api_key, "status": "valid",
270
+ "detail": "token valid"}
271
+ except TokenException as exc:
272
+ return {"name": name, "api_key": api_key, "status": "expired",
273
+ "detail": f"token expired/invalid: {exc}"}
274
+ except PermissionException as exc:
275
+ return {"name": name, "api_key": api_key, "status": "forbidden",
276
+ "detail": f"permission denied: {exc}"}
277
+ except Exception as exc:
278
+ msg = str(exc)
279
+ low = msg.lower()
280
+ if "403" in msg or "forbidden" in low:
281
+ status = "forbidden"
282
+ elif "token" in low or "session" in low:
283
+ status = "expired"
284
+ else:
285
+ status = "error"
286
+ return {"name": name, "api_key": api_key, "status": status,
287
+ "detail": msg}
288
+
289
+ def get_all_api_keys(self) -> list[str]:
290
+ """Return a list of all registered api_keys."""
291
+ return list(self._clients.keys())
292
+
293
+ def auto_login(
294
+ self,
295
+ api_key: str,
296
+ user_id: str,
297
+ password: str,
298
+ totp_secret: str,
299
+ ) -> bool:
300
+ """Automate the full Kite login flow using credentials + TOTP.
301
+
302
+ Steps:
303
+ 1. POST user_id + password to Kite login endpoint.
304
+ 2. Generate a TOTP code using the shared secret via pyotp.
305
+ 3. POST the TOTP for two-factor authentication.
306
+ 4. Visit the Kite Connect login URL to capture the request_token.
307
+ 5. Exchange the request_token for an access_token.
308
+
309
+ Args:
310
+ api_key: The Kite Connect API key (must already be init'd).
311
+ user_id: Zerodha client/user ID (e.g. ``AB1234``).
312
+ password: Zerodha login password.
313
+ totp_secret: Base32-encoded TOTP secret from Zerodha 2FA setup.
314
+
315
+ Returns:
316
+ True if auto-login succeeded, False otherwise.
317
+ """
318
+ kite = self._clients.get(api_key)
319
+ api_secret = self._api_secrets.get(api_key)
320
+ if not kite or not api_secret:
321
+ logger.error("Account not initialized for api_key=%s…", api_key[:8])
322
+ return False
323
+
324
+ # Build a requests.Session and apply the per-account proxy so that
325
+ # login and 2FA calls also flow through the proxy (not just KiteConnect SDK calls).
326
+ session = http_requests.Session()
327
+ account_proxies = self._proxies.get(api_key, {})
328
+ if account_proxies:
329
+ session.proxies.update(account_proxies)
330
+ logger.info("auto_login: using proxy for %s (api_key=%s…)", user_id, api_key[:8])
331
+
332
+ try:
333
+ # Step 1: Login with user_id + password
334
+ login_resp = session.post(
335
+ "https://kite.zerodha.com/api/login",
336
+ data={"user_id": user_id, "password": password},
337
+ )
338
+ login_data = login_resp.json()
339
+
340
+ if login_data.get("status") != "success":
341
+ logger.error(
342
+ "Login failed for %s: %s",
343
+ user_id,
344
+ login_data.get("message", "Unknown error"),
345
+ )
346
+ return False
347
+
348
+ request_id = login_data["data"]["request_id"]
349
+ logger.info("Login step 1 passed for %s", user_id)
350
+
351
+ # Step 2: Generate TOTP
352
+ totp = pyotp.TOTP(totp_secret)
353
+ otp_value = totp.now()
354
+
355
+ # Step 3: Submit 2FA
356
+ twofa_resp = session.post(
357
+ "https://kite.zerodha.com/api/twofa",
358
+ data={
359
+ "request_id": request_id,
360
+ "twofa_value": otp_value,
361
+ "user_id": user_id,
362
+ "twofa_type": "totp",
363
+ },
364
+ )
365
+ twofa_data = twofa_resp.json()
366
+
367
+ if twofa_data.get("status") != "success":
368
+ logger.error(
369
+ "2FA failed for %s: %s",
370
+ user_id,
371
+ twofa_data.get("message", "Unknown error"),
372
+ )
373
+ return False
374
+
375
+ logger.info("2FA passed for %s", user_id)
376
+
377
+ # Step 4: Visit Kite Connect login URL to capture request_token.
378
+ # We follow redirects manually hop-by-hop because the final redirect target
379
+ # (the app's redirect URL, e.g. localhost or custom domain) might not be
380
+ # reachable from the server, causing requests to raise a ConnectionError
381
+ # if we allow automatic redirects.
382
+ current_url = f"https://kite.zerodha.com/connect/login?v=3&api_key={api_key}"
383
+ request_token = None
384
+
385
+ for hop in range(1, 11):
386
+ logger.info("Auto-login hop %d: %s", hop, current_url)
387
+ try:
388
+ resp = session.get(current_url, allow_redirects=False)
389
+ except http_requests.exceptions.RequestException as exc:
390
+ logger.warning("Network error/unreachable during redirect hop %d: %s", hop, exc)
391
+ # Check if the url itself contained the token before network error
392
+ parsed = urlparse(current_url)
393
+ query_params = parse_qs(parsed.query)
394
+ token = query_params.get("request_token", [None])[0]
395
+ if token:
396
+ request_token = token
397
+ break
398
+
399
+ # Check if current_url already contains the request_token
400
+ parsed = urlparse(current_url)
401
+ query_params = parse_qs(parsed.query)
402
+ token = query_params.get("request_token", [None])[0]
403
+ if token:
404
+ request_token = token
405
+ break
406
+
407
+ location = resp.headers.get("Location")
408
+ if not location:
409
+ break
410
+
411
+ # Resolve relative redirect URLs
412
+ if location.startswith("/"):
413
+ parsed_prev = urlparse(current_url)
414
+ location = f"{parsed_prev.scheme}://{parsed_prev.netloc}{location}"
415
+
416
+ current_url = location
417
+
418
+ # Check if target redirect URL contains the request_token
419
+ parsed = urlparse(current_url)
420
+ query_params = parse_qs(parsed.query)
421
+ token = query_params.get("request_token", [None])[0]
422
+ if token:
423
+ request_token = token
424
+ break
425
+
426
+ # If target URL is not a Zerodha domain (e.g. localhost or client app domain),
427
+ # do not actually request it (it may not be reachable or could trigger external side-effects),
428
+ # and we've already checked if it has the request_token.
429
+ parsed_loc = urlparse(current_url)
430
+ if parsed_loc.netloc and not parsed_loc.netloc.endswith(".zerodha.com") and "zerodha.com" not in parsed_loc.netloc:
431
+ logger.info("Reached non-Zerodha redirect target: %s", current_url)
432
+ break
433
+
434
+ if not request_token:
435
+ logger.error("Could not extract request_token from redirect chain for %s", user_id)
436
+ return False
437
+
438
+ logger.info("Captured request_token for %s", user_id)
439
+
440
+ # Step 5: Exchange request_token for access_token
441
+ return self.complete_login(api_key, request_token)
442
+
443
+ except Exception as exc:
444
+ logger.error(
445
+ "Auto-login failed for %s (api_key=%s…): %s",
446
+ user_id,
447
+ api_key[:8],
448
+ exc,
449
+ exc_info=True,
450
+ )
451
+ return False
452
+
453
+ # Maximum quantity per single order leg. Zerodha rejects NFO orders
454
+ # above the exchange-defined freeze quantity (e.g. 1800 for NIFTY).
455
+ # We use a slightly conservative default so the split fires before the
456
+ # exchange-level rejection.
457
+ FREEZE_QTY_LIMIT: int = 1755
458
+
459
+ def place_order(
460
+ self,
461
+ api_key: str,
462
+ tradingsymbol: str,
463
+ exchange: str,
464
+ transaction_type: str,
465
+ quantity: int,
466
+ order_type: str,
467
+ price: float | None = None,
468
+ trigger_price: float | None = None,
469
+ product: str = "NRML",
470
+ ) -> list[str]:
471
+ """Place an order for a specific account.
472
+
473
+ If *quantity* exceeds ``FREEZE_QTY_LIMIT`` the order is automatically
474
+ split into multiple legs of at most ``FREEZE_QTY_LIMIT`` each.
475
+
476
+ Args:
477
+ api_key: The Kite Connect API key.
478
+ tradingsymbol: The symbol to trade (e.g. INFY, NIFTY2662325000CE).
479
+ exchange: The exchange (e.g. NSE, NFO).
480
+ transaction_type: BUY or SELL.
481
+ quantity: Total order quantity (will be split if necessary).
482
+ order_type: MARKET, LIMIT, SL, SL-M.
483
+ price: Order price (required for LIMIT/SL orders).
484
+ trigger_price: Trigger price (required for SL/SL-M orders).
485
+ product: MIS, NRML, CNC.
486
+
487
+ Returns:
488
+ A list of order IDs (one per leg).
489
+ """
490
+ kite = self._clients.get(api_key)
491
+ if not kite:
492
+ raise ValueError(f"Account not found for api_key={api_key[:8]}…")
493
+
494
+ if not self.is_authenticated(api_key):
495
+ raise RuntimeError(
496
+ f"Account '{self._account_names.get(api_key, api_key)}' is not authenticated."
497
+ )
498
+
499
+ # Normalize parameters (uppercase strings)
500
+ transaction_type = transaction_type.upper()
501
+ order_type = order_type.upper()
502
+ product = product.upper()
503
+ exchange = exchange.upper()
504
+ tradingsymbol = tradingsymbol.upper()
505
+
506
+ # Split quantity into legs of at most FREEZE_QTY_LIMIT
507
+ legs: list[int] = []
508
+ remaining = quantity
509
+ while remaining > 0:
510
+ leg_qty = min(remaining, self.FREEZE_QTY_LIMIT)
511
+ legs.append(leg_qty)
512
+ remaining -= leg_qty
513
+
514
+ total_legs = len(legs)
515
+ if total_legs > 1:
516
+ logger.info(
517
+ "Splitting %s %s order for %d %s into %d legs: %s (freeze limit=%d)",
518
+ order_type, transaction_type, quantity, tradingsymbol,
519
+ total_legs, legs, self.FREEZE_QTY_LIMIT,
520
+ )
521
+
522
+ order_ids: list[str] = []
523
+ for i, leg_qty in enumerate(legs):
524
+ # Build order parameters
525
+ params = {
526
+ "variety": kite.VARIETY_REGULAR,
527
+ "exchange": exchange,
528
+ "tradingsymbol": tradingsymbol,
529
+ "transaction_type": transaction_type,
530
+ "quantity": leg_qty,
531
+ "product": product,
532
+ "order_type": order_type,
533
+ "validity": kite.VALIDITY_DAY,
534
+ }
535
+
536
+ if price is not None and price > 0:
537
+ params["price"] = price
538
+ if trigger_price is not None and trigger_price > 0:
539
+ params["trigger_price"] = trigger_price
540
+
541
+ leg_desc = f" (leg {i + 1}/{total_legs})" if total_legs > 1 else ""
542
+ logger.info(
543
+ "Placing %s %s order for %d %s on %s (%s) for account '%s'%s",
544
+ order_type,
545
+ transaction_type,
546
+ leg_qty,
547
+ tradingsymbol,
548
+ exchange,
549
+ product,
550
+ self._account_names.get(api_key, api_key),
551
+ leg_desc,
552
+ )
553
+
554
+ try:
555
+ order_id = kite.place_order(**params)
556
+ order_ids.append(str(order_id))
557
+ except Exception as exc:
558
+ logger.error(
559
+ "Failed to place order%s for api_key=%s…: %s",
560
+ leg_desc, api_key[:8], exc,
561
+ )
562
+ raise RuntimeError(
563
+ f"Failed to place order{leg_desc}: {exc}"
564
+ + (f" ({len(order_ids)}/{total_legs} legs placed successfully)" if total_legs > 1 else "")
565
+ ) from exc
566
+
567
+ return order_ids
568
+
569
+ def exit_positions(
570
+ self,
571
+ api_key: str,
572
+ tradingsymbol: str | None = None,
573
+ price: float | None = None,
574
+ ) -> list[dict[str, Any]]:
575
+ """Exit/square off open positions for a specific account.
576
+
577
+ Args:
578
+ api_key: The Kite Connect API key.
579
+ tradingsymbol: If specified, only square off positions matching this symbol.
580
+ If None, square off all open positions.
581
+ price: If specified, place limit orders at this price. If None, place market orders.
582
+
583
+ Returns:
584
+ A list of details of placed exit orders:
585
+ [{'tradingsymbol': str, 'quantity': int, 'product': str, 'order_id': str}]
586
+ """
587
+ kite = self._clients.get(api_key)
588
+ if not kite:
589
+ raise ValueError(f"Account not found for api_key={api_key[:8]}…")
590
+
591
+ if not self.is_authenticated(api_key):
592
+ raise RuntimeError(f"Account '{self._account_names.get(api_key, api_key)}' is not authenticated.")
593
+
594
+ # Normalize target symbol
595
+ if tradingsymbol:
596
+ tradingsymbol = tradingsymbol.upper()
597
+
598
+ # 1. Fetch current positions
599
+ try:
600
+ positions_data = kite.positions()
601
+ net_positions = positions_data.get("net", [])
602
+ except Exception as exc:
603
+ logger.error("Failed to fetch positions during exit for api_key=%s…", api_key[:8])
604
+ raise RuntimeError(f"Failed to fetch positions: {exc}") from exc
605
+
606
+ orders_placed = []
607
+
608
+ # 2. Iterate and square off open positions
609
+ for pos in net_positions:
610
+ qty = pos.get("quantity", 0)
611
+ if qty == 0:
612
+ continue
613
+
614
+ symbol = pos.get("tradingsymbol", "")
615
+ if tradingsymbol and symbol != tradingsymbol:
616
+ # If a specific symbol was requested, skip others
617
+ continue
618
+
619
+ # Square-off logic:
620
+ # - If long (qty > 0): SELL
621
+ # - If short (qty < 0): BUY
622
+ transaction_type = kite.TRANSACTION_TYPE_SELL if qty > 0 else kite.TRANSACTION_TYPE_BUY
623
+ exit_qty = abs(qty)
624
+ product = pos.get("product", "")
625
+ exchange = pos.get("exchange", "")
626
+
627
+ logger.info(
628
+ "Square off position for account '%s': %s %d %s (%s)",
629
+ self._account_names.get(api_key, api_key),
630
+ transaction_type,
631
+ exit_qty,
632
+ symbol,
633
+ product,
634
+ )
635
+
636
+ try:
637
+ order_ids = self.place_order(
638
+ api_key=api_key,
639
+ tradingsymbol=symbol,
640
+ exchange=exchange,
641
+ transaction_type=transaction_type,
642
+ quantity=exit_qty,
643
+ order_type=kite.ORDER_TYPE_LIMIT if price is not None else kite.ORDER_TYPE_MARKET,
644
+ price=price,
645
+ product=product,
646
+ )
647
+ for order_id in order_ids:
648
+ orders_placed.append({
649
+ "tradingsymbol": symbol,
650
+ "quantity": exit_qty,
651
+ "product": product,
652
+ "order_id": order_id
653
+ })
654
+ except Exception as exc:
655
+ logger.error(
656
+ "Failed to place exit order for %s in account %s: %s",
657
+ symbol,
658
+ self._account_names.get(api_key, api_key),
659
+ exc,
660
+ )
661
+ # Raise an error only if the targeted symbol exit failed completely
662
+ if tradingsymbol:
663
+ raise RuntimeError(f"Failed to place exit order for {symbol}: {exc}") from exc
664
+
665
+
666
+ return orders_placed
667
+
668
+ def modify_order(
669
+ self,
670
+ api_key: str,
671
+ order_id: str,
672
+ quantity: int | None = None,
673
+ price: float | None = None,
674
+ order_type: str | None = None,
675
+ trigger_price: float | None = None,
676
+ ) -> str:
677
+ """Modify an open order.
678
+
679
+ Returns:
680
+ The order ID.
681
+ """
682
+ kite = self._clients.get(api_key)
683
+ if not kite:
684
+ raise ValueError(f"Account not found for api_key={api_key[:8]}…")
685
+
686
+ if not self.is_authenticated(api_key):
687
+ raise RuntimeError(f"Account '{self._account_names.get(api_key, api_key)}' is not authenticated.")
688
+
689
+ params = {
690
+ "variety": kite.VARIETY_REGULAR,
691
+ "order_id": order_id,
692
+ }
693
+ if quantity is not None:
694
+ params["quantity"] = quantity
695
+ if price is not None:
696
+ params["price"] = price
697
+ if order_type is not None:
698
+ params["order_type"] = order_type.upper()
699
+ if trigger_price is not None:
700
+ params["trigger_price"] = trigger_price
701
+
702
+ try:
703
+ return kite.modify_order(**params)
704
+ except Exception as exc:
705
+ logger.error("Failed to modify order %s for api_key=%s…", order_id, api_key[:8])
706
+ raise RuntimeError(f"Failed to modify order: {exc}") from exc
707
+
708
+ def cancel_order(
709
+ self,
710
+ api_key: str,
711
+ order_id: str,
712
+ ) -> str:
713
+ """Cancel an open order.
714
+
715
+ Returns:
716
+ The order ID.
717
+ """
718
+ kite = self._clients.get(api_key)
719
+ if not kite:
720
+ raise ValueError(f"Account not found for api_key={api_key[:8]}…")
721
+
722
+ if not self.is_authenticated(api_key):
723
+ raise RuntimeError(f"Account '{self._account_names.get(api_key, api_key)}' is not authenticated.")
724
+
725
+ try:
726
+ return kite.cancel_order(
727
+ variety=kite.VARIETY_REGULAR,
728
+ order_id=order_id,
729
+ )
730
+ except Exception as exc:
731
+ logger.error("Failed to cancel order %s for api_key=%s…", order_id, api_key[:8])
732
+ raise RuntimeError(f"Failed to cancel order: {exc}") from exc
733
+
734
+ def get_option_chain(
735
+ self,
736
+ api_key: str,
737
+ underlying: str,
738
+ expiry_week: int = 0,
739
+ expiry_date: str | None = None,
740
+ ) -> dict:
741
+ """Fetch option chain for a specific underlying and expiry week.
742
+
743
+ Uses kite.instruments("NFO") to get the full instrument list, then
744
+ filters locally by the underlying name and the requested expiry.
745
+
746
+ Args:
747
+ api_key: Any one authenticated account's api_key.
748
+ underlying: Underlying index/stock name (e.g. NIFTY, BANKNIFTY).
749
+ expiry_week: 0 = current/nearest weekly expiry, 1 = next week, etc.
750
+ expiry_date: ISO date string (YYYY-MM-DD) to target a specific expiry.
751
+ If provided, expiry_week is ignored.
752
+
753
+ Returns:
754
+ Dict with keys: underlying, expiry, strikes, available_expiries, status, message.
755
+ """
756
+ import datetime
757
+
758
+ kite = self._clients.get(api_key)
759
+ if not kite:
760
+ raise ValueError(f"Account not found for api_key={api_key[:8]}…")
761
+
762
+ if not self.is_authenticated(api_key):
763
+ raise RuntimeError(
764
+ f"Account '{self._account_names.get(api_key, api_key)}' is not authenticated."
765
+ )
766
+
767
+ underlying = underlying.upper().strip()
768
+ logger.info("Fetching NFO instruments for option chain (%s)…", underlying)
769
+
770
+ # Fetch full NFO instrument list
771
+ instruments = kite.instruments("NFO")
772
+
773
+ # Filter to options (CE/PE) for the requested underlying
774
+ options = [
775
+ inst for inst in instruments
776
+ if inst.get("name", "").upper() == underlying
777
+ and inst.get("instrument_type") in ("CE", "PE")
778
+ ]
779
+
780
+ if not options:
781
+ return {
782
+ "underlying": underlying,
783
+ "expiry": "",
784
+ "strikes": [],
785
+ "available_expiries": [],
786
+ "status": "error",
787
+ "message": f"No options found for underlying '{underlying}' in NFO. "
788
+ f"Check the name (e.g. NIFTY, BANKNIFTY, FINNIFTY, MIDCPNIFTY).",
789
+ }
790
+
791
+ # Collect all unique expiry dates (sorted ascending)
792
+ today = datetime.date.today()
793
+ all_expiries = sorted(
794
+ set(
795
+ inst["expiry"] for inst in options
796
+ if isinstance(inst.get("expiry"), datetime.date) and inst["expiry"] >= today
797
+ )
798
+ )
799
+
800
+ if not all_expiries:
801
+ return {
802
+ "underlying": underlying,
803
+ "expiry": "",
804
+ "strikes": [],
805
+ "available_expiries": [],
806
+ "status": "error",
807
+ "message": "No active expiry dates found.",
808
+ }
809
+
810
+ # Build available_expiries labels
811
+ available_expiries = []
812
+ for i, exp in enumerate(all_expiries):
813
+ if i == 0:
814
+ label = "Current Week"
815
+ elif i == 1:
816
+ label = "Next Week"
817
+ else:
818
+ label = f"Week +{i}"
819
+ available_expiries.append({"expiry": exp.isoformat(), "week_label": label})
820
+
821
+ # Resolve target expiry
822
+ if expiry_date:
823
+ try:
824
+ target_expiry = datetime.date.fromisoformat(expiry_date)
825
+ except ValueError:
826
+ return {
827
+ "underlying": underlying,
828
+ "expiry": "",
829
+ "strikes": [],
830
+ "available_expiries": available_expiries,
831
+ "status": "error",
832
+ "message": f"Invalid expiry_date format '{expiry_date}'. Use YYYY-MM-DD.",
833
+ }
834
+ if target_expiry not in all_expiries:
835
+ return {
836
+ "underlying": underlying,
837
+ "expiry": expiry_date,
838
+ "strikes": [],
839
+ "available_expiries": available_expiries,
840
+ "status": "error",
841
+ "message": f"Expiry date '{expiry_date}' not found for {underlying}.",
842
+ }
843
+ else:
844
+ idx = min(expiry_week, len(all_expiries) - 1)
845
+ target_expiry = all_expiries[idx]
846
+
847
+ # Filter to selected expiry and group by strike
848
+ strike_map: dict[float, dict] = {}
849
+ for inst in options:
850
+ if inst.get("expiry") != target_expiry:
851
+ continue
852
+ strike = float(inst.get("strike", 0))
853
+ inst_type = inst.get("instrument_type") # CE or PE
854
+ tradingsymbol = inst.get("tradingsymbol", "")
855
+ lot_size = int(inst.get("lot_size", 0))
856
+
857
+ if strike not in strike_map:
858
+ strike_map[strike] = {"strike": strike}
859
+
860
+ if inst_type == "CE":
861
+ strike_map[strike]["ce_symbol"] = tradingsymbol
862
+ strike_map[strike]["ce_lot_size"] = lot_size
863
+ elif inst_type == "PE":
864
+ strike_map[strike]["pe_symbol"] = tradingsymbol
865
+ strike_map[strike]["pe_lot_size"] = lot_size
866
+
867
+ strikes = sorted(strike_map.values(), key=lambda x: x["strike"])
868
+
869
+ # Batch-fetch LTP for all option symbols in one call
870
+ all_symbols = []
871
+ for s in strikes:
872
+ if s.get("ce_symbol"):
873
+ all_symbols.append(f"NFO:{s['ce_symbol']}")
874
+ if s.get("pe_symbol"):
875
+ all_symbols.append(f"NFO:{s['pe_symbol']}")
876
+
877
+ ltp_data: dict = {}
878
+ if all_symbols:
879
+ try:
880
+ # kite.ltp() accepts up to 500 symbols at once
881
+ for i in range(0, len(all_symbols), 500):
882
+ chunk = all_symbols[i : i + 500]
883
+ ltp_data.update(kite.ltp(chunk))
884
+ except Exception as exc:
885
+ logger.warning("Failed to fetch LTP for option chain: %s", exc)
886
+
887
+ # Enrich each strike entry with live LTP and instrument tokens. The
888
+ # tokens let the live TUI subscribe to these options on the WebSocket so
889
+ # the option-chain LTPs can stream rather than being a one-shot snapshot.
890
+ for s in strikes:
891
+ ce_key = f"NFO:{s.get('ce_symbol', '')}"
892
+ pe_key = f"NFO:{s.get('pe_symbol', '')}"
893
+ if ce_key in ltp_data:
894
+ s["ce_ltp"] = ltp_data[ce_key].get("last_price")
895
+ s["ce_token"] = ltp_data[ce_key].get("instrument_token")
896
+ if pe_key in ltp_data:
897
+ s["pe_ltp"] = ltp_data[pe_key].get("last_price")
898
+ s["pe_token"] = ltp_data[pe_key].get("instrument_token")
899
+
900
+ logger.info(
901
+ "Option chain: %s, expiry=%s, strikes=%d",
902
+ underlying,
903
+ target_expiry.isoformat(),
904
+ len(strikes),
905
+ )
906
+
907
+ return {
908
+ "underlying": underlying,
909
+ "expiry": target_expiry.isoformat(),
910
+ "strikes": strikes,
911
+ "available_expiries": available_expiries,
912
+ "status": "success",
913
+ "message": "",
914
+ }
915
+
916
+ def get_orders(self, api_key: str) -> list[dict]:
917
+ """Fetch today's order book for a specific authenticated account.
918
+
919
+ Args:
920
+ api_key: The API key of the account to query.
921
+
922
+ Returns:
923
+ List of order dicts as returned by KiteConnect.orders().
924
+ """
925
+ kite = self._clients.get(api_key)
926
+ if not kite:
927
+ raise ValueError(f"Account not found for api_key={api_key[:8]}…")
928
+ if not self.is_authenticated(api_key):
929
+ raise RuntimeError(
930
+ f"Account '{self._account_names.get(api_key, api_key)}' is not authenticated."
931
+ )
932
+ return kite.orders()
933
+
934
+ def get_margins(self, api_key: str) -> dict[str, Any]:
935
+ """Fetch equity margin summary for an account.
936
+
937
+ Returns a dict with:
938
+ - ``net`` — total available buying power after SPAN/exposure blocked
939
+ for open F&O positions (``equity.net``).
940
+ - ``cash`` — current available cash balance (``equity.available.live_balance``).
941
+ This differs from ``available.cash`` / ``available.opening_balance``
942
+ which reflect the ledger balance at day start, not the live figure.
943
+
944
+ Both values are ``None`` if the account is not authenticated or the
945
+ call fails (callers should treat ``None`` as "unavailable").
946
+ """
947
+ kite = self._clients.get(api_key)
948
+ if not kite or not self.is_authenticated(api_key):
949
+ return {"net": None, "cash": None}
950
+ try:
951
+ data = kite.margins(segment="equity")
952
+ net = data.get("net")
953
+ # live_balance is the real-time available cash, not the stale opening_balance
954
+ cash = data.get("available", {}).get("live_balance")
955
+ collateral = data.get("available", {}).get("collateral")
956
+ return {"net": net, "cash": cash, "collateral": collateral}
957
+ except Exception as exc:
958
+ logger.warning("get_margins failed for api_key=%s…: %s", api_key[:8], exc)
959
+ return {"net": None, "cash": None, "collateral": None}
960
+
961
+ def get_nfo_lot_sizes(self) -> dict[str, int]:
962
+ """Return the cached NFO tradingsymbol → lot_size map.
963
+
964
+ Fetches from Zerodha on the first call, then returns the in-memory
965
+ cache on all subsequent calls — so live_session can call this freely
966
+ at every REST refresh without triggering repeated API requests.
967
+
968
+ Returns an empty dict if no authenticated account is available or the
969
+ initial fetch fails.
970
+ """
971
+ if self._nfo_lot_size_cache:
972
+ return self._nfo_lot_size_cache
973
+ for api_key in self.get_all_api_keys():
974
+ if self.is_authenticated(api_key):
975
+ kite = self._clients.get(api_key)
976
+ if kite:
977
+ try:
978
+ instruments = kite.instruments("NFO")
979
+ self._nfo_lot_size_cache = {
980
+ inst["tradingsymbol"]: int(inst.get("lot_size", 1) or 1)
981
+ for inst in instruments
982
+ if inst.get("tradingsymbol")
983
+ }
984
+ return self._nfo_lot_size_cache
985
+ except Exception as exc:
986
+ logger.warning("get_nfo_lot_sizes failed: %s", exc)
987
+ return {}
988
+ return {}
989
+
990
+ def get_market_indices(self) -> dict[str, Any]:
991
+ # 1. Try Kite first
992
+ for api_key in self.get_all_api_keys():
993
+ if self.is_authenticated(api_key):
994
+ kite = self._clients.get(api_key)
995
+ if kite:
996
+ try:
997
+ data = kite.ltp(["NSE:NIFTY 50", "BSE:SENSEX", "NSE:INDIA VIX"])
998
+ nifty = data.get("NSE:NIFTY 50", {}).get("last_price")
999
+ sensex = data.get("BSE:SENSEX", {}).get("last_price")
1000
+ vix = data.get("NSE:INDIA VIX", {}).get("last_price")
1001
+ if nifty or sensex or vix:
1002
+ return {
1003
+ "status": "success",
1004
+ "nifty": nifty,
1005
+ "sensex": sensex,
1006
+ "vix": vix,
1007
+ }
1008
+ except Exception as exc:
1009
+ logger.warning("Kite indices fetch failed for api_key=%s…: %s", api_key[:8], exc)
1010
+
1011
+ # 2. Fallback to Yahoo Finance chart API (in parallel)
1012
+ try:
1013
+ logger.info("Fetching indices from Yahoo Finance fallback...")
1014
+ import requests as http_requests
1015
+ headers = {"User-Agent": "Mozilla/5.0"}
1016
+ from concurrent.futures import ThreadPoolExecutor
1017
+
1018
+ symbols = {
1019
+ "nifty": "^NSEI",
1020
+ "sensex": "^BSESN",
1021
+ "vix": "^INDIAVIX",
1022
+ }
1023
+
1024
+ def fetch_symbol_price(item):
1025
+ name, code = item
1026
+ url = f"https://query1.finance.yahoo.com/v8/finance/chart/{code}?interval=1m&range=1d"
1027
+ resp = http_requests.get(url, headers=headers, timeout=5)
1028
+ price = resp.json()["chart"]["result"][0]["meta"]["regularMarketPrice"]
1029
+ return name, float(price)
1030
+
1031
+ with ThreadPoolExecutor(max_workers=3) as executor:
1032
+ results = dict(executor.map(fetch_symbol_price, symbols.items()))
1033
+
1034
+ return {
1035
+ "status": "success",
1036
+ "nifty": results["nifty"],
1037
+ "sensex": results["sensex"],
1038
+ "vix": results["vix"],
1039
+ }
1040
+ except Exception as exc:
1041
+ logger.error("Yahoo Finance fallback failed: %s", exc)
1042
+ return {"status": "error", "message": f"Kite call had insufficient permission, and Yahoo fallback failed: {exc}"}