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/telegram_bot.py ADDED
@@ -0,0 +1,1074 @@
1
+ import logging
2
+ import asyncio
3
+ import re
4
+ from typing import Optional
5
+ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, KeyboardButton, BotCommand
6
+ from telegram.ext import (
7
+ ApplicationBuilder,
8
+ CommandHandler,
9
+ CallbackQueryHandler,
10
+ ContextTypes,
11
+ MessageHandler,
12
+ filters,
13
+ )
14
+ from cli.api_client import KCLIClient
15
+
16
+ # Configure logging
17
+ logger = logging.getLogger("kcli.bot")
18
+
19
+ # User ID to strictly restrict access to
20
+ def restrict_user(func):
21
+ """Decorator to ensure only the allowed user can access the bot commands/actions."""
22
+ async def wrapper(*args, **kwargs):
23
+ # Resolve the Update argument positionally
24
+ # (index 1 for method calls: self, update, context; index 0 for plain handlers: update, context)
25
+ update = None
26
+ if len(args) == 3:
27
+ update = args[1]
28
+ elif len(args) == 2:
29
+ update = args[0]
30
+
31
+ # Resolve the target chat_id from the bot instance (self is the first arg in method decorators)
32
+ bot_instance = args[0]
33
+ allowed_chat_id = getattr(bot_instance, "chat_id", None)
34
+
35
+ if not update or not getattr(update, "effective_chat", None):
36
+ return
37
+ chat_id = update.effective_chat.id
38
+ if allowed_chat_id is None or chat_id != allowed_chat_id:
39
+ logger.warning(f"Blocked unauthorized message/action from Chat ID: {chat_id}")
40
+ # Silently ignore to avoid exposing the bot to scanners
41
+ return
42
+ return await func(*args, **kwargs)
43
+ return wrapper
44
+
45
+
46
+ def clean_option_symbol(symbol: str) -> str:
47
+ """Parses and formats option symbols into a compact format, stripping index names like NIFTY."""
48
+ # 1. Weekly option pattern: e.g. NIFTY2670722800PE
49
+ # Group 1: Symbol (e.g. NIFTY)
50
+ # Group 2: Year (26)
51
+ # Group 3: Month character (1-9, O, N, D)
52
+ # Group 4: Date (07)
53
+ # Group 5: Strike (22800)
54
+ # Group 6: Option Type (PE)
55
+ m_weekly = re.match(r"^([A-Z]+)(\d{2})([1-9ONDond])(\d{2})(\d+)(CE|PE)$", symbol)
56
+ if m_weekly:
57
+ _, year, month_char, date, strike, opt_type = m_weekly.groups()
58
+ months_map = {
59
+ "1": "Jan", "2": "Feb", "3": "Mar", "4": "Apr", "5": "May", "6": "Jun",
60
+ "7": "Jul", "8": "Aug", "9": "Sep", "O": "Oct", "N": "Nov", "D": "Dec"
61
+ }
62
+ month_name = months_map.get(month_char.upper(), month_char)
63
+ return f"{date}{month_name}{year} {strike}{opt_type}"
64
+
65
+ # 2. Monthly option pattern: e.g. NIFTY26JUL21100CE
66
+ # Group 1: Symbol (NIFTY)
67
+ # Group 2: Year (26)
68
+ # Group 3: Month name (JUL)
69
+ # Group 4: Strike (21100)
70
+ # Group 5: Option Type (CE)
71
+ m_monthly = re.match(r"^([A-Z]+)(\d{2})([A-Z]{3})(\d+)(CE|PE)$", symbol)
72
+ if m_monthly:
73
+ _, year, month_name, strike, opt_type = m_monthly.groups()
74
+ month_cap = month_name.capitalize()
75
+ return f"{month_cap}{year} {strike}{opt_type}"
76
+
77
+ return symbol
78
+
79
+
80
+ def get_main_menu_keyboard() -> ReplyKeyboardMarkup:
81
+ """Returns a persistent ReplyKeyboardMarkup containing the main slash commands."""
82
+ keyboard = [
83
+ ["/positions", "/orders"],
84
+ ["/status", "/init"]
85
+ ]
86
+ return ReplyKeyboardMarkup(keyboard, resize_keyboard=True, is_persistent=True)
87
+
88
+
89
+
90
+ class PrettyTable:
91
+ """ASCII table generator mimicking PrettyTable."""
92
+ def __init__(self) -> None:
93
+ self.field_names: list[str] = []
94
+ self.rows: list[list[str]] = []
95
+
96
+ def add_row(self, row: list[str]) -> None:
97
+ self.rows.append(row)
98
+
99
+ def get_string(self) -> str:
100
+ if not self.field_names:
101
+ return ""
102
+ # Calculate max widths
103
+ col_widths = [len(h) for h in self.field_names]
104
+ for row in self.rows:
105
+ for i, val in enumerate(row):
106
+ col_widths[i] = max(col_widths[i], len(str(val)))
107
+
108
+ # Border separator
109
+ border = "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
110
+
111
+ lines = [border]
112
+ # Centered header row
113
+ header_row = "|" + "|".join(f" {h:^{col_widths[i]}} " for i, h in enumerate(self.field_names)) + "|"
114
+ lines.append(header_row)
115
+ lines.append(border)
116
+
117
+ # Left-align symbol (col 0), right-align other numeric columns
118
+ for row in self.rows:
119
+ parts = []
120
+ for i, val in enumerate(row):
121
+ val_str = str(val)
122
+ if i == 0:
123
+ parts.append(f" {val_str:<{col_widths[i]}} ")
124
+ else:
125
+ parts.append(f" {val_str:>{col_widths[i]}} ")
126
+ lines.append("|" + "|".join(parts) + "|")
127
+
128
+ lines.append(border)
129
+ return "\n".join(lines)
130
+
131
+
132
+ class KCLITelegramBot:
133
+ """Telegram Bot wrapper for KCLIClient."""
134
+
135
+ def __init__(self, client: KCLIClient, token: str, chat_id: int) -> None:
136
+ self.client = client
137
+ self.token = token
138
+ self.chat_id = chat_id
139
+ self.app = None
140
+
141
+ async def start(self) -> None:
142
+ """Start the Telegram bot loop."""
143
+ self.app = ApplicationBuilder().token(self.token).build()
144
+
145
+ # Command handlers
146
+ self.app.add_handler(CommandHandler("start", self.cmd_start))
147
+ self.app.add_handler(CommandHandler("help", self.cmd_start))
148
+ self.app.add_handler(CommandHandler("positions", self.cmd_positions))
149
+ self.app.add_handler(CommandHandler("pos", self.cmd_positions))
150
+ self.app.add_handler(CommandHandler("orders", self.cmd_orders))
151
+ self.app.add_handler(CommandHandler("status", self.cmd_status))
152
+ self.app.add_handler(CommandHandler("buy", self.cmd_buy))
153
+ self.app.add_handler(CommandHandler("sell", self.cmd_sell))
154
+ self.app.add_handler(CommandHandler("modify", self.cmd_modify))
155
+ self.app.add_handler(CommandHandler("init", self.cmd_init))
156
+ self.app.add_handler(CommandHandler("token", self.cmd_token))
157
+ self.app.add_handler(CommandHandler("kcli", self.cmd_kcli))
158
+
159
+ # Inline button handlers
160
+ self.app.add_handler(CallbackQueryHandler(self.handle_callback))
161
+
162
+ # Register slash commands in the Bot command menu
163
+ await self.app.bot.set_my_commands([
164
+ BotCommand("positions", "View and manage active positions"),
165
+ BotCommand("orders", "View and cancel pending orders"),
166
+ BotCommand("status", "Check account connection status"),
167
+ BotCommand("init", "Initialize account sessions and get login links"),
168
+ BotCommand("token", "Complete login: /token <account_name> <token>"),
169
+ BotCommand("kcli", "Execute any kcli CLI command: /kcli <args>"),
170
+ BotCommand("buy", "Place buy order: /buy <symbol> <qty> [price]"),
171
+ BotCommand("sell", "Place sell order: /sell <symbol> <qty> [price]"),
172
+ BotCommand("modify", "Modify pending order: /modify <order_id> <qty> <price>"),
173
+ ])
174
+
175
+ logger.info("Initializing Telegram bot application...")
176
+ await self.app.initialize()
177
+ logger.info("Starting Telegram bot polling...")
178
+ await self.app.start()
179
+ await self.app.updater.start_polling()
180
+
181
+ async def stop(self) -> None:
182
+ """Stop the Telegram bot loop."""
183
+ if self.app:
184
+ logger.info("Stopping Telegram bot polling...")
185
+ await self.app.updater.stop()
186
+ await self.app.stop()
187
+ await self.app.shutdown()
188
+
189
+ @restrict_user
190
+ async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
191
+ """Send welcome message and command list."""
192
+ welcome_text = (
193
+ "🪁 *Welcome to KiteCLI Bot!*\n\n"
194
+ "This bot allows you to manage positions and orders across all authenticated Zerodha accounts.\n\n"
195
+ "*Core Commands:*\n"
196
+ "• `/positions` (or `/pos`) - Display open positions & exit buttons\n"
197
+ "• `/orders` - View pending orders & modify/cancel options\n"
198
+ "• `/status` - Check account authentication status\n"
199
+ "• `/init` - Initialize account sessions & get login links\n"
200
+ "• `/token <account> <token>` - Complete manual login\n\n"
201
+ "*Trade Commands:*\n"
202
+ "• `/buy <symbol> <qty> [price]` - Place a market or limit buy order\n"
203
+ "• `/sell <symbol> <qty> [price]` - Place a market or limit sell order\n"
204
+ "• `/modify <order_id> <qty> <price>` - Modify a pending limit order's price/quantity\n\n"
205
+ "_Examples:_\n"
206
+ "• `/buy NIFTY2670722200PE 50` (Market Buy)\n"
207
+ "• `/buy NIFTY2670722200PE 50 85.20` (Limit Buy)"
208
+ )
209
+ await update.message.reply_text(welcome_text, reply_markup=get_main_menu_keyboard(), parse_mode="Markdown")
210
+
211
+ @restrict_user
212
+ async def cmd_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
213
+ """Render status check for all accounts."""
214
+ try:
215
+ status_resp = self.client.get_status()
216
+ accounts = status_resp.get("accounts", [])
217
+ if not accounts:
218
+ await update.message.reply_text("❌ No accounts configured in config.yaml.", reply_markup=get_main_menu_keyboard())
219
+ return
220
+
221
+ msg_lines = ["🔌 *Account Status:*"]
222
+ for acct in accounts:
223
+ name = acct.get("name", "Account")
224
+ auth = acct.get("authenticated", False)
225
+ status_icon = "🟢" if auth else "🔴"
226
+ status_lbl = "Session Active" if auth else "Not Authenticated"
227
+ msg_lines.append(f"{status_icon} *{name}*: {status_lbl}")
228
+
229
+ await update.message.reply_text("\n".join(msg_lines), reply_markup=get_main_menu_keyboard(), parse_mode="Markdown")
230
+ except Exception as exc:
231
+ await update.message.reply_text(f"❌ Failed to fetch status: {exc}")
232
+
233
+ @restrict_user
234
+ async def cmd_init(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
235
+ """Initialise accounts and attempt auto-login or provide manual login links."""
236
+ try:
237
+ loading_msg = await update.message.reply_text("⏳ Initialising accounts and checking session status...")
238
+
239
+ # Auto-login or request logins
240
+ result = self.client.init_accounts(self.client.accounts)
241
+ accounts = result.get("accounts", [])
242
+
243
+ for acct in accounts:
244
+ name = acct.get("name", "Account")
245
+ api_key = acct.get("api_key", "")
246
+ auto_logged_in = acct.get("auto_logged_in", False)
247
+ msg = acct.get("message", "")
248
+ login_url = acct.get("login_url", "")
249
+
250
+ if auto_logged_in:
251
+ await update.message.reply_text(
252
+ f"✅ *{name}*: Session active / Auto-login successful!\n`{msg}`",
253
+ reply_markup=get_main_menu_keyboard(),
254
+ parse_mode="Markdown"
255
+ )
256
+ else:
257
+ # Manual login needed
258
+ instruction = (
259
+ f"🔑 *{name}* requires manual authentication:\n\n"
260
+ f"1. [Click here to Login]({login_url})\n"
261
+ f"2. Copy the `request_token` from the redirect URL.\n"
262
+ f"3. Send it back to the bot by replying with:\n"
263
+ f"`/token {name} <token>`"
264
+ )
265
+ await update.message.reply_text(
266
+ instruction,
267
+ reply_markup=get_main_menu_keyboard(),
268
+ parse_mode="Markdown",
269
+ disable_web_page_preview=True
270
+ )
271
+ await loading_msg.delete()
272
+ except Exception as exc:
273
+ await update.message.reply_text(f"❌ Initialization failed: {exc}", reply_markup=get_main_menu_keyboard())
274
+
275
+ @restrict_user
276
+ async def cmd_token(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
277
+ """Handle request_token callback completion."""
278
+ args = context.args
279
+ if len(args) < 2:
280
+ await update.message.reply_text(
281
+ "❌ Invalid format. Please use:\n`/token <account_name_or_api_key> <request_token>`",
282
+ parse_mode="Markdown"
283
+ )
284
+ return
285
+
286
+ target_name = args[0]
287
+ request_token = args[1]
288
+
289
+ target_account = None
290
+ for acct in self.client.accounts:
291
+ if acct.get("name") == target_name or acct.get("api_key") == target_name:
292
+ target_account = acct
293
+ break
294
+
295
+ if not target_account:
296
+ await update.message.reply_text(
297
+ f"❌ Account '{target_name}' not found in configuration.",
298
+ parse_mode="Markdown"
299
+ )
300
+ return
301
+
302
+ api_key = target_account.get("api_key")
303
+ name = target_account.get("name", api_key)
304
+
305
+ loading_msg = await update.message.reply_text(f"⏳ Completing login for *{name}*...", parse_mode="Markdown")
306
+
307
+ try:
308
+ resp = self.client.complete_callback(api_key, request_token.strip())
309
+ await loading_msg.delete()
310
+ if resp.get("status") == "error":
311
+ await update.message.reply_text(
312
+ f"❌ *{name}* authentication failed: {resp.get('message', 'Callback failed')}",
313
+ parse_mode="Markdown"
314
+ )
315
+ else:
316
+ await update.message.reply_text(
317
+ f"✅ *{name}* authenticated successfully! Session is now active.",
318
+ reply_markup=get_main_menu_keyboard(),
319
+ parse_mode="Markdown"
320
+ )
321
+ except Exception as exc:
322
+ if 'loading_msg' in locals():
323
+ try:
324
+ await loading_msg.delete()
325
+ except Exception:
326
+ pass
327
+ await update.message.reply_text(
328
+ f"❌ *{name}* login failed: {exc}",
329
+ parse_mode="Markdown"
330
+ )
331
+
332
+ @restrict_user
333
+ async def cmd_kcli(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
334
+ """Run a TUI-like command (e.g. 'account ZK8719 && sell NIFTY2670722200PE 50' or 'exit 1') and return output."""
335
+ raw_text = " ".join(context.args).strip()
336
+ if not raw_text:
337
+ await update.message.reply_text("❌ Usage: `/kcli <TUI_commands>`\nExample: `/kcli account ZK8719 && sell NIFTY2670722200PE 50`", parse_mode="Markdown")
338
+ return
339
+
340
+ loading_msg = await update.message.reply_text(f"⏳ Executing kcli command: `{raw_text}`...")
341
+
342
+ # Split commands by '&&'
343
+ sub_commands = [c.strip() for c in raw_text.split("&&") if c.strip()]
344
+
345
+ selected_account_key = "ALL"
346
+ output_lines = []
347
+
348
+ try:
349
+ for sub_cmd in sub_commands:
350
+ parts = [p.strip() for p in sub_cmd.split() if p.strip()]
351
+ if not parts:
352
+ continue
353
+ primary = parts[0].lower()
354
+
355
+ # 1. Account selection: account <name>
356
+ if primary in ("account", "acct", "a"):
357
+ if len(parts) < 2:
358
+ raise ValueError("Usage: account <name|all>")
359
+ target_name = parts[1]
360
+ if target_name.lower() in ("none", "clear", "null", "all"):
361
+ selected_account_key = "ALL"
362
+ output_lines.append("Account selection cleared (targeting all accounts).")
363
+ else:
364
+ resolved_acct = None
365
+ for acct in self.client.accounts:
366
+ if acct.get("name") == target_name or acct.get("api_key") == target_name:
367
+ resolved_acct = acct
368
+ break
369
+ if not resolved_acct:
370
+ raise ValueError(f"Account '{target_name}' not found.")
371
+ selected_account_key = resolved_acct["api_key"]
372
+ output_lines.append(f"Selected account: {resolved_acct.get('name', selected_account_key)}")
373
+
374
+ # 2. Buy/Sell: buy/sell <symbol> <qty> [price]
375
+ elif primary in ("buy", "sell"):
376
+ if len(parts) < 3:
377
+ raise ValueError(f"Usage: {primary} <symbol> <qty> [price]")
378
+ symbol = parts[1].upper()
379
+
380
+ try:
381
+ qty = int(parts[2])
382
+ except ValueError:
383
+ raise ValueError("Quantity must be an integer.")
384
+
385
+ price = None
386
+ order_type = "MARKET"
387
+ if len(parts) >= 4:
388
+ try:
389
+ price = float(parts[3])
390
+ order_type = "LIMIT"
391
+ except ValueError:
392
+ raise ValueError("Price must be a valid number.")
393
+
394
+ exchange = "NFO" if len(symbol) > 6 else "NSE"
395
+
396
+ # Resolve api keys
397
+ api_keys = [selected_account_key] if selected_account_key != "ALL" else [a["api_key"] for a in self.client.accounts]
398
+
399
+ output_lines.append(f"Placing {order_type} {primary.upper()} for {symbol} (Qty: {qty})...")
400
+ res = self.client.place_order(
401
+ api_keys=api_keys,
402
+ tradingsymbol=symbol,
403
+ exchange=exchange,
404
+ transaction_type=primary.upper(),
405
+ quantity=qty,
406
+ order_type=order_type,
407
+ price=price
408
+ )
409
+ for r in res.get("results", []):
410
+ icon = "✅" if r.get("status") == "success" else "❌"
411
+ output_lines.append(f" {icon} {r.get('name')}: {r.get('message', 'Success')}")
412
+
413
+ # 3. Exit: exit <symbol|id> [price]
414
+ elif primary == "exit":
415
+ if len(parts) < 2:
416
+ raise ValueError("Usage: exit <symbol|id|all> [price]")
417
+ target = parts[1]
418
+
419
+ price = None
420
+ if len(parts) >= 3:
421
+ try:
422
+ price = float(parts[2])
423
+ except ValueError:
424
+ raise ValueError("Price must be a valid number.")
425
+
426
+ # Resolve exit targets
427
+ api_keys = [selected_account_key] if selected_account_key != "ALL" else [a["api_key"] for a in self.client.accounts]
428
+
429
+ symbol_to_exit = None
430
+ if target.isdigit():
431
+ pos_id = int(target)
432
+ pos_resp = self.client.get_positions(api_keys)
433
+ all_positions = []
434
+ for acct in pos_resp.get("accounts", []):
435
+ for p in acct.get("positions", []):
436
+ if p.get("quantity", 0) != 0:
437
+ all_positions.append(p)
438
+
439
+ if pos_id <= 0 or pos_id > len(all_positions):
440
+ raise ValueError(f"Position ID {pos_id} not found.")
441
+ symbol_to_exit = all_positions[pos_id - 1]["tradingsymbol"]
442
+ api_keys = [all_positions[pos_id - 1]["api_key"]]
443
+ else:
444
+ symbol_to_exit = target if target.lower() != "all" else None
445
+
446
+ output_lines.append(f"Exiting position: {target} (Price: {price if price else 'MARKET'})...")
447
+ res = self.client.exit_positions(
448
+ api_keys=api_keys,
449
+ symbol=symbol_to_exit,
450
+ price=price
451
+ )
452
+ for r in res.get("results", []):
453
+ icon = "✅" if r.get("status") == "success" else "❌"
454
+ output_lines.append(f" {icon} {r.get('name')}: {r.get('message', 'Success')}")
455
+
456
+ # 4. Status
457
+ elif primary == "status":
458
+ res = self.client.get_status()
459
+ output_lines.append("🔌 Account Status:")
460
+ for acct in res.get("accounts", []):
461
+ icon = "🟢" if acct.get("authenticated") else "🔴"
462
+ output_lines.append(f" {icon} {acct.get('name')}: {'Active' if acct.get('authenticated') else 'Inactive'}")
463
+
464
+ # 5. Positions / Pos
465
+ elif primary in ("positions", "pos"):
466
+ res = self.client.get_positions([selected_account_key] if selected_account_key != "ALL" else None)
467
+ for acct in res.get("accounts", []):
468
+ output_lines.append(f"📊 Account: {acct.get('name')} (P&L: {acct.get('total_pnl', 0.0):.2f})")
469
+ positions = [p for p in acct.get("positions", []) if p.get("quantity", 0) != 0]
470
+ if not positions:
471
+ output_lines.append(" No open positions.")
472
+ else:
473
+ for p in positions:
474
+ sym = clean_option_symbol(p.get("tradingsymbol"))
475
+ output_lines.append(f" • {sym} | Qty: {p.get('quantity')} | Avg: {p.get('average_price'):.2f} | LTP: {p.get('last_price'):.2f}")
476
+
477
+ else:
478
+ raise ValueError(f"Unsupported command '{primary}' in /kcli.")
479
+
480
+ await loading_msg.delete()
481
+ final_output = "\n".join(output_lines)
482
+ if len(final_output) > 4000:
483
+ final_output = final_output[:3900] + "\n... (truncated)"
484
+ await update.message.reply_text(f"💻 *kcli output:*\n```\n{final_output}\n```", parse_mode="Markdown")
485
+
486
+ except Exception as exc:
487
+ if 'loading_msg' in locals():
488
+ try:
489
+ await loading_msg.delete()
490
+ except Exception:
491
+ pass
492
+ await update.message.reply_text(f"❌ Execution failed: {exc}")
493
+
494
+ def _format_account_positions(self, api_key: str) -> tuple[str, list[list[InlineKeyboardButton]]]:
495
+ """Fetch and format positions for a specific account into a clean monospaced table and separate matching buttons."""
496
+ pos_resp = self.client.get_positions([api_key])
497
+ accounts = pos_resp.get("accounts", [])
498
+ if not accounts:
499
+ return "Account not found.", []
500
+
501
+ acct = accounts[0]
502
+ name = acct.get("name", "Account")
503
+ total_pnl = acct.get("total_pnl", 0.0)
504
+ positions = [p for p in acct.get("positions", []) if p.get("quantity", 0) != 0]
505
+
506
+ pnl_sign = "+" if total_pnl >= 0 else ""
507
+ if not positions:
508
+ return f"✅ No active open positions for *{name}*.", []
509
+
510
+ # Construct beautiful ASCII PrettyTable
511
+ table = PrettyTable()
512
+ table.field_names = ["Symbol", "Qty", "Avg", "LTP"]
513
+
514
+ keyboard_rows = []
515
+ current_row = []
516
+ for pos in positions:
517
+ sym = pos.get("tradingsymbol", "")
518
+ qty = pos.get("quantity", 0)
519
+ avg = pos.get("average_price", 0.0)
520
+ ltp = pos.get("last_price", 0.0)
521
+
522
+ display_sym = clean_option_symbol(sym)
523
+ table.add_row([display_sym, str(qty), f"{avg:.2f}", f"{ltp:.2f}"])
524
+
525
+ btn = InlineKeyboardButton(
526
+ display_sym,
527
+ callback_data=f"select_pos:{sym}:{api_key}:{qty}:{avg:.2f}:{ltp:.2f}"
528
+ )
529
+ current_row.append(btn)
530
+ if len(current_row) == 2:
531
+ keyboard_rows.append(current_row)
532
+ current_row = []
533
+
534
+ if current_row:
535
+ keyboard_rows.append(current_row)
536
+
537
+ msg_lines = [
538
+ f"📊 *{name}* (P&L: {pnl_sign}₹{total_pnl:.2f})",
539
+ f"```\n{table.get_string()}\n```",
540
+ "👇 _Select a position to Modify or Exit:_"
541
+ ]
542
+ return "\n".join(msg_lines), keyboard_rows
543
+
544
+ @restrict_user
545
+ async def cmd_positions(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
546
+ """Fetch and display open positions across all accounts as separate interactive tables."""
547
+ try:
548
+ api_keys = [acct["api_key"] for acct in self.client.accounts]
549
+ has_any = False
550
+ for api_key in api_keys:
551
+ msg_text, keyboard = self._format_account_positions(api_key)
552
+ if keyboard: # Only send messages for accounts with active positions
553
+ has_any = True
554
+ reply_markup = InlineKeyboardMarkup(keyboard)
555
+ await update.message.reply_text(msg_text, reply_markup=reply_markup, parse_mode="Markdown")
556
+
557
+ if not has_any:
558
+ await update.message.reply_text("✅ No active open positions across any accounts.", reply_markup=get_main_menu_keyboard())
559
+ except Exception as exc:
560
+ await update.message.reply_text(f"❌ Error fetching positions: {exc}")
561
+
562
+ @restrict_user
563
+ async def cmd_orders(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
564
+ """Fetch and display active pending orders."""
565
+ try:
566
+ api_keys = [acct["api_key"] for acct in self.client.accounts]
567
+ orders_resp = self.client.get_orders(api_keys)
568
+ accounts_data = orders_resp.get("accounts", [])
569
+
570
+ has_any_pending = False
571
+ for acct in accounts_data:
572
+ name = acct.get("name")
573
+ api_key = acct.get("api_key")
574
+ orders = acct.get("orders", [])
575
+
576
+ # Filter pending orders
577
+ pending = [
578
+ o for o in orders
579
+ if o.get("status") in ("OPEN", "TRIGGER PENDING", "AMO SUBMITTED")
580
+ ]
581
+
582
+ if not pending:
583
+ continue
584
+
585
+ has_any_pending = True
586
+ for o in pending:
587
+ order_id = o.get("order_id")
588
+ sym = o.get("tradingsymbol")
589
+ qty = o.get("quantity")
590
+ price = o.get("price")
591
+ tx_type = o.get("transaction_type")
592
+ ord_type = o.get("order_type")
593
+ status = o.get("status")
594
+
595
+ msg_text = (
596
+ f"⏳ *Pending Order* | `{name}`\n"
597
+ f"• `{sym}`\n"
598
+ f" *ID*: `{order_id}`\n"
599
+ f" *Type*: `{tx_type}` ({ord_type}) | Qty: `{qty}`\n"
600
+ f" *Price*: `{price}` | Status: `{status}`\n"
601
+ )
602
+
603
+ keyboard = [
604
+ [
605
+ InlineKeyboardButton(
606
+ "Cancel Order",
607
+ callback_data=f"confirm_cancel:{order_id}:{api_key}:{sym}"
608
+ ),
609
+ InlineKeyboardButton(
610
+ "Modify Info",
611
+ callback_data=f"prompt_modify:{order_id}:{sym}:{qty}:{price}"
612
+ )
613
+ ]
614
+ ]
615
+ reply_markup = InlineKeyboardMarkup(keyboard)
616
+ await update.message.reply_text(msg_text, reply_markup=reply_markup, parse_mode="Markdown")
617
+
618
+ if not has_any_pending:
619
+ await update.message.reply_text("✅ No active pending orders found.", reply_markup=get_main_menu_keyboard())
620
+
621
+ except Exception as exc:
622
+ await update.message.reply_text(f"❌ Error fetching orders: {exc}")
623
+
624
+ @restrict_user
625
+ async def cmd_buy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
626
+ """Place buy order across all accounts."""
627
+ await self._place_order_helper("BUY", update, context)
628
+
629
+ @restrict_user
630
+ async def cmd_sell(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
631
+ """Place sell order across all accounts."""
632
+ await self._place_order_helper("SELL", update, context)
633
+
634
+ async def _place_order_helper(
635
+ self,
636
+ transaction_type: str,
637
+ update: Update,
638
+ context: ContextTypes.DEFAULT_TYPE
639
+ ) -> None:
640
+ """Common logic to parse and place buy/sell orders, supporting optional @account routing."""
641
+ args = context.args
642
+ if not args or len(args) < 2:
643
+ await update.message.reply_text(
644
+ f"❌ Invalid syntax. Use:\n"
645
+ f"`/{transaction_type.lower()} <SYMBOL> <QTY> [LIMIT_PRICE] [@ACCOUNT_NAME]`\n\n"
646
+ f"_Examples:_\n"
647
+ f"• `/{transaction_type.lower()} NIFTY2670722200PE 50` (Market, All Accounts)\n"
648
+ f"• `/{transaction_type.lower()} NIFTY2670722200PE 50 85.20 @ZK8719` (Limit, ZK8719 only)",
649
+ parse_mode="Markdown"
650
+ )
651
+ return
652
+
653
+ # Check for optional target account prefixed with '@'
654
+ target_account_name = None
655
+ for arg in args:
656
+ if arg.startswith("@") and len(arg) > 1:
657
+ target_account_name = arg[1:]
658
+ args = [a for a in args if a != arg]
659
+ break
660
+
661
+ symbol = args[0].upper()
662
+ try:
663
+ qty = int(args[1])
664
+ except ValueError:
665
+ await update.message.reply_text("❌ Quantity must be an integer.")
666
+ return
667
+
668
+ price = None
669
+ order_type = "MARKET"
670
+ if len(args) >= 3:
671
+ try:
672
+ price_str = args[2].lstrip("@")
673
+ price = float(price_str)
674
+ order_type = "LIMIT"
675
+ except ValueError:
676
+ await update.message.reply_text("❌ Price must be a valid number.")
677
+ return
678
+
679
+ # Infer exchange (Nifty/Sensex options belong to NFO/BFO, indices/equities NSE)
680
+ exchange = "NFO"
681
+ if len(symbol) <= 6:
682
+ exchange = "NSE"
683
+
684
+ # Resolve target account
685
+ target_key = "ALL"
686
+ display_target = "ALL authenticated accounts"
687
+ if target_account_name:
688
+ resolved_acct = None
689
+ for acct in self.client.accounts:
690
+ if acct.get("name") == target_account_name or acct.get("api_key") == target_account_name:
691
+ resolved_acct = acct
692
+ break
693
+ if not resolved_acct:
694
+ await update.message.reply_text(
695
+ f"❌ Account '{target_account_name}' not found in configuration."
696
+ )
697
+ return
698
+ target_key = resolved_acct["api_key"]
699
+ display_target = f"account *{resolved_acct.get('name', target_key)}*"
700
+
701
+ confirm_text = (
702
+ f"🛒 *Confirm {transaction_type} Order*\n\n"
703
+ f"• *Symbol*: `{symbol}`\n"
704
+ f"• *Quantity*: `{qty}`\n"
705
+ f"• *Type*: `{order_type}`"
706
+ )
707
+ if price is not None:
708
+ confirm_text += f"\n• *Price*: `{price:.2f}`"
709
+
710
+ confirm_text += f"\n\nPlace this order on {display_target}?"
711
+
712
+ callback_payload = f"do_place:{transaction_type}:{symbol}:{qty}:{order_type}:{exchange}:{target_key}"
713
+ if price is not None:
714
+ callback_payload += f":{price}"
715
+
716
+ keyboard = [
717
+ [
718
+ InlineKeyboardButton("✅ Confirm Order", callback_data=callback_payload),
719
+ InlineKeyboardButton("❌ Cancel", callback_data="cancel_action")
720
+ ]
721
+ ]
722
+ reply_markup = InlineKeyboardMarkup(keyboard)
723
+ await update.message.reply_text(confirm_text, reply_markup=reply_markup, parse_mode="Markdown")
724
+
725
+ @restrict_user
726
+ async def cmd_modify(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
727
+ """Modify a pending order."""
728
+ args = context.args
729
+ if not args or len(args) < 3:
730
+ await update.message.reply_text(
731
+ "❌ Invalid syntax. Use:\n"
732
+ "`/modify <ORDER_ID> <NEW_QTY> <NEW_PRICE>`\n\n"
733
+ "_Example:_\n"
734
+ "`/modify 240618000123 50 82.50`",
735
+ parse_mode="Markdown"
736
+ )
737
+ return
738
+
739
+ order_id = args[0]
740
+ try:
741
+ qty = int(args[1])
742
+ except ValueError:
743
+ await update.message.reply_text("❌ Quantity must be an integer.")
744
+ return
745
+
746
+ try:
747
+ price = float(args[2].lstrip("@"))
748
+ except ValueError:
749
+ await update.message.reply_text("❌ Price must be a valid number.")
750
+ return
751
+
752
+ # Find the order across accounts to determine api_key
753
+ api_keys = [acct["api_key"] for acct in self.client.accounts]
754
+ orders_resp = self.client.get_orders(api_keys)
755
+ accounts_data = orders_resp.get("accounts", [])
756
+
757
+ target_api_key = None
758
+ acct_name = ""
759
+ symbol = ""
760
+ for acct in accounts_data:
761
+ for o in acct.get("orders", []):
762
+ if o.get("order_id") == order_id:
763
+ target_api_key = acct.get("api_key")
764
+ acct_name = acct.get("name", "Account")
765
+ symbol = o.get("tradingsymbol", "")
766
+ break
767
+
768
+ if not target_api_key:
769
+ await update.message.reply_text(f"❌ Order `{order_id}` was not found in active pending orders.")
770
+ return
771
+
772
+ confirm_text = (
773
+ f"🔄 *Confirm Order Modification* | `{acct_name}`\n\n"
774
+ f"• *Symbol*: `{symbol}`\n"
775
+ f"• *Order ID*: `{order_id}`\n"
776
+ f"• *New Quantity*: `{qty}`\n"
777
+ f"• *New Price*: `{price:.2f}`\n\n"
778
+ f"Apply this modification?"
779
+ )
780
+
781
+ keyboard = [
782
+ [
783
+ InlineKeyboardButton(
784
+ "✅ Confirm Modify",
785
+ callback_data=f"do_modify:{target_api_key}:{order_id}:{qty}:{price}"
786
+ ),
787
+ InlineKeyboardButton("❌ Cancel", callback_data="cancel_action")
788
+ ]
789
+ ]
790
+ reply_markup = InlineKeyboardMarkup(keyboard)
791
+ await update.message.reply_text(confirm_text, reply_markup=reply_markup, parse_mode="Markdown")
792
+
793
+ @restrict_user
794
+ async def handle_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
795
+ """Handle inline button actions and confirmations."""
796
+ query = update.callback_query
797
+ await query.answer()
798
+
799
+ data = query.data.split(":")
800
+ action = data[0]
801
+
802
+ if action == "select_pos":
803
+ symbol = data[1]
804
+ api_key = data[2]
805
+ qty = int(data[3])
806
+ avg = float(data[4]) if len(data) > 4 else 0.0
807
+ ltp = float(data[5]) if len(data) > 5 else 0.0
808
+
809
+ acct_name = "Account"
810
+ for acct in self.client.accounts:
811
+ if acct.get("api_key") == api_key:
812
+ acct_name = acct.get("name", "Account")
813
+ break
814
+
815
+ msg = (
816
+ f"🎯 *Selected Position:* `{symbol}`\n"
817
+ f"• *Account:* `{acct_name}`\n"
818
+ f"• *Current Qty:* `{qty}`\n"
819
+ f"• *Average Price:* `{avg:.2f}`\n"
820
+ f"• *LTP:* `{ltp:.2f}`\n\n"
821
+ f"Choose an action:"
822
+ )
823
+
824
+ keyboard = [
825
+ [
826
+ InlineKeyboardButton("🚨 Exit Position", callback_data=f"confirm_exit_single:{symbol}:{api_key}:{qty}"),
827
+ InlineKeyboardButton("➕ Add More", callback_data=f"confirm_add_more:{symbol}:{api_key}:{qty}")
828
+ ],
829
+ [
830
+ InlineKeyboardButton("🔙 Back to Positions", callback_data=f"back_to_positions:{api_key}")
831
+ ]
832
+ ]
833
+ reply_markup = InlineKeyboardMarkup(keyboard)
834
+ await query.edit_message_text(msg, reply_markup=reply_markup, parse_mode="Markdown")
835
+
836
+ elif action == "confirm_exit_single":
837
+ symbol = data[1]
838
+ api_key = data[2]
839
+ qty = int(data[3])
840
+
841
+ confirm_text = (
842
+ f"🚨 *Market Exit Confirmation*\n\n"
843
+ f"Are you sure you want to exit position `{symbol}` (Qty: `{qty}`) at market price?"
844
+ )
845
+ keyboard = [
846
+ [
847
+ InlineKeyboardButton("✅ Confirm Market Exit", callback_data=f"do_exit_single:{symbol}:{api_key}"),
848
+ InlineKeyboardButton("❌ Cancel", callback_data="cancel_action")
849
+ ]
850
+ ]
851
+ reply_markup = InlineKeyboardMarkup(keyboard)
852
+ await query.edit_message_reply_markup(reply_markup=reply_markup)
853
+
854
+ elif action == "do_exit_single":
855
+ symbol = data[1]
856
+ api_key = data[2]
857
+ await query.edit_message_text(f"⏳ Placing market exit order for `{symbol}`...")
858
+ try:
859
+ res = self.client.exit_positions([api_key], tradingsymbol=symbol)
860
+ res_lines = [f"📊 *Market Exit Result:* `{symbol}`"]
861
+ for r in res.get("results", []):
862
+ name = r.get("name")
863
+ status = r.get("status")
864
+ msg = r.get("message", "")
865
+ icon = "✅" if status == "success" else "❌"
866
+ res_lines.append(f"{icon} *{name}*: {msg}")
867
+
868
+ await query.edit_message_text("\n".join(res_lines), parse_mode="Markdown")
869
+ except Exception as exc:
870
+ await query.edit_message_text(f"❌ Exit execution failed: {exc}")
871
+
872
+ elif action == "confirm_add_more":
873
+ symbol = data[1]
874
+ api_key = data[2]
875
+ qty = int(data[3])
876
+
877
+ tx_type = "BUY" if qty > 0 else "SELL"
878
+ abs_qty = abs(qty)
879
+ exchange = "NFO" if len(symbol) > 6 else "NSE"
880
+
881
+ confirm_text = (
882
+ f"➕ *Confirm Add More Position*\n\n"
883
+ f"• *Symbol*: `{symbol}`\n"
884
+ f"• *Current Qty*: `{qty}`\n"
885
+ f"• *Order*: `{tx_type}` `{abs_qty}` (Market)\n\n"
886
+ f"Place market order to increase position size?"
887
+ )
888
+
889
+ callback_payload = f"do_place_add:{tx_type}:{symbol}:{abs_qty}:{exchange}:{api_key}"
890
+ keyboard = [
891
+ [
892
+ InlineKeyboardButton("✅ Confirm Add More", callback_data=callback_payload),
893
+ InlineKeyboardButton("❌ Cancel", callback_data="cancel_action")
894
+ ]
895
+ ]
896
+ reply_markup = InlineKeyboardMarkup(keyboard)
897
+ await query.edit_message_reply_markup(reply_markup=reply_markup)
898
+
899
+ elif action == "do_place_add":
900
+ tx_type = data[1]
901
+ symbol = data[2]
902
+ qty = int(data[3])
903
+ exchange = data[4]
904
+ api_key = data[5]
905
+
906
+ await query.edit_message_text(f"⏳ Placing market order to add to `{symbol}`...")
907
+ try:
908
+ res = self.client.place_order(
909
+ api_keys=[api_key],
910
+ tradingsymbol=symbol,
911
+ exchange=exchange,
912
+ transaction_type=tx_type,
913
+ quantity=qty,
914
+ order_type="MARKET"
915
+ )
916
+ res_lines = [f"📊 *Order Execution Result:* `{symbol}`"]
917
+ for r in res.get("results", []):
918
+ name = r.get("name")
919
+ status = r.get("status")
920
+ msg = r.get("message", "")
921
+ icon = "✅" if status == "success" else "❌"
922
+ res_lines.append(f"{icon} *{name}*: {msg}")
923
+
924
+ await query.edit_message_text("\n".join(res_lines), parse_mode="Markdown")
925
+ except Exception as exc:
926
+ await query.edit_message_text(f"❌ Order placement failed: {exc}")
927
+
928
+ elif action == "back_to_positions":
929
+ api_key = data[1]
930
+ await query.edit_message_text("⏳ Reloading positions...")
931
+ try:
932
+ msg_text, keyboard = self._format_account_positions(api_key)
933
+ reply_markup = InlineKeyboardMarkup(keyboard) if keyboard else None
934
+ await query.edit_message_text(msg_text, reply_markup=reply_markup, parse_mode="Markdown")
935
+ except Exception as exc:
936
+ await query.edit_message_text(f"❌ Error reloading positions: {exc}")
937
+
938
+ elif action == "confirm_exit":
939
+ symbol = data[1]
940
+ confirm_text = (
941
+ f"🚨 *Market Exit Confirmation*\n\n"
942
+ f"Are you sure you want to exit position `{symbol}` across *ALL* accounts at market price?"
943
+ )
944
+ keyboard = [
945
+ [
946
+ InlineKeyboardButton("✅ Yes, Market Exit", callback_data=f"do_exit:{symbol}"),
947
+ InlineKeyboardButton("❌ No, Keep Open", callback_data="cancel_action")
948
+ ]
949
+ ]
950
+ reply_markup = InlineKeyboardMarkup(keyboard)
951
+ await query.edit_message_reply_markup(reply_markup=reply_markup)
952
+
953
+ elif action == "do_exit":
954
+ symbol = data[1]
955
+ await query.edit_message_text(f"⏳ Placing market exit orders for `{symbol}`...")
956
+ try:
957
+ api_keys = [acct["api_key"] for acct in self.client.accounts]
958
+ res = self.client.exit_positions(api_keys, tradingsymbol=symbol)
959
+
960
+ res_lines = [f"📊 *Market Exit Results:* `{symbol}`"]
961
+ for r in res.get("results", []):
962
+ name = r.get("name")
963
+ status = r.get("status")
964
+ msg = r.get("message", "")
965
+ icon = "✅" if status == "success" else "❌"
966
+ res_lines.append(f"{icon} *{name}*: {msg}")
967
+
968
+ await query.edit_message_text("\n".join(res_lines), parse_mode="Markdown")
969
+ except Exception as exc:
970
+ await query.edit_message_text(f"❌ Exit execution failed: {exc}")
971
+
972
+ elif action == "confirm_cancel":
973
+ order_id = data[1]
974
+ api_key = data[2]
975
+ symbol = data[3]
976
+ confirm_text = (
977
+ f"🚨 *Cancel Order Confirmation*\n\n"
978
+ f"Cancel order `{order_id}` (`{symbol}`)?"
979
+ )
980
+ keyboard = [
981
+ [
982
+ InlineKeyboardButton(
983
+ "✅ Yes, Cancel Order",
984
+ callback_data=f"do_cancel:{order_id}:{api_key}"
985
+ ),
986
+ InlineKeyboardButton("❌ No, Keep Pending", callback_data="cancel_action")
987
+ ]
988
+ ]
989
+ reply_markup = InlineKeyboardMarkup(keyboard)
990
+ await query.edit_message_reply_markup(reply_markup=reply_markup)
991
+
992
+ elif action == "do_cancel":
993
+ order_id = data[1]
994
+ api_key = data[2]
995
+ await query.edit_message_text(f"⏳ Sending cancellation for `{order_id}`...")
996
+ try:
997
+ res = self.client.cancel_order(api_key, order_id)
998
+ icon = "✅" if res.get("status") == "success" else "❌"
999
+ await query.edit_message_text(f"{icon} {res.get('message')}")
1000
+ except Exception as exc:
1001
+ await query.edit_message_text(f"❌ Cancel execution failed: {exc}")
1002
+
1003
+ elif action == "prompt_modify":
1004
+ order_id = data[1]
1005
+ sym = data[2]
1006
+ qty = data[3]
1007
+ price = data[4]
1008
+ instr = (
1009
+ f"🔄 *Modify Order `{order_id}` (`{sym}`)*\n\n"
1010
+ f"Current: Qty `{qty}` @ Price `{price}`\n\n"
1011
+ f"To apply modifications, please type:\n"
1012
+ f"`/modify {order_id} <qty> <price>`"
1013
+ )
1014
+ await query.edit_message_text(instr, parse_mode="Markdown")
1015
+
1016
+ elif action == "do_place":
1017
+ tx_type = data[1]
1018
+ symbol = data[2]
1019
+ qty = int(data[3])
1020
+ ord_type = data[4]
1021
+ exchange = data[5]
1022
+ target_key = data[6]
1023
+ price = float(data[7]) if len(data) > 7 else None
1024
+
1025
+ await query.edit_message_text(f"⏳ Placing `{ord_type}` `{tx_type}` orders for `{symbol}`...")
1026
+ try:
1027
+ if target_key == "ALL":
1028
+ api_keys = [acct["api_key"] for acct in self.client.accounts]
1029
+ else:
1030
+ api_keys = [target_key]
1031
+
1032
+ res = self.client.place_order(
1033
+ api_keys=api_keys,
1034
+ tradingsymbol=symbol,
1035
+ exchange=exchange,
1036
+ transaction_type=tx_type,
1037
+ quantity=qty,
1038
+ order_type=ord_type,
1039
+ price=price
1040
+ )
1041
+
1042
+ res_lines = [f"📊 *Order Execution Results:* `{symbol}`"]
1043
+ for r in res.get("results", []):
1044
+ name = r.get("name")
1045
+ status = r.get("status")
1046
+ msg = r.get("message", "")
1047
+ icon = "✅" if status == "success" else "❌"
1048
+ res_lines.append(f"{icon} *{name}*: {msg}")
1049
+
1050
+ await query.edit_message_text("\n".join(res_lines), parse_mode="Markdown")
1051
+ except Exception as exc:
1052
+ await query.edit_message_text(f"❌ Order placement failed: {exc}")
1053
+
1054
+ elif action == "do_modify":
1055
+ api_key = data[1]
1056
+ order_id = data[2]
1057
+ qty = int(data[3])
1058
+ price = float(data[4])
1059
+
1060
+ await query.edit_message_text(f"⏳ Sending modification for `{order_id}`...")
1061
+ try:
1062
+ res = self.client.modify_order(
1063
+ api_key=api_key,
1064
+ order_id=order_id,
1065
+ quantity=qty,
1066
+ price=price
1067
+ )
1068
+ icon = "✅" if res.get("status") == "success" else "❌"
1069
+ await query.edit_message_text(f"{icon} {res.get('message')}")
1070
+ except Exception as exc:
1071
+ await query.edit_message_text(f"❌ Modification failed: {exc}")
1072
+
1073
+ elif action == "cancel_action":
1074
+ await query.edit_message_text("❌ Action cancelled.")