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/main.py ADDED
@@ -0,0 +1,351 @@
1
+ """
2
+ KiteCLI โ€” Multi-account Kite Connect positions viewer.
3
+
4
+ Entry-point module that defines all ``kcli`` commands using Typer.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ import asyncio
12
+
13
+ from cli.api_client import KCLIClient, KCLIClientError
14
+ from cli.config import CONFIG_FILE, create_default_config, load_config
15
+ from cli.display import (
16
+ console,
17
+ display_banner,
18
+ display_error,
19
+ display_info,
20
+ display_login_urls,
21
+ display_positions,
22
+ display_status,
23
+ display_success,
24
+ )
25
+ from cli.live_session import KCLILiveSession
26
+
27
+
28
+ app = typer.Typer(
29
+ name="kcli",
30
+ help="[bold cyan]Kite Connect CLI[/bold cyan] โ€” Multi-account trading positions viewer ๐Ÿช",
31
+ no_args_is_help=True,
32
+ rich_markup_mode="rich",
33
+ )
34
+
35
+
36
+ # โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
37
+
38
+ def _load_config_or_exit() -> dict:
39
+ """Load config and exit with a helpful message if missing."""
40
+ config = load_config()
41
+ if config is None:
42
+ display_error(
43
+ f"Config file not found at {CONFIG_FILE}\n"
44
+ " Run [bold]kcli config --init[/bold] to create one."
45
+ )
46
+ raise typer.Exit(code=1)
47
+ return config
48
+
49
+
50
+ def _build_client(config: dict) -> KCLIClient:
51
+ """Construct a local KCLIClient from the loaded config."""
52
+ accounts = config.get("accounts", [])
53
+ return KCLIClient(accounts)
54
+
55
+
56
+ def _ensure_accounts_initialized(client: KCLIClient, config: dict) -> None:
57
+ """Show per-account init status; in local mode accounts are init'd in KCLIClient.__init__."""
58
+ status_resp = client.get_status()
59
+ for acct in status_resp.get("accounts", []):
60
+ name = acct.get("name", "Account")
61
+ if acct.get("authenticated"):
62
+ display_success(f"{name}: session active โœ“")
63
+ else:
64
+ display_info(f"{name}: not authenticated โ€” run [bold]kcli init[/bold]")
65
+
66
+
67
+ def _mask_secret(value: str, visible: int = 4) -> str:
68
+ """Mask a secret string, showing only the last ``visible`` chars."""
69
+ if len(value) <= visible:
70
+ return "*" * len(value)
71
+ return "*" * (len(value) - visible) + value[-visible:]
72
+
73
+
74
+ # โ”€โ”€ commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
75
+
76
+ @app.command()
77
+ def init() -> None:
78
+ """[bold]Initialise accounts[/bold] โ€” authenticate with Kite Connect.
79
+
80
+ Sends account credentials to the server, shows login URLs,
81
+ then completes the auth callback for each account.
82
+ """
83
+ display_banner()
84
+
85
+ config = _load_config_or_exit()
86
+ accounts = config.get("accounts", [])
87
+ if not accounts:
88
+ display_error("No accounts configured. Edit your config file to add accounts.")
89
+ raise typer.Exit(code=1)
90
+ client = _build_client(config)
91
+ console.print()
92
+
93
+ accounts = config.get("accounts", [])
94
+ if not accounts:
95
+ display_error("No accounts configured. Edit your config file to add accounts.")
96
+ raise typer.Exit(code=1)
97
+
98
+ # Init accounts (auto-login is attempted)
99
+ try:
100
+ display_info("Initialising accountsโ€ฆ")
101
+ result = client.init_accounts(accounts)
102
+ console.print()
103
+ except KCLIClientError as exc:
104
+ display_error(str(exc))
105
+ raise typer.Exit(code=1)
106
+
107
+ login_accounts = result.get("accounts", [])
108
+
109
+ # Separate auto-logged-in accounts from those needing manual login
110
+ auto_logged = [a for a in login_accounts if a.get("auto_logged_in")]
111
+ manual_needed = [a for a in login_accounts if not a.get("auto_logged_in")]
112
+
113
+ # Show auto-login results
114
+ for acct in auto_logged:
115
+ display_success(f"{acct.get('name', 'Account')}: Auto-login successful! โœ“")
116
+ console.print()
117
+
118
+ # Handle accounts that need manual login
119
+ if manual_needed:
120
+ display_login_urls(manual_needed)
121
+ console.print()
122
+
123
+ # Show fallback messages for failed auto-logins
124
+ for acct in manual_needed:
125
+ msg = acct.get("message", "")
126
+ if msg:
127
+ display_info(f"{acct.get('name', 'Account')}: {msg}")
128
+
129
+ console.print(
130
+ " [bold yellow]After logging in, enter the request_token from the "
131
+ "redirect URL for each account.[/bold yellow]\n"
132
+ )
133
+
134
+ for acct in manual_needed:
135
+ name = acct.get("name", "Account")
136
+ api_key = acct.get("api_key", "")
137
+
138
+ request_token = typer.prompt(
139
+ f" ๐Ÿ”‘ request_token for {name}",
140
+ )
141
+
142
+ try:
143
+ resp = client.complete_callback(api_key, request_token.strip())
144
+ if resp.get("status") == "error":
145
+ display_error(f"{name}: {resp.get('message', 'Callback failed')}")
146
+ else:
147
+ display_success(f"{name}: Authenticated successfully! โœ“")
148
+ except KCLIClientError as exc:
149
+ display_error(f"{name}: {exc}")
150
+
151
+ console.print()
152
+
153
+ display_success("Initialisation complete. Run [bold]kcli positions[/bold] to view positions.")
154
+
155
+
156
+ @app.command()
157
+ def positions() -> None:
158
+ """[bold]View positions[/bold] โ€” fetch and display open positions across all accounts."""
159
+ display_banner()
160
+
161
+ config = _load_config_or_exit()
162
+ client = _build_client(config)
163
+
164
+ accounts = config.get("accounts", [])
165
+ api_keys = [acct.get("api_key", "") for acct in accounts]
166
+
167
+ try:
168
+ display_info("Fetching positionsโ€ฆ")
169
+ console.print()
170
+ result = client.get_positions(api_keys)
171
+ except KCLIClientError as exc:
172
+ display_error(str(exc))
173
+ raise typer.Exit(code=1)
174
+
175
+ accounts_data = result.get("accounts", [])
176
+ if not accounts_data:
177
+ display_info("No position data returned.")
178
+ raise typer.Exit()
179
+
180
+ display_positions(accounts_data)
181
+
182
+
183
+ @app.command()
184
+ def status() -> None:
185
+ """[bold]Account status[/bold] โ€” check authentication state of all accounts."""
186
+ display_banner()
187
+
188
+ config = _load_config_or_exit()
189
+ client = _build_client(config)
190
+ result = client.get_status()
191
+
192
+ status_accounts = result.get("accounts", [])
193
+ if not status_accounts:
194
+ display_info("No account status returned.")
195
+ raise typer.Exit()
196
+
197
+ display_status(status_accounts)
198
+
199
+
200
+ @app.command()
201
+ def live() -> None:
202
+ """[bold]Live dashboard[/bold] โ€” Interactive positions monitor and order terminal."""
203
+ config = _load_config_or_exit()
204
+ accounts = config.get("accounts", [])
205
+ if not accounts:
206
+ display_error("No accounts configured. Edit your config file to add accounts.")
207
+ raise typer.Exit(code=1)
208
+ client = _build_client(config)
209
+
210
+ # Launch interactive session
211
+ session = KCLILiveSession(client, accounts)
212
+ try:
213
+ asyncio.run(session.run())
214
+ except KeyboardInterrupt:
215
+ pass
216
+ except Exception as exc:
217
+ display_error(f"TUI Error: {exc}")
218
+
219
+
220
+ @app.command("config")
221
+ def config_cmd(
222
+ init: bool = typer.Option(False, "--init", help="Create a default config file."),
223
+ show: bool = typer.Option(False, "--show", help="Display the current configuration."),
224
+ path: bool = typer.Option(False, "--path", help="Print the config file path."),
225
+ ) -> None:
226
+ """[bold]Configuration[/bold] โ€” manage the kcli config file."""
227
+ display_banner()
228
+
229
+ if path:
230
+ console.print(f" ๐Ÿ“ Config file: [bold]{CONFIG_FILE}[/bold]")
231
+ return
232
+
233
+ if init:
234
+ if CONFIG_FILE.exists():
235
+ overwrite = typer.confirm(
236
+ f" Config already exists at {CONFIG_FILE}. Overwrite?",
237
+ default=False,
238
+ )
239
+ if not overwrite:
240
+ display_info("Aborted. Existing config left unchanged.")
241
+ return
242
+
243
+ create_default_config()
244
+ display_success(f"Default config created at {CONFIG_FILE}")
245
+ display_info(
246
+ "Edit the config file with your Kite Connect credentials:\n"
247
+ f" [bold]{CONFIG_FILE}[/bold]"
248
+ )
249
+ return
250
+
251
+ # Default behaviour: --show (also the fallback when no flags given)
252
+ config = load_config()
253
+ if config is None:
254
+ display_error(
255
+ f"No config found at {CONFIG_FILE}\n"
256
+ " Run [bold]kcli config --init[/bold] to create one."
257
+ )
258
+ raise typer.Exit(code=1)
259
+
260
+ # Pretty-print config with masked secrets
261
+ console.print()
262
+ console.print(" [bold cyan]Accounts[/bold cyan]")
263
+ for i, acct in enumerate(config.get("accounts", []), start=1):
264
+ console.print(f" [bold]{i}. {acct.get('name', 'Account')}[/bold]")
265
+ console.print(f" api_key : {acct.get('api_key', 'N/A')}")
266
+ console.print(
267
+ f" api_secret : [dim]{_mask_secret(acct.get('api_secret', ''))}[/dim]"
268
+ )
269
+ if acct.get("user_id"):
270
+ console.print(f" user_id : {acct.get('user_id', 'N/A')}")
271
+ if acct.get("password"):
272
+ console.print(
273
+ f" password : [dim]{_mask_secret(acct.get('password', ''))}[/dim]"
274
+ )
275
+ if acct.get("totp_secret"):
276
+ console.print(
277
+ f" totp_secret: [dim]{_mask_secret(acct.get('totp_secret', ''))}[/dim]"
278
+ )
279
+ console.print()
280
+
281
+
282
+ @app.command("bot")
283
+ def bot_cmd(
284
+ token: Optional[str] = typer.Option(None, "--token", help="Telegram Bot Token"),
285
+ chat_id: Optional[int] = typer.Option(None, "--chat-id", help="Authorized Telegram Chat ID"),
286
+ ) -> None:
287
+ """[bold]Run Telegram bot[/bold] โ€” run the bot in polling mode to manage positions and orders."""
288
+ display_banner()
289
+
290
+ config = _load_config_or_exit()
291
+
292
+ import os
293
+ tg_config = config.get("telegram", {})
294
+ resolved_token = token or tg_config.get("bot_token") or os.environ.get("TELEGRAM_BOT_TOKEN")
295
+ resolved_chat_id = chat_id or tg_config.get("chat_id") or os.environ.get("TELEGRAM_CHAT_ID")
296
+
297
+ if not resolved_token:
298
+ display_error(
299
+ "Telegram Bot Token not provided.\n"
300
+ " Please configure it in ~/.kcli/config.yaml under a `telegram:` section, or pass it via `--token`."
301
+ )
302
+ raise typer.Exit(code=1)
303
+
304
+ if not resolved_chat_id:
305
+ display_error(
306
+ "Authorized Telegram Chat ID not provided.\n"
307
+ " Please configure it in ~/.kcli/config.yaml under `telegram: chat_id`, pass it via `--chat-id`, or set the `TELEGRAM_CHAT_ID` environment variable."
308
+ )
309
+ raise typer.Exit(code=1)
310
+
311
+ try:
312
+ resolved_chat_id = int(resolved_chat_id)
313
+ except ValueError:
314
+ display_error("Chat ID must be a numeric value.")
315
+ raise typer.Exit(code=1)
316
+
317
+ client = _build_client(config)
318
+
319
+ # Initialize connection diagnostics
320
+ display_info("Initializing client session...")
321
+ _ensure_accounts_initialized(client, config)
322
+
323
+ from cli.telegram_bot import KCLITelegramBot
324
+ bot = KCLITelegramBot(client=client, token=resolved_token, chat_id=resolved_chat_id)
325
+
326
+ display_success(f"Starting Telegram Bot for Chat ID: {resolved_chat_id}... (Press Ctrl+C to exit)")
327
+
328
+ loop = asyncio.get_event_loop()
329
+ try:
330
+ loop.run_until_complete(bot.start())
331
+ while True:
332
+ loop.run_until_complete(asyncio.sleep(1))
333
+ except KeyboardInterrupt:
334
+ display_info("\nStopping bot...")
335
+ finally:
336
+ try:
337
+ loop.run_until_complete(bot.stop())
338
+ except Exception as e:
339
+ logger.error(f"Error during bot stop: {e}")
340
+ display_success("Bot stopped cleanly.")
341
+
342
+
343
+ # โ”€โ”€ entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
344
+
345
+ def main() -> None:
346
+ """CLI entry point."""
347
+ app()
348
+
349
+
350
+ if __name__ == "__main__":
351
+ main()
cli/nli.py ADDED
@@ -0,0 +1,140 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import socket
5
+ import requests
6
+ import urllib3.util.connection as connection
7
+ from typing import Any, Dict, List
8
+
9
+ # Force IPv4 resolution globally to prevent macOS IPv6 lookup timeouts/delays
10
+ def allowed_gai_family():
11
+ return socket.AF_INET
12
+
13
+ connection.allowed_gai_family = allowed_gai_family
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SYSTEM_INSTRUCTION = """
18
+ You are the Natural Language Interface (NLI) parsing engine for kcli, a multi-account Zerodha Kite option trading terminal.
19
+ Your task is to translate user natural language requests into exact kcli CLI commands.
20
+
21
+ Available Command Syntax:
22
+ 1. Switch account context:
23
+ - Syntax: `account <name>` (e.g. `account ZK8719`, `account SS1009`)
24
+ 2. Exit/square off specific position:
25
+ - Syntax: `exit <symbol>` (e.g. `exit NIFTY26JUN22200PE`)
26
+ 3. Exit all positions:
27
+ - Syntax: `exit all` (exits all open positions across targeted accounts)
28
+ 4. Place orders:
29
+ - Syntax: `buy <symbol> <quantity> [price] [product]` or `sell <symbol> <quantity> [price] [product]`
30
+ - Quantity can be in lots using the 'L' suffix (e.g. `27L` for 27 lots, `2L` for 2 lots).
31
+ - Product can be specified at the end: `MIS` or `NRML`. Default is MIS.
32
+ - Price can be optionally specified as a raw numeric decimal/float (e.g., `1.4` or `125.5`).
33
+ - **CRITICAL**: Do NOT prefix the price with the `@` symbol. (For example, `buy NIFTY26JUN22300PE 50 @1.4` is WRONG; use `buy NIFTY26JUN22300PE 50 1.4` instead).
34
+ - Example 1 (Market Order): `sell NIFTY26JUN24000CE 27L`
35
+ - Example 2 (Limit Order): `buy NIFTY26JUN22300PE 1L 1.4`
36
+ 5. Show Option Chain:
37
+ - Syntax: `oc <UNDERLYING> [week <N>]` (e.g. `oc NIFTY`, `oc NIFTY week 1`)
38
+ 6. Sync and Utilities:
39
+ - Syntax: `refresh` (refresh data)
40
+ - Syntax: `clear` (clear logs)
41
+
42
+ Command Chaining:
43
+ Multiple commands can be chained together using ` && `.
44
+ - Example: `account ZK8719 && exit all`
45
+ - Example: `account VJR419 && sell NIFTY26JUN24000CE 27L && sell NIFTY26JUN22000PE 27L`
46
+
47
+ Resolving Exits and Replication Orders via Open Positions:
48
+ - You must always inspect the provided `open_positions` list.
49
+ - If the user asks to "exit", "close", "replicate", or "sell" weekly options:
50
+ 1. First, search the provided `open_positions` list across all accounts. If any account already holds active positions for the matching expiry or category, extract their exact symbols (`tradingsymbol`) and use those same symbols to formulate the commands. This ensures you replicate the exact same strikes and symbols across accounts.
51
+ 2. If no accounts have matching positions open, look up the target option symbols in the provided `available_options` list matching the target expiry and strikes.
52
+ - Do NOT invent or estimate option symbols. Always use either `open_positions` or the fallback `available_options` list.
53
+
54
+ Context Provided:
55
+ We will pass you the user's input, the selected account context, available account names, the nearest weekly expiries, the list of available option contracts near the spot price, and the list of active open positions across all accounts. Use this context to resolve abbreviations (e.g. "zk" -> "ZK8719").
56
+
57
+ OUTPUT FORMAT:
58
+ You MUST return a JSON object with the following fields:
59
+ - `command`: The exact matched kcli CLI command string. If the request cannot be translated, return an empty string.
60
+ - `confidence`: Confidence score float between 0.0 and 1.0.
61
+ - `explanation`: A short description explaining what the command will do.
62
+
63
+ Strict Rules:
64
+ - Do NOT wrap your output in markdown code blocks like ```json ... ```. Output raw JSON only.
65
+ - Generate only valid kcli commands as defined above. Do not invent any new commands.
66
+ """
67
+
68
+ async def parse_natural_language(
69
+ api_key: str,
70
+ user_input: str,
71
+ selected_account: str | None,
72
+ accounts_list: List[str],
73
+ open_positions: List[Dict[str, Any]],
74
+ nifty_spot: float | None,
75
+ nearest_expiries: Dict[str, str] | None = None,
76
+ available_options: List[Dict[str, Any]] | None = None,
77
+ model: str = "gemini-2.5-flash"
78
+ ) -> Dict[str, Any]:
79
+ """
80
+ Send natural language request to Gemini API and get structured JSON response.
81
+ """
82
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
83
+ headers = {"Content-Type": "application/json"}
84
+
85
+ # Formulate context payload for the prompt using local state and option database fallback
86
+ context = {
87
+ "user_input": user_input,
88
+ "current_context": {
89
+ "selected_account": selected_account,
90
+ "nifty_spot": nifty_spot,
91
+ "available_accounts": accounts_list,
92
+ "nearest_expiry_by_underlying": nearest_expiries,
93
+ "available_options": available_options,
94
+ "open_positions": [
95
+ {
96
+ "account": pos.get("account_name"),
97
+ "symbol": pos.get("tradingsymbol"),
98
+ "quantity": pos.get("quantity"),
99
+ "pnl": pos.get("pnl")
100
+ }
101
+ for pos in open_positions
102
+ ]
103
+ }
104
+ }
105
+
106
+ prompt = json.dumps(context, indent=2)
107
+
108
+ payload = {
109
+ "contents": [{"parts": [{"text": prompt}]}],
110
+ "systemInstruction": {"parts": [{"text": SYSTEM_INSTRUCTION}]},
111
+ "generationConfig": {
112
+ "responseMimeType": "application/json"
113
+ }
114
+ }
115
+
116
+ try:
117
+ resp = await asyncio.to_thread(
118
+ requests.post,
119
+ url,
120
+ headers=headers,
121
+ json=payload,
122
+ proxies={"http": None, "https": None},
123
+ timeout=15
124
+ )
125
+
126
+ if resp.status_code == 200:
127
+ result = resp.json()
128
+ try:
129
+ text = result["candidates"][0]["content"]["parts"][0]["text"]
130
+ parsed = json.loads(text.strip())
131
+ return parsed
132
+ except (KeyError, IndexError, json.JSONDecodeError) as exc:
133
+ logger.error("Failed to parse Gemini JSON output: %s. Raw text: %s", exc, text if 'text' in locals() else "None")
134
+ return {"command": "", "confidence": 0.0, "explanation": f"API parsing error: {exc}"}
135
+ else:
136
+ logger.error("Gemini API error (HTTP %s): %s", resp.status_code, resp.text)
137
+ return {"command": "", "confidence": 0.0, "explanation": f"Gemini API returned status {resp.status_code}"}
138
+ except Exception as exc:
139
+ logger.error("Gemini API request failed: %s", exc)
140
+ return {"command": "", "confidence": 0.0, "explanation": f"Request failed: {exc}"}