kitecli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +11 -0
- cli/advisor.py +283 -0
- cli/api_client.py +421 -0
- cli/config.py +81 -0
- cli/display.py +395 -0
- cli/kite_manager.py +1042 -0
- cli/live_session.py +3841 -0
- cli/main.py +351 -0
- cli/nli.py +140 -0
- cli/recorder.py +321 -0
- cli/telegram_bot.py +1074 -0
- kitecli-0.1.0.dist-info/METADATA +259 -0
- kitecli-0.1.0.dist-info/RECORD +16 -0
- kitecli-0.1.0.dist-info/WHEEL +5 -0
- kitecli-0.1.0.dist-info/entry_points.txt +2 -0
- kitecli-0.1.0.dist-info/top_level.txt +1 -0
cli/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)
|