kitecli 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kitecli-0.1.1 → kitecli-0.2.0}/PKG-INFO +4 -2
- kitecli-0.2.0/cli/api_client.py +567 -0
- kitecli-0.2.0/cli/base_manager.py +145 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/config.py +15 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/kite_manager.py +104 -25
- kitecli-0.2.0/cli/kotak_manager.py +1014 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/live_session.py +103 -49
- {kitecli-0.1.1 → kitecli-0.2.0}/kitecli.egg-info/PKG-INFO +4 -2
- {kitecli-0.1.1 → kitecli-0.2.0}/kitecli.egg-info/SOURCES.txt +3 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/kitecli.egg-info/requires.txt +3 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/pyproject.toml +5 -2
- kitecli-0.2.0/tests/test_multi_broker.py +470 -0
- kitecli-0.1.1/cli/api_client.py +0 -421
- {kitecli-0.1.1 → kitecli-0.2.0}/README.md +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/__init__.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/advisor.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/display.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/executor.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/main.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/nli.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/parser.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/recorder.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/cli/telegram_bot.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/kitecli.egg-info/dependency_links.txt +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/kitecli.egg-info/entry_points.txt +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/kitecli.egg-info/top_level.txt +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/setup.cfg +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/tests/test_nli.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/tests/test_parser.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/tests/test_telegram.py +0 -0
- {kitecli-0.1.1 → kitecli-0.2.0}/tests/test_ui.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kitecli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.0
|
|
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
|
+
|