kitecli 0.1.1__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 (32) hide show
  1. {kitecli-0.1.1 → kitecli-0.2.1}/PKG-INFO +4 -2
  2. kitecli-0.2.1/cli/api_client.py +567 -0
  3. kitecli-0.2.1/cli/base_manager.py +145 -0
  4. {kitecli-0.1.1 → kitecli-0.2.1}/cli/config.py +15 -0
  5. {kitecli-0.1.1 → kitecli-0.2.1}/cli/display.py +0 -6
  6. {kitecli-0.1.1 → kitecli-0.2.1}/cli/executor.py +1 -1
  7. {kitecli-0.1.1 → kitecli-0.2.1}/cli/kite_manager.py +104 -25
  8. kitecli-0.2.1/cli/kotak_manager.py +1190 -0
  9. {kitecli-0.1.1 → kitecli-0.2.1}/cli/live_session.py +268 -80
  10. {kitecli-0.1.1 → kitecli-0.2.1}/cli/telegram_bot.py +63 -25
  11. {kitecli-0.1.1 → kitecli-0.2.1}/kitecli.egg-info/PKG-INFO +4 -2
  12. {kitecli-0.1.1 → kitecli-0.2.1}/kitecli.egg-info/SOURCES.txt +3 -0
  13. {kitecli-0.1.1 → kitecli-0.2.1}/kitecli.egg-info/requires.txt +3 -0
  14. {kitecli-0.1.1 → kitecli-0.2.1}/pyproject.toml +5 -2
  15. kitecli-0.2.1/tests/test_multi_broker.py +537 -0
  16. {kitecli-0.1.1 → kitecli-0.2.1}/tests/test_telegram.py +62 -0
  17. kitecli-0.2.1/tests/test_ui.py +98 -0
  18. kitecli-0.1.1/cli/api_client.py +0 -421
  19. kitecli-0.1.1/tests/test_ui.py +0 -74
  20. {kitecli-0.1.1 → kitecli-0.2.1}/README.md +0 -0
  21. {kitecli-0.1.1 → kitecli-0.2.1}/cli/__init__.py +0 -0
  22. {kitecli-0.1.1 → kitecli-0.2.1}/cli/advisor.py +0 -0
  23. {kitecli-0.1.1 → kitecli-0.2.1}/cli/main.py +0 -0
  24. {kitecli-0.1.1 → kitecli-0.2.1}/cli/nli.py +0 -0
  25. {kitecli-0.1.1 → kitecli-0.2.1}/cli/parser.py +0 -0
  26. {kitecli-0.1.1 → kitecli-0.2.1}/cli/recorder.py +0 -0
  27. {kitecli-0.1.1 → kitecli-0.2.1}/kitecli.egg-info/dependency_links.txt +0 -0
  28. {kitecli-0.1.1 → kitecli-0.2.1}/kitecli.egg-info/entry_points.txt +0 -0
  29. {kitecli-0.1.1 → kitecli-0.2.1}/kitecli.egg-info/top_level.txt +0 -0
  30. {kitecli-0.1.1 → kitecli-0.2.1}/setup.cfg +0 -0
  31. {kitecli-0.1.1 → kitecli-0.2.1}/tests/test_nli.py +0 -0
  32. {kitecli-0.1.1 → kitecli-0.2.1}/tests/test_parser.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.1.1
4
- Summary: Kite Connect CLI — Multi-account Zerodha trading positions viewer
3
+ Version: 0.2.1
4
+ Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
7
7
  Requires-Python: >=3.10
@@ -20,6 +20,8 @@ Requires-Dist: uvicorn[standard]>=0.27.0; extra == "server"
20
20
  Requires-Dist: pydantic>=2.0.0; extra == "server"
21
21
  Provides-Extra: bot
22
22
  Requires-Dist: python-telegram-bot>=20.0; extra == "bot"
