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/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.")
|