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/live_session.py ADDED
@@ -0,0 +1,3841 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+ import logging
4
+ from urllib.parse import urlparse
5
+ import threading
6
+
7
+ from prompt_toolkit.application import Application
8
+ from prompt_toolkit.buffer import Buffer
9
+ from prompt_toolkit.document import Document
10
+ from prompt_toolkit.formatted_text import ANSI, HTML
11
+ from prompt_toolkit.key_binding import KeyBindings
12
+ from prompt_toolkit.layout.containers import DynamicContainer, HSplit, VSplit, Window
13
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
14
+ from prompt_toolkit.layout.layout import Layout
15
+ from prompt_toolkit.layout.dimension import Dimension as D
16
+ from prompt_toolkit.styles import Style
17
+ from prompt_toolkit.widgets import Frame, TextArea
18
+
19
+ from prompt_toolkit.layout.mouse_handlers import MouseHandlers
20
+ from prompt_toolkit.history import InMemoryHistory, FileHistory
21
+ from collections import UserDict
22
+ from prompt_toolkit.filters import has_focus
23
+ from prompt_toolkit.data_structures import Point
24
+
25
+ from cli.api_client import KCLIClient, KCLIClientError
26
+ from cli.display import render_positions_to_string
27
+ from cli.recorder import DataRecorder
28
+ import json
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _silence_websocket_loggers() -> None:
34
+ """Route noisy third-party and CLI loggers to a file instead of the
35
+ terminal.
36
+
37
+ kiteconnect/autobahn/twisted/urllib3 and our own CLI module emit log warnings/errors
38
+ on their module-level loggers. The root logger has no handlers, so Python's
39
+ "last resort" handler writes them to stderr — which corrupts this
40
+ full-screen prompt_toolkit TUI. We attach a dedicated file handler to those
41
+ loggers and disable propagation so nothing reaches the terminal.
42
+ """
43
+ from pathlib import Path
44
+
45
+ log_dir = Path.home() / ".kcli"
46
+ log_dir.mkdir(parents=True, exist_ok=True)
47
+ log_file = log_dir / "websocket.log"
48
+
49
+ file_handler = logging.FileHandler(str(log_file))
50
+ file_handler.setFormatter(
51
+ logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
52
+ )
53
+
54
+ for name in ("kiteconnect", "kiteconnect.ticker", "autobahn", "twisted", "cli", "urllib3"):
55
+ noisy = logging.getLogger(name)
56
+ # Avoid stacking duplicate handlers if run() is called more than once.
57
+ if not any(isinstance(h, logging.FileHandler) for h in noisy.handlers):
58
+ noisy.addHandler(file_handler)
59
+ noisy.propagate = False
60
+ if name == "cli":
61
+ noisy.setLevel(logging.INFO)
62
+ else:
63
+ noisy.setLevel(logging.WARNING)
64
+
65
+
66
+ def probe_ws_auth(api_key: str, access_token: str, proxy_str: str = None,
67
+ timeout: float = 12.0) -> tuple[str, str]:
68
+ """Probe Zerodha's WebSocket endpoint to check whether streaming auth works.
69
+
70
+ This performs a single, lightweight WebSocket *upgrade* handshake against
71
+ ``wss://ws.kite.trade`` (optionally through the account's HTTP proxy) and
72
+ inspects the HTTP status of the response.
73
+
74
+ Why this exists: a token can authenticate REST (``/user/profile`` returns
75
+ 200) yet still be rejected by the WebSocket with ``403 Authentication
76
+ failed`` — this happens per api_key/app (e.g. an app without an active
77
+ streaming subscription, or a token not generated via the api_secret
78
+ ``generate_session`` flow). A REST-only check (``kite.profile()``) cannot
79
+ detect this, so we probe the actual streaming handshake to decide whether
80
+ to start the ticker and avoid an endless 403 reconnect storm.
81
+
82
+ Returns a ``(status, detail)`` tuple where status is one of:
83
+ - ``"ok"`` → server returned 101 Switching Protocols.
84
+ - ``"auth_failed"`` → server returned 403/401 (token rejected for streaming).
85
+ - ``"proxy_blocked"`` → proxy refused the CONNECT tunnel.
86
+ - ``"error"`` → network/other failure (inconclusive).
87
+ """
88
+ import socket
89
+ import base64
90
+ import ssl
91
+ import os
92
+
93
+ host = "ws.kite.trade"
94
+ port = 443
95
+
96
+ # Build TLS context. The production kiteconnect ticker does not verify the
97
+ # certificate either, but prefer a verified context when certifi is present.
98
+ try:
99
+ import certifi
100
+ ctx = ssl.create_default_context(cafile=certifi.where())
101
+ except Exception:
102
+ ctx = ssl.create_default_context()
103
+ ctx.check_hostname = False
104
+ ctx.verify_mode = ssl.CERT_NONE
105
+
106
+ raw_sock = None
107
+ try:
108
+ if proxy_str:
109
+ p_str = proxy_str if "://" in proxy_str else f"http://{proxy_str}"
110
+ pr = urlparse(p_str)
111
+ raw_sock = socket.create_connection((pr.hostname, pr.port), timeout=timeout)
112
+ connect_req = (
113
+ f"CONNECT {host}:{port} HTTP/1.1\r\n"
114
+ f"Host: {host}:{port}\r\n"
115
+ )
116
+ if pr.username and pr.password:
117
+ auth = base64.b64encode(
118
+ f"{pr.username}:{pr.password}".encode()
119
+ ).decode("ascii")
120
+ connect_req += f"Proxy-Authorization: Basic {auth}\r\n"
121
+ connect_req += "\r\n"
122
+ raw_sock.sendall(connect_req.encode())
123
+ connect_resp = raw_sock.recv(4096).decode(errors="replace")
124
+ first = connect_resp.splitlines()[0] if connect_resp else ""
125
+ if " 200" not in first:
126
+ return "proxy_blocked", first.strip() or "proxy CONNECT failed"
127
+ else:
128
+ raw_sock = socket.create_connection((host, port), timeout=timeout)
129
+
130
+ try:
131
+ tls = ctx.wrap_socket(raw_sock, server_hostname=host)
132
+ except ssl.SSLError:
133
+ # Fall back to an unverified handshake (matches ticker behaviour).
134
+ ctx2 = ssl.create_default_context()
135
+ ctx2.check_hostname = False
136
+ ctx2.verify_mode = ssl.CERT_NONE
137
+ tls = ctx2.wrap_socket(raw_sock, server_hostname=host)
138
+
139
+ key = base64.b64encode(os.urandom(16)).decode("ascii")
140
+ upgrade = (
141
+ f"GET /?api_key={api_key}&access_token={access_token} HTTP/1.1\r\n"
142
+ f"Host: {host}\r\n"
143
+ f"Upgrade: websocket\r\n"
144
+ f"Connection: Upgrade\r\n"
145
+ f"Sec-WebSocket-Key: {key}\r\n"
146
+ f"Sec-WebSocket-Version: 13\r\n"
147
+ f"X-Kite-Version: 3\r\n"
148
+ f"User-Agent: kite3-python\r\n\r\n"
149
+ )
150
+ tls.sendall(upgrade.encode())
151
+ resp = tls.recv(8192).decode(errors="replace")
152
+ try:
153
+ tls.close()
154
+ except Exception:
155
+ pass
156
+
157
+ status_line = resp.splitlines()[0] if resp else ""
158
+ if "101" in status_line:
159
+ return "ok", ""
160
+ if "403" in status_line or "401" in status_line:
161
+ msg = "Authentication failed."
162
+ if '"message"' in resp:
163
+ try:
164
+ import json as _json
165
+ body = resp.split("\r\n\r\n", 1)[1]
166
+ msg = _json.loads(body).get("message", msg)
167
+ except Exception:
168
+ pass
169
+ return "auth_failed", msg
170
+ return "error", status_line.strip() or "unexpected response"
171
+ except Exception as exc:
172
+ return "error", str(exc)
173
+ finally:
174
+ if raw_sock is not None:
175
+ try:
176
+ raw_sock.close()
177
+ except Exception:
178
+ pass
179
+
180
+
181
+ # ── Monkeypatch Autobahn to support Proxy Authentication ───────────
182
+ try:
183
+ import base64
184
+ import txaio
185
+
186
+ # Safely select framework for txaio if not already selected
187
+ if not getattr(txaio, "_explicit_framework", None):
188
+ try:
189
+ txaio.use_twisted()
190
+ except Exception:
191
+ try:
192
+ txaio.use_asyncio()
193
+ except Exception:
194
+ pass
195
+
196
+ from autobahn.websocket.protocol import WebSocketClientProtocol
197
+
198
+ def custom_startProxyConnect(self):
199
+ """Autobahn startProxyConnect override to inject Proxy-Authorization."""
200
+ request = f"CONNECT {self.factory.host}:{self.factory.port} HTTP/1.1\x0d\x0a"
201
+ request += f"Host: {self.factory.host}:{self.factory.port}\x0d\x0a"
202
+
203
+ # Check if proxy dict contains username/password
204
+ if (
205
+ hasattr(self, "factory")
206
+ and getattr(self.factory, "proxy", None)
207
+ and "username" in self.factory.proxy
208
+ and "password" in self.factory.proxy
209
+ ):
210
+ usr = self.factory.proxy["username"]
211
+ pwd = self.factory.proxy["password"]
212
+ auth_str = f"{usr}:{pwd}"
213
+ auth_b64 = base64.b64encode(auth_str.encode("utf-8")).decode("ascii")
214
+ request += f"Proxy-Authorization: Basic {auth_b64}\x0d\x0a"
215
+
216
+ request += "\x0d\x0a"
217
+ self.sendData(request.encode("utf-8"))
218
+
219
+ WebSocketClientProtocol.startProxyConnect = custom_startProxyConnect
220
+ except Exception as patch_exc:
221
+ logger.warning("Failed to monkeypatch Autobahn proxy connect: %s", patch_exc)
222
+
223
+
224
+ class DragInterceptDict(UserDict):
225
+ def __init__(self, session, original_defaultdict):
226
+ super().__init__()
227
+ self.session = session
228
+ self.data = original_defaultdict
229
+
230
+ def __getitem__(self, y):
231
+ row = self.data[y]
232
+ return DragInterceptRow(self.session, row, y)
233
+
234
+ class DragInterceptRow(UserDict):
235
+ def __init__(self, session, original_defaultdict, y):
236
+ super().__init__()
237
+ self.session = session
238
+ self.data = original_defaultdict
239
+ self.y = y
240
+
241
+ def __getitem__(self, x):
242
+ if getattr(self.session, "dragging_vertical", False) or getattr(self.session, "dragging_horizontal", False):
243
+ return lambda mouse_event: self.session.handle_global_drag(mouse_event, x, self.y)
244
+ return self.data[x]
245
+
246
+
247
+ class ScrollableFormattedTextControl(FormattedTextControl):
248
+ def __init__(self, *args, **kwargs):
249
+ super().__init__(*args, **kwargs)
250
+ self.vertical_scroll_position = 0
251
+
252
+ def create_content(self, width, height):
253
+ ui_content = super().create_content(width, height)
254
+ line_count = ui_content.line_count or 1
255
+ self.vertical_scroll_position = max(0, min(self.vertical_scroll_position, line_count - 1))
256
+ ui_content.cursor_position = Point(x=0, y=self.vertical_scroll_position)
257
+ return ui_content
258
+
259
+
260
+ class ScrollableBufferControl(BufferControl):
261
+ """BufferControl that supports mouse wheel and keyboard scrolling via cursor synchronization."""
262
+ pass
263
+
264
+
265
+ class ScrollableWindow(Window):
266
+ """Window that honours get_vertical_scroll even when wrap_lines=True."""
267
+
268
+ def _scroll_when_linewrapping(self, ui_content, width, height):
269
+ if self.get_vertical_scroll:
270
+ desired = self.get_vertical_scroll(self)
271
+ line_count = ui_content.line_count or 1
272
+ self.vertical_scroll = max(0, min(desired, line_count - 1))
273
+ self.vertical_scroll_2 = 0
274
+ return
275
+ super()._scroll_when_linewrapping(ui_content, width, height)
276
+
277
+
278
+ class KCLILiveSession:
279
+ """Manages the interactive live positions dashboard and trading command line."""
280
+
281
+ def __init__(self, client: KCLIClient, accounts: list[dict]) -> None:
282
+ self._main_thread_id = threading.get_ident()
283
+ self.client = client
284
+ self.accounts = accounts
285
+ self.running = True
286
+ self.tickers = {}
287
+ self.websocket_connected = {a["api_key"]: False for a in accounts if a.get("api_key")}
288
+ self.subscribed_tokens = set()
289
+ self.ws_ltp_cache: dict[int, float] = {}
290
+ self._refresh_in_progress = False
291
+ self._refresh_pending = False
292
+ # Throttle state for noisy per-account WebSocket close/error logging.
293
+ self._ws_log_throttle = {}
294
+
295
+ # Market index instrument tokens (constant on Kite). These are streamed
296
+ # over the WebSocket alongside position tokens so NIFTY/SENSEX/INDIA VIX
297
+ # update live rather than only on REST refresh. Verified via quote/ltp:
298
+ # NSE:NIFTY 50 -> 256265, BSE:SENSEX -> 265, NSE:INDIA VIX -> 264969
299
+ self.index_tokens = {256265: "nifty", 265: "sensex", 264969: "vix"}
300
+ self.index_values = {"nifty": None, "sensex": None, "vix": None}
301
+
302
+ # Designated primary streaming account. Index and option-chain ticks are
303
+ # subscribed only on this account's ticker (chosen in
304
+ # _initial_fetch_and_connect) so we don't redundantly stream the same
305
+ # instruments on every account.
306
+ self.primary_api_key = None
307
+ # Option-chain streaming state.
308
+ self.oc_data = None # structured {underlying, expiry, strikes, ...}
309
+ self.oc_token_map = {} # instrument_token -> (strike_value, "ce"|"pe")
310
+ self.oc_subscribed_tokens = set()
311
+
312
+ # In-memory store of active positions used to resolve partial symbols
313
+ self.active_positions = []
314
+ self.position_id_map = {}
315
+ self.last_positions_response = None
316
+ self.last_orders_response = None
317
+ # Margin data keyed by api_key; fetched on order fill.
318
+ self.margins_by_api_key: dict = {}
319
+ # NFO tradingsymbol → lot_size; fetched once by kite_manager, cached there.
320
+
321
+ # Log message list (plain text for Buffer-based display)
322
+ self.logs = ["Type 'help' to see commands. Scroll logs using Mouse Wheel."]
323
+
324
+ self.selected_symbol = None
325
+ self.selected_account_name = None
326
+ self.selected_account_api_key = None
327
+ self.pending_order = None
328
+ self.selected_order = None
329
+
330
+ # Build account api_key -> user_id mapping
331
+ self.api_key_to_user_id = {
332
+ a.get("api_key"): a.get("user_id", "UNKNOWN")
333
+ for a in accounts
334
+ if a.get("api_key")
335
+ }
336
+
337
+ # Initialize and start SQLite recorder
338
+ self.recorder = DataRecorder()
339
+ self.recorder.start()
340
+
341
+ # Info pane state
342
+ # mode: "orders_pending" | "orders_executed" | "oc" | "advisor"
343
+ self.info_mode: str = "orders_pending"
344
+ self._last_oc_text: str = "Press F3 or run 'oc <UNDERLYING>' to fetch option chain."
345
+ self._last_pending_text: str = "Fetching pending orders..."
346
+ self._last_executed_text: str = "Fetching executed orders..."
347
+ self._last_advisor_text: str = "Press F4 to view Tuesday Option Strangle Advisor plan."
348
+ self._advisor_alerted_today: str | None = None
349
+ self._last_advisor_time_check: float = 0.0
350
+
351
+ # Load Gemini API key
352
+ import os
353
+ from cli.config import load_config
354
+ cfg = load_config() or {}
355
+ self.gemini_api_key = cfg.get("gemini_api_key") or os.environ.get("GEMINI_API_KEY")
356
+ self._skip_confirmation: bool = False
357
+
358
+ # Pane resize state (adjusted via Ctrl+arrows)
359
+ self.left_width_pct: int = 50 # % of terminal width for left pane (20–80)
360
+ self.log_height_lines: int = 10 # rows for the log sub-pane in the left half
361
+ self.dragging_vertical = False
362
+ self.dragging_horizontal = False
363
+
364
+ # Style definition for UI elements (professional dark theme)
365
+ self.style = Style.from_dict({
366
+ "header": "bg:#21262d fg:#e6edf3 bold",
367
+ "prompt_label": "fg:#58a6ff bold",
368
+ "input_text": "fg:#ffffff",
369
+ "log_title": "fg:#d29922 bold",
370
+ "selected_row": "bg:#1f6feb fg:#ffffff bold",
371
+ "info_header": "fg:#56d364 bold",
372
+ "divider": "bg:#21262d fg:#30363d",
373
+ "divider.dragging": "bg:#0969da fg:#ffffff bold",
374
+ "market_indices": "bg:#161b22",
375
+ # Quick action bar
376
+ "quickaction": "bg:#161b22 fg:#8b949e",
377
+ "quickaction.hint": "bg:#161b22 fg:#484f58 italic",
378
+ # Buttons (Soft professional colors)
379
+ "btn.buy": "bg:#1b4a2d fg:#56d364 bold",
380
+ "btn.sell": "bg:#5e1c18 fg:#ff7b72 bold",
381
+ "btn.exit": "bg:#4a1b4d fg:#e85ffd bold",
382
+ "btn.modify": "bg:#18315e fg:#79c0ff bold",
383
+ "btn.modify_matching": "bg:#005f73 fg:#94d2bd bold",
384
+ "btn.cancel_matching": "bg:#8b263e fg:#ff8b94 bold",
385
+ "btn.cancel": "bg:#30363d fg:#8b949e bold",
386
+ "btn.refresh": "bg:#0f3542 fg:#39c5bb bold",
387
+
388
+ # Frame and Borders (Focused Container Highlight)
389
+ "frame.border": "fg:#30363d",
390
+ "frame.label": "fg:#8b949e bold",
391
+ "focused_frame.border": "fg:#58a6ff bold",
392
+ "focused_frame.label": "fg:#58a6ff bold",
393
+ })
394
+
395
+ def _strip_rich_markup(self, text: str) -> str:
396
+ """Strip Rich-style markup tags from text for plain display."""
397
+ import re
398
+ return re.sub(r"\[/?[^\[\]]*\]", "", text)
399
+
400
+ @staticmethod
401
+ def _clean_error(text: str) -> str:
402
+ """Extract a short human-readable message from a verbose exception string.
403
+
404
+ Strips nested Python exception chains, long URLs, and connection pool
405
+ boilerplate so the logs pane shows concise, actionable messages.
406
+ """
407
+ import re
408
+ s = str(text).strip()
409
+
410
+ # 1. Pull the innermost meaningful message from chained exceptions.
411
+ # e.g. "...Failed to place order: The instrument..." → "The instrument..."
412
+ for sep in (": ", " — "):
413
+ parts = s.split(sep)
414
+ # Walk from the end, pick the last part that looks like a real message
415
+ for part in reversed(parts):
416
+ part = part.strip()
417
+ if part and not part.startswith("HTTPSConnectionPool") and not part.startswith("Max retries"):
418
+ s = part
419
+ break
420
+
421
+ # 2. Simplify common connection error patterns
422
+ if "407 Proxy Authentication Required" in s:
423
+ return "Proxy authentication failed (407) — check proxy credentials in config"
424
+ if "ProxyError" in s or "Tunnel connection failed" in s:
425
+ return "Proxy connection failed — check proxy settings"
426
+ if "ConnectionError" in s or "Max retries exceeded" in s:
427
+ return "Network error — connection failed"
428
+ if "TimeoutError" in s or "timed out" in s.lower():
429
+ return "Connection timed out"
430
+ if "TokenException" in s or "Invalid token" in s:
431
+ return "Token expired — run 'kcli init' to re-login"
432
+ if "PermissionException" in s or "403" in s:
433
+ return "Permission denied (403) — check account entitlements"
434
+
435
+ # 3. Strip trailing context like "(Caused by ...)" and "url: /..."
436
+ s = re.sub(r'\s*\(Caused by.*', '', s)
437
+ s = re.sub(r'\s*with url:.*', '', s)
438
+ s = re.sub(r'\s*HTTPSConnectionPool\(.*?\)', '', s)
439
+
440
+ return s.strip() or str(text)[:80]
441
+
442
+ def log_message(self, message: str) -> None:
443
+ """Add a timestamped message to the logs and update logs pane."""
444
+ if hasattr(self, "app") and self.app and self.app.loop:
445
+ if threading.get_ident() != getattr(self, "_main_thread_id", None):
446
+ self.app.loop.call_soon_threadsafe(self.log_message, message)
447
+ return
448
+
449
+ timestamp = datetime.now().strftime("%H:%M:%S")
450
+ plain = self._strip_rich_markup(message)
451
+ plain = self._clean_error(plain)
452
+
453
+ # Compute max usable width for the logs pane so long lines don't wrap
454
+ # and spill outside the Frame. Frame border = 2, padding = 2, safety = 2.
455
+ try:
456
+ total_cols = self.app.output.get_size().columns if (hasattr(self, "app") and self.app and self.app.output) else 80
457
+ left_width = int(total_cols * (self.left_width_pct / 100.0))
458
+ max_line = max(40, left_width - 8)
459
+ except Exception:
460
+ max_line = 120
461
+
462
+ line = f"[{timestamp}] {plain}"
463
+ if len(line) > max_line:
464
+ line = line[:max_line - 1] + "…"
465
+
466
+ self.logs.append(line)
467
+ if len(self.logs) > 500:
468
+ self.logs.pop(0)
469
+
470
+ if hasattr(self, "logs_buffer"):
471
+ full_text = "\n".join(self.logs)
472
+ # Move cursor to end to auto-scroll to latest
473
+ self.logs_buffer.set_document(
474
+ Document(text=full_text, cursor_position=len(full_text)),
475
+ bypass_readonly=True,
476
+ )
477
+ if hasattr(self, "_logs_control"):
478
+ self._logs_control.vertical_scroll_position = 999999
479
+
480
+ if hasattr(self, "app"):
481
+ self.app.invalidate()
482
+
483
+ async def _run_api_call(self, func, *args, **kwargs):
484
+ """Helper to run blocking client operations in an executor to keep TUI responsive."""
485
+ loop = asyncio.get_running_loop()
486
+ from functools import partial
487
+ fn = partial(func, *args, **kwargs)
488
+ return await loop.run_in_executor(None, fn)
489
+
490
+ def update_prompt_label(self) -> None:
491
+ """Update prompt label based on selected symbol and account contexts."""
492
+ if self.selected_order:
493
+ self.prompt_control.text = f" kcli [@{self.selected_order.get('account_name')}:{self.selected_order.get('order_id')[-6:]}]> "
494
+ elif self.selected_account_name and self.selected_symbol:
495
+ self.prompt_control.text = f" kcli [@{self.selected_account_name}:{self.selected_symbol}]> "
496
+ elif self.selected_account_name:
497
+ self.prompt_control.text = f" kcli [@{self.selected_account_name}]> "
498
+ elif self.selected_symbol:
499
+ self.prompt_control.text = f" kcli [{self.selected_symbol}]> "
500
+ else:
501
+ self.prompt_control.text = " kcli> "
502
+ # Invalidate so the action bar re-renders with fresh fragments
503
+ if hasattr(self, "app"):
504
+ self.app.invalidate()
505
+
506
+
507
+ def _build_action_bar_text(self):
508
+ """Build the formatted fragment list for the quick-action bar.
509
+
510
+ Always shows all 5 buttons. When no symbol is selected the buttons
511
+ appear dimmed and carry no handler — clicking them does nothing.
512
+ When a symbol is selected each button inserts the matching command
513
+ template into the input field and shifts focus there.
514
+ """
515
+ from prompt_toolkit.mouse_events import MouseEventType
516
+
517
+ sym = getattr(self, "selected_symbol", None)
518
+ acct = getattr(self, "selected_account_name", None)
519
+ order = getattr(self, "selected_order", None)
520
+
521
+ # Find matching active position to determine default qty
522
+ matched_pos = None
523
+ if sym:
524
+ for pos in getattr(self, "active_positions", []):
525
+ if self.selected_account_api_key and pos.get("api_key") != self.selected_account_api_key:
526
+ continue
527
+ if pos.get("tradingsymbol", "").upper() == sym.upper():
528
+ matched_pos = pos
529
+ break
530
+ qty = abs(matched_pos.get("quantity", 1)) if matched_pos else 1
531
+
532
+ # Get live price (LTP) from matched position to pre-fill price
533
+ price = matched_pos.get("last_price") if matched_pos else None
534
+ if price is None:
535
+ price = 0.0
536
+ else:
537
+ try:
538
+ price = float(price)
539
+ except (ValueError, TypeError):
540
+ price = 0.0
541
+ price_str = f" {price:.2f}" if price > 0 else ""
542
+
543
+ def _fill(snippet):
544
+ """Return a mouse handler that pre-fills the input with snippet."""
545
+ def _handler(*args, **kwargs):
546
+ self.log_message("DEBUG: Quickaction button _handler called!")
547
+ if len(args) == 2:
548
+ mouse_event = args[1]
549
+ elif len(args) == 1:
550
+ mouse_event = args[0]
551
+ else:
552
+ self.log_message("DEBUG: Quickaction button: no mouse_event found in args")
553
+ return
554
+
555
+ self.log_message(f"DEBUG: Quickaction mouse event type: {mouse_event.event_type}")
556
+ if mouse_event.event_type not in (MouseEventType.MOUSE_DOWN, MouseEventType.MOUSE_UP):
557
+ return
558
+
559
+ if snippet == "refresh":
560
+ self.log_message("Triggering manual refresh via action bar button...")
561
+ if hasattr(self, "app") and self.app and self.app.loop:
562
+ asyncio.run_coroutine_threadsafe(self._trigger_immediate_refresh(), self.app.loop)
563
+ return
564
+
565
+ from prompt_toolkit.document import Document
566
+ buf = self.input_field.buffer
567
+ buf.set_document(
568
+ Document(text=snippet, cursor_position=len(snippet)),
569
+ bypass_readonly=False,
570
+ )
571
+ if hasattr(self, "app"):
572
+ self.app.layout.focus(self.input_field)
573
+ self.app.invalidate()
574
+ return _handler
575
+
576
+ # Determine snippets based on context
577
+ if order:
578
+ o_id = order.get("order_id")
579
+ o_qty = order.get("quantity", 1)
580
+ o_price = order.get("price", 0.0)
581
+ o_sym = order.get("tradingsymbol")
582
+
583
+ modify_snippet = f"order {o_id} {o_qty} {o_price:.2f}"
584
+ cancel_snippet = f"cancel {o_id}"
585
+
586
+ matching_orders = self._find_all_matching_pending_orders(o_sym) if o_sym else []
587
+
588
+ buttons = [
589
+ (" MODIFY ", "class:btn.modify", "bg:#1a1a1a fg:#444444", modify_snippet),
590
+ ]
591
+
592
+ if len(matching_orders) > 1:
593
+ modify_matching_snippet = f"order {o_sym} {o_qty} {o_price:.2f}"
594
+ cancel_matching_snippet = f"cancel {o_sym}"
595
+ buttons.append((" MODIFY MATCHING ", "class:btn.modify_matching", "bg:#1a1a1a fg:#444444", modify_matching_snippet))
596
+ buttons.append((" CANCEL MATCHING ", "class:btn.cancel_matching", "bg:#1a1a1a fg:#444444", cancel_matching_snippet))
597
+
598
+ buttons.extend([
599
+ (" CANCEL ", "class:btn.cancel", "bg:#1a1a1a fg:#444444", cancel_snippet),
600
+ (" REFRESH ", "class:btn.refresh", "bg:#1a1a1a fg:#444444", "refresh"),
601
+ ])
602
+ else:
603
+ if sym:
604
+ # Context 3: If account and symbol are selected
605
+ buy_snippet = f"buy {qty}{price_str} "
606
+ sell_snippet = f"sell {qty}{price_str} "
607
+ elif acct:
608
+ # Context 2: If account is selected (but no symbol)
609
+ buy_snippet = "buy <symbol> <qty> [price] [product]"
610
+ sell_snippet = "sell <symbol> <qty> [price] [product]"
611
+ else:
612
+ # Context 1: If nothing is selected
613
+ buy_snippet = "account <name> && buy <symbol> <qty> [price] [product]"
614
+ sell_snippet = "account <name> && sell <symbol> <qty> [price] [product]"
615
+
616
+ # Resolve near-week option symbols to build explicit exit command
617
+ squareoff_parts = []
618
+ try:
619
+ from cli.advisor import get_nifty_options
620
+ import datetime
621
+
622
+ ref_key = None
623
+ for a in self.accounts:
624
+ if self.client.is_authenticated(a["api_key"]):
625
+ ref_key = a["api_key"]
626
+ break
627
+
628
+ if ref_key:
629
+ options = get_nifty_options(self.client, ref_key)
630
+ if options:
631
+ today = datetime.date.today()
632
+ underlying_near_expiry = {}
633
+ for inst in options:
634
+ name = inst.get("name")
635
+ expiry = inst.get("expiry")
636
+ if name and isinstance(expiry, datetime.date) and expiry >= today:
637
+ if name not in underlying_near_expiry:
638
+ underlying_near_expiry[name] = expiry
639
+ else:
640
+ underlying_near_expiry[name] = min(underlying_near_expiry[name], expiry)
641
+
642
+ # Determine targeted accounts
643
+ target_key = self.selected_account_api_key
644
+ target_accounts = [a for a in self.accounts if a["api_key"] == target_key] if target_key else self.accounts
645
+
646
+ accounts_data = self.last_positions_response.get("accounts", []) if self.last_positions_response else []
647
+
648
+ for a_cfg in target_accounts:
649
+ a_key = a_cfg["api_key"]
650
+ a_name = a_cfg["name"]
651
+
652
+ # Find positions for this specific account
653
+ acct_data = next((x for x in accounts_data if x.get("api_key") == a_key), None)
654
+ if acct_data:
655
+ acct_exits = []
656
+ for pos in acct_data.get("positions", []):
657
+ if pos.get("quantity", 0) == 0:
658
+ continue
659
+ sym_name = pos.get("tradingsymbol", "")
660
+ inst = next((x for x in options if x.get("tradingsymbol") == sym_name), None)
661
+ if inst:
662
+ name = inst.get("name")
663
+ exp = inst.get("expiry")
664
+ if exp == underlying_near_expiry.get(name):
665
+ lp = pos.get("last_price")
666
+ try:
667
+ p_val = float(lp) if lp is not None else 0.0
668
+ p_str = f" {p_val:.2f}" if p_val > 0 else ""
669
+ except (ValueError, TypeError):
670
+ p_str = ""
671
+ acct_exits.append((sym_name, p_str))
672
+
673
+ if acct_exits:
674
+ if not target_key:
675
+ squareoff_parts.append(f"account {a_name}")
676
+ for sym_name, p_str in acct_exits:
677
+ squareoff_parts.append(f"exit {sym_name}{p_str}")
678
+ except Exception:
679
+ pass
680
+
681
+ if sym:
682
+ # Target selected position with limit price
683
+ squareoff_snippet = f"exit {sym}{price_str}"
684
+ else:
685
+ squareoff_snippet = " && ".join(squareoff_parts) if squareoff_parts else "exit near-week"
686
+
687
+ # ── button definitions: (label, active_style, dim_style, snippet) ──
688
+ buttons = [
689
+ (" BUY ", "bg:#005f00 fg:#afffaf bold", "bg:#1a1a1a fg:#444444", buy_snippet),
690
+ (" SELL ", "bg:#5f0000 fg:#ffafaf bold", "bg:#1a1a1a fg:#444444", sell_snippet),
691
+ (" SQUAREOFF ", "bg:#800080 fg:#ffcfff bold", "bg:#1a1a1a fg:#444444", squareoff_snippet),
692
+ (" REFRESH ", "class:btn.refresh", "bg:#1a1a1a fg:#444444", "refresh"),
693
+ ]
694
+
695
+ frags = []
696
+
697
+ # Left label — show selected context or neutral hint
698
+ if order:
699
+ ctx = f"Order:{order.get('order_id')[-6:]} @{order.get('account_name')}"
700
+ frags.append(("bg:#262626 fg:#00afaf bold", f" [{ctx}] "))
701
+ elif sym:
702
+ ctx = sym + (f" @{acct}" if acct else "")
703
+ frags.append(("bg:#262626 fg:#00afaf bold", f" [{ctx}] "))
704
+ elif acct:
705
+ frags.append(("bg:#262626 fg:#00afaf bold", f" [@{acct}] "))
706
+ else:
707
+ frags.append(("bg:#1a1a1a fg:#555555", " Actions: "))
708
+
709
+ for label, active_style, dim_style, snippet in buttons:
710
+ frags.append(("bg:#1a1a1a fg:#333333", " ")) # spacer
711
+ if snippet is not None:
712
+ frags.append((active_style, label, _fill(snippet)))
713
+ else:
714
+ frags.append((dim_style, label))
715
+
716
+ frags.append(("bg:#1a1a1a fg:#333333", " ")) # trailing pad
717
+ return frags
718
+
719
+
720
+ def _make_click_handler(self, idx: int):
721
+ """Create a mouse handler for a specific position ID."""
722
+ def handler(*args, **kwargs):
723
+ from prompt_toolkit.mouse_events import MouseEventType
724
+ # Compatible with handler(mouse_event) and handler(app, mouse_event)
725
+ if len(args) == 2:
726
+ mouse_event = args[1]
727
+ elif len(args) == 1:
728
+ mouse_event = args[0]
729
+ else:
730
+ return
731
+
732
+ if mouse_event.event_type in (MouseEventType.MOUSE_DOWN, MouseEventType.MOUSE_UP):
733
+ pos = self.position_id_map.get(idx)
734
+ if pos:
735
+ if self.selected_symbol != pos.get("tradingsymbol") or self.selected_account_api_key != pos.get("api_key"):
736
+ self.selected_symbol = pos.get("tradingsymbol")
737
+ self.selected_account_api_key = pos.get("api_key")
738
+ self.selected_account_name = pos.get("account_name")
739
+ self.selected_order = None
740
+ self.update_prompt_label()
741
+ self.log_message(f"Selected [bold]{self.selected_symbol}[/bold] (@{self.selected_account_name}) via click.")
742
+ if hasattr(self, "app"):
743
+ self.app.invalidate()
744
+ return handler
745
+
746
+ def _make_account_click_handler(self, acct: dict):
747
+ """Create a mouse handler for a specific account."""
748
+ def handler(*args, **kwargs):
749
+ from prompt_toolkit.mouse_events import MouseEventType
750
+ if len(args) == 2:
751
+ mouse_event = args[1]
752
+ elif len(args) == 1:
753
+ mouse_event = args[0]
754
+ else:
755
+ return
756
+
757
+ if mouse_event.event_type in (MouseEventType.MOUSE_DOWN, MouseEventType.MOUSE_UP):
758
+ if self.selected_account_api_key != acct.get("api_key"):
759
+ self.selected_account_name = acct.get("name")
760
+ self.selected_account_api_key = acct.get("api_key")
761
+ self.selected_order = None
762
+ self.update_prompt_label()
763
+ self.log_message(f"Selected account [bold]@{self.selected_account_name}[/bold] via click.")
764
+ if hasattr(self, "app"):
765
+ self.app.invalidate()
766
+ return handler
767
+
768
+ def _update_positions_display(self) -> None:
769
+ """Render positions to a formatted string aligned to the left container width."""
770
+ if not getattr(self, "last_positions_response", None):
771
+ self.positions_control.text = "Fetching positions, please wait..."
772
+ return
773
+
774
+ # Calculate width of the positions container dynamically
775
+ total_cols = 80
776
+ if hasattr(self, "app") and self.app and self.app.output:
777
+ total_cols = self.app.output.get_size().columns
778
+
779
+ # left_width matches width=D(weight=lw) where lw = self.left_width_pct
780
+ # the vertical divider takes 1 char.
781
+ left_width = int((total_cols - 1) * (self.left_width_pct / 100.0))
782
+ # Frame has borders (2 chars) and padding (2 chars) and a safety margin (2 chars)
783
+ width = max(40, left_width - 6)
784
+
785
+ # Memoize: only re-render when an input that affects output changes.
786
+ # _build_body() runs this on every invalidate (mouse move/scroll), so
787
+ # without this guard we'd re-parse ANSI and rebuild handlers constantly.
788
+ cache_key = (
789
+ getattr(self, "_positions_version", 0),
790
+ width,
791
+ self.selected_symbol,
792
+ self.selected_account_api_key,
793
+ self.selected_account_name,
794
+ )
795
+ if getattr(self, "_positions_cache_key", None) == cache_key:
796
+ return
797
+ self._positions_cache_key = cache_key
798
+
799
+ # Filter accounts' positions to only include non-zero quantity for the TUI rendering
800
+ filtered_accounts = []
801
+ for acct in self.last_positions_response.get("accounts", []):
802
+ filtered_acct = dict(acct)
803
+ filtered_acct["positions"] = [p for p in acct.get("positions", []) if p.get("quantity", 0) != 0]
804
+ filtered_accounts.append(filtered_acct)
805
+
806
+ rendered = render_positions_to_string(
807
+ filtered_accounts,
808
+ width=width,
809
+ show_indices=True
810
+ )
811
+
812
+ # Parse ANSI into formatted text and attach click handlers
813
+ lines = rendered.split("\n")
814
+ all_line_frags = []
815
+ for line in lines:
816
+ line_frags = list(ANSI(line).__pt_formatted_text__())
817
+ plain_line = "".join(frag[1] for frag in line_frags)
818
+
819
+ matched_idx = None
820
+ for idx in self.position_id_map.keys():
821
+ if f"[{idx}]" in plain_line:
822
+ matched_idx = idx
823
+ break
824
+
825
+ matched_acct = None
826
+ for acct in self.accounts:
827
+ name = acct.get("name")
828
+ if name and name in plain_line:
829
+ matched_acct = acct
830
+ break
831
+
832
+ if matched_idx is not None:
833
+ selected_pos = self.position_id_map.get(matched_idx)
834
+ is_selected = (
835
+ selected_pos
836
+ and self.selected_symbol
837
+ and selected_pos.get("tradingsymbol") == self.selected_symbol
838
+ and (self.selected_account_api_key is None or selected_pos.get("api_key") == self.selected_account_api_key)
839
+ )
840
+ click_handler = self._make_click_handler(matched_idx)
841
+
842
+ updated_frags = []
843
+ for frag in line_frags:
844
+ style = frag[0]
845
+ text = frag[1]
846
+ if is_selected:
847
+ style = "class:selected_row"
848
+ updated_frags.append((style, text, click_handler))
849
+ line_frags = updated_frags
850
+
851
+ if matched_acct is not None:
852
+ acct_click_handler = self._make_account_click_handler(matched_acct)
853
+ is_acct_selected = self.selected_account_api_key == matched_acct.get("api_key")
854
+ updated_frags = []
855
+ for frag in line_frags:
856
+ style = frag[0]
857
+ text = frag[1]
858
+ if is_acct_selected:
859
+ style = "class:selected_row"
860
+ updated_frags.append((style, text, acct_click_handler))
861
+ line_frags = updated_frags
862
+
863
+ all_line_frags.append(line_frags)
864
+
865
+ # Reconstruct full list of formatted text fragments with newlines
866
+ all_fragments = []
867
+ for i, line_frags in enumerate(all_line_frags):
868
+ all_fragments.extend(line_frags)
869
+ if i < len(all_line_frags) - 1:
870
+ all_fragments.append(("", "\n"))
871
+
872
+ self.positions_control.text = all_fragments
873
+
874
+ # Update title bar info
875
+ self._update_header_display()
876
+
877
+ def _update_header_display(self) -> None:
878
+ """Update the header text and style based on WebSocket status."""
879
+ total = len(self.accounts)
880
+ connected = sum(1 for k in getattr(self, "websocket_connected", {}).values() if k)
881
+
882
+ def _header_click_handler(*args, **kwargs):
883
+ from prompt_toolkit.mouse_events import MouseEventType
884
+ mouse_event = None
885
+ if len(args) == 2:
886
+ mouse_event = args[1]
887
+ elif len(args) == 1:
888
+ mouse_event = args[0]
889
+
890
+ if mouse_event and mouse_event.event_type == MouseEventType.MOUSE_UP:
891
+ self.log_message("Triggering manual WebSocket reconnection...")
892
+ if hasattr(self, "app") and self.app and self.app.loop:
893
+ asyncio.run_coroutine_threadsafe(self.reconnect_websockets(), self.app.loop)
894
+
895
+ if connected == total:
896
+ status_style = "fg:#00ff00 bold"
897
+ status_label = "WebSockets Active"
898
+ elif connected > 0:
899
+ status_style = "fg:#ff8700 bold"
900
+ status_label = f"WebSockets Partial ({connected}/{total})"
901
+ else:
902
+ status_style = "fg:#ff0000 bold"
903
+ status_label = "WebSockets Inactive"
904
+
905
+ now = datetime.now().strftime("%H:%M:%S")
906
+
907
+ frags = [
908
+ ("", "🪁 KiteCLI Live │ "),
909
+ (status_style, status_label, _header_click_handler),
910
+ ("", f" │ Last Update: {now} │ Accounts: {total} │ Ctrl+C: Quit │ Escape: Deselect"),
911
+ ]
912
+ self.header_control.text = frags
913
+ if hasattr(self, "app") and self.app:
914
+ self.app.invalidate()
915
+
916
+ async def reconnect_websockets(self) -> None:
917
+ """Close existing WebSocket connections and start fresh ones, updating the UI."""
918
+ self.log_message("[#58a6ff]Closing existing WebSocket connections...[/#]")
919
+
920
+ # 1. Nullify callbacks on the old tickers before closing to prevent async race conditions
921
+ for api_key, ticker in list(self.tickers.items()):
922
+ try:
923
+ ticker.on_connect = None
924
+ ticker.on_close = None
925
+ ticker.on_error = None
926
+ ticker.on_ticks = None
927
+ ticker.close()
928
+ except Exception:
929
+ pass
930
+ self.tickers.clear()
931
+
932
+ # 2. Reset connection flags locally and redraw the header status immediately
933
+ for api_key in list(self.websocket_connected.keys()):
934
+ self.websocket_connected[api_key] = False
935
+ self._update_header_display()
936
+
937
+ # 3. Establish the new connection (triggers REST fetch & connects WS)
938
+ await self._initial_fetch_and_connect()
939
+
940
+ # 4. Redraw positions and invalidate TUI to reflect new values
941
+ self._update_display_and_invalidate()
942
+ self.log_message("[#00ff00]✓ WebSockets reconnected successfully.[/#]")
943
+
944
+ async def _diagnose_tokens(self) -> dict[str, str]:
945
+ """Check each account's token validity and report it in the Status Logs.
946
+
947
+ Returns a mapping of api_key -> status so the caller can skip or warn
948
+ about accounts whose WebSocket handshake will be rejected (e.g. 403).
949
+ """
950
+ self.log_message("Running token diagnostics...")
951
+ statuses: dict[str, str] = {}
952
+ any_bad = False
953
+ for acct in self.accounts:
954
+ api_key = acct.get("api_key")
955
+ if not api_key:
956
+ continue
957
+ try:
958
+ result = await self._run_api_call(self.client.check_token, api_key)
959
+ except Exception as exc:
960
+ result = {"name": acct.get("name", api_key), "status": "error",
961
+ "detail": str(exc)}
962
+ status = result.get("status", "error")
963
+ statuses[api_key] = status
964
+ name = result.get("name", api_key)
965
+ detail = result.get("detail", "")
966
+ if status == "valid":
967
+ # REST works, but that does NOT guarantee streaming works: the
968
+ # WebSocket can still reject this token with 403 "Authentication
969
+ # failed" (per api_key/app — e.g. no active streaming
970
+ # subscription). Probe the actual WS handshake so we only start
971
+ # tickers that will succeed and avoid a 403 reconnect storm.
972
+ access_token = self.client.get_access_token(api_key)
973
+ proxy_str = acct.get("proxy")
974
+ ws_status, ws_detail = await self._run_api_call(
975
+ probe_ws_auth, api_key, access_token, proxy_str
976
+ )
977
+ if ws_status == "ok":
978
+ self.log_message(f"[#00ff00]✓ Streaming OK:[/#] @{name}")
979
+ elif ws_status == "auth_failed":
980
+ any_bad = True
981
+ statuses[api_key] = "stream_forbidden"
982
+ self.log_message(
983
+ f"[#ff0000]✗ Streaming rejected:[/#] @{name} — "
984
+ f"WebSocket auth failed ({ws_detail}). REST works, so the "
985
+ f"token is valid but this api_key lacks streaming access. "
986
+ f"Check the app's subscription on the Kite Connect dashboard."
987
+ )
988
+ elif ws_status == "proxy_blocked":
989
+ any_bad = True
990
+ statuses[api_key] = "proxy_blocked"
991
+ self.log_message(
992
+ f"[#ff8700]⚠ Proxy blocked streaming:[/#] @{name} — "
993
+ f"proxy refused the WebSocket tunnel ({ws_detail})."
994
+ )
995
+ else:
996
+ # Inconclusive probe — let the ticker try anyway.
997
+ self.log_message(
998
+ f"[#ff8700]⚠ Streaming check inconclusive:[/#] @{name} "
999
+ f"({ws_detail}). Will attempt to connect."
1000
+ )
1001
+ elif status == "no_token":
1002
+ any_bad = True
1003
+ self.log_message(f"[#ff8700]⚠ No token:[/#] @{name} — login required (run 'kcli init').")
1004
+ elif status == "expired":
1005
+ any_bad = True
1006
+ self.log_message(f"[#ff8700]⚠ Token expired:[/#] @{name} — re-login (run 'kcli init'). {detail}")
1007
+ elif status == "forbidden":
1008
+ any_bad = True
1009
+ self.log_message(f"[#ff0000]✗ Forbidden:[/#] @{name} — token rejected / no streaming entitlement. {detail}")
1010
+ else:
1011
+ any_bad = True
1012
+ self.log_message(f"[#ff0000]✗ Token check failed:[/#] @{name} — {detail}")
1013
+
1014
+ if any_bad:
1015
+ self.log_message(
1016
+ "[#ff8700]Note:[/#] Accounts flagged above are skipped for "
1017
+ "streaming to avoid 403/1006 reconnect storms. 'Streaming "
1018
+ "rejected' with working REST usually means that api_key's app "
1019
+ "has no active streaming subscription — re-init won't fix it."
1020
+ )
1021
+ return statuses
1022
+
1023
+ def _select_primary_account(self, token_statuses: dict[str, str]) -> str | None:
1024
+ """Pick the primary streaming account.
1025
+
1026
+ Preference order:
1027
+ 1. A config account explicitly flagged ``primary: true`` — but only if
1028
+ it is stream-capable (token status ``valid``).
1029
+ 2. Otherwise the first stream-capable account in config order.
1030
+
1031
+ Returns the api_key, or ``None`` if no account can stream.
1032
+ """
1033
+ def streamable(acct) -> bool:
1034
+ return token_statuses.get(acct.get("api_key")) == "valid"
1035
+
1036
+ flagged = [a for a in self.accounts if a.get("primary")]
1037
+ for acct in flagged:
1038
+ if streamable(acct):
1039
+ return acct.get("api_key")
1040
+ if flagged:
1041
+ self.log_message(
1042
+ f"[#ff8700]Configured primary @{flagged[0].get('name')} cannot "
1043
+ f"stream;[/#] falling back to another stream-capable account."
1044
+ )
1045
+
1046
+ for acct in self.accounts:
1047
+ if streamable(acct):
1048
+ return acct.get("api_key")
1049
+ return None
1050
+
1051
+ async def _initial_fetch_and_connect(self) -> None:
1052
+ """Initial fetch of data and connect all WebSockets."""
1053
+ self.log_message("Initializing connections and fetching data...")
1054
+ try:
1055
+ await self._trigger_immediate_refresh()
1056
+ except Exception as exc:
1057
+ self.log_message(f"[#ff0000]Initial fetch failed:[/#] {exc}")
1058
+
1059
+ # Diagnose token validity up front. The same api_key/access_token pair is
1060
+ # used for the WebSocket ticker, so a REST 403/expired here explains the
1061
+ # "1006 / 403 Forbidden" handshake failures.
1062
+ token_statuses = await self._diagnose_tokens()
1063
+
1064
+ # Choose the primary streaming account (used for index + option-chain
1065
+ # streaming). Index/OC ticks are subscribed only on this account.
1066
+ self.primary_api_key = self._select_primary_account(token_statuses)
1067
+ if self.primary_api_key:
1068
+ self.log_message(
1069
+ f"[#58a6ff]Primary streaming account:[/#] "
1070
+ f"@{self._get_account_name(self.primary_api_key)}"
1071
+ )
1072
+ else:
1073
+ self.log_message(
1074
+ "[#ff8700]No stream-capable account:[/#] indices and option "
1075
+ "chain will use REST snapshots only (no live streaming)."
1076
+ )
1077
+
1078
+ # Connect WebSockets for all accounts
1079
+ from kiteconnect import KiteTicker
1080
+ for acct in self.accounts:
1081
+ api_key = acct.get("api_key")
1082
+ access_token = self.client.get_access_token(api_key)
1083
+ # Skip accounts whose token we already know is bad — avoids the
1084
+ # reconnect storm of 403 handshakes.
1085
+ if token_statuses.get(api_key) not in (None, "valid"):
1086
+ self.log_message(
1087
+ f"[#ff8700]Skipping WebSocket for @{acct.get('name')}:[/#] "
1088
+ f"token not valid ({token_statuses.get(api_key)})."
1089
+ )
1090
+ continue
1091
+ if api_key and access_token:
1092
+ try:
1093
+ # reconnect=True/reconnect_max_tries belong on KiteTicker()
1094
+ # constructor, not connect(). Max 50 tries (library default max
1095
+ # is 300; 50 gives ~15+ mins of exponential-backoff recovery).
1096
+ ticker = KiteTicker(
1097
+ api_key, access_token,
1098
+ reconnect=True,
1099
+ reconnect_max_tries=5,
1100
+ )
1101
+
1102
+ ticker.on_connect = self._make_on_connect(api_key, ticker)
1103
+ ticker.on_ticks = self._on_ticks
1104
+ ticker.on_order_update = self._make_on_order_update(api_key)
1105
+ ticker.on_close = self._make_on_close(api_key)
1106
+ ticker.on_error = self._make_on_error(api_key)
1107
+
1108
+ # Parse proxy if configured for this account
1109
+ proxy_str = acct.get("proxy")
1110
+ proxy_dict = None
1111
+ if proxy_str:
1112
+ from urllib.parse import urlparse
1113
+ try:
1114
+ p_str = proxy_str if "://" in proxy_str else f"http://{proxy_str}"
1115
+ parsed = urlparse(p_str)
1116
+ if parsed.hostname and parsed.port:
1117
+ proxy_dict = {
1118
+ "host": parsed.hostname,
1119
+ "port": int(parsed.port),
1120
+ }
1121
+ if parsed.username and parsed.password:
1122
+ proxy_dict["username"] = parsed.username
1123
+ proxy_dict["password"] = parsed.password
1124
+ except Exception as p_err:
1125
+ self.log_message(f"[#ff8700]Failed to parse proxy for {acct.get('name')}:[/#] {p_err}")
1126
+
1127
+ # connect() only accepts threaded + proxy
1128
+ connect_kwargs = dict(threaded=True)
1129
+ if proxy_dict:
1130
+ connect_kwargs["proxy"] = proxy_dict
1131
+ ticker.connect(**connect_kwargs)
1132
+ self.tickers[api_key] = ticker
1133
+ except Exception as exc:
1134
+ self.log_message(f"[#ff0000]Failed to connect WebSocket for {acct.get('name')}:[/#] {exc}")
1135
+
1136
+ def _get_account_name(self, api_key: str) -> str:
1137
+ """Helper to get account name by api_key."""
1138
+ for acct in self.accounts:
1139
+ if acct.get("api_key") == api_key:
1140
+ return acct.get("name", api_key)
1141
+ return api_key
1142
+
1143
+ def _make_on_connect(self, api_key: str, ticker):
1144
+ def on_connect(ws, response):
1145
+ self.websocket_connected[api_key] = True
1146
+ if hasattr(self, "app") and self.app and self.app.loop:
1147
+ self.app.loop.call_soon_threadsafe(self._update_header_display)
1148
+ name = self._get_account_name(api_key)
1149
+ self.log_message(f"[#00ff00]WebSocket connected:[/#] @{name}")
1150
+ # All market-data (position prices, indices, option chain) streams on
1151
+ # the primary ticker only — instrument prices are global, so there's
1152
+ # no need to subscribe them on every account. Non-primary tickers
1153
+ # stay connected purely for their own order-update postbacks, which
1154
+ # require no subscription.
1155
+ is_primary = api_key == self.primary_api_key
1156
+ # If no primary was selected, fall back to the first connected ticker
1157
+ # so position prices still stream somewhere.
1158
+ if not self.primary_api_key and self.tickers:
1159
+ is_primary = api_key == next(iter(self.tickers.keys()))
1160
+ if not is_primary:
1161
+ return
1162
+ tokens = set(self.subscribed_tokens)
1163
+ tokens |= set(self.index_tokens)
1164
+ tokens |= set(self.oc_subscribed_tokens)
1165
+ if tokens:
1166
+ token_list = list(tokens)
1167
+ ticker.subscribe(token_list)
1168
+ ticker.set_mode(ticker.MODE_LTP, token_list)
1169
+ return on_connect
1170
+
1171
+ def _ws_should_log(self, key: str, min_interval: float = 10.0) -> bool:
1172
+ """Return True if a throttled WebSocket message for ``key`` should be
1173
+ logged now (rate-limited to one message per ``min_interval`` seconds).
1174
+
1175
+ Prevents reconnect storms (e.g. repeated 1006 closures) from flooding
1176
+ the Status Logs pane.
1177
+ """
1178
+ import time
1179
+
1180
+ now = time.monotonic()
1181
+ last = self._ws_log_throttle.get(key, 0.0)
1182
+ if now - last >= min_interval:
1183
+ self._ws_log_throttle[key] = now
1184
+ return True
1185
+ return False
1186
+
1187
+ def _make_on_close(self, api_key: str):
1188
+ def on_close(ws, code, reason):
1189
+ self.websocket_connected[api_key] = False
1190
+ if hasattr(self, "app") and self.app and self.app.loop:
1191
+ self.app.loop.call_soon_threadsafe(self._update_header_display)
1192
+ name = self._get_account_name(api_key)
1193
+ if self._ws_should_log(f"close:{api_key}"):
1194
+ self.log_message(
1195
+ f"[#ff8700]WebSocket closed:[/#] @{name} ({code} {reason}) "
1196
+ f"— reconnecting..."
1197
+ )
1198
+ return on_close
1199
+
1200
+ def _make_on_error(self, api_key: str):
1201
+ def on_error(ws, code, reason):
1202
+ self.websocket_connected[api_key] = False
1203
+ if hasattr(self, "app") and self.app and self.app.loop:
1204
+ self.app.loop.call_soon_threadsafe(self._update_header_display)
1205
+ name = self._get_account_name(api_key)
1206
+ reason_str = str(reason).lower()
1207
+ # Detect permanent auth failures (403, token expired) — stop reconnecting
1208
+ # immediately to avoid hammering Zerodha and getting rate-limited.
1209
+ is_auth_failure = (
1210
+ code == 403
1211
+ or "403" in reason_str
1212
+ or "auth_failed" in reason_str
1213
+ or "token" in reason_str
1214
+ )
1215
+ if is_auth_failure:
1216
+ self.log_message(
1217
+ f"[#ff0000]WebSocket auth failed:[/#] @{name} — token expired or invalid. "
1218
+ f"Run [bold]kcli init[/bold] to re-authenticate."
1219
+ )
1220
+ # Stop reconnecting — close the ticker cleanly
1221
+ ticker = self.tickers.get(api_key)
1222
+ if ticker:
1223
+ try:
1224
+ ticker.close()
1225
+ except Exception:
1226
+ pass
1227
+ elif self._ws_should_log(f"error:{api_key}"):
1228
+ self.log_message(f"[#ff0000]WebSocket error:[/#] @{name} ({code} {reason})")
1229
+ return on_error
1230
+
1231
+ def _make_on_order_update(self, api_key: str):
1232
+ def on_order_update(ws, data):
1233
+ status = data.get("status")
1234
+ symbol = data.get("tradingsymbol")
1235
+ qty = data.get("quantity")
1236
+ filled = data.get("filled_quantity", 0)
1237
+ ord_type = data.get("transaction_type")
1238
+ name = self._get_account_name(api_key)
1239
+ qty_desc = f"{filled}/{qty}"
1240
+ self.log_message(f"[#00afaf]Order update [@{name}]:[/#] {ord_type} {qty_desc} {symbol} -> {status}")
1241
+
1242
+ # Record order update to database
1243
+ if hasattr(self, "recorder"):
1244
+ user_id = self.api_key_to_user_id.get(api_key, "UNKNOWN")
1245
+ self.recorder.enqueue_order(data, self.index_values, user_id)
1246
+
1247
+ # Trigger immediate refresh of positions and orders
1248
+ if hasattr(self, "app") and self.app and self.app.loop:
1249
+ asyncio.run_coroutine_threadsafe(self._trigger_immediate_refresh(), self.app.loop)
1250
+ return on_order_update
1251
+
1252
+ def _on_ticks(self, ws, ticks):
1253
+ updated = False
1254
+ indices_updated = False
1255
+ oc_updated = False
1256
+ for tick in ticks:
1257
+ token = tick.get("instrument_token")
1258
+ ltp = tick.get("last_price")
1259
+ if not token or ltp is None:
1260
+ continue
1261
+
1262
+ # Market index tick → update the header values.
1263
+ if token in self.index_tokens:
1264
+ self.index_values[self.index_tokens[token]] = ltp
1265
+ indices_updated = True
1266
+ continue
1267
+
1268
+ # Option-chain tick → update the matching strike's CE/PE LTP.
1269
+ if token in self.oc_token_map and self.oc_data:
1270
+ strike_val, side = self.oc_token_map[token]
1271
+ for s in self.oc_data.get("strikes", []):
1272
+ if s.get("strike") == strike_val:
1273
+ s[f"{side}_ltp"] = ltp
1274
+ oc_updated = True
1275
+ break
1276
+
1277
+ # Cache the latest LTP.
1278
+ self.ws_ltp_cache[int(token)] = ltp
1279
+
1280
+ # Position tick → update the matching positions across all accounts.
1281
+ resp = getattr(self, "last_positions_response", None)
1282
+ if resp:
1283
+ for acct in resp.get("accounts", []):
1284
+ for pos in acct.get("positions", []):
1285
+ pos_token = pos.get("instrument_token")
1286
+ if pos_token is not None and int(pos_token) == int(token):
1287
+ pos["last_price"] = ltp
1288
+ # Recalculate P&L only for open positions (quantity != 0)
1289
+ qty = pos.get("quantity", 0)
1290
+ if qty != 0:
1291
+ avg_price = pos.get("average_price", 0.0)
1292
+ pos["pnl"] = (ltp - avg_price) * qty
1293
+ if avg_price > 0:
1294
+ pos["pnl_pct"] = (pos["pnl"] / (avg_price * abs(qty))) * 100
1295
+ else:
1296
+ pos["pnl_pct"] = 0.0
1297
+ # For closed positions, realised P&L remains unchanged and is already set.
1298
+ updated = True
1299
+
1300
+ # Recalculate total P&L for account
1301
+ acct["total_pnl"] = sum(p.get("pnl", 0.0) for p in acct.get("positions", []))
1302
+
1303
+ if indices_updated and hasattr(self, "app") and self.app and self.app.loop:
1304
+ self.app.loop.call_soon_threadsafe(self._refresh_indices_header)
1305
+
1306
+ if oc_updated and hasattr(self, "app") and self.app and self.app.loop:
1307
+ self.app.loop.call_soon_threadsafe(self._update_oc_and_invalidate)
1308
+
1309
+ if updated:
1310
+ self._positions_version = getattr(self, "_positions_version", 0) + 1
1311
+ if hasattr(self, "app") and self.app and self.app.loop:
1312
+ self.app.loop.call_soon_threadsafe(self._update_display_and_invalidate)
1313
+
1314
+ def _update_display_and_invalidate(self) -> None:
1315
+ """Helper to re-render positions and invalidate TUI."""
1316
+ self._update_positions_display()
1317
+ if hasattr(self, "app") and self.app:
1318
+ self.app.invalidate()
1319
+
1320
+ def _refresh_indices_header(self) -> None:
1321
+ """Re-render the NIFTY/SENSEX/INDIA VIX header from ``self.index_values``
1322
+ and invalidate the TUI. Safe to call from the event loop thread."""
1323
+ self.market_indices_control.text = self._render_indices_html()
1324
+ if hasattr(self, "app") and self.app:
1325
+ self.app.invalidate()
1326
+
1327
+ def _render_indices_html(self) -> HTML:
1328
+ """Build the formatted market-index header from the current values."""
1329
+ def fmt(v):
1330
+ return f"{v:,.2f}" if v else "N/A"
1331
+
1332
+ nifty_str = fmt(self.index_values.get("nifty"))
1333
+ sensex_str = fmt(self.index_values.get("sensex"))
1334
+ vix_str = fmt(self.index_values.get("vix"))
1335
+ return HTML(
1336
+ f" <ansicyan><b>NIFTY 50:</b></ansicyan> <style fg='#ffffff'>{nifty_str}</style> "
1337
+ f"│ <ansiyellow><b>SENSEX:</b></ansiyellow> <style fg='#ffffff'>{sensex_str}</style> "
1338
+ f"│ <ansired><b>INDIA VIX:</b></ansired> <style fg='#ffffff'>{vix_str}</style>"
1339
+ )
1340
+
1341
+ def _get_active_ticker(self):
1342
+ """Get the primary ticker if it is connected, else fallback to any connected ticker."""
1343
+ ticker = None
1344
+ if self.primary_api_key and self.websocket_connected.get(self.primary_api_key):
1345
+ ticker = self.tickers.get(self.primary_api_key)
1346
+
1347
+ if ticker is None:
1348
+ connected_keys = [k for k, conn in self.websocket_connected.items() if conn]
1349
+ for key in connected_keys:
1350
+ if key in self.tickers:
1351
+ ticker = self.tickers[key]
1352
+ break
1353
+
1354
+ if ticker is None and self.tickers:
1355
+ ticker = next(iter(self.tickers.values()))
1356
+ return ticker
1357
+
1358
+ def _update_subscriptions(self, new_tokens: set[int]) -> None:
1359
+ """Subscribe to position instrument tokens and unsubscribe inactive ones.
1360
+
1361
+ Instrument prices are global (the same LTP regardless of which account
1362
+ holds the position), so we stream each token on the **primary** ticker
1363
+ only — not on every account — to avoid receiving the same price tick
1364
+ once per account. The tick handler (`_on_ticks`) then applies each
1365
+ price to all accounts' matching positions, so per-account positions and
1366
+ P&L remain fully independent.
1367
+ """
1368
+ to_subscribe = new_tokens - self.subscribed_tokens
1369
+ to_unsubscribe = self.subscribed_tokens - new_tokens
1370
+ # Never unsubscribe the market index tokens — they must keep streaming.
1371
+ to_unsubscribe -= set(self.index_tokens)
1372
+ # Keep tokens still needed by the live option chain subscribed.
1373
+ to_unsubscribe -= set(self.oc_subscribed_tokens)
1374
+
1375
+ # Stream position prices on the active ticker
1376
+ ticker = self._get_active_ticker()
1377
+
1378
+ if to_subscribe and ticker is not None:
1379
+ try:
1380
+ ticker.subscribe(list(to_subscribe))
1381
+ ticker.set_mode(ticker.MODE_LTP, list(to_subscribe))
1382
+ except Exception:
1383
+ pass
1384
+ # Track intent even if no ticker is connected yet; on_connect re-subscribes.
1385
+ self.subscribed_tokens.update(to_subscribe)
1386
+
1387
+ if to_unsubscribe:
1388
+ if ticker is not None:
1389
+ try:
1390
+ ticker.unsubscribe(list(to_unsubscribe))
1391
+ except Exception:
1392
+ pass
1393
+ self.subscribed_tokens.difference_update(to_unsubscribe)
1394
+
1395
+ async def _trigger_immediate_refresh(self) -> None:
1396
+ """Trigger an immediate fetch of positions, orders, margins, and indices from Zerodha.
1397
+ Uses a debounced non-overlapping loop to prevent concurrent API execution.
1398
+ """
1399
+ if getattr(self, "_refresh_in_progress", False):
1400
+ self._refresh_pending = True
1401
+ return
1402
+
1403
+ self._refresh_in_progress = True
1404
+ self._refresh_pending = False
1405
+ try:
1406
+ while True:
1407
+ api_keys = [acct["api_key"] for acct in self.accounts]
1408
+
1409
+ # Fetch positions, margins, orders, and indices concurrently.
1410
+ positions_task = self._run_api_call(self.client.get_positions, api_keys)
1411
+ margins_task = self._run_api_call(self.client.get_margins, api_keys)
1412
+ orders_task = self._run_api_call(self.client.get_orders, api_keys)
1413
+ indices_task = self._run_api_call(self.client.get_market_indices)
1414
+
1415
+ response, margins_resp, orders_resp, indices_resp = await asyncio.gather(
1416
+ positions_task, margins_task, orders_task, indices_task,
1417
+ return_exceptions=True,
1418
+ )
1419
+
1420
+ # Guard and carry-forward individual account failures to prevent empty sections on error
1421
+ if isinstance(response, dict) and "accounts" in response:
1422
+ old_response = getattr(self, "last_positions_response", None)
1423
+ old_accounts_map = {}
1424
+ if isinstance(old_response, dict):
1425
+ old_accounts_map = {
1426
+ acct.get("api_key"): acct
1427
+ for acct in old_response.get("accounts", [])
1428
+ }
1429
+
1430
+ for acct in response.get("accounts", []):
1431
+ api_key = acct.get("api_key")
1432
+ status = acct.get("status", "")
1433
+ # If this specific account failed REST fetch, carry forward its previous positions
1434
+ if status.startswith("error") or status == "unauthenticated":
1435
+ old_acct = old_accounts_map.get(api_key)
1436
+ if old_acct:
1437
+ acct["positions"] = old_acct.get("positions", [])
1438
+ acct["total_pnl"] = old_acct.get("total_pnl", 0.0)
1439
+ # Override the error status to keep it from clearing
1440
+ acct["status"] = old_acct.get("status", "success")
1441
+
1442
+ # Merge margin data into the positions response so the renderer
1443
+ # can display it without a separate lookup.
1444
+ if isinstance(margins_resp, dict):
1445
+ margin_map = {m["api_key"]: m for m in margins_resp.get("accounts", [])}
1446
+ # Keep old margin data if the current one has error or is missing
1447
+ for k, old_m in getattr(self, "margins_by_api_key", {}).items():
1448
+ if k not in margin_map or margin_map[k].get("status", "").startswith("error"):
1449
+ margin_map[k] = old_m
1450
+ self.margins_by_api_key = margin_map
1451
+ if isinstance(response, dict):
1452
+ for acct in response.get("accounts", []):
1453
+ key = acct.get("api_key", "")
1454
+ m = margin_map.get(key, {})
1455
+ acct["margin_net"] = m.get("net")
1456
+ acct["margin_cash"] = m.get("cash")
1457
+
1458
+ if isinstance(response, dict):
1459
+ # Re-apply cached real-time WebSocket LTPs to prevent regressing to stale REST data.
1460
+ for acct in response.get("accounts", []):
1461
+ for pos in acct.get("positions", []):
1462
+ pos_token = pos.get("instrument_token")
1463
+ if pos_token is not None:
1464
+ t_int = int(pos_token)
1465
+ if t_int in self.ws_ltp_cache:
1466
+ cached_ltp = self.ws_ltp_cache[t_int]
1467
+ pos["last_price"] = cached_ltp
1468
+ qty = pos.get("quantity", 0)
1469
+ if qty != 0:
1470
+ avg_price = pos.get("average_price", 0.0)
1471
+ pos["pnl"] = (cached_ltp - avg_price) * qty
1472
+ if avg_price > 0:
1473
+ pos["pnl_pct"] = (pos["pnl"] / (avg_price * abs(qty))) * 100
1474
+ else:
1475
+ pos["pnl_pct"] = 0.0
1476
+ acct["total_pnl"] = sum(p.get("pnl", 0.0) for p in acct.get("positions", []))
1477
+
1478
+ self.last_positions_response = response
1479
+ self._positions_version = getattr(self, "_positions_version", 0) + 1
1480
+
1481
+ self.active_positions = []
1482
+ self.position_id_map = {}
1483
+ pos_idx = 1
1484
+ new_tokens = set()
1485
+ for acct in response.get("accounts", []):
1486
+ for pos in acct.get("positions", []):
1487
+ if pos.get("quantity", 0) != 0:
1488
+ pos["api_key"] = acct.get("api_key")
1489
+ pos["account_name"] = acct.get("name")
1490
+ # Annotate with lot size so the display layer can show lots.
1491
+ sym = pos.get("tradingsymbol", "")
1492
+ lot_size = self.client.get_nfo_lot_sizes().get(sym, 1)
1493
+ pos["lot_size"] = lot_size
1494
+ pos["lots"] = pos.get("quantity", 0) / lot_size
1495
+ self.active_positions.append(pos)
1496
+ self.position_id_map[pos_idx] = pos
1497
+ pos_idx += 1
1498
+ if pos.get("instrument_token"):
1499
+ new_tokens.add(int(pos["instrument_token"]))
1500
+
1501
+ self._update_subscriptions(new_tokens)
1502
+
1503
+ # Record position snapshots to database
1504
+ if hasattr(self, "recorder"):
1505
+ for acct in response.get("accounts", []):
1506
+ api_key = acct.get("api_key", "")
1507
+ user_id = self.api_key_to_user_id.get(api_key, "UNKNOWN")
1508
+ positions = acct.get("positions", [])
1509
+ self.recorder.enqueue_positions(positions, self.index_values, user_id)
1510
+
1511
+ if isinstance(orders_resp, dict):
1512
+ self.last_orders_response = orders_resp
1513
+ self._last_pending_text = self._render_orders_pane(orders_resp, "orders_pending")
1514
+ self._last_executed_text = self._render_orders_pane(orders_resp, "orders_executed")
1515
+ if self.info_mode in ("orders_pending", "orders_executed"):
1516
+ self._update_info_buffer(reset_scroll=False)
1517
+
1518
+ # Indices REST snapshot (WebSocket ticks keep these live thereafter).
1519
+ try:
1520
+ if isinstance(indices_resp, dict) and indices_resp.get("status") == "success":
1521
+ self.index_values["nifty"] = indices_resp.get("nifty")
1522
+ self.index_values["sensex"] = indices_resp.get("sensex")
1523
+ self.index_values["vix"] = indices_resp.get("vix")
1524
+ self.market_indices_control.text = self._render_indices_html()
1525
+ elif isinstance(indices_resp, dict):
1526
+ msg = indices_resp.get("message", "Unknown error")
1527
+ self.market_indices_control.text = HTML(f" <style fg='#ff5f5f'>Indices: {msg}</style>")
1528
+ except Exception as exc:
1529
+ self.market_indices_control.text = HTML(f" <style fg='#ff5f5f'>Indices Error: {exc}</style>")
1530
+
1531
+ # Force UI rendering updates on main thread
1532
+ if hasattr(self, "app") and self.app and self.app.loop:
1533
+ self.app.loop.call_soon_threadsafe(self._update_display_and_invalidate)
1534
+
1535
+ # Debounce: check if a new request was queued during our run
1536
+ if getattr(self, "_refresh_pending", False):
1537
+ self._refresh_pending = False
1538
+ continue
1539
+ else:
1540
+ break
1541
+ finally:
1542
+ self._refresh_in_progress = False
1543
+
1544
+ def resolve_symbol(self, input_sym: str) -> tuple[str | None, str | None, str | None]:
1545
+ """Resolve a symbol against current active positions.
1546
+
1547
+ Accepts either:
1548
+ - A numeric position ID (e.g. "1", "3")
1549
+ - An exact trading symbol (case-insensitive, spaces ignored)
1550
+
1551
+ No fuzzy or pattern matching is performed. If the symbol doesn't
1552
+ match any active position it is returned as-is (upper-cased) so
1553
+ the server can validate it when placing the order.
1554
+
1555
+ Returns:
1556
+ (resolved_symbol, api_key, error_message)
1557
+ """
1558
+ normalized_input = input_sym.replace(" ", "").upper()
1559
+ if not normalized_input:
1560
+ return None, None, "Empty symbol."
1561
+
1562
+ # Numeric input → position ID lookup (only for small integers < 100 to avoid strike price collision)
1563
+ if normalized_input.isdigit():
1564
+ idx = int(normalized_input)
1565
+ if idx < 100:
1566
+ if hasattr(self, "position_id_map") and idx in self.position_id_map:
1567
+ pos = self.position_id_map[idx]
1568
+ return pos.get("tradingsymbol"), pos.get("api_key"), None
1569
+ else:
1570
+ return None, None, f"Invalid position ID '{input_sym}'."
1571
+
1572
+ # Exact match against active positions (case-insensitive, spaces stripped)
1573
+ matched_pos = None
1574
+ for pos in getattr(self, "active_positions", []):
1575
+ if self.selected_account_api_key and pos.get("api_key") != self.selected_account_api_key:
1576
+ continue
1577
+ sym = pos.get("tradingsymbol", "")
1578
+ if sym.replace(" ", "").upper() == normalized_input:
1579
+ if matched_pos is None:
1580
+ matched_pos = pos
1581
+ else:
1582
+ # Same symbol in multiple accounts — don't pin api_key
1583
+ return sym, None, None
1584
+
1585
+ if matched_pos:
1586
+ return matched_pos.get("tradingsymbol"), matched_pos.get("api_key"), None
1587
+
1588
+ # No match in active positions — pass through as-is (for new orders)
1589
+ return normalized_input, None, None
1590
+
1591
+ def _find_all_matching_pending_orders(self, query: str) -> list[tuple[dict, dict]]:
1592
+ """Find all pending orders in self.last_orders_response matching an ID, suffix, or symbol."""
1593
+ if not getattr(self, "last_orders_response", None):
1594
+ return []
1595
+
1596
+ PENDING_STATUSES = {"OPEN", "TRIGGER PENDING", "AMO REQ RECEIVED", "PUT ORDER REQ RECEIVED"}
1597
+ matches = []
1598
+
1599
+ normalized_query = query.strip().upper()
1600
+ if not normalized_query:
1601
+ return []
1602
+
1603
+ for acct in self.last_orders_response.get("accounts", []):
1604
+ for o in acct.get("orders", []):
1605
+ if o.get("status", "").upper() in PENDING_STATUSES:
1606
+ order_id = str(o.get("order_id", ""))
1607
+ symbol = str(o.get("tradingsymbol", "")).upper()
1608
+
1609
+ # 1. Exact or suffix match on order ID
1610
+ if order_id == query or order_id.endswith(query) or (len(query) >= 4 and query in order_id):
1611
+ matches.append((acct, o))
1612
+ # 2. Match on trading symbol (exact match or query is substring of symbol)
1613
+ elif symbol == normalized_query or normalized_query in symbol:
1614
+ matches.append((acct, o))
1615
+
1616
+ return matches
1617
+
1618
+ def _find_pending_order(self, query: str) -> tuple[tuple[dict, dict] | None, str | None]:
1619
+ """Find a pending order in self.last_orders_response by ID or suffix.
1620
+
1621
+ Returns:
1622
+ ((account, order), None) if a unique match is found,
1623
+ (None, error_message) otherwise.
1624
+ """
1625
+ if not getattr(self, "last_orders_response", None):
1626
+ return None, "No orders cache available. Please wait for a refresh."
1627
+
1628
+ PENDING_STATUSES = {"OPEN", "TRIGGER PENDING", "AMO REQ RECEIVED", "PUT ORDER REQ RECEIVED"}
1629
+ matches = []
1630
+
1631
+ # Normalize the query (strip whitespace)
1632
+ normalized_query = query.strip()
1633
+ if not normalized_query:
1634
+ return None, "Empty order search query."
1635
+
1636
+ for acct in self.last_orders_response.get("accounts", []):
1637
+ for o in acct.get("orders", []):
1638
+ if o.get("status", "").upper() in PENDING_STATUSES:
1639
+ order_id = str(o.get("order_id", ""))
1640
+ # Check exact match, ends with match, or general substring
1641
+ if order_id == normalized_query or order_id.endswith(normalized_query) or (len(normalized_query) >= 4 and normalized_query in order_id):
1642
+ matches.append((acct, o))
1643
+
1644
+ if not matches:
1645
+ return None, f"No pending order matches '{query}'."
1646
+
1647
+ if len(matches) > 1:
1648
+ # Check if there is an exact endswith match with the suffix
1649
+ exact_matches = [m for m in matches if m[1].get("order_id", "").endswith(normalized_query)]
1650
+ if len(exact_matches) == 1:
1651
+ return exact_matches[0], None
1652
+
1653
+ desc = ", ".join(m[1].get("order_id") for m in matches)
1654
+ return None, f"Multiple pending orders match '{query}': {desc}. Please be more specific."
1655
+
1656
+ return matches[0], None
1657
+
1658
+ def resolve_account(self, input_acc: str) -> tuple[dict | None, str | None]:
1659
+ """Resolve an account name, 1-based index, or API key.
1660
+
1661
+ Returns:
1662
+ (resolved_account_dict, error_message)
1663
+ """
1664
+ normalized = input_acc.strip().lower()
1665
+ if not normalized:
1666
+ return None, "Empty account query."
1667
+
1668
+ # 1. Check if index (1-based)
1669
+ if normalized.isdigit():
1670
+ idx = int(normalized) - 1
1671
+ if 0 <= idx < len(self.accounts):
1672
+ return self.accounts[idx], None
1673
+ return None, f"Invalid account index '{input_acc}'. Available: 1 to {len(self.accounts)}"
1674
+
1675
+ # 2. Match name (exact case-insensitive)
1676
+ for acct in self.accounts:
1677
+ if acct.get("name", "").lower() == normalized:
1678
+ return acct, None
1679
+
1680
+ # 3. Match api_key (exact)
1681
+ for acct in self.accounts:
1682
+ if acct.get("api_key", "").lower() == normalized:
1683
+ return acct, None
1684
+
1685
+ # 4. Match prefix of name
1686
+ matches = [acct for acct in self.accounts if acct.get("name", "").lower().startswith(normalized)]
1687
+ if len(matches) == 1:
1688
+ return matches[0], None
1689
+ elif len(matches) > 1:
1690
+ names = [m.get("name") for m in matches]
1691
+ return None, f"Ambiguous account '{input_acc}'. Matches: {', '.join(names)}"
1692
+
1693
+ return None, f"No account found matching '{input_acc}'"
1694
+
1695
+ def _select_symbol_from_text(self, text: str) -> None:
1696
+ """Helper to validate and set the selected symbol from clicked/highlighted text."""
1697
+ if not text:
1698
+ return
1699
+
1700
+ symbol = text.strip().upper()
1701
+ if not symbol:
1702
+ return
1703
+ if not symbol[0].isalpha():
1704
+ return
1705
+
1706
+ excluded = {
1707
+ "CE", "PE", "LTP", "STRIKE", "SYMBOL", "LOT", "N/A", "WEEK", "OPTION",
1708
+ "CHAIN", "EXPIRY", "TIP", "TO", "BUY", "TYPE", "AVAILABLE", "EXPIRIES",
1709
+ "USE", "OR", "ORDERS", "PENDING", "EXECUTED", "ACCOUNTS", "ACCOUNT",
1710
+ "CONFIRM", "EXIT", "ALL", "POSITIONS", "REFRESH", "SECONDS", "CLEAR",
1711
+ "QUIT", "HELP", "STATUS", "LOGS", "AND", "THE", "FOR", "IN", "ON",
1712
+ "OF", "AT", "BY", "WITH", "IS", "ARE", "WAS", "WERE", "BE", "BEEN",
1713
+ "E.G.", "E.G", "I.E.", "I.E", "LIMIT", "MARKET", "CNC", "MIS",
1714
+ "NRML", "CO", "BO", "OPEN", "COMPLETE", "NO", "ERROR"
1715
+ }
1716
+ if symbol in excluded:
1717
+ return
1718
+
1719
+ if not all(c.isalnum() or c in "-." for c in symbol):
1720
+ return
1721
+
1722
+ resolved_sym, api_key, err = self.resolve_symbol(symbol)
1723
+ if resolved_sym:
1724
+ self.selected_symbol = resolved_sym
1725
+ if api_key:
1726
+ self.selected_account_api_key = api_key
1727
+ for acct in self.accounts:
1728
+ if acct.get("api_key") == api_key:
1729
+ self.selected_account_name = acct.get("name")
1730
+ break
1731
+ else:
1732
+ self.selected_symbol = symbol
1733
+
1734
+ self.update_prompt_label()
1735
+ self.log_message(f"Selected symbol [bold]{self.selected_symbol}[/bold] via click/selection.")
1736
+ if hasattr(self, "app"):
1737
+ self.app.invalidate()
1738
+
1739
+
1740
+
1741
+
1742
+ def _resolve_qty(self, qty_arg: str, symbol: str | None) -> tuple[str, str, str | None]:
1743
+ """Resolve a quantity argument that may use the lot suffix (e.g. '2L').
1744
+
1745
+ Args:
1746
+ qty_arg: Raw qty string from the command line, e.g. '2L', '75', '1L'.
1747
+ symbol: Trading symbol used to look up lot_size from the cache.
1748
+
1749
+ Returns:
1750
+ (raw_qty_str, display_str, error)
1751
+ - raw_qty_str: plain integer string ready for execute_order, e.g. '150'
1752
+ - display_str: human-friendly string shown in confirmation, e.g. '2L (150 qty)'
1753
+ - error: non-None string if the input is invalid.
1754
+ """
1755
+ cleaned = qty_arg.strip()
1756
+ if cleaned.upper().endswith("L"):
1757
+ # Lot-based quantity
1758
+ lots_str = cleaned[:-1]
1759
+ if not lots_str.isdigit() or int(lots_str) <= 0:
1760
+ return "", "", f"Invalid lot quantity '{qty_arg}'. Use e.g. '2L' for 2 lots."
1761
+ lots = int(lots_str)
1762
+ lot_size = 1
1763
+ if symbol:
1764
+ lot_size = self.client.get_nfo_lot_sizes().get(symbol.upper(), 0)
1765
+ if not lot_size:
1766
+ return "", "", (
1767
+ f"No lot size found for '{symbol}'. "
1768
+ "Use raw quantity (e.g. '75') for equity symbols."
1769
+ )
1770
+ raw_qty = lots * lot_size
1771
+ return str(raw_qty), f"{lots}L ({raw_qty} qty)", None
1772
+ else:
1773
+ # Plain raw quantity — backward compatible
1774
+ if not cleaned.isdigit() or int(cleaned) <= 0:
1775
+ return "", "", f"Invalid quantity '{qty_arg}'. Must be a positive integer or lot notation (e.g. '2L')."
1776
+ return cleaned, cleaned, None
1777
+
1778
+ async def execute_order(self, symbol: str, transaction_type: str, qty_str: str, price_str: str = None, product: str = "NRML", api_keys: list[str] = []) -> None:
1779
+ """Place order across specified accounts in executor."""
1780
+ # 1. Validate quantity
1781
+ try:
1782
+ qty = int(qty_str)
1783
+ if qty <= 0:
1784
+ raise ValueError("Quantity must be positive.")
1785
+ except ValueError:
1786
+ self.log_message(f"[#ff0000]Error:[/#] Invalid quantity '{qty_str}'. Must be a positive integer.")
1787
+ return
1788
+
1789
+ # 2. Parse price and order type
1790
+ price = None
1791
+ order_type = "MARKET"
1792
+ if price_str:
1793
+ try:
1794
+ price = float(price_str)
1795
+ if price > 0:
1796
+ order_type = "LIMIT"
1797
+ else:
1798
+ price = None
1799
+ except ValueError:
1800
+ self.log_message(f"[#ff5555]Warning:[/#] Price '{price_str}' not a float, placing as MARKET order.")
1801
+
1802
+ # 3. Auto-detect exchange
1803
+ exchange = "NFO"
1804
+ if symbol.endswith("CE") or symbol.endswith("PE"):
1805
+ exchange = "NFO"
1806
+ elif any(symbol.endswith(exp) for exp in ("FUT", "CE", "PE")):
1807
+ exchange = "NFO"
1808
+ else:
1809
+ exchange = "NSE"
1810
+
1811
+ # For NSE, NRML is not allowed. If product is still default NRML, auto-switch to CNC
1812
+ if exchange == "NSE" and product.upper() == "NRML":
1813
+ product = "CNC"
1814
+
1815
+ accts_desc = f"account {self.selected_account_name}" if (api_keys and hasattr(self, "selected_account_name") and self.selected_account_name) else "all accounts"
1816
+ self.log_message(f"Placing {order_type} {transaction_type.upper()} order for {qty} {symbol} ({product.upper()}) at price {price or 'MARKET'} on {accts_desc}...")
1817
+
1818
+ # 4. Place order
1819
+ try:
1820
+ response = await self._run_api_call(
1821
+ self.client.place_order,
1822
+ api_keys=api_keys,
1823
+ tradingsymbol=symbol,
1824
+ exchange=exchange,
1825
+ transaction_type=transaction_type.upper(),
1826
+ quantity=qty,
1827
+ order_type=order_type,
1828
+ price=price,
1829
+ product=product.upper(),
1830
+ )
1831
+
1832
+ # Print individual results
1833
+ results = response.get("results", [])
1834
+ for res in results:
1835
+ name = res.get("name", "Unknown")
1836
+ status = res.get("status", "error")
1837
+ msg = res.get("message", "")
1838
+ legs = res.get("legs", 1)
1839
+ ord_id = res.get("order_id")
1840
+ if status == "success":
1841
+ if legs > 1:
1842
+ self.log_message(f"[#00ff00]✓ {name}:[/#] Split into {legs} legs. IDs: {ord_id}")
1843
+ else:
1844
+ self.log_message(f"[#00ff00]✓ {name}:[/#] Placed. ID: {ord_id}")
1845
+ else:
1846
+ self.log_message(f"[#ff0000]✗ {name}:[/#] Failed — {msg}")
1847
+
1848
+ except KCLIClientError as exc:
1849
+ self.log_message(f"[#ff0000]Order Execution Failed:[/#] {exc}")
1850
+ except Exception as exc:
1851
+ self.log_message(f"[#ff0000]Unexpected Error:[/#] {exc}")
1852
+
1853
+ async def execute_exit(self, symbol: str = None, api_keys: list[str] = [], price: float | None = None) -> None:
1854
+ """Execute exit of positions across specified accounts in executor."""
1855
+ if symbol and symbol.lower() == "all":
1856
+ symbol = None
1857
+ accts_desc = f"account {self.selected_account_name}" if (api_keys and hasattr(self, "selected_account_name") and self.selected_account_name) else "all accounts"
1858
+ price_desc = f" @{price:.2f}" if price is not None else ""
1859
+ if symbol:
1860
+ self.log_message(f"Exiting open positions for {symbol} across {accts_desc}{price_desc}...")
1861
+ else:
1862
+ self.log_message(f"Exiting ALL open positions across {accts_desc}{price_desc}...")
1863
+
1864
+ try:
1865
+ # Place exit request on server
1866
+ response = await self._run_api_call(
1867
+ self.client.exit_positions,
1868
+ api_keys=api_keys,
1869
+ tradingsymbol=symbol,
1870
+ price=price,
1871
+ )
1872
+
1873
+ # Print exit results
1874
+ results = response.get("results", [])
1875
+ for res in results:
1876
+ name = res.get("name", "Unknown")
1877
+ status = res.get("status", "error")
1878
+ msg = res.get("message", "")
1879
+ placed = res.get("orders_placed", [])
1880
+
1881
+ if status == "success":
1882
+ if placed:
1883
+ symbols_exited = ", ".join(f"{item.get('tradingsymbol')} ({item.get('quantity')})" for item in placed)
1884
+ self.log_message(f"[#00ff00]✓ {name}:[/#] Exited {symbols_exited}")
1885
+ else:
1886
+ self.log_message(f"[#87afaf]~ {name}:[/#] {msg}")
1887
+ else:
1888
+ self.log_message(f"[#ff0000]✗ {name}:[/#] Failed — {msg}")
1889
+
1890
+ except KCLIClientError as exc:
1891
+ self.log_message(f"[#ff0000]Exit Execution Failed:[/#] {exc}")
1892
+
1893
+ async def execute_modify(self, order_id: str, qty_str: str, price_str: str, api_key: str) -> None:
1894
+ """Modify open order in executor."""
1895
+ # 1. Validate quantity
1896
+ try:
1897
+ qty = int(qty_str)
1898
+ if qty <= 0:
1899
+ raise ValueError("Quantity must be positive.")
1900
+ except ValueError:
1901
+ self.log_message(f"[#ff0000]Error:[/#] Invalid quantity '{qty_str}'. Must be a positive integer.")
1902
+ return
1903
+
1904
+ # 2. Parse price
1905
+ price = None
1906
+ order_type = "LIMIT"
1907
+ if price_str:
1908
+ try:
1909
+ price = float(price_str)
1910
+ if price <= 0:
1911
+ price = None
1912
+ order_type = "MARKET"
1913
+ except ValueError:
1914
+ self.log_message(f"[#ff0000]Error:[/#] Invalid price '{price_str}'. Must be a positive float.")
1915
+ return
1916
+
1917
+ self.log_message(f"Modifying order {order_id} to quantity {qty} @ {price or 'MARKET'}...")
1918
+
1919
+ try:
1920
+ response = await self._run_api_call(
1921
+ self.client.modify_order,
1922
+ api_key=api_key,
1923
+ order_id=order_id,
1924
+ quantity=qty,
1925
+ price=price,
1926
+ order_type=order_type,
1927
+ )
1928
+
1929
+ status = response.get("status", "error")
1930
+ msg = response.get("message", "")
1931
+ if status == "success":
1932
+ self.log_message(f"[#00ff00]✓ Order modified successfully.[/#] ID: {order_id}")
1933
+ # Clear order selection after successful modification
1934
+ if self.selected_order and self.selected_order.get("order_id") == order_id:
1935
+ self.selected_order = None
1936
+ self.update_prompt_label()
1937
+ # Trigger a refresh to show updated orders/positions
1938
+ if hasattr(self, "app") and self.app and self.app.loop:
1939
+ asyncio.run_coroutine_threadsafe(self._trigger_immediate_refresh(), self.app.loop)
1940
+ else:
1941
+ self.log_message(f"[#ff0000]✗ Modify failed:[/#] {msg}")
1942
+
1943
+ except Exception as exc:
1944
+ self.log_message(f"[#ff0000]Modify Execution Failed:[/#] {exc}")
1945
+
1946
+ async def execute_cancel(self, order_id: str, api_key: str) -> None:
1947
+ """Cancel open order in executor."""
1948
+ self.log_message(f"Cancelling order {order_id}...")
1949
+
1950
+ try:
1951
+ response = await self._run_api_call(
1952
+ self.client.cancel_order,
1953
+ api_key=api_key,
1954
+ order_id=order_id,
1955
+ )
1956
+
1957
+ status = response.get("status", "error")
1958
+ msg = response.get("message", "")
1959
+ if status == "success":
1960
+ self.log_message(f"[#00ff00]✓ Order cancelled successfully.[/#] ID: {order_id}")
1961
+ # Clear order selection after successful cancellation
1962
+ if self.selected_order and self.selected_order.get("order_id") == order_id:
1963
+ self.selected_order = None
1964
+ self.update_prompt_label()
1965
+ # Trigger a refresh to show updated orders/positions
1966
+ if hasattr(self, "app") and self.app and self.app.loop:
1967
+ asyncio.run_coroutine_threadsafe(self._trigger_immediate_refresh(), self.app.loop)
1968
+ else:
1969
+ self.log_message(f"[#ff0000]✗ Cancel failed:[/#] {msg}")
1970
+
1971
+ except Exception as exc:
1972
+ self.log_message(f"[#ff0000]Cancel Execution Failed:[/#] {exc}")
1973
+
1974
+ def _log_command(self, cmd_text: str, action: dict | str, status: str, result: str | None = None, api_key: str | None = None) -> None:
1975
+ if not hasattr(self, "recorder"):
1976
+ return
1977
+ user_id = self.api_key_to_user_id.get(api_key) if api_key else None
1978
+ parsed_str = json.dumps(action) if isinstance(action, dict) else str(action)
1979
+ self.recorder.enqueue_command(
1980
+ command_text=cmd_text,
1981
+ parsed_action=parsed_str,
1982
+ status=status,
1983
+ result_message=result,
1984
+ index_values=self.index_values,
1985
+ user_id=user_id,
1986
+ )
1987
+
1988
+ def handle_input(self, buffer) -> None:
1989
+ """Process entered command line with error safety wrapper."""
1990
+ try:
1991
+ return self._handle_input_core(buffer)
1992
+ except Exception as exc:
1993
+ self.log_message(f"[#ff0000]Error processing input:[/#] {exc}")
1994
+ logger.error("Error processing input: %s", exc, exc_info=True)
1995
+
1996
+ def _handle_input_core(self, buffer) -> None:
1997
+ """Process entered command line, supporting command chaining via &&."""
1998
+ raw_text = buffer.text.strip()
1999
+ if not raw_text:
2000
+ return
2001
+
2002
+ # Support command chaining via '&&'
2003
+ commands = [c.strip() for c in raw_text.split("&&") if c.strip()]
2004
+ for cmd in commands:
2005
+ self._execute_single_command(cmd)
2006
+
2007
+ def _execute_single_command(self, cmd: str) -> None:
2008
+ """Process a single command line."""
2009
+ if not cmd:
2010
+ return
2011
+
2012
+ # Check if we are waiting for confirmation of a pending order
2013
+ if hasattr(self, "pending_order") and self.pending_order:
2014
+ ans = cmd.lower().strip()
2015
+ p = self.pending_order
2016
+
2017
+ # Reset confirmation state upfront so nested commands are not intercepted
2018
+ self.pending_order = None
2019
+ self.update_prompt_label()
2020
+
2021
+ if ans in ("y", "yes"):
2022
+ self.log_message("[#00ff00]Order Confirmed.[/#]")
2023
+ self._log_command(
2024
+ cmd_text=f"confirm: {ans}",
2025
+ action={"confirmed_action": p},
2026
+ status="success",
2027
+ api_key=p.get("api_key") if p.get("api_key") else (p.get("api_keys")[0] if p.get("api_keys") else None),
2028
+ )
2029
+ if p["type"] == "exit":
2030
+ asyncio.create_task(self.execute_exit(p["symbol"], p.get("api_keys", []), p.get("price")))
2031
+ elif p["type"] == "exit_near_week":
2032
+ for sym in p["symbols"]:
2033
+ asyncio.create_task(self.execute_exit(sym, p.get("api_keys", []), p.get("price")))
2034
+ elif p["type"] == "modify":
2035
+ asyncio.create_task(
2036
+ self.execute_modify(
2037
+ p["order_id"],
2038
+ p["qty"],
2039
+ p["price"],
2040
+ p["api_key"],
2041
+ )
2042
+ )
2043
+ elif p["type"] == "modify_multi":
2044
+ for order_info in p["orders"]:
2045
+ asyncio.create_task(
2046
+ self.execute_modify(
2047
+ order_info["order_id"],
2048
+ p["qty"],
2049
+ p["price"],
2050
+ order_info["api_key"],
2051
+ )
2052
+ )
2053
+ elif p["type"] == "cancel":
2054
+ asyncio.create_task(
2055
+ self.execute_cancel(
2056
+ p["order_id"],
2057
+ p["api_key"],
2058
+ )
2059
+ )
2060
+ elif p["type"] == "cancel_multi":
2061
+ for order_info in p["orders"]:
2062
+ asyncio.create_task(
2063
+ self.execute_cancel(
2064
+ order_info["order_id"],
2065
+ order_info["api_key"],
2066
+ )
2067
+ )
2068
+ elif p["type"] == "nli_command":
2069
+ self.log_message(f"Executing NLI Command: [bold]{p['command']}[/bold]")
2070
+ self._skip_confirmation = True
2071
+ try:
2072
+ for sub_cmd in p["command"].split(" && "):
2073
+ sub_cmd = sub_cmd.strip()
2074
+ if sub_cmd:
2075
+ self._execute_single_command(sub_cmd)
2076
+ finally:
2077
+ self._skip_confirmation = False
2078
+ else:
2079
+ asyncio.create_task(
2080
+ self.execute_order(
2081
+ p["symbol"],
2082
+ p["type"],
2083
+ p["qty"],
2084
+ p["price"],
2085
+ p["product"],
2086
+ api_keys=p.get("api_keys", []),
2087
+ )
2088
+ )
2089
+ else:
2090
+ self.log_message("[#ff5555]Order Cancelled.[/#]")
2091
+ self._log_command(
2092
+ cmd_text=f"confirm: {ans}",
2093
+ action={"cancelled_action": p},
2094
+ status="cancelled",
2095
+ api_key=p.get("api_key") if p.get("api_key") else (p.get("api_keys")[0] if p.get("api_keys") else None),
2096
+ )
2097
+ return
2098
+
2099
+ if cmd.startswith("/"):
2100
+ nli_text = cmd[1:].strip()
2101
+ if nli_text:
2102
+ asyncio.create_task(self.resolve_nli_command(nli_text))
2103
+ return
2104
+
2105
+ parts = cmd.split()
2106
+ primary_cmd = parts[0].lower()
2107
+
2108
+ if primary_cmd == "quit" and len(parts) == 1:
2109
+ self._log_command(cmd, "quit", "success")
2110
+ self.running = False
2111
+ self.app.exit()
2112
+ return
2113
+
2114
+ if primary_cmd == "exit" and len(parts) == 1:
2115
+ if hasattr(self, "selected_symbol") and self.selected_symbol:
2116
+ target_keys = [self.selected_account_api_key] if self.selected_account_api_key else []
2117
+ self._log_command(
2118
+ cmd_text=cmd,
2119
+ action={"action": "exit_selected_symbol", "symbol": self.selected_symbol},
2120
+ status="success",
2121
+ api_key=self.selected_account_api_key,
2122
+ )
2123
+ asyncio.create_task(self.execute_exit(self.selected_symbol, target_keys))
2124
+ return
2125
+ else:
2126
+ self._log_command(cmd, "quit_via_exit", "success")
2127
+ self.running = False
2128
+ self.app.exit()
2129
+ return
2130
+
2131
+ if primary_cmd == "clear":
2132
+ self._log_command(cmd, "clear", "success")
2133
+ self.logs = []
2134
+ self.logs_control.text = ANSI("")
2135
+ return
2136
+
2137
+ if primary_cmd == "help":
2138
+ self._log_command(cmd, "help", "success")
2139
+ self.log_message("[#00afaf]Available Commands:[/#]")
2140
+ self.log_message(" [bold]buy / sell [symbol|id] <qty|lotsL> [price] [product][/bold]")
2141
+ self.log_message(" e.g. [bold]sell 2L[/bold] (2 lots of selected position)")
2142
+ self.log_message(" e.g. [bold]sell 3 2L[/bold] (2 lots of position ID 3)")
2143
+ self.log_message(" e.g. [bold]sell NIFTY25JUN24000CE 1L[/bold] (1 lot by symbol)")
2144
+ self.log_message(" e.g. [bold]buy 75[/bold] (75 raw qty, backward-compatible)")
2145
+ self.log_message(" [bold]oc <UNDERLYING> [week <N> | <YYYY-MM-DD>][/bold] - Show option chain (right pane)")
2146
+ self.log_message(" e.g. [bold]oc NIFTY[/bold] — current week strikes")
2147
+ self.log_message(" e.g. [bold]oc NIFTY week 1[/bold] — next week strikes")
2148
+ self.log_message(" e.g. [bold]oc BANKNIFTY 2024-06-27[/bold] — specific expiry")
2149
+ self.log_message(" [bold]select <id|none> / s <id|none>[/bold] - Select active position (or 'none' to clear)")
2150
+ self.log_message(" [bold]select account <name|index|none> / s a <name|index|none>[/bold]")
2151
+ self.log_message(" [bold]account <name|index|none> / acct <...>/ a <...>[/bold] - Select specific account context")
2152
+ self.log_message(" [bold]deselect[/bold] - Clear current active selection")
2153
+ self.log_message(" [bold]exit [symbol|id][/bold] - Exit position (current selection if symbol omitted)")
2154
+ self.log_message(" [bold]exit all[/bold] - Exit ALL open positions across all/selected accounts")
2155
+ self.log_message(" [bold]refresh[/bold] - Trigger manual sync of positions/orders")
2156
+ self.log_message(" [bold]reconnect[/bold] - Restart all WebSocket connections")
2157
+ self.log_message(" [bold]clear[/bold] - Clear logs screen")
2158
+ self.log_message(" [bold]quit / exit[/bold] - Close dashboard")
2159
+ self.log_message("[#00afaf]Right Pane (Info Panel):[/#]")
2160
+ self.log_message(" [bold]F1[/bold] — Pending Orders (per account, real-time)")
2161
+ self.log_message(" [bold]F2[/bold] — Executed Orders (per account, today)")
2162
+ self.log_message(" [bold]F3[/bold] — Option Chain (after running 'oc' command)")
2163
+ self.log_message("[#00afaf]Navigation:[/#]")
2164
+ self.log_message(" [bold]Tab[/bold] — Cycle focus: Input → Positions → Logs → Info Pane")
2165
+ self.log_message(" [bold]Mouse Drag[/bold] — Click & hold/drag pane borders to resize")
2166
+ self.log_message(" [bold]Ctrl+←→[/bold] — Resize left/right split (5% per press)")
2167
+ self.log_message(" [bold]Ctrl+↑↓[/bold] — Resize Status Logs height (2 rows per press)")
2168
+ self.log_message(" [bold]Escape[/bold] — Clear/deselect the current selection")
2169
+ return
2170
+
2171
+ if primary_cmd == "refresh":
2172
+ self._log_command(cmd, "refresh", "success")
2173
+ self.log_message("Triggering manual refresh...")
2174
+ if hasattr(self, "app") and self.app and self.app.loop:
2175
+ asyncio.run_coroutine_threadsafe(self._trigger_immediate_refresh(), self.app.loop)
2176
+ return
2177
+
2178
+ if primary_cmd == "reconnect":
2179
+ self._log_command(cmd, "reconnect", "success")
2180
+ if hasattr(self, "app") and self.app and self.app.loop:
2181
+ asyncio.run_coroutine_threadsafe(self.reconnect_websockets(), self.app.loop)
2182
+ return
2183
+
2184
+ # Deselect Command
2185
+ if primary_cmd == "deselect":
2186
+ self._log_command(cmd, "deselect", "success")
2187
+ self.selected_symbol = None
2188
+ self.selected_account_name = None
2189
+ self.selected_account_api_key = None
2190
+ self.selected_order = None
2191
+ self.update_prompt_label()
2192
+ self.log_message("Selection cleared.")
2193
+ return
2194
+
2195
+ # Select Position / Account / Order Command
2196
+ if primary_cmd in ("select", "s"):
2197
+ if len(parts) < 2:
2198
+ self.log_message("[#ff0000]Usage:[/#] select <id|none> OR select order <id_suffix|none> OR select account <name|index|none>")
2199
+ return
2200
+
2201
+ raw_id = parts[1]
2202
+
2203
+ # Check if selecting an account: select account <query> or s a <query>
2204
+ if raw_id.lower() in ("account", "acct", "a"):
2205
+ if len(parts) < 3:
2206
+ self.log_message("[#ff0000]Usage:[/#] select account <name|index|none>")
2207
+ return
2208
+ raw_acc = " ".join(parts[2:])
2209
+ if raw_acc.lower() in ("none", "clear", "null", "empty", "all"):
2210
+ self._log_command(cmd, {"action": "clear_account_selection"}, "success")
2211
+ self.selected_account_name = None
2212
+ self.selected_account_api_key = None
2213
+ self.selected_order = None
2214
+ self.update_prompt_label()
2215
+ self.log_message("Account selection cleared (orders will target all accounts).")
2216
+ return
2217
+
2218
+ acct, err = self.resolve_account(raw_acc)
2219
+ if err:
2220
+ self._log_command(cmd, {"action": "select_account", "query": raw_acc}, "error", err)
2221
+ self.log_message(f"[#ff0000]Error:[/#] {err}")
2222
+ return
2223
+
2224
+ self.selected_account_name = acct.get("name")
2225
+ self.selected_account_api_key = acct.get("api_key")
2226
+ self.selected_order = None
2227
+ self.update_prompt_label()
2228
+ self._log_command(cmd, {"action": "select_account", "name": self.selected_account_name}, "success", api_key=self.selected_account_api_key)
2229
+ self.log_message(f"Selected account: [bold]{self.selected_account_name}[/bold]. Orders will target this account only.")
2230
+ return
2231
+
2232
+ # Check if selecting an order: select order <id_suffix> or s o <id_suffix>
2233
+ if raw_id.lower() in ("order", "ord", "o"):
2234
+ if len(parts) < 3:
2235
+ self.log_message("[#ff0000]Usage:[/#] select order <id_suffix|none>")
2236
+ return
2237
+ raw_ord = parts[2]
2238
+ if raw_ord.lower() in ("none", "clear", "null", "empty"):
2239
+ self._log_command(cmd, {"action": "clear_order_selection"}, "success")
2240
+ self.selected_order = None
2241
+ self.update_prompt_label()
2242
+ self.log_message("Order selection cleared.")
2243
+ return
2244
+
2245
+ acct_and_order, err = self._find_pending_order(raw_ord)
2246
+ if err:
2247
+ self._log_command(cmd, {"action": "select_order", "query": raw_ord}, "error", err)
2248
+ self.log_message(f"[#ff0000]Error:[/#] {err}")
2249
+ return
2250
+
2251
+ acct, order = acct_and_order
2252
+ self.selected_order = {
2253
+ "order_id": order.get("order_id"),
2254
+ "tradingsymbol": order.get("tradingsymbol"),
2255
+ "transaction_type": order.get("transaction_type"),
2256
+ "quantity": order.get("quantity"),
2257
+ "price": order.get("price"),
2258
+ "order_type": order.get("order_type"),
2259
+ "product": order.get("product"),
2260
+ "api_key": acct.get("api_key"),
2261
+ "account_name": acct.get("name")
2262
+ }
2263
+
2264
+ # Clear active symbol selection to avoid confusion
2265
+ self.selected_symbol = None
2266
+
2267
+ self.update_prompt_label()
2268
+ self._log_command(cmd, {"action": "select_order", "order": self.selected_order}, "success", api_key=self.selected_order["api_key"])
2269
+ self.log_message(f"Selected pending order [bold]{self.selected_order['order_id']}[/bold] ({self.selected_order['tradingsymbol']} | {self.selected_order['transaction_type']} | {self.selected_order['quantity']} @ {self.selected_order['price']:.2f}) on @{self.selected_order['account_name']}.")
2270
+ return
2271
+
2272
+ if raw_id.lower() in ("none", "clear", "null", "empty"):
2273
+ self._log_command(cmd, {"action": "clear_position_selection"}, "success")
2274
+ self.selected_symbol = None
2275
+ self.selected_account_name = None
2276
+ self.selected_account_api_key = None
2277
+ self.selected_order = None
2278
+ self.update_prompt_label()
2279
+ self.log_message("Selection cleared.")
2280
+ return
2281
+
2282
+ symbol, api_key, err = self.resolve_symbol(raw_id)
2283
+ if err:
2284
+ self._log_command(cmd, {"action": "select_position", "query": raw_id}, "error", err)
2285
+ self.log_message(f"[#ff0000]Error:[/#] {err}")
2286
+ return
2287
+
2288
+ self.selected_symbol = symbol
2289
+ self.selected_order = None
2290
+ if api_key:
2291
+ self.selected_account_api_key = api_key
2292
+ for acct in self.accounts:
2293
+ if acct.get("api_key") == api_key:
2294
+ self.selected_account_name = acct.get("name")
2295
+ break
2296
+
2297
+ self.update_prompt_label()
2298
+ self._log_command(
2299
+ cmd_text=cmd,
2300
+ action={"action": "select_position", "symbol": symbol, "api_key": api_key},
2301
+ status="success",
2302
+ api_key=api_key,
2303
+ )
2304
+ self.log_message(f"Selected {symbol} (@{self.selected_account_name or 'All Accounts'}). You can now type: [bold]buy|sell <qty> [price] [product][/bold] directly!")
2305
+ return
2306
+
2307
+ # Direct Account Selection Command
2308
+ if primary_cmd in ("account", "acct", "a"):
2309
+ if len(parts) < 2:
2310
+ self.log_message("[#ff0000]Usage:[/#] account <name|index|none>")
2311
+ return
2312
+ raw_acc = " ".join(parts[1:])
2313
+ if raw_acc.lower() in ("none", "clear", "null", "empty", "all"):
2314
+ self.selected_account_name = None
2315
+ self.selected_account_api_key = None
2316
+ self.selected_order = None
2317
+ self.update_prompt_label()
2318
+ self.log_message("Account selection cleared (orders will target all accounts).")
2319
+ return
2320
+
2321
+ acct, err = self.resolve_account(raw_acc)
2322
+ if err:
2323
+ self.log_message(f"[#ff0000]Error:[/#] {err}")
2324
+ return
2325
+
2326
+ self.selected_account_name = acct.get("name")
2327
+ self.selected_account_api_key = acct.get("api_key")
2328
+ self.selected_order = None
2329
+ self.update_prompt_label()
2330
+ self.log_message(f"Selected account: [bold]{self.selected_account_name}[/bold]. Orders will target this account only.")
2331
+ return
2332
+
2333
+ # Modify Order Command: order <id_suffix|full_id|symbol> <quantity|lotsL> <price>
2334
+ if primary_cmd == "order":
2335
+ if len(parts) < 4:
2336
+ self.log_message("[#ff0000]Usage:[/#] order <id_suffix|full_id|symbol> <quantity|lotsL> <price>")
2337
+ return
2338
+
2339
+ raw_id_or_sym = parts[1]
2340
+ qty_str = parts[2]
2341
+ price_str = parts[3]
2342
+
2343
+ # Find all matching pending orders
2344
+ matches = self._find_all_matching_pending_orders(raw_id_or_sym)
2345
+ if not matches:
2346
+ self.log_message(f"[#ff0000]Error:[/#] No pending order matches ID/suffix/symbol '{raw_id_or_sym}'.")
2347
+ return
2348
+
2349
+ # Resolve quantity with lot size if it has L
2350
+ # Use the first matched order's symbol for lot-size resolution helper
2351
+ first_acct, first_order = matches[0]
2352
+ first_sym = first_order.get("tradingsymbol")
2353
+ raw_qty_str, qty_display, qty_err = self._resolve_qty(qty_str, first_sym)
2354
+ if qty_err:
2355
+ self.log_message(f"[#ff0000]Error:[/#] {qty_err}")
2356
+ return
2357
+
2358
+ # Parse price
2359
+ try:
2360
+ price_val = float(price_str)
2361
+ except ValueError:
2362
+ self.log_message(f"[#ff0000]Error:[/#] Price '{price_str}' must be a float.")
2363
+ return
2364
+
2365
+ price_desc = f"@{price_val:.2f}" if price_val > 0 else "MARKET"
2366
+
2367
+ # If there is exactly 1 match, run standard single order modify
2368
+ if len(matches) == 1:
2369
+ acct, order = matches[0]
2370
+ order_id = order.get("order_id")
2371
+ symbol = order.get("tradingsymbol")
2372
+ api_key = acct.get("api_key")
2373
+ self.pending_order = {
2374
+ "type": "modify",
2375
+ "order_id": order_id,
2376
+ "qty": raw_qty_str,
2377
+ "price": price_str,
2378
+ "api_key": api_key,
2379
+ "symbol": symbol
2380
+ }
2381
+ self._log_command(
2382
+ cmd_text=cmd,
2383
+ action=self.pending_order,
2384
+ status="pending_confirmation",
2385
+ api_key=api_key,
2386
+ )
2387
+ self.prompt_control.text = f" Confirm MODIFY order {order_id[-6:]} to {qty_display} {symbol} {price_desc}? (y/n)> "
2388
+ self.log_message(f"[#ff8700]Pending Confirmation:[/#] MODIFY order {order_id} ({symbol}) to {qty_display} {price_desc}. Press [bold]y[/bold] to confirm, any other key to cancel.")
2389
+ else:
2390
+ # Multi-order modify
2391
+ self.pending_order = {
2392
+ "type": "modify_multi",
2393
+ "orders": [
2394
+ {
2395
+ "order_id": o.get("order_id"),
2396
+ "api_key": a.get("api_key"),
2397
+ "account_name": a.get("name"),
2398
+ "symbol": o.get("tradingsymbol")
2399
+ }
2400
+ for a, o in matches
2401
+ ],
2402
+ "qty": raw_qty_str,
2403
+ "price": price_str
2404
+ }
2405
+ self._log_command(
2406
+ cmd_text=cmd,
2407
+ action=self.pending_order,
2408
+ status="pending_confirmation",
2409
+ api_key=None,
2410
+ )
2411
+ accts_list = ", ".join(f"@{o['account_name']}" for o in self.pending_order["orders"])
2412
+ self.prompt_control.text = f" Confirm MODIFY {len(matches)} orders ({first_sym}) to {qty_display} {price_desc} on {accts_list}? (y/n)> "
2413
+ self.log_message(
2414
+ f"[#ff8700]Pending Confirmation:[/#] MODIFY {len(matches)} pending orders for {first_sym} "
2415
+ f"to {qty_display} {price_desc} on accounts {accts_list}. Press [bold]y[/bold] to confirm, any other key to cancel."
2416
+ )
2417
+ return
2418
+
2419
+ # Cancel Order Command: cancel [id_suffix|full_id|symbol]
2420
+ if primary_cmd == "cancel":
2421
+ target_id_or_sym = None
2422
+ if len(parts) >= 2:
2423
+ target_id_or_sym = parts[1]
2424
+ elif self.selected_order:
2425
+ target_id_or_sym = self.selected_order.get("order_id")
2426
+
2427
+ if not target_id_or_sym:
2428
+ self.log_message("[#ff0000]Usage:[/#] cancel <id_suffix|full_id|symbol> (or select a pending order first)")
2429
+ return
2430
+
2431
+ matches = self._find_all_matching_pending_orders(target_id_or_sym)
2432
+ if not matches:
2433
+ self.log_message(f"[#ff0000]Error:[/#] No pending order matches ID/suffix/symbol '{target_id_or_sym}'.")
2434
+ return
2435
+
2436
+ if len(matches) == 1:
2437
+ acct, order = matches[0]
2438
+ order_id = order.get("order_id")
2439
+ symbol = order.get("tradingsymbol")
2440
+ api_key = acct.get("api_key")
2441
+ qty = order.get("quantity")
2442
+ price = order.get("price")
2443
+ tx_type = order.get("transaction_type")
2444
+ price_desc = f"@{price:.2f}" if price else "MARKET"
2445
+
2446
+ self.pending_order = {
2447
+ "type": "cancel",
2448
+ "order_id": order_id,
2449
+ "api_key": api_key,
2450
+ "symbol": symbol
2451
+ }
2452
+ self._log_command(
2453
+ cmd_text=cmd,
2454
+ action=self.pending_order,
2455
+ status="pending_confirmation",
2456
+ api_key=api_key,
2457
+ )
2458
+ self.prompt_control.text = f" Confirm CANCEL order {order_id[-6:]} ({tx_type} {qty} {symbol} {price_desc})? (y/n)> "
2459
+ self.log_message(f"[#ff8700]Pending Confirmation:[/#] CANCEL order {order_id} ({tx_type} {qty} {symbol} {price_desc}). Press [bold]y[/bold] to confirm, any other key to cancel.")
2460
+ else:
2461
+ self.pending_order = {
2462
+ "type": "cancel_multi",
2463
+ "orders": [
2464
+ {
2465
+ "order_id": o.get("order_id"),
2466
+ "api_key": a.get("api_key"),
2467
+ "account_name": a.get("name"),
2468
+ "symbol": o.get("tradingsymbol")
2469
+ }
2470
+ for a, o in matches
2471
+ ]
2472
+ }
2473
+ self._log_command(
2474
+ cmd_text=cmd,
2475
+ action=self.pending_order,
2476
+ status="pending_confirmation",
2477
+ api_key=None,
2478
+ )
2479
+ first_sym = matches[0][1].get("tradingsymbol")
2480
+ accts_list = ", ".join(f"@{o['account_name']}" for o in self.pending_order["orders"])
2481
+ self.prompt_control.text = f" Confirm CANCEL {len(matches)} orders ({first_sym}) on {accts_list}? (y/n)> "
2482
+ self.log_message(
2483
+ f"[#ff8700]Pending Confirmation:[/#] CANCEL {len(matches)} pending orders for {first_sym} "
2484
+ f"on accounts {accts_list}. Press [bold]y[/bold] to confirm, any other key to cancel."
2485
+ )
2486
+ return
2487
+
2488
+ # Direct Buy/Sell Commands
2489
+ if primary_cmd in ("buy", "sell"):
2490
+ args = parts[1:]
2491
+ if not args:
2492
+ self.log_message(f"[#ff0000]Usage:[/#] {primary_cmd} [symbol|id] <qty|lotsL> [price] [product]")
2493
+ self.log_message(f" e.g. [bold]{primary_cmd} 2L[/bold] — 2 lots of selected position")
2494
+ self.log_message(f" e.g. [bold]{primary_cmd} 3 2L[/bold] — 2 lots of position #3")
2495
+ self.log_message(f" e.g. [bold]{primary_cmd} NIFTY24DEC24000CE 1L[/bold] — 1 lot by symbol")
2496
+ return
2497
+
2498
+ symbol = None
2499
+ api_key = None
2500
+ qty_str = None
2501
+ price_str = None
2502
+ product = "NRML"
2503
+
2504
+ def _is_qty_token(s):
2505
+ """Return True if s looks like a qty: plain int or NL notation."""
2506
+ return s.isdigit() or (s.upper().endswith("L") and s[:-1].isdigit())
2507
+
2508
+ # Case 1: First argument is a valid position ID (plain integer < 100,
2509
+ # exists in position_id_map). Must be plain digit, not e.g. '2L'.
2510
+ if args[0].isdigit() and int(args[0]) < 100 and hasattr(self, "position_id_map") and int(args[0]) in self.position_id_map:
2511
+ symbol, api_key, err = self.resolve_symbol(args[0])
2512
+ pos = self.position_id_map.get(int(args[0]))
2513
+ if len(args) < 2:
2514
+ # Default to full position size, expressed in lots when possible
2515
+ if pos:
2516
+ lot_size = pos.get("lot_size", 1) or 1
2517
+ raw_qty = abs(pos.get("quantity", 0))
2518
+ qty_str = f"{raw_qty // lot_size}L" if lot_size > 1 and raw_qty % lot_size == 0 else str(raw_qty)
2519
+ self.log_message(f"Omitted quantity. Defaulting to position size {qty_str}.")
2520
+ else:
2521
+ self.log_message(f"[#ff0000]Usage:[/#] {primary_cmd} <id> <qty|lotsL> [price] [product]")
2522
+ return
2523
+ else:
2524
+ qty_str = args[1]
2525
+ if len(args) > 2:
2526
+ price_str = args[2]
2527
+ if len(args) > 3:
2528
+ product = args[3]
2529
+
2530
+ # Case 1b: Active selection exists and first argument is a qty token
2531
+ elif _is_qty_token(args[0]) and hasattr(self, "selected_symbol") and self.selected_symbol:
2532
+ symbol = self.selected_symbol
2533
+ api_key = self.selected_account_api_key
2534
+ qty_str = args[0]
2535
+ if len(args) > 1:
2536
+ price_str = args[1]
2537
+ if len(args) > 2:
2538
+ product = args[2]
2539
+
2540
+ else:
2541
+ # Case 2: Parse symbol and quantity.
2542
+ # Scan right-to-left for a qty token to avoid strike prices/dates.
2543
+ # IMPORTANT: NL tokens (e.g. '2L') take priority over plain integers
2544
+ # to their right (which are likely prices). So first try to find an NL
2545
+ # token; only fall back to plain-integer scan if none found.
2546
+ qty_idx = -1
2547
+
2548
+ # Pass 1: look for rightmost NL token (e.g. '2L', '1L')
2549
+ for i in range(len(args) - 1, -1, -1):
2550
+ arg = args[i]
2551
+ if arg.upper().endswith("L") and arg[:-1].isdigit():
2552
+ qty_idx = i
2553
+ break
2554
+
2555
+ # Pass 2: if no NL token, fall back to rightmost plain integer
2556
+ if qty_idx == -1:
2557
+ for i in range(len(args) - 1, -1, -1):
2558
+ arg = args[i]
2559
+ if arg.isdigit():
2560
+ # Skip strike prices: followed by CE/PE/FUT
2561
+ if i + 1 < len(args) and args[i+1].upper() in ("CE", "PE", "FUT"):
2562
+ continue
2563
+ qty_idx = i
2564
+ break
2565
+
2566
+ if qty_idx != -1:
2567
+ qty_str = args[qty_idx]
2568
+
2569
+ if qty_idx == 0:
2570
+ # No symbol specified — use currently selected symbol
2571
+ if hasattr(self, "selected_symbol") and self.selected_symbol:
2572
+ symbol = self.selected_symbol
2573
+ api_key = self.selected_account_api_key
2574
+ else:
2575
+ self.log_message(f"[#ff0000]Error:[/#] No position selected. Type 'select <id>' first or specify a symbol.")
2576
+ return
2577
+ else:
2578
+ raw_sym = " ".join(args[:qty_idx])
2579
+ symbol, api_key, err = self.resolve_symbol(raw_sym)
2580
+ if err:
2581
+ self.log_message(f"[#ff0000]Error:[/#] {err}")
2582
+ return
2583
+
2584
+ if len(args) > qty_idx + 1:
2585
+ price_str = args[qty_idx + 1]
2586
+ if len(args) > qty_idx + 2:
2587
+ product = args[qty_idx + 2]
2588
+ else:
2589
+ # No qty token — check if entire args is a symbol with a known position
2590
+ raw_sym = " ".join(args)
2591
+ symbol, api_key, err = self.resolve_symbol(raw_sym)
2592
+ if err:
2593
+ self.log_message(f"[#ff0000]Error:[/#] {err}")
2594
+ return
2595
+
2596
+ matched_pos = None
2597
+ normalized_sym = symbol.replace(" ", "").upper()
2598
+ for pos in getattr(self, "active_positions", []):
2599
+ if pos.get("tradingsymbol", "").replace(" ", "").upper() == normalized_sym:
2600
+ matched_pos = pos
2601
+ break
2602
+
2603
+ if matched_pos:
2604
+ lot_size = matched_pos.get("lot_size", 1) or 1
2605
+ raw_qty = abs(matched_pos.get("quantity", 0))
2606
+ qty_str = f"{raw_qty // lot_size}L" if lot_size > 1 and raw_qty % lot_size == 0 else str(raw_qty)
2607
+ api_key = matched_pos.get("api_key")
2608
+ self.log_message(f"Omitted quantity. Defaulting to position size {qty_str}.")
2609
+ else:
2610
+ self.log_message(f"[#ff0000]Error:[/#] Missing quantity. Usage: {primary_cmd} [symbol|id] <qty|lotsL> [price] [product]")
2611
+ return
2612
+
2613
+ # Resolve lot notation → raw qty
2614
+ raw_qty_str, qty_display, qty_err = self._resolve_qty(qty_str, symbol)
2615
+ if qty_err:
2616
+ self.log_message(f"[#ff0000]Error:[/#] {qty_err}")
2617
+ return
2618
+
2619
+ target_key = api_key or self.selected_account_api_key
2620
+ target_keys = [target_key] if target_key else []
2621
+
2622
+ if getattr(self, "_skip_confirmation", False):
2623
+ asyncio.create_task(
2624
+ self.execute_order(
2625
+ symbol,
2626
+ primary_cmd,
2627
+ raw_qty_str,
2628
+ price_str,
2629
+ product,
2630
+ api_keys=target_keys
2631
+ )
2632
+ )
2633
+ return
2634
+
2635
+ # Set pending order for confirmation (store resolved raw qty)
2636
+ self.pending_order = {
2637
+ "symbol": symbol,
2638
+ "type": primary_cmd,
2639
+ "qty": raw_qty_str,
2640
+ "price": price_str,
2641
+ "product": product,
2642
+ "api_keys": target_keys
2643
+ }
2644
+
2645
+ if target_keys:
2646
+ names = []
2647
+ for k in target_keys:
2648
+ for acct in self.accounts:
2649
+ if acct.get("api_key") == k:
2650
+ names.append(f"@{acct.get('name')}")
2651
+ break
2652
+ accts_desc = ", ".join(names)
2653
+ else:
2654
+ accts_desc = "All Accounts"
2655
+
2656
+ # Log pending buy/sell command
2657
+ self._log_command(
2658
+ cmd_text=cmd,
2659
+ action=self.pending_order,
2660
+ status="pending_confirmation",
2661
+ api_key=target_keys[0] if len(target_keys) == 1 else None,
2662
+ )
2663
+
2664
+ price_desc = f"at price {price_str}" if price_str else "at MARKET"
2665
+ self.prompt_control.text = f" Confirm {primary_cmd.upper()} {qty_display} {symbol} ({product}) {price_desc} on {accts_desc}? (y/n)> "
2666
+ self.log_message(f"[#ff8700]Pending Confirmation:[/#] {primary_cmd.upper()} {qty_display} {symbol} ({product}) {price_desc} on {accts_desc}. Press [bold]y[/bold] to confirm, any other key to cancel.")
2667
+ return
2668
+ return
2669
+
2670
+ # Exit Positions Command
2671
+ if primary_cmd == "exit":
2672
+ price_val = None
2673
+ if len(parts) < 2:
2674
+ if hasattr(self, "selected_symbol") and self.selected_symbol:
2675
+ raw_symbol = self.selected_symbol
2676
+ else:
2677
+ self.log_message("[#ff0000]Usage:[/#] exit <symbol|id> [price] OR exit all [price]")
2678
+ return
2679
+ else:
2680
+ last_part = parts[-1]
2681
+ is_numeric = False
2682
+ try:
2683
+ if last_part.replace(".", "", 1).isdigit() and not last_part.isalpha():
2684
+ is_numeric = True
2685
+ except Exception:
2686
+ pass
2687
+
2688
+ if is_numeric:
2689
+ if len(parts) == 2 and hasattr(self, "selected_symbol") and self.selected_symbol:
2690
+ price_val = float(last_part)
2691
+ raw_symbol = self.selected_symbol
2692
+ elif len(parts) >= 3:
2693
+ price_val = float(last_part)
2694
+ raw_symbol = " ".join(parts[1:-1])
2695
+ else:
2696
+ raw_symbol = " ".join(parts[1:])
2697
+ else:
2698
+ raw_symbol = " ".join(parts[1:])
2699
+
2700
+ # If the user typed "exit none" or "exit clear", they want to clear/exit the selection context
2701
+ if raw_symbol.lower() in ("none", "clear"):
2702
+ self.selected_symbol = None
2703
+ self.selected_account_name = None
2704
+ self.selected_account_api_key = None
2705
+ self.update_prompt_label()
2706
+ self.log_message("Selection cleared.")
2707
+ return
2708
+
2709
+ if raw_symbol.lower() in ("near-week", "near"):
2710
+ # Handle near-week option exit
2711
+ target_key = self.selected_account_api_key
2712
+ target_keys = [target_key] if target_key else [a["api_key"] for a in self.accounts]
2713
+
2714
+ # Retrieve options database to resolve expiries
2715
+ from cli.advisor import get_nifty_options
2716
+ import datetime
2717
+
2718
+ ref_key = None
2719
+ for a in self.accounts:
2720
+ if self.client.is_authenticated(a["api_key"]):
2721
+ ref_key = a["api_key"]
2722
+ break
2723
+
2724
+ if not ref_key:
2725
+ self.log_message("[#ff0000]Error:[/#] No authenticated accounts to resolve expiries.")
2726
+ return
2727
+
2728
+ options = get_nifty_options(self.client, ref_key)
2729
+ if not options:
2730
+ self.log_message("[#ff0000]Error:[/#] Failed to load options database.")
2731
+ return
2732
+
2733
+ today = datetime.date.today()
2734
+ underlying_near_expiry = {}
2735
+ for inst in options:
2736
+ name = inst.get("name")
2737
+ expiry = inst.get("expiry")
2738
+ if name and isinstance(expiry, datetime.date) and expiry >= today:
2739
+ if name not in underlying_near_expiry:
2740
+ underlying_near_expiry[name] = expiry
2741
+ else:
2742
+ underlying_near_expiry[name] = min(underlying_near_expiry[name], expiry)
2743
+
2744
+ # Scan active positions matching near expiry
2745
+ target_symbols = set()
2746
+ accounts_data = []
2747
+ if self.last_positions_response:
2748
+ accounts_data = self.last_positions_response.get("accounts", [])
2749
+
2750
+ for acct in accounts_data:
2751
+ if acct.get("api_key") not in target_keys:
2752
+ continue
2753
+ for pos in acct.get("positions", []):
2754
+ if pos.get("quantity", 0) == 0:
2755
+ continue
2756
+ sym = pos.get("tradingsymbol", "")
2757
+ inst = next((x for x in options if x.get("tradingsymbol") == sym), None)
2758
+ if inst:
2759
+ name = inst.get("name")
2760
+ exp = inst.get("expiry")
2761
+ if exp == underlying_near_expiry.get(name):
2762
+ target_symbols.add(sym)
2763
+
2764
+ if not target_symbols:
2765
+ self.log_message("No open near-week option positions found.")
2766
+ return
2767
+
2768
+ symbols_list = sorted(list(target_symbols))
2769
+ if getattr(self, "_skip_confirmation", False):
2770
+ for sym in symbols_list:
2771
+ asyncio.create_task(self.execute_exit(sym, target_keys, price_val))
2772
+ return
2773
+
2774
+ self.pending_order = {
2775
+ "type": "exit_near_week",
2776
+ "symbols": symbols_list,
2777
+ "api_keys": target_keys,
2778
+ "price": price_val,
2779
+ }
2780
+
2781
+ if self.selected_account_name:
2782
+ accts_desc = f"@{self.selected_account_name}"
2783
+ else:
2784
+ accts_desc = "All Accounts"
2785
+
2786
+ price_desc = f" @{price_val:.2f}" if price_val is not None else ""
2787
+
2788
+ # Log pending command
2789
+ self._log_command(
2790
+ cmd_text=cmd,
2791
+ action=self.pending_order,
2792
+ status="pending_confirmation",
2793
+ api_key=target_keys[0] if len(target_keys) == 1 else None,
2794
+ )
2795
+
2796
+ self.prompt_control.text = f" Confirm SQUAREOFF {', '.join(symbols_list)} on {accts_desc}{price_desc}? (y/n)> "
2797
+ self.log_message(f"[#ff8700]Pending Confirmation:[/#] SQUAREOFF {', '.join(symbols_list)} on {accts_desc}{price_desc}. Press [bold]y[/bold] to confirm.")
2798
+ return
2799
+
2800
+ if getattr(self, "_skip_confirmation", False):
2801
+ target_key = self.selected_account_api_key
2802
+ if raw_symbol and raw_symbol.lower() != "all":
2803
+ symbol, api_key, err = self.resolve_symbol(raw_symbol)
2804
+ if err:
2805
+ self.log_message(f"[#ff0000]Error:[/#] {err}")
2806
+ return
2807
+ target_key = api_key or self.selected_account_api_key
2808
+ asyncio.create_task(self.execute_exit(symbol, [target_key] if target_key else [], price_val))
2809
+ else:
2810
+ asyncio.create_task(self.execute_exit(None, [target_key] if target_key else [], price_val))
2811
+ return
2812
+
2813
+ # Set pending exit for confirmation
2814
+ self.pending_order = {
2815
+ "symbol": raw_symbol,
2816
+ "type": "exit",
2817
+ "qty": "",
2818
+ "price": price_val,
2819
+ "product": ""
2820
+ }
2821
+
2822
+ target_key = None
2823
+ if raw_symbol and raw_symbol.lower() != "all":
2824
+ symbol, api_key, err = self.resolve_symbol(raw_symbol)
2825
+ if err:
2826
+ self.log_message(f"[#ff0000]Error:[/#] {err}")
2827
+ self.pending_order = None
2828
+ return
2829
+ self.pending_order["symbol"] = symbol
2830
+ target_key = api_key or self.selected_account_api_key
2831
+ else:
2832
+ target_key = self.selected_account_api_key
2833
+
2834
+ target_keys = [target_key] if target_key else []
2835
+ self.pending_order["api_keys"] = target_keys
2836
+
2837
+ if target_keys:
2838
+ names = []
2839
+ for k in target_keys:
2840
+ for acct in self.accounts:
2841
+ if acct.get("api_key") == k:
2842
+ names.append(f"@{acct.get('name')}")
2843
+ break
2844
+ accts_desc = ", ".join(names)
2845
+ else:
2846
+ accts_desc = "All Accounts"
2847
+
2848
+ # Log pending exit command
2849
+ self._log_command(
2850
+ cmd_text=cmd,
2851
+ action=self.pending_order,
2852
+ status="pending_confirmation",
2853
+ api_key=target_keys[0] if len(target_keys) == 1 else None,
2854
+ )
2855
+
2856
+ price_desc = f" @{price_val:.2f}" if price_val is not None else ""
2857
+ if raw_symbol and raw_symbol.lower() != "all":
2858
+ symbol = self.pending_order["symbol"]
2859
+ self.prompt_control.text = f" Confirm EXIT of {symbol} on {accts_desc}{price_desc}? (y/n)> "
2860
+ self.log_message(f"[#ff8700]Pending Confirmation:[/#] EXIT open positions for {symbol} on {accts_desc}{price_desc}. Press [bold]y[/bold] to confirm, any other key to cancel.")
2861
+ else:
2862
+ self.prompt_control.text = f" Confirm EXIT of ALL positions on {accts_desc}{price_desc}? (y/n)> "
2863
+ self.log_message(f"[#ff8700]Pending Confirmation:[/#] EXIT ALL open positions on {accts_desc}{price_desc}. Press [bold]y[/bold] to confirm, any other key to cancel.")
2864
+ return
2865
+
2866
+ # Option Chain Command
2867
+ # Usage:
2868
+ # oc <UNDERLYING> → current week
2869
+ # oc <UNDERLYING> week <N> → N-th week (0=current, 1=next, ...)
2870
+ # oc <UNDERLYING> <YYYY-MM-DD> → specific expiry date
2871
+ if primary_cmd in ("oc", "optionchain", "chain"):
2872
+ if len(parts) < 2:
2873
+ self.log_message("[#ff0000]Usage:[/#] oc <UNDERLYING> [week <N> | <YYYY-MM-DD>]")
2874
+ self.log_message(" e.g. [bold]oc NIFTY[/bold] — current week expiry")
2875
+ self.log_message(" e.g. [bold]oc NIFTY week 1[/bold] — next week expiry")
2876
+ self.log_message(" e.g. [bold]oc BANKNIFTY 2024-06-27[/bold] — specific date")
2877
+ return
2878
+
2879
+ underlying = parts[1].upper()
2880
+ expiry_week = 0
2881
+ expiry_date = None
2882
+
2883
+ if len(parts) >= 3:
2884
+ # 'week N' suffix
2885
+ if parts[2].lower() == "week" and len(parts) >= 4:
2886
+ try:
2887
+ expiry_week = int(parts[3])
2888
+ except ValueError:
2889
+ self.log_message(f"[#ff0000]Error:[/#] Invalid week number '{parts[3]}'.")
2890
+ return
2891
+ else:
2892
+ # Try to parse as a date
2893
+ import re as _re
2894
+ if _re.match(r"\d{4}-\d{2}-\d{2}", parts[2]):
2895
+ expiry_date = parts[2]
2896
+ else:
2897
+ self.log_message(f"[#ff0000]Error:[/#] Unknown option '{parts[2]}'. Use 'week <N>' or 'YYYY-MM-DD'.")
2898
+ return
2899
+
2900
+ # Use the primary streaming account so the option chain can stream;
2901
+ # fall back to the first configured account for the REST fetch.
2902
+ if not self.accounts:
2903
+ self.log_message("[#ff0000]Error:[/#] No accounts configured.")
2904
+ return
2905
+ api_key = self.primary_api_key or self.accounts[0]["api_key"]
2906
+
2907
+ self.log_message(f"Fetching option chain for [bold]{underlying}[/bold] (expiry_week={expiry_week if not expiry_date else expiry_date})...")
2908
+ asyncio.create_task(self._fetch_and_display_option_chain(api_key, underlying, expiry_week, expiry_date))
2909
+ return
2910
+
2911
+
2912
+ self.log_message(f"[#ff0000]Unknown command:[/#] '{primary_cmd}'. Type 'help' for options.")
2913
+
2914
+ async def _fetch_and_display_option_chain(
2915
+ self,
2916
+ api_key: str,
2917
+ underlying: str,
2918
+ expiry_week: int = 0,
2919
+ expiry_date: str | None = None,
2920
+ ) -> None:
2921
+ """Fetch and display the option chain in the info pane (right panel)."""
2922
+ try:
2923
+ response = await self._run_api_call(
2924
+ self.client.get_option_chain,
2925
+ api_key=api_key,
2926
+ underlying=underlying,
2927
+ expiry_week=expiry_week,
2928
+ expiry_date=expiry_date,
2929
+ )
2930
+ except Exception as exc:
2931
+ self.log_message(f"Option chain error: {exc}")
2932
+ return
2933
+
2934
+ if response.get("status") != "success":
2935
+ self.log_message(f"Option chain error: {response.get('message', 'Unknown error')}")
2936
+ expiries = response.get("available_expiries", [])
2937
+ if expiries:
2938
+ self.log_message("Available expiries:")
2939
+ for e in expiries:
2940
+ self.log_message(f" {e['week_label']}: {e['expiry']}")
2941
+ return
2942
+
2943
+ expiry = response.get("expiry", "")
2944
+ strikes = response.get("strikes", [])
2945
+ expiries = response.get("available_expiries", [])
2946
+
2947
+ # Store structured data so streaming ticks can update LTPs in place and
2948
+ # the pane can be re-rendered without another REST fetch.
2949
+ self.oc_data = {
2950
+ "underlying": underlying,
2951
+ "expiry": expiry,
2952
+ "strikes": strikes,
2953
+ "available_expiries": expiries,
2954
+ }
2955
+
2956
+ # Subscribe the option tokens on the primary ticker so the LTPs stream.
2957
+ self._update_oc_subscriptions(strikes)
2958
+
2959
+ self._last_oc_text = self._render_oc_text()
2960
+ self.info_mode = "oc"
2961
+ self._update_info_buffer()
2962
+ stream_note = " (streaming)" if self.primary_api_key else ""
2963
+ self.log_message(
2964
+ f"Option chain loaded for {underlying} expiry {expiry}{stream_note}. "
2965
+ f"Press F3 to view in right pane."
2966
+ )
2967
+
2968
+ def _render_oc_text(self) -> str:
2969
+ """Render the current ``self.oc_data`` into the option-chain pane text."""
2970
+ data = self.oc_data
2971
+ if not data:
2972
+ return self._last_oc_text
2973
+
2974
+ underlying = data.get("underlying", "")
2975
+ expiry = data.get("expiry", "")
2976
+ strikes = data.get("strikes", [])
2977
+ expiries = data.get("available_expiries", [])
2978
+
2979
+ lines = [
2980
+ f"=== Option Chain: {underlying} | Expiry: {expiry} ===",
2981
+ "",
2982
+ f"{'CE LTP':>8} {'CE Symbol':<24} {'Strike':>8} {'PE Symbol':<24} {'PE LTP':>8}",
2983
+ "-" * 80,
2984
+ ]
2985
+ for s in strikes:
2986
+ strike = s.get("strike", 0)
2987
+ ce_sym = s.get("ce_symbol") or "-"
2988
+ pe_sym = s.get("pe_symbol") or "-"
2989
+ ce_ltp = s.get("ce_ltp")
2990
+ pe_ltp = s.get("pe_ltp")
2991
+ ce_ltp_str = f"{ce_ltp:>8.2f}" if ce_ltp is not None else f"{'N/A':>8}"
2992
+ pe_ltp_str = f"{pe_ltp:>8.2f}" if pe_ltp is not None else f"{'N/A':>8}"
2993
+ lines.append(
2994
+ f"{ce_ltp_str} {ce_sym:<24} {strike:>8.0f} {pe_sym:<24} {pe_ltp_str}"
2995
+ )
2996
+
2997
+ lines += [
2998
+ "-" * 80,
2999
+ "",
3000
+ "Available expiries (use 'oc <UNDERLYING> week <N>' or 'oc <UNDERLYING> YYYY-MM-DD'):",
3001
+ ]
3002
+ for e in expiries:
3003
+ lines.append(f" [{e['week_label']}] {e['expiry']}")
3004
+ lines.append("")
3005
+ lines.append("Tip: To buy CE, type: buy <CE_SYMBOL> <qty>")
3006
+ return "\n".join(lines)
3007
+
3008
+ def _update_oc_subscriptions(self, strikes: list[dict]) -> None:
3009
+ """Subscribe the option-chain instrument tokens on the primary ticker
3010
+ (unsubscribing any tokens from a previously displayed chain).
3011
+
3012
+ Also rebuilds ``self.oc_token_map`` so incoming ticks can be routed to
3013
+ the correct strike/side for live LTP updates.
3014
+ """
3015
+ new_tokens: set[int] = set()
3016
+ token_map: dict[int, tuple] = {}
3017
+ for s in strikes:
3018
+ ce_tok = s.get("ce_token")
3019
+ pe_tok = s.get("pe_token")
3020
+ if ce_tok:
3021
+ new_tokens.add(int(ce_tok))
3022
+ token_map[int(ce_tok)] = (s.get("strike"), "ce")
3023
+ if pe_tok:
3024
+ new_tokens.add(int(pe_tok))
3025
+ token_map[int(pe_tok)] = (s.get("strike"), "pe")
3026
+
3027
+ self.oc_token_map = token_map
3028
+
3029
+ ticker = self._get_active_ticker()
3030
+ old_tokens = self.oc_subscribed_tokens
3031
+
3032
+ # Don't unsubscribe tokens that are also position/index tokens.
3033
+ protected = set(self.subscribed_tokens) | set(self.index_tokens)
3034
+ to_unsub = (old_tokens - new_tokens) - protected
3035
+ to_sub = new_tokens - protected
3036
+
3037
+ if ticker:
3038
+ if to_unsub:
3039
+ try:
3040
+ ticker.unsubscribe(list(to_unsub))
3041
+ except Exception:
3042
+ pass
3043
+ if to_sub:
3044
+ try:
3045
+ ticker.subscribe(list(to_sub))
3046
+ ticker.set_mode(ticker.MODE_LTP, list(to_sub))
3047
+ except Exception:
3048
+ pass
3049
+
3050
+ self.oc_subscribed_tokens = new_tokens
3051
+
3052
+ def _update_oc_and_invalidate(self) -> None:
3053
+ """Re-render the option-chain pane from streamed values and refresh TUI."""
3054
+ self._last_oc_text = self._render_oc_text()
3055
+ if self.info_mode == "oc":
3056
+ self._update_info_buffer(reset_scroll=False)
3057
+ if hasattr(self, "app") and self.app:
3058
+ self.app.invalidate()
3059
+
3060
+ def _render_advisor_text(self) -> str:
3061
+ """Render Tuesday strangle advisor plan into plain text."""
3062
+ import datetime
3063
+
3064
+ today = datetime.date.today()
3065
+ is_tuesday = today.weekday() == 1
3066
+
3067
+ if not is_tuesday:
3068
+ return "=== Tuesday Expiry Option Strangle Advisor ===\n\n[INFO] Today is not Tuesday. Expiry strangle planning is only active on Tuesdays."
3069
+
3070
+ from cli.advisor import generate_tuesday_plan
3071
+
3072
+ # Fetch live Nifty spot price
3073
+ nifty_spot = self.index_values.get("nifty")
3074
+
3075
+ accounts_positions = []
3076
+ if self.last_positions_response:
3077
+ accounts_positions = self.last_positions_response.get("accounts", [])
3078
+
3079
+ try:
3080
+ plan = generate_tuesday_plan(
3081
+ client=self.client,
3082
+ accounts_positions=accounts_positions,
3083
+ margins_by_api_key=self.margins_by_api_key,
3084
+ api_key_to_user_id=self.api_key_to_user_id,
3085
+ nifty_spot=nifty_spot
3086
+ )
3087
+ except Exception as exc:
3088
+ return f"=== Tuesday Expiry Advisor ===\n\nFailed to plan: {exc}"
3089
+
3090
+ if plan.get("status") == "error":
3091
+ return f"=== Tuesday Expiry Advisor ===\n\nError: {plan.get('message')}"
3092
+
3093
+ lines = []
3094
+
3095
+ lines.append("=== Tuesday Expiry Option Strangle Advisor ===")
3096
+ spot_desc = f"{plan.get('nifty_spot'):,.2f}" if plan.get('nifty_spot') else "N/A"
3097
+ lines.append(f"NIFTY Index Spot: {spot_desc}")
3098
+
3099
+ exp = plan.get("expiries", {})
3100
+ lines.append(f"Expiries: E0={exp.get('E0')} | E1={exp.get('E1')} | E2={exp.get('E2')}")
3101
+
3102
+ strikes = plan.get("strikes", {})
3103
+ symbols = plan.get("symbols", {})
3104
+ lines.append("Target Strikes:")
3105
+ lines.append(f" - E1 (5% OTM) CE: {strikes.get('E1_CE')} ({symbols.get('E1_CE') or 'NOT FOUND'})")
3106
+ lines.append(f" - E1 (5% OTM) PE: {strikes.get('E1_PE')} ({symbols.get('E1_PE') or 'NOT FOUND'})")
3107
+ lines.append(f" - E2 (7% OTM) CE: {strikes.get('E2_CE')} ({symbols.get('E2_CE') or 'NOT FOUND'})")
3108
+ lines.append(f" - E2 (7% OTM) PE: {strikes.get('E2_PE')} ({symbols.get('E2_PE') or 'NOT FOUND'})")
3109
+ lines.append("-" * 75)
3110
+ lines.append("")
3111
+
3112
+ for acct in plan.get("accounts", []):
3113
+ lines.append(f"Account: {acct.get('name')} ({acct.get('user_id')})")
3114
+ lines.append(f" Margin: Cash={acct.get('cash')/100000:.2f}L | Collateral={acct.get('collateral')/100000:.2f}L | Total={acct.get('total_capital')/100000:.2f}L")
3115
+ lines.append(f" Allocated (50/50): E1={acct.get('lots_e1')} Lots (~{acct.get('lots_e1')*1.3:.1f}L) | E2={acct.get('lots_e2')} Lots (~{acct.get('lots_e2')*1.3:.1f}L)")
3116
+
3117
+ ex_e0 = acct.get("exits_e0", [])
3118
+ ex_e1 = acct.get("exits_e1", [])
3119
+ lines.append(f" Exits to Execute: E0={ex_e0 or '(none)'} | E1={ex_e1 or '(none)'}")
3120
+
3121
+ lines.append("")
3122
+ lines.append(" [Stage 1 Command (E0/E1 Exits + E1 Entry - 5% OTM)]")
3123
+ if acct.get("stage_1_cmd"):
3124
+ lines.append(f" > {acct.get('stage_1_cmd')}")
3125
+ else:
3126
+ lines.append(" > (no action needed or insufficient capital)")
3127
+
3128
+ lines.append("")
3129
+ lines.append(" [Stage 2 Command (E2 Entry - 7% OTM - ONLY if margin > 3L after Stage 1)]")
3130
+ if acct.get("stage_2_cmd"):
3131
+ lines.append(f" > {acct.get('stage_2_cmd')}")
3132
+ else:
3133
+ lines.append(" > (no action or insufficient capital)")
3134
+
3135
+ lines.append("-" * 75)
3136
+ lines.append("")
3137
+
3138
+ return "\n".join(lines)
3139
+
3140
+ async def resolve_nli_command(self, user_text: str) -> None:
3141
+ """Call Gemini NLI to translate natural language string, then stage confirmation."""
3142
+ if not getattr(self, "gemini_api_key", None):
3143
+ self.log_message("[#ff0000]Error:[/#] Gemini API Key is missing. Please add 'gemini_api_key' to ~/.kcli/config.yaml or set GEMINI_API_KEY env var.")
3144
+ return
3145
+
3146
+ self.log_message(f"[#d787ff]Translating NLI command via Gemini: \"{user_text}\"...[/#]")
3147
+
3148
+ # Build context
3149
+ selected_acct = self.selected_account_name
3150
+ accounts_list = [a.get("name") for a in self.accounts]
3151
+
3152
+ # Get open positions (from active_positions list)
3153
+ open_positions = getattr(self, "active_positions", [])
3154
+ nifty_spot = self.index_values.get("nifty")
3155
+
3156
+ # Resolve nearest active weekly expiry for each underlying and compile option list fallback
3157
+ nearest_expiries_str = {}
3158
+ available_options_list = []
3159
+ try:
3160
+ from cli.advisor import get_nifty_options
3161
+ import datetime
3162
+
3163
+ ref_key = None
3164
+ for a in self.accounts:
3165
+ if self.client.is_authenticated(a["api_key"]):
3166
+ ref_key = a["api_key"]
3167
+ break
3168
+
3169
+ if ref_key:
3170
+ options = get_nifty_options(self.client, ref_key)
3171
+ if options:
3172
+ today = datetime.date.today()
3173
+ nearest_expiries = {}
3174
+ for inst in options:
3175
+ name = inst.get("name")
3176
+ expiry = inst.get("expiry")
3177
+ if name and isinstance(expiry, datetime.date) and expiry >= today:
3178
+ if name not in nearest_expiries:
3179
+ nearest_expiries[name] = expiry
3180
+ else:
3181
+ nearest_expiries[name] = min(nearest_expiries[name], expiry)
3182
+
3183
+ nearest_expiries_str = {k: v.strftime("%Y-%m-%d") for k, v in nearest_expiries.items()}
3184
+
3185
+ spot = nifty_spot or 23500.0 # fallback
3186
+ for opt in options:
3187
+ try:
3188
+ strike = float(opt.get("strike", 0))
3189
+ if abs(strike - spot) / spot <= 0.15:
3190
+ available_options_list.append({
3191
+ "symbol": opt.get("tradingsymbol"),
3192
+ "expiry": opt.get("expiry").strftime("%Y-%m-%d") if isinstance(opt.get("expiry"), datetime.date) else str(opt.get("expiry")),
3193
+ "strike": strike,
3194
+ "type": opt.get("instrument_type")
3195
+ })
3196
+ except (ValueError, TypeError):
3197
+ pass
3198
+ except Exception:
3199
+ pass
3200
+
3201
+ from cli.nli import parse_natural_language
3202
+
3203
+ try:
3204
+ result = await parse_natural_language(
3205
+ api_key=self.gemini_api_key,
3206
+ user_input=user_text,
3207
+ selected_account=selected_acct,
3208
+ accounts_list=accounts_list,
3209
+ open_positions=open_positions,
3210
+ nifty_spot=nifty_spot,
3211
+ nearest_expiries=nearest_expiries_str,
3212
+ available_options=available_options_list
3213
+ )
3214
+ except Exception as exc:
3215
+ self.log_message(f"[#ff0000]NLI Translation Failed:[/#] {exc}")
3216
+ return
3217
+
3218
+ translated_cmd = result.get("command", "").strip()
3219
+ explanation = result.get("explanation", "").strip()
3220
+ confidence = result.get("confidence", 0.0)
3221
+
3222
+ if not translated_cmd:
3223
+ self.log_message(f"[#ff5555]NLI Translation Failed:[/#] Could not interpret query. ({explanation or 'Low confidence'})")
3224
+ return
3225
+
3226
+ # Stage confirmation
3227
+ self.pending_order = {
3228
+ "type": "nli_command",
3229
+ "command": translated_cmd,
3230
+ "explanation": explanation
3231
+ }
3232
+
3233
+ # Log pending command status
3234
+ self._log_command(
3235
+ cmd_text=f"/{user_text}",
3236
+ action=self.pending_order,
3237
+ status="pending_confirmation",
3238
+ api_key=self.selected_account_api_key
3239
+ )
3240
+
3241
+ self.prompt_control.text = f" Confirm NLI Action: {translated_cmd}? (y/n)> "
3242
+ self.log_message(f"[#d787ff]Pending Translation:[/#] \"{translated_cmd}\" ({explanation}, conf={confidence:.2f}). Press [bold]y[/bold] to confirm.")
3243
+
3244
+
3245
+ def _render_orders_pane(self, response: dict, mode: str) -> str:
3246
+ """Render pending or executed orders from a /api/orders response into plain text."""
3247
+ PENDING_STATUSES = {"OPEN", "TRIGGER PENDING", "AMO REQ RECEIVED", "PUT ORDER REQ RECEIVED"}
3248
+ EXECUTED_STATUSES = {"COMPLETE"}
3249
+ filter_statuses = PENDING_STATUSES if mode == "orders_pending" else EXECUTED_STATUSES
3250
+ label = "PENDING ORDERS" if mode == "orders_pending" else "EXECUTED ORDERS (TODAY)"
3251
+
3252
+ lines = [f"=== {label} ===", ""]
3253
+ any_orders = False
3254
+
3255
+ for acct in response.get("accounts", []):
3256
+ name = acct.get("name", "?")
3257
+ orders = [
3258
+ o for o in acct.get("orders", [])
3259
+ if o.get("status", "").upper() in filter_statuses
3260
+ ]
3261
+
3262
+ lines.append(f"Account: {name}")
3263
+ lines.append("-" * 60)
3264
+
3265
+ if acct.get("status", "").startswith("error"):
3266
+ lines.append(f" Error: {acct['status']}")
3267
+ elif not orders:
3268
+ lines.append(" (no orders)")
3269
+ else:
3270
+ any_orders = True
3271
+ for o in orders:
3272
+ sym = o.get("tradingsymbol", "?")
3273
+ tx = o.get("transaction_type", "?")
3274
+ qty = o.get("quantity", 0)
3275
+ filled = o.get("filled_quantity", 0)
3276
+ otype = o.get("order_type", "?")
3277
+ product = o.get("product", "?")
3278
+ status = o.get("status", "?")
3279
+ price = o.get("price", 0.0)
3280
+ avg = o.get("average_price", 0.0)
3281
+ oid = o.get("order_id", "?")
3282
+
3283
+ if mode == "orders_executed":
3284
+ price_desc = f"avg={avg:.2f}" if avg else "MARKET"
3285
+ else:
3286
+ price_desc = f"@ {price:.2f}" if price else "MARKET"
3287
+
3288
+ # Always show filled/total format (e.g. 0/910, 130/910, 910/910)
3289
+ qty_desc = f"{filled}/{qty}"
3290
+ lines.append(
3291
+ f" [{oid[-6:]}] {tx} {sym} | {qty_desc} | "
3292
+ f"{otype} {price_desc} | {product} | {status}"
3293
+ )
3294
+ lines.append("")
3295
+
3296
+ if not any_orders and mode == "orders_pending":
3297
+ lines.append("No pending orders across all accounts.")
3298
+ return "\n".join(lines)
3299
+
3300
+ def _update_info_buffer(self, reset_scroll: bool = True) -> None:
3301
+ """Push current info_mode text into the info_buffer.
3302
+
3303
+ Args:
3304
+ reset_scroll: When True (default) the pane scrolls back to the top.
3305
+ Pass False for live tick/data refreshes where the user
3306
+ may have scrolled down — preserves their position.
3307
+ """
3308
+ if not hasattr(self, "info_buffer"):
3309
+ return
3310
+ if self.info_mode == "orders_pending":
3311
+ text = self._last_pending_text
3312
+ elif self.info_mode == "orders_executed":
3313
+ text = self._last_executed_text
3314
+ elif self.info_mode == "advisor":
3315
+ try:
3316
+ self._last_advisor_text = self._render_advisor_text()
3317
+ except Exception as exc:
3318
+ self._last_advisor_text = f"=== Tuesday Expiry Advisor ===\n\nFailed to plan: {exc}"
3319
+ text = self._last_advisor_text
3320
+ else:
3321
+ text = self._last_oc_text
3322
+ if reset_scroll:
3323
+ new_cursor = 0
3324
+ else:
3325
+ old_doc = self.info_buffer.document
3326
+ old_row = old_doc.cursor_position_row
3327
+ old_col = old_doc.cursor_position_col
3328
+ temp_doc = Document(text=text)
3329
+ target_row = min(old_row, max(0, len(temp_doc.lines) - 1))
3330
+ new_cursor = temp_doc.translate_row_col_to_index(target_row, old_col)
3331
+
3332
+ self.info_buffer.set_document(
3333
+ Document(text=text, cursor_position=new_cursor),
3334
+ bypass_readonly=True,
3335
+ )
3336
+ if reset_scroll and hasattr(self, "_info_control"):
3337
+ self._info_control.vertical_scroll_position = 0
3338
+ if hasattr(self, "app"):
3339
+ self.app.invalidate()
3340
+
3341
+ def _get_frame_style(self, body_window) -> str:
3342
+ """Return focused_frame style if the window or its content has focus."""
3343
+ if hasattr(self, "app") and self.app:
3344
+ if self.app.layout.has_focus(body_window):
3345
+ return "class:focused_frame"
3346
+ if hasattr(body_window, "content") and self.app.layout.has_focus(body_window.content):
3347
+ return "class:focused_frame"
3348
+ return "class:frame"
3349
+
3350
+ def handle_global_drag(self, mouse_event, x, y) -> None:
3351
+ """Handle global mouse drag events to resize panes."""
3352
+ from prompt_toolkit.mouse_events import MouseEventType
3353
+
3354
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
3355
+ self.dragging_vertical = False
3356
+ self.dragging_horizontal = False
3357
+ if hasattr(self, "vertical_divider"):
3358
+ self.vertical_divider.style = "class:divider"
3359
+ if hasattr(self, "horizontal_divider"):
3360
+ self.horizontal_divider.style = "class:divider"
3361
+ self.app.invalidate()
3362
+ return
3363
+
3364
+ if mouse_event.event_type == MouseEventType.MOUSE_MOVE:
3365
+ if self.dragging_vertical:
3366
+ total_cols = self.app.output.get_size().columns
3367
+ if total_cols > 0:
3368
+ pct = int((x / total_cols) * 100)
3369
+ self.left_width_pct = max(20, min(80, pct))
3370
+ self.app.invalidate()
3371
+ elif self.dragging_horizontal:
3372
+ total_rows = self.app.output.get_size().rows
3373
+ if total_rows > 0:
3374
+ # y is absolute screen row (0-based)
3375
+ # Workspace is total_rows - 2 rows high (excluding header and input)
3376
+ # Logs pane height should be rows from y to bottom of workspace (total_rows - 2)
3377
+ log_height = (total_rows - 2) - y
3378
+ self.log_height_lines = max(4, min(30, log_height))
3379
+ self.app.invalidate()
3380
+
3381
+ async def run(self) -> None:
3382
+ """Launch the interactive prompt_toolkit dashboard."""
3383
+ # Keep noisy third-party WebSocket loggers off the full-screen TUI.
3384
+ _silence_websocket_loggers()
3385
+ # ── UI Controls ─────────────────────────────────────────────
3386
+ self.header_control = FormattedTextControl(
3387
+ text="🪁 KiteCLI Live │ Loading dashboard...",
3388
+ style="class:header",
3389
+ )
3390
+ self.market_indices_control = FormattedTextControl(
3391
+ text=" NIFTY 50: Loading... │ SENSEX: Loading... │ INDIA VIX: Loading...",
3392
+ focusable=False,
3393
+ show_cursor=False,
3394
+ )
3395
+
3396
+ # Active Positions — ScrollableFormattedTextControl with focusable=True so
3397
+ # the Window can receive keyboard scroll (Page Up/Down, arrow keys)
3398
+ # and mouse-wheel scroll when focused.
3399
+ self.positions_control = ScrollableFormattedTextControl(
3400
+ text="Fetching positions, please wait...",
3401
+ focusable=True,
3402
+ show_cursor=False,
3403
+ )
3404
+
3405
+ # Status Logs — Buffer-backed: scroll, selection, auto-scroll to end
3406
+ initial_log_text = "\n".join(self.logs)
3407
+ self.logs_buffer = Buffer(
3408
+ name="logs_buffer",
3409
+ read_only=True,
3410
+ document=Document(text=initial_log_text, cursor_position=len(initial_log_text)),
3411
+ )
3412
+ # Enable focus_on_click=True so clicking the logs pane focuses it and allows selection
3413
+ self._logs_control = ScrollableBufferControl(buffer=self.logs_buffer, focusable=True, focus_on_click=True)
3414
+
3415
+ # Info Pane (right half) — Buffer-backed: scroll, selection
3416
+ self.info_buffer = Buffer(
3417
+ name="info_buffer",
3418
+ read_only=True,
3419
+ document=Document(text=self._last_pending_text, cursor_position=0),
3420
+ )
3421
+ # Enable focus_on_click=True so clicking the info pane focuses it and allows selection
3422
+ self._info_control = ScrollableBufferControl(buffer=self.info_buffer, focusable=True, focus_on_click=True)
3423
+
3424
+ # Command input
3425
+ # Pass FileHistory to support persistent command line history
3426
+ from pathlib import Path
3427
+ history_dir = Path.home() / ".kcli"
3428
+ history_dir.mkdir(parents=True, exist_ok=True)
3429
+ history_file = history_dir / "history.txt"
3430
+
3431
+ self.input_field = TextArea(
3432
+ multiline=False,
3433
+ prompt="",
3434
+ style="class:input_text",
3435
+ accept_handler=self.handle_input,
3436
+ history=FileHistory(str(history_file)),
3437
+ focus_on_click=True,
3438
+ )
3439
+ self.prompt_control = FormattedTextControl(text=" kcli> ")
3440
+
3441
+ # Quick-action bar — use a *callable* so fragments are freshly
3442
+ # generated on every render (avoids stale _fragment_cache bugs).
3443
+ self.quickaction_control = FormattedTextControl(
3444
+ text=self._build_action_bar_text, # callable, not a list
3445
+ focusable=False,
3446
+ show_cursor=False,
3447
+ )
3448
+
3449
+ # Custom mouse handlers to copy selected text to macOS system clipboard on mouse release
3450
+ def _get_word_at_pos(text, pos):
3451
+ if not text or pos < 0 or pos >= len(text):
3452
+ return ""
3453
+ start = pos
3454
+ while start > 0 and (text[start-1].isalnum() or text[start-1] in "_-."):
3455
+ start -= 1
3456
+ end = pos
3457
+ while end < len(text) and (text[end].isalnum() or text[end] in "_-."):
3458
+ end += 1
3459
+ if start == end and pos > 0 and (text[pos-1].isalnum() or text[pos-1] in "_-."):
3460
+ start = pos - 1
3461
+ while start > 0 and (text[start-1].isalnum() or text[start-1] in "_-."):
3462
+ start -= 1
3463
+ end = pos
3464
+ return text[start:end]
3465
+
3466
+ original_info_mouse_handler = self._info_control.mouse_handler
3467
+ def new_info_mouse_handler(mouse_event):
3468
+ res = original_info_mouse_handler(mouse_event)
3469
+ from prompt_toolkit.mouse_events import MouseEventType
3470
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
3471
+ buf = self._info_control.buffer
3472
+ selected_text = None
3473
+ if buf.selection_state is not None:
3474
+ from_, to_ = buf.document.selection_range()
3475
+ selected_text = buf.document.text[from_:to_]
3476
+ else:
3477
+ pos = buf.document.cursor_position
3478
+ text = buf.document.text
3479
+ selected_text = _get_word_at_pos(text, pos)
3480
+
3481
+ if selected_text:
3482
+ selected_text = selected_text.strip()
3483
+ import subprocess
3484
+ try:
3485
+ proc = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE)
3486
+ proc.communicate(selected_text.encode('utf-8'))
3487
+ except Exception:
3488
+ pass
3489
+ self._select_symbol_from_text(selected_text)
3490
+ return res
3491
+ self._info_control.mouse_handler = new_info_mouse_handler
3492
+
3493
+ original_logs_mouse_handler = self._logs_control.mouse_handler
3494
+ def new_logs_mouse_handler(mouse_event):
3495
+ res = original_logs_mouse_handler(mouse_event)
3496
+ from prompt_toolkit.mouse_events import MouseEventType
3497
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
3498
+ buf = self._logs_control.buffer
3499
+ selected_text = None
3500
+ if buf.selection_state is not None:
3501
+ from_, to_ = buf.document.selection_range()
3502
+ selected_text = buf.document.text[from_:to_]
3503
+ else:
3504
+ pos = buf.document.cursor_position
3505
+ text = buf.document.text
3506
+ selected_text = _get_word_at_pos(text, pos)
3507
+
3508
+ if selected_text:
3509
+ selected_text = selected_text.strip()
3510
+ import subprocess
3511
+ try:
3512
+ proc = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE)
3513
+ proc.communicate(selected_text.encode('utf-8'))
3514
+ except Exception:
3515
+ pass
3516
+ self._select_symbol_from_text(selected_text)
3517
+ return res
3518
+ self._logs_control.mouse_handler = new_logs_mouse_handler
3519
+
3520
+ # Define persistent Windows to preserve scroll state
3521
+ self.positions_window = ScrollableWindow(
3522
+ content=self.positions_control,
3523
+ wrap_lines=True,
3524
+ allow_scroll_beyond_bottom=True,
3525
+ get_vertical_scroll=lambda win: self.positions_control.vertical_scroll_position,
3526
+ )
3527
+ self.logs_window = ScrollableWindow(
3528
+ content=self._logs_control,
3529
+ wrap_lines=True,
3530
+ height=lambda: self.log_height_lines,
3531
+ get_vertical_scroll=lambda win: self._logs_control.buffer.document.cursor_position_row,
3532
+ )
3533
+ self.info_window = ScrollableWindow(
3534
+ content=self._info_control,
3535
+ wrap_lines=True,
3536
+ allow_scroll_beyond_bottom=True,
3537
+ get_vertical_scroll=lambda win: self._info_control.buffer.document.cursor_position_row,
3538
+ )
3539
+
3540
+ def make_scroll_handler(window, control):
3541
+ original_handler = control.mouse_handler
3542
+ def scroll_mouse_handler(mouse_event):
3543
+ from prompt_toolkit.mouse_events import MouseEventType
3544
+ if mouse_event.event_type == MouseEventType.SCROLL_UP:
3545
+ if hasattr(control, "buffer"):
3546
+ for _ in range(3):
3547
+ control.buffer.cursor_up()
3548
+ else:
3549
+ control.vertical_scroll_position = max(0, control.vertical_scroll_position - 3)
3550
+ self.app.invalidate()
3551
+ return None
3552
+ elif mouse_event.event_type == MouseEventType.SCROLL_DOWN:
3553
+ if hasattr(control, "buffer"):
3554
+ for _ in range(3):
3555
+ control.buffer.cursor_down()
3556
+ else:
3557
+ if window.render_info:
3558
+ max_scroll = max(0, window.render_info.content_height - window.render_info.window_height)
3559
+ control.vertical_scroll_position = min(max_scroll, control.vertical_scroll_position + 3)
3560
+ else:
3561
+ control.vertical_scroll_position += 3
3562
+ self.app.invalidate()
3563
+ return None
3564
+ if original_handler:
3565
+ return original_handler(mouse_event)
3566
+ return NotImplemented
3567
+ return scroll_mouse_handler
3568
+
3569
+ self.positions_control.mouse_handler = make_scroll_handler(self.positions_window, self.positions_control)
3570
+ self._logs_control.mouse_handler = make_scroll_handler(self.logs_window, self._logs_control)
3571
+ self._info_control.mouse_handler = make_scroll_handler(self.info_window, self._info_control)
3572
+
3573
+ # ── Key Bindings ───────────────────────────────────────────
3574
+ kb = KeyBindings()
3575
+
3576
+ @kb.add("c-c")
3577
+ def _quit(event):
3578
+ self.running = False
3579
+ event.app.exit()
3580
+
3581
+ @kb.add("escape")
3582
+ def _deselect(event):
3583
+ if (
3584
+ (hasattr(self, "selected_symbol") and self.selected_symbol)
3585
+ or (hasattr(self, "selected_account_name") and self.selected_account_name)
3586
+ or (hasattr(self, "selected_order") and self.selected_order)
3587
+ ):
3588
+ self.selected_symbol = None
3589
+ self.selected_account_name = None
3590
+ self.selected_account_api_key = None
3591
+ self.selected_order = None
3592
+ self.update_prompt_label()
3593
+ self.log_message("Selection cleared.")
3594
+
3595
+ @kb.add("up", filter=has_focus(self.input_field))
3596
+ def _input_up(event):
3597
+ event.current_buffer.history_backward()
3598
+
3599
+ @kb.add("down", filter=has_focus(self.input_field))
3600
+ def _input_down(event):
3601
+ event.current_buffer.history_forward()
3602
+
3603
+ @kb.add("up", filter=~has_focus(self.input_field))
3604
+ def _scroll_up_kb(event):
3605
+ cur = event.app.layout.current_control
3606
+ if cur == self.positions_control:
3607
+ self.positions_control.vertical_scroll_position = max(0, self.positions_control.vertical_scroll_position - 1)
3608
+ elif hasattr(cur, "buffer"):
3609
+ cur.buffer.cursor_up()
3610
+
3611
+ @kb.add("down", filter=~has_focus(self.input_field))
3612
+ def _scroll_down_kb(event):
3613
+ cur = event.app.layout.current_control
3614
+ if cur == self.positions_control:
3615
+ w = self.positions_window
3616
+ c = self.positions_control
3617
+ if w.render_info:
3618
+ max_scroll = max(0, w.render_info.content_height - w.render_info.window_height)
3619
+ c.vertical_scroll_position = min(max_scroll, c.vertical_scroll_position + 1)
3620
+ else:
3621
+ c.vertical_scroll_position += 1
3622
+ elif hasattr(cur, "buffer"):
3623
+ cur.buffer.cursor_down()
3624
+
3625
+ @kb.add("pageup", filter=~has_focus(self.input_field))
3626
+ def _page_up_kb(event):
3627
+ cur = event.app.layout.current_control
3628
+ if cur == self.positions_control:
3629
+ self.positions_control.vertical_scroll_position = max(0, self.positions_control.vertical_scroll_position - 10)
3630
+ elif hasattr(cur, "buffer"):
3631
+ for _ in range(10):
3632
+ cur.buffer.cursor_up()
3633
+
3634
+ @kb.add("pagedown", filter=~has_focus(self.input_field))
3635
+ def _page_down_kb(event):
3636
+ cur = event.app.layout.current_control
3637
+ if cur == self.positions_control:
3638
+ w = self.positions_window
3639
+ c = self.positions_control
3640
+ if w.render_info:
3641
+ max_scroll = max(0, w.render_info.content_height - w.render_info.window_height)
3642
+ c.vertical_scroll_position = min(max_scroll, c.vertical_scroll_position + 10)
3643
+ else:
3644
+ c.vertical_scroll_position += 10
3645
+ elif hasattr(cur, "buffer"):
3646
+ for _ in range(10):
3647
+ cur.buffer.cursor_down()
3648
+
3649
+ @kb.add("tab")
3650
+ def _tab(event):
3651
+ """Cycle focus: input -> positions -> logs -> info pane -> input."""
3652
+ cur = event.app.layout.current_control
3653
+ cycle = [
3654
+ self.input_field.control,
3655
+ self.positions_control,
3656
+ self._logs_control,
3657
+ self._info_control,
3658
+ ]
3659
+ try:
3660
+ idx = cycle.index(cur)
3661
+ nxt = cycle[(idx + 1) % len(cycle)]
3662
+ except ValueError:
3663
+ nxt = self.input_field.control
3664
+ event.app.layout.focus(nxt)
3665
+
3666
+ # F1/F2/F3 — switch info pane content
3667
+ @kb.add("f1")
3668
+ def _f1(event):
3669
+ self.info_mode = "orders_pending"
3670
+ self._update_info_buffer()
3671
+
3672
+ @kb.add("f2")
3673
+ def _f2(event):
3674
+ self.info_mode = "orders_executed"
3675
+ self._update_info_buffer()
3676
+
3677
+ @kb.add("f3")
3678
+ def _f3(event):
3679
+ self.info_mode = "oc"
3680
+ self._update_info_buffer()
3681
+
3682
+ @kb.add("f4")
3683
+ def _f4(event):
3684
+ self.info_mode = "advisor"
3685
+ self._update_info_buffer()
3686
+
3687
+ # Ctrl+Left/Right — adjust left/right pane split
3688
+ @kb.add("c-left")
3689
+ def _narrow_left(event):
3690
+ self.left_width_pct = max(20, self.left_width_pct - 5)
3691
+ event.app.invalidate()
3692
+
3693
+ @kb.add("c-right")
3694
+ def _widen_left(event):
3695
+ self.left_width_pct = min(80, self.left_width_pct + 5)
3696
+ event.app.invalidate()
3697
+
3698
+ # Ctrl+Up/Down — adjust log pane height within left half
3699
+ @kb.add("c-up")
3700
+ def _shrink_log(event):
3701
+ self.log_height_lines = max(4, self.log_height_lines - 2)
3702
+ event.app.invalidate()
3703
+
3704
+ @kb.add("c-down")
3705
+ def _grow_log(event):
3706
+ self.log_height_lines = min(30, self.log_height_lines + 2)
3707
+ event.app.invalidate()
3708
+
3709
+ # Build dividers
3710
+ self.vertical_divider = Window(
3711
+ content=FormattedTextControl(
3712
+ text=[("", "┃\n")] * 120,
3713
+ focusable=False,
3714
+ ),
3715
+ width=1,
3716
+ style="class:divider",
3717
+ )
3718
+
3719
+ self.horizontal_divider = Window(
3720
+ content=FormattedTextControl(
3721
+ text="━" * 250,
3722
+ focusable=False,
3723
+ ),
3724
+ height=1,
3725
+ style="class:divider",
3726
+ )
3727
+
3728
+ # Divider mouse handlers
3729
+ def v_divider_mouse_handler(mouse_event):
3730
+ from prompt_toolkit.mouse_events import MouseEventType
3731
+ if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
3732
+ self.dragging_vertical = True
3733
+ self.vertical_divider.style = "class:divider.dragging"
3734
+ self.app.invalidate()
3735
+
3736
+ def h_divider_mouse_handler(mouse_event):
3737
+ from prompt_toolkit.mouse_events import MouseEventType
3738
+ if mouse_event.event_type == MouseEventType.MOUSE_DOWN:
3739
+ self.dragging_horizontal = True
3740
+ self.horizontal_divider.style = "class:divider.dragging"
3741
+ self.app.invalidate()
3742
+
3743
+ self.vertical_divider.content.mouse_handler = v_divider_mouse_handler
3744
+ self.horizontal_divider.content.mouse_handler = h_divider_mouse_handler
3745
+
3746
+ # Monkey-patch MouseHandlers.__init__ to wrap with DragInterceptDict
3747
+ original_init = MouseHandlers.__init__
3748
+ def new_init(lh_self, *args, **kwargs):
3749
+ original_init(lh_self, *args, **kwargs)
3750
+ lh_self.mouse_handlers = DragInterceptDict(self, lh_self.mouse_handlers)
3751
+ MouseHandlers.__init__ = new_init
3752
+
3753
+ # ── Dynamic Layout ─────────────────────────────────────────
3754
+ # DynamicContainer rebuilds the body on each render using current
3755
+ # self.left_width_pct and self.log_height_lines values.
3756
+ def _build_body():
3757
+ self._update_positions_display()
3758
+ lw = self.left_width_pct # left weight (20-80)
3759
+ rw = 100 - lw # right weight
3760
+
3761
+ return VSplit([
3762
+ # ── LEFT half: Positions (scrollable) + Logs (scrollable) ──
3763
+ HSplit([
3764
+ Frame(
3765
+ title="Active Positions (Live Ticks) [Tab: focus]",
3766
+ body=self.positions_window,
3767
+ style=self._get_frame_style(self.positions_window),
3768
+ ),
3769
+ self.horizontal_divider,
3770
+ Frame(
3771
+ title="Status Logs [Tab: focus] [Ctrl+↑↓: resize]",
3772
+ body=self.logs_window,
3773
+ style=self._get_frame_style(self.logs_window),
3774
+ ),
3775
+ ], width=D(weight=lw)),
3776
+ self.vertical_divider,
3777
+ # ── RIGHT half: Info Pane (scrollable) ──
3778
+ Frame(
3779
+ title="[F1] Pending [F2] Executed [F3] Option Chain [F4] Advisor [Ctrl+←→: resize]",
3780
+ body=HSplit([
3781
+ Window(
3782
+ content=self.market_indices_control,
3783
+ height=1,
3784
+ style="class:market_indices",
3785
+ ),
3786
+ Window(height=1, char="─", style="class:divider"),
3787
+ self.info_window,
3788
+ ]),
3789
+ style=self._get_frame_style(self.info_window),
3790
+ width=D(weight=rw),
3791
+ ),
3792
+ ])
3793
+
3794
+ layout = Layout(
3795
+ HSplit([
3796
+ Window(content=self.header_control, height=1, style="class:header"),
3797
+ DynamicContainer(lambda: _build_body()),
3798
+ # ── Quick-action button bar ──
3799
+ Window(
3800
+ content=self.quickaction_control,
3801
+ height=1,
3802
+ style="class:quickaction",
3803
+ ),
3804
+ # ── Command input row ──
3805
+ VSplit([
3806
+ Window(
3807
+ content=self.prompt_control,
3808
+ dont_extend_width=True,
3809
+ style="class:prompt_label",
3810
+ wrap_lines=True,
3811
+ ),
3812
+ self.input_field,
3813
+ ], height=3),
3814
+ ]),
3815
+ focused_element=self.input_field,
3816
+ )
3817
+
3818
+ self.app = Application(
3819
+ layout=layout,
3820
+ key_bindings=kb,
3821
+ style=self.style,
3822
+ full_screen=True,
3823
+ mouse_support=True,
3824
+ )
3825
+
3826
+ asyncio.create_task(self._initial_fetch_and_connect())
3827
+
3828
+ try:
3829
+ await self.app.run_async()
3830
+ finally:
3831
+ self.running = False
3832
+ if hasattr(self, "recorder"):
3833
+ try:
3834
+ self.recorder.stop()
3835
+ except Exception as exc:
3836
+ logger.error("Error stopping recorder: %s", exc, exc_info=True)
3837
+ for api_key, ticker in list(self.tickers.items()):
3838
+ try:
3839
+ ticker.close()
3840
+ except Exception as exc:
3841
+ logger.warning("Failed to close ticker for %s: %s", api_key[:8], exc)