23
+ Provides-Extra: kotak
24
+ Requires-Dist: neo-api-client>=1.0.0; extra == "kotak"
23
25
  Provides-Extra: dev
24
26
  Requires-Dist: pytest>=8.0.0; extra == "dev"
25
27
  Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
@@ -0,0 +1,567 @@
1
+ """
2
+ Local KiteCLI client.
3
+
4
+ Wraps broker-specific account managers (KiteAccountManager for Zerodha,
5
+ KotakAccountManager for Kotak) via a unified registry. Public API is
6
+ identical to the previous single-broker version so that cli/main.py and
7
+ cli/live_session.py require zero changes.
8
+ """
9
+
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from cli.kite_manager import KiteAccountManager
12
+
13
+
14
+ class KCLIClientError(Exception):
15
+ """Raised when a broker API call fails."""
16
+
17
+
18
+ # Broker-specific singleton managers (shared across KCLIClient instances)
19
+ _kite_manager = KiteAccountManager()
20
+ # Kotak manager is imported lazily so that projects without neo-api-client
21
+ # installed still work fine as pure-Zerodha setups.
22
+ _kotak_manager = None # type: ignore[assignment]
23
+
24
+
25
+ def _get_kotak_manager():
26
+ """Return the KotakAccountManager singleton, importing it on first use."""
27
+ global _kotak_manager
28
+ if _kotak_manager is None:
29
+ try:
30
+ from cli.kotak_manager import KotakAccountManager
31
+ _kotak_manager = KotakAccountManager()
32
+ except ImportError as exc:
33
+ raise ImportError(
34
+ "Kotak Neo support requires the 'neo-api-client' package. "
35
+ "Install it with: pip install neo-api-client"
36
+ ) from exc
37
+ return _kotak_manager
38
+
39
+
40
+ # Module-level map: account_key → manager instance (populated in KCLIClient.__init__)
41
+ _account_manager_map: dict = {}
42
+
43
+
44
+ def _manager_for(account_key: str):
45
+ """Return the broker manager responsible for the given account key."""
46
+ return _account_manager_map.get(account_key, _kite_manager)
47
+
48
+
49
+ # Legacy compatibility alias used by live_session.py for WebSocket init
50
+ _manager = _kite_manager
51
+
52
+
53
+ class KCLIClient:
54
+ """Local client that delegates to per-broker account managers.
55
+
56
+ Args:
57
+ accounts: List of account dicts from config. Each account may
58
+ include a ``"broker"`` key (``"zerodha"`` or ``"kotak"``;
59
+ defaults to ``"zerodha"`` when omitted). Accounts are
60
+ initialised eagerly in parallel so that session tokens are
61
+ restored before the first command runs.
62
+ """
63
+
64
+ def __init__(self, accounts: list[dict]) -> None:
65
+ self._accounts = accounts
66
+ self.accounts = accounts
67
+ # Determine the account_key field per account (api_key for Zerodha,
68
+ # consumer_key for Kotak).
69
+ self._api_keys = [] # Zerodha api_keys (used by WebSocket layer)
70
+
71
+ def init_one(acct):
72
+ broker = acct.get("broker", "zerodha").lower()
73
+ if broker == "kotak":
74
+ mgr = _get_kotak_manager()
75
+ account_key = acct.get("consumer_key", acct.get("api_key", ""))
76
+ mgr.init_account_kotak(
77
+ consumer_key=account_key,
78
+ consumer_secret=acct.get("consumer_secret", ""),
79
+ mobile_number=acct.get("mobile_number", ""),
80
+ password=acct.get("password", ""),
81
+ mpin=acct.get("mpin", ""),
82
+ ucc=acct.get("ucc", ""),
83
+ name=acct.get("name", ""),
84
+ totp_secret=acct.get("totp_secret", ""),
85
+ proxy=acct.get("proxy"),
86
+ )
87
+ _account_manager_map[account_key] = mgr
88
+ # Ensure api_key field is populated for downstream code
89
+ acct.setdefault("api_key", account_key)
90
+ else:
91
+ # Default: Zerodha
92
+ api_key = acct.get("api_key", "")
93
+ _kite_manager.init_account_kite(
94
+ api_key=api_key,
95
+ api_secret=acct.get("api_secret", ""),
96
+ name=acct.get("name", ""),
97
+ proxy=acct.get("proxy"),
98
+ )
99
+ _account_manager_map[api_key] = _kite_manager
100
+ self._api_keys.append(api_key)
101
+
102
+ with ThreadPoolExecutor(max_workers=max(1, len(accounts))) as executor:
103
+ list(executor.map(init_one, accounts))
104
+
105
+
106
+ # ── compatibility helpers ──────────────────────────────────────
107
+
108
+ def health_check(self) -> bool:
109
+ """Always True — no server to ping in local mode."""
110
+ return True
111
+
112
+ # ── public API (mirrors old KCLIClient exactly, but runs in parallel) ──
113
+
114
+ def init_accounts(self, accounts: list[dict]) -> dict:
115
+ """Initialise (or re-initialise) accounts and attempt auto-login in parallel."""
116
+ def init_one(acct):
117
+ broker = acct.get("broker", "zerodha").lower()
118
+ if broker == "kotak":
119
+ mgr = _get_kotak_manager()
120
+ account_key = acct.get("consumer_key", acct.get("api_key", ""))
121
+ name = acct.get("name", account_key)
122
+ mgr.init_account_kotak(
123
+ consumer_key=account_key,
124
+ consumer_secret=acct.get("consumer_secret", ""),
125
+ mobile_number=acct.get("mobile_number", ""),
126
+ password=acct.get("password", ""),
127
+ mpin=acct.get("mpin", ""),
128
+ ucc=acct.get("ucc", ""),
129
+ name=name,
130
+ totp_secret=acct.get("totp_secret", ""),
131
+ proxy=acct.get("proxy"),
132
+ )
133
+ _account_manager_map[account_key] = mgr
134
+ acct.setdefault("api_key", account_key)
135
+ # Attempt auto-login for Kotak
136
+ if not mgr.is_authenticated(account_key):
137
+ success = mgr.auto_login(account_key)
138
+ return {
139
+ "name": name, "api_key": account_key,
140
+ "login_url": "",
141
+ "auto_logged_in": success,
142
+ "message": "Auto-login successful" if success else "Auto-login failed",
143
+ }
144
+ return {
145
+ "name": name, "api_key": account_key,
146
+ "login_url": "", "auto_logged_in": True,
147
+ "message": "Session restored",
148
+ }
149
+ else:
150
+ # Zerodha
151
+ api_key = acct.get("api_key", "")
152
+ name = acct.get("name", api_key)
153
+ login_url = _kite_manager.init_account_kite(
154
+ api_key=api_key,
155
+ api_secret=acct.get("api_secret", ""),
156
+ name=name,
157
+ proxy=acct.get("proxy"),
158
+ )
159
+ _account_manager_map[api_key] = _kite_manager
160
+
161
+ if _kite_manager.is_authenticated(api_key):
162
+ return {
163
+ "name": name, "api_key": api_key,
164
+ "login_url": login_url, "auto_logged_in": True,
165
+ "message": "Session restored from saved token",
166
+ }
167
+
168
+ user_id = acct.get("user_id")
169
+ password = acct.get("password")
170
+ totp_secret = acct.get("totp_secret")
171
+ if user_id and password and totp_secret:
172
+ success = _kite_manager.auto_login_kite(
173
+ api_key=api_key, user_id=user_id,
174
+ password=password, totp_secret=totp_secret,
175
+ )
176
+ return {
177
+ "name": name, "api_key": api_key,
178
+ "login_url": login_url, "auto_logged_in": success,
179
+ "message": "Auto-login successful" if success else "Auto-login failed. Use manual login URL.",
180
+ }
181
+ return {
182
+ "name": name, "api_key": api_key,
183
+ "login_url": login_url, "auto_logged_in": False,
184
+ "message": "Credentials incomplete — manual login required.",
185
+ }
186
+
187
+ with ThreadPoolExecutor(max_workers=max(1, len(accounts))) as executor:
188
+ results = list(executor.map(init_one, accounts))
189
+
190
+ return {"accounts": results}
191
+
192
+
193
+ def is_authenticated(self, api_key: str) -> bool:
194
+ """Check if the given account is authenticated."""
195
+ return _manager_for(api_key).is_authenticated(api_key)
196
+
197
+ def login(self, api_key: str, request_token: str) -> dict:
198
+ """Complete Kite OAuth login with a request token (Zerodha only)."""
199
+ try:
200
+ success = _kite_manager.complete_login(api_key, request_token.strip())
201
+ if success:
202
+ return {"status": "success", "message": "Login successful"}
203
+ return {"status": "error", "message": "Login failed — check request_token"}
204
+ except Exception as exc:
205
+ raise KCLIClientError(str(exc)) from exc
206
+
207
+ def complete_callback(self, api_key: str, request_token: str) -> dict:
208
+ """Complete Kite OAuth login with a request token (Zerodha only)."""
209
+ try:
210
+ success = _kite_manager.complete_login(api_key, request_token.strip())
211
+ if success:
212
+ return {"status": "success", "message": "Login successful"}
213
+ return {"status": "error", "message": "Login failed — check request_token"}
214
+ except Exception as exc:
215
+ raise KCLIClientError(str(exc)) from exc
216
+
217
+ def get_positions(self, api_keys: list[str]) -> dict:
218
+ """Fetch open positions for the given accounts in parallel."""
219
+ keys = api_keys or [a.get("api_key") for a in self._accounts if a.get("api_key")]
220
+
221
+ def fetch_one(api_key):
222
+ mgr = _manager_for(api_key)
223
+ info = mgr.get_account_info(api_key)
224
+ if not info.get("authenticated"):
225
+ return {
226
+ "name": info.get("name", api_key),
227
+ "api_key": api_key,
228
+ "positions": [],
229
+ "total_pnl": 0.0,
230
+ "status": "unauthenticated",
231
+ }
232
+ try:
233
+ positions = mgr.get_positions(api_key)
234
+ return {
235
+ "name": info.get("name", api_key),
236
+ "api_key": api_key,
237
+ "positions": positions,
238
+ "total_pnl": 0.0,
239
+ "status": "success",
240
+ }
241
+ except Exception as exc:
242
+ return {
243
+ "name": info.get("name", api_key),
244
+ "api_key": api_key,
245
+ "positions": [],
246
+ "total_pnl": 0.0,
247
+ "status": f"error: {exc}",
248
+ }
249
+
250
+ with ThreadPoolExecutor(max_workers=max(1, len(keys))) as executor:
251
+ results = list(executor.map(fetch_one, keys))
252
+
253
+ # Check if there are any positions needing instrument_token/LTP resolution
254
+ symbols_to_resolve = set()
255
+ for res in results:
256
+ if res.get("status") == "success":
257
+ for pos in res.get("positions", []):
258
+ if not pos.get("instrument_token") and pos.get("tradingsymbol"):
259
+ symbols_to_resolve.add(pos["tradingsymbol"])
260
+
261
+ if symbols_to_resolve:
262
+ # Find the first available authenticated Zerodha account
263
+ zerodha_api_key = None
264
+ for key in self._api_keys:
265
+ if self.is_authenticated(key):
266
+ zerodha_api_key = key
267
+ break
268
+
269
+ if zerodha_api_key:
270
+ try:
271
+ ltp_map = self.get_ltp_and_tokens(zerodha_api_key, list(symbols_to_resolve))
272
+ if ltp_map:
273
+ for res in results:
274
+ if res.get("status") == "success":
275
+ for pos in res.get("positions", []):
276
+ if not pos.get("instrument_token") and pos.get("tradingsymbol"):
277
+ sym = pos["tradingsymbol"]
278
+ if sym in ltp_map:
279
+ pos["instrument_token"] = ltp_map[sym].get("instrument_token")
280
+ ltp = ltp_map[sym].get("last_price")
281
+ if ltp is not None:
282
+ pos["last_price"] = ltp
283
+ qty = pos.get("quantity", 0)
284
+ avg = pos.get("average_price", 0.0)
285
+ pnl = (ltp - avg) * qty if qty != 0 else pos.get("realised", 0.0)
286
+ pos["pnl"] = pnl
287
+ pos["unrealised"] = (ltp - avg) * qty if qty != 0 else 0.0
288
+ if avg > 0 and qty != 0:
289
+ pos["pnl_pct"] = (pnl / (avg * abs(qty))) * 100
290
+ else:
291
+ pos["pnl_pct"] = 0.0
292
+ except Exception:
293
+ pass
294
+
295
+ # Calculate final total_pnl for each account
296
+ for res in results:
297
+ if res.get("status") == "success":
298
+ res["total_pnl"] = sum(p.get("pnl", 0.0) for p in res.get("positions", []))
299
+
300
+ return {"accounts": results}
301
+
302
+ def get_status(self) -> dict:
303
+ """Get authentication status for all accounts."""
304
+ accounts = []
305
+ for acct in self._accounts:
306
+ key = acct.get("api_key")
307
+ if key:
308
+ accounts.append(_manager_for(key).get_account_info(key))
309
+ return {"accounts": accounts}
310
+
311
+ def place_order(
312
+ self,
313
+ api_keys: list[str],
314
+ tradingsymbol: str,
315
+ exchange: str,
316
+ transaction_type: str,
317
+ quantity: int,
318
+ order_type: str,
319
+ price: float | None = None,
320
+ trigger_price: float | None = None,
321
+ product: str = "NRML",
322
+ ) -> dict:
323
+ """Place an order across specified accounts in parallel."""
324
+ keys = api_keys or [a.get("api_key") for a in self._accounts if a.get("api_key")]
325
+
326
+ def place_one(api_key):
327
+ mgr = _manager_for(api_key)
328
+ info = mgr.get_account_info(api_key)
329
+ if not info.get("authenticated"):
330
+ return {
331
+ "name": info.get("name", api_key),
332
+ "api_key": api_key,
333
+ "status": "error",
334
+ "order_id": None,
335
+ "message": "Account not authenticated",
336
+ }
337
+ try:
338
+ order_ids = mgr.place_order(
339
+ api_key=api_key,
340
+ tradingsymbol=tradingsymbol,
341
+ exchange=exchange,
342
+ transaction_type=transaction_type,
343
+ quantity=quantity,
344
+ order_type=order_type,
345
+ price=price,
346
+ trigger_price=trigger_price,
347
+ product=product,
348
+ )
349
+ ids_str = ", ".join(order_ids)
350
+ return {
351
+ "name": info.get("name", api_key),
352
+ "api_key": api_key,
353
+ "status": "success",
354
+ "order_id": ids_str,
355
+ "order_ids": order_ids,
356
+ "legs": len(order_ids),
357
+ "message": f"Order placed: {ids_str}" if len(order_ids) == 1 else f"Order split into {len(order_ids)} legs: {ids_str}",
358
+ }
359
+ except Exception as exc:
360
+ return {
361
+ "name": info.get("name", api_key),
362
+ "api_key": api_key,
363
+ "status": "error",
364
+ "order_id": None,
365
+ "message": str(exc),
366
+ }
367
+
368
+ with ThreadPoolExecutor(max_workers=max(1, len(keys))) as executor:
369
+ results = list(executor.map(place_one, keys))
370
+
371
+ return {"results": results}
372
+
373
+ def exit_positions(
374
+ self,
375
+ api_keys: list[str],
376
+ tradingsymbol: str | None = None,
377
+ price: float | None = None,
378
+ ) -> dict:
379
+ """Exit positions across specified accounts in parallel."""
380
+ keys = api_keys or [a.get("api_key") for a in self._accounts if a.get("api_key")]
381
+
382
+ def exit_one(api_key):
383
+ mgr = _manager_for(api_key)
384
+ info = mgr.get_account_info(api_key)
385
+ if not info.get("authenticated"):
386
+ return {
387
+ "name": info.get("name", api_key),
388
+ "api_key": api_key,
389
+ "status": "error",
390
+ "message": "Account not authenticated",
391
+ "orders_placed": [],
392
+ }
393
+ try:
394
+ orders = mgr.exit_positions(api_key, tradingsymbol, price)
395
+ return {
396
+ "name": info.get("name", api_key),
397
+ "api_key": api_key,
398
+ "status": "success",
399
+ "message": f"Exited {len(orders)} position(s)",
400
+ "orders_placed": orders,
401
+ }
402
+ except Exception as exc:
403
+ return {
404
+ "name": info.get("name", api_key),
405
+ "api_key": api_key,
406
+ "status": "error",
407
+ "message": str(exc),
408
+ "orders_placed": [],
409
+ }
410
+
411
+ with ThreadPoolExecutor(max_workers=max(1, len(keys))) as executor:
412
+ results = list(executor.map(exit_one, keys))
413
+
414
+ return {"results": results}
415
+
416
+ def get_option_chain(
417
+ self,
418
+ api_key: str,
419
+ underlying: str,
420
+ expiry_week: int = 0,
421
+ expiry_date: str | None = None,
422
+ ) -> dict:
423
+ """Fetch option chain for a specific underlying and expiry."""
424
+ try:
425
+ return _manager.get_option_chain(
426
+ api_key=api_key,
427
+ underlying=underlying,
428
+ expiry_week=expiry_week,
429
+ expiry_date=expiry_date,
430
+ )
431
+ except Exception as exc:
432
+ raise KCLIClientError(str(exc)) from exc
433
+
434
+ def get_orders(self, api_keys: list[str]) -> dict:
435
+ """Fetch today's order book for specified accounts in parallel."""
436
+ keys = api_keys or [a.get("api_key") for a in self._accounts if a.get("api_key")]
437
+
438
+ def fetch_one(api_key):
439
+ mgr = _manager_for(api_key)
440
+ info = mgr.get_account_info(api_key)
441
+ if not info.get("authenticated"):
442
+ return {
443
+ "name": info.get("name", api_key),
444
+ "api_key": api_key,
445
+ "orders": [],
446
+ "status": "unauthenticated",
447
+ }
448
+ try:
449
+ orders = mgr.get_orders(api_key)
450
+ return {
451
+ "name": info.get("name", api_key),
452
+ "api_key": api_key,
453
+ "orders": orders,
454
+ "status": "success",
455
+ }
456
+ except Exception as exc:
457
+ return {
458
+ "name": info.get("name", api_key),
459
+ "api_key": api_key,
460
+ "orders": [],
461
+ "status": f"error: {exc}",
462
+ }
463
+
464
+ with ThreadPoolExecutor(max_workers=max(1, len(keys))) as executor:
465
+ results = list(executor.map(fetch_one, keys))
466
+
467
+ return {"accounts": results}
468
+
469
+ def modify_order(
470
+ self,
471
+ api_key: str,
472
+ order_id: str,
473
+ quantity: int | None = None,
474
+ price: float | None = None,
475
+ order_type: str | None = None,
476
+ trigger_price: float | None = None,
477
+ ) -> dict:
478
+ """Modify an order on a specific account."""
479
+ try:
480
+ mgr = _manager_for(api_key)
481
+ res_id = mgr.modify_order(
482
+ api_key=api_key,
483
+ order_id=order_id,
484
+ quantity=quantity,
485
+ price=price,
486
+ order_type=order_type,
487
+ trigger_price=trigger_price,
488
+ )
489
+ return {"status": "success", "order_id": res_id, "message": f"Order modified: {res_id}"}
490
+ except Exception as exc:
491
+ return {"status": "error", "order_id": None, "message": str(exc)}
492
+
493
+ def cancel_order(
494
+ self,
495
+ api_key: str,
496
+ order_id: str,
497
+ ) -> dict:
498
+ """Cancel an order on a specific account."""
499
+ try:
500
+ mgr = _manager_for(api_key)
501
+ res_id = mgr.cancel_order(api_key=api_key, order_id=order_id)
502
+ return {"status": "success", "order_id": res_id, "message": f"Order cancelled: {res_id}"}
503
+ except Exception as exc:
504
+ return {"status": "error", "order_id": None, "message": str(exc)}
505
+
506
+ def get_nfo_lot_sizes(self) -> dict:
507
+ """Fetch NFO tradingsymbol → lot_size map (one-shot, cache at startup)."""
508
+ return _kite_manager.get_nfo_lot_sizes()
509
+
510
+ def get_market_indices(self) -> dict:
511
+ """Fetch live Nifty, Sensex, and India VIX."""
512
+ return _kite_manager.get_market_indices()
513
+
514
+ def get_margins(self, api_keys: list[str]) -> dict:
515
+ """Fetch equity margin summary for each account in parallel."""
516
+ keys = api_keys or [a.get("api_key") for a in self._accounts if a.get("api_key")]
517
+
518
+ def fetch_one(api_key):
519
+ mgr = _manager_for(api_key)
520
+ result = mgr.get_margins(api_key)
521
+ return {
522
+ "api_key": api_key,
523
+ "net": result["net"],
524
+ "cash": result["cash"],
525
+ "collateral": result.get("collateral")
526
+ }
527
+
528
+ with ThreadPoolExecutor(max_workers=max(1, len(keys))) as executor:
529
+ results = list(executor.map(fetch_one, keys))
530
+
531
+ return {"accounts": results}
532
+
533
+ def get_access_token(self, api_key: str) -> str | None:
534
+ """Get the access token for an authenticated account."""
535
+ return _manager_for(api_key).get_access_token(api_key)
536
+
537
+ def create_ticker(self, api_key: str):
538
+ """Create a WebSocket ticker for the broker associated with api_key."""
539
+ mgr = _manager_for(api_key)
540
+ if hasattr(mgr, "create_ticker"):
541
+ return mgr.create_ticker(api_key)
542
+ return None
543
+
544
+ def check_token(self, api_key: str) -> dict:
545
+ """Validate an account's access token. Returns status/detail dict."""
546
+ # check_token is Zerodha-specific (kite.profile()). For non-Zerodha accounts
547
+ # return a simplified status based on is_authenticated.
548
+ mgr = _manager_for(api_key)
549
+ if mgr.broker_name == "zerodha":
550
+ return _kite_manager.check_token(api_key)
551
+ # Generic fallback for other brokers
552
+ authenticated = mgr.is_authenticated(api_key)
553
+ info = mgr.get_account_info(api_key)
554
+ return {
555
+ "name": info.get("name", api_key),
556
+ "api_key": api_key,
557
+ "status": "valid" if authenticated else "no_token",
558
+ "detail": "authenticated" if authenticated else "not authenticated",
559
+ }
560
+
561
+ def get_ltp_and_tokens(self, api_key: str, symbols: list[str]) -> dict:
562
+ """Fetch LTP and instrument tokens for the given symbols using the designated manager."""
563
+ mgr = _manager_for(api_key)
564
+ if hasattr(mgr, "get_ltp_and_tokens"):
565
+ return mgr.get_ltp_and_tokens(api_key, symbols)
566
+ return {}
567
+