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/__init__.py +11 -0
- cli/advisor.py +283 -0
- cli/api_client.py +421 -0
- cli/config.py +81 -0
- cli/display.py +395 -0
- cli/kite_manager.py +1042 -0
- cli/live_session.py +3841 -0
- cli/main.py +351 -0
- cli/nli.py +140 -0
- cli/recorder.py +321 -0
- cli/telegram_bot.py +1074 -0
- kitecli-0.1.0.dist-info/METADATA +259 -0
- kitecli-0.1.0.dist-info/RECORD +16 -0
- kitecli-0.1.0.dist-info/WHEEL +5 -0
- kitecli-0.1.0.dist-info/entry_points.txt +2 -0
- kitecli-0.1.0.dist-info/top_level.txt +1 -0
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}"}
|