kitecli 0.3.3__tar.gz → 0.4.0__tar.gz

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.
Files changed (36) hide show
  1. {kitecli-0.3.3 → kitecli-0.4.0}/PKG-INFO +1 -1
  2. {kitecli-0.3.3 → kitecli-0.4.0}/cli/api_client.py +67 -0
  3. {kitecli-0.3.3 → kitecli-0.4.0}/cli/base_manager.py +4 -0
  4. {kitecli-0.3.3 → kitecli-0.4.0}/cli/display.py +331 -0
  5. {kitecli-0.3.3 → kitecli-0.4.0}/cli/executor.py +16 -1
  6. {kitecli-0.3.3 → kitecli-0.4.0}/cli/kite_manager.py +103 -0
  7. {kitecli-0.3.3 → kitecli-0.4.0}/cli/kotak_manager.py +123 -0
  8. {kitecli-0.3.3 → kitecli-0.4.0}/cli/live_session.py +890 -112
  9. {kitecli-0.3.3 → kitecli-0.4.0}/cli/main.py +60 -0
  10. {kitecli-0.3.3 → kitecli-0.4.0}/cli/parser.py +216 -22
  11. {kitecli-0.3.3 → kitecli-0.4.0}/cli/recorder.py +169 -2
  12. {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/PKG-INFO +1 -1
  13. {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/SOURCES.txt +2 -0
  14. {kitecli-0.3.3 → kitecli-0.4.0}/pyproject.toml +1 -1
  15. kitecli-0.4.0/tests/test_command_matrix.py +401 -0
  16. {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_multi_broker.py +126 -0
  17. {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_parser.py +109 -0
  18. kitecli-0.4.0/tests/test_scheduled_orders.py +394 -0
  19. {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_ui.py +68 -0
  20. {kitecli-0.3.3 → kitecli-0.4.0}/README.md +0 -0
  21. {kitecli-0.3.3 → kitecli-0.4.0}/cli/__init__.py +0 -0
  22. {kitecli-0.3.3 → kitecli-0.4.0}/cli/advisor.py +0 -0
  23. {kitecli-0.3.3 → kitecli-0.4.0}/cli/charges.py +0 -0
  24. {kitecli-0.3.3 → kitecli-0.4.0}/cli/config.py +0 -0
  25. {kitecli-0.3.3 → kitecli-0.4.0}/cli/greeks.py +0 -0
  26. {kitecli-0.3.3 → kitecli-0.4.0}/cli/indicators.py +0 -0
  27. {kitecli-0.3.3 → kitecli-0.4.0}/cli/nli.py +0 -0
  28. {kitecli-0.3.3 → kitecli-0.4.0}/cli/telegram_bot.py +0 -0
  29. {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/dependency_links.txt +0 -0
  30. {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/entry_points.txt +0 -0
  31. {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/requires.txt +0 -0
  32. {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/top_level.txt +0 -0
  33. {kitecli-0.3.3 → kitecli-0.4.0}/setup.cfg +0 -0
  34. {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_conditional_orders.py +0 -0
  35. {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_nli.py +0 -0
  36. {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_telegram.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -537,6 +537,73 @@ class KCLIClient:
537
537
 
538
538
  return {"accounts": results}
539
539
 
540
+ def get_holdings(self, api_keys: list[str] | None = None) -> dict:
541
+ """Fetch stock/equity holdings for specified accounts in parallel."""
542
+ keys = api_keys or [a.get("api_key") for a in self._accounts if a.get("api_key")]
543
+
544
+ def fetch_one(api_key):
545
+ mgr = _manager_for(api_key)
546
+ info = mgr.get_account_info(api_key)
547
+ broker = getattr(mgr, "broker_name", "zerodha")
548
+ if not info.get("authenticated"):
549
+ return {
550
+ "name": info.get("name", api_key),
551
+ "api_key": api_key,
552
+ "broker": broker,
553
+ "holdings": [],
554
+ "total_investment": 0.0,
555
+ "total_current_value": 0.0,
556
+ "total_pnl": 0.0,
557
+ "total_day_change": 0.0,
558
+ "status": "unauthenticated",
559
+ }
560
+ try:
561
+ holdings = mgr.get_holdings(api_key)
562
+ tot_inv = sum(h.get("investment_value", 0.0) for h in holdings)
563
+ tot_cur = sum(h.get("current_value", 0.0) for h in holdings)
564
+ tot_pnl = tot_cur - tot_inv
565
+ tot_day = sum(h.get("day_change", 0.0) for h in holdings)
566
+ return {
567
+ "name": info.get("name", api_key),
568
+ "api_key": api_key,
569
+ "broker": broker,
570
+ "holdings": holdings,
571
+ "total_investment": tot_inv,
572
+ "total_current_value": tot_cur,
573
+ "total_pnl": tot_pnl,
574
+ "total_day_change": tot_day,
575
+ "status": "success",
576
+ }
577
+ except Exception as exc:
578
+ return {
579
+ "name": info.get("name", api_key),
580
+ "api_key": api_key,
581
+ "broker": broker,
582
+ "holdings": [],
583
+ "total_investment": 0.0,
584
+ "total_current_value": 0.0,
585
+ "total_pnl": 0.0,
586
+ "total_day_change": 0.0,
587
+ "status": f"error: {exc}",
588
+ }
589
+
590
+ with ThreadPoolExecutor(max_workers=max(1, len(keys))) as executor:
591
+ results = list(executor.map(fetch_one, keys))
592
+
593
+ grand_inv = sum(a.get("total_investment", 0.0) for a in results)
594
+ grand_cur = sum(a.get("total_current_value", 0.0) for a in results)
595
+ grand_pnl = grand_cur - grand_inv
596
+ grand_day = sum(a.get("total_day_change", 0.0) for a in results)
597
+
598
+ return {
599
+ "accounts": results,
600
+ "grand_total_investment": grand_inv,
601
+ "grand_total_current_value": grand_cur,
602
+ "grand_total_pnl": grand_pnl,
603
+ "grand_total_day_change": grand_day,
604
+ "status": "success",
605
+ }
606
+
540
607
  def modify_order(
541
608
  self,
542
609
  api_key: str,
@@ -81,6 +81,10 @@ class BaseBrokerManager(ABC):
81
81
  def get_margins(self, account_key: str) -> dict[str, Any]:
82
82
  """Return {"net": float|None, "cash": float|None, "collateral": float|None}."""
83
83
 
84
+ @abstractmethod
85
+ def get_holdings(self, account_key: str) -> list[dict[str, Any]]:
86
+ """Fetch equity/stock holdings using the normalised schema."""
87
+
84
88
  # ── order management ──────────────────────────────────────────────────────
85
89
 
86
90
  @abstractmethod
@@ -404,6 +404,337 @@ def display_status(accounts: list[dict]) -> None:
404
404
  )
405
405
 
406
406
 
407
+ # ── Holdings Display ───────────────────────────────────────────────
408
+
409
+ def _clean_instrument_name(name: str) -> str:
410
+ """Clean up verbose mutual fund or stock names for compact table display."""
411
+ import re
412
+ s = name.strip()
413
+ # Mutual fund scheme name compressions
414
+ s = re.sub(r"\s*-\s*DIRECT\s+PLAN\s*-\s*GROWTH(?:\s+OPTION)?", " (Dir-Gr)", s, flags=re.IGNORECASE)
415
+ s = re.sub(r"\s*-\s*DIRECT\s+PLAN\s*-\s*IDCW(?:\s+OPTION)?", " (Dir-IDCW)", s, flags=re.IGNORECASE)
416
+ s = re.sub(r"\s*-\s*DIRECT\s+PLAN", " (Dir)", s, flags=re.IGNORECASE)
417
+ s = re.sub(r"\s*-\s*REGULAR\s+PLAN\s*-\s*GROWTH(?:\s+OPTION)?", " (Reg-Gr)", s, flags=re.IGNORECASE)
418
+ s = re.sub(r"\s*-\s*REGULAR\s+PLAN", " (Reg)", s, flags=re.IGNORECASE)
419
+ s = re.sub(r"\s*-\s*GROWTH\s+PLAN", " (Gr)", s, flags=re.IGNORECASE)
420
+ s = re.sub(r"\s*-\s*GROWTH\s+OPTION", " (Gr)", s, flags=re.IGNORECASE)
421
+ s = re.sub(r"\s+FUND\s+OF\s+FUND", " FoF", s, flags=re.IGNORECASE)
422
+ s = re.sub(r"\s+INDEX\s+FUND", " Index", s, flags=re.IGNORECASE)
423
+ s = re.sub(r"\s+ETF\s+FUND\s+OF\s+FUND", " ETF FoF", s, flags=re.IGNORECASE)
424
+ return s.strip()
425
+
426
+
427
+ def _build_holdings_summary_table(accounts_data: list[dict]) -> Table:
428
+ """Build a summary Table showing total holdings metrics per account."""
429
+ table = Table(
430
+ show_header=True,
431
+ header_style="bold #58a6ff",
432
+ border_style="#30363d",
433
+ row_styles=["", "dim"],
434
+ pad_edge=True,
435
+ expand=False,
436
+ )
437
+ table.add_column("Account", style="bold #e6edf3", no_wrap=True)
438
+ table.add_column("Invested", justify="right", no_wrap=True)
439
+ table.add_column("Current Value", justify="right", no_wrap=True)
440
+ table.add_column("Total P&L", justify="right", no_wrap=True)
441
+ table.add_column("P&L %", justify="right", no_wrap=True)
442
+ table.add_column("Day P&L", justify="right", no_wrap=True)
443
+
444
+ for acct in accounts_data:
445
+ name = acct.get("name", "Unknown")
446
+ status = acct.get("status", "success")
447
+ holdings = acct.get("holdings", [])
448
+
449
+ if status != "success" and not holdings:
450
+ table.add_row(
451
+ name,
452
+ "₹0.00",
453
+ "₹0.00",
454
+ Text(f"✗ {status}", style="#f85149"),
455
+ "0.00%",
456
+ "₹0.00",
457
+ )
458
+ continue
459
+
460
+ tot_inv = float(acct.get("total_investment", 0.0))
461
+ tot_cur = float(acct.get("total_current_value", 0.0))
462
+ tot_pnl = tot_cur - tot_inv
463
+ tot_day = float(acct.get("total_day_change", 0.0))
464
+ pnl_pct = (tot_pnl / tot_inv * 100.0) if tot_inv > 0 else 0.0
465
+
466
+ pnl_color = _pnl_style(tot_pnl)
467
+ day_color = _pnl_style(tot_day)
468
+
469
+ table.add_row(
470
+ name,
471
+ _format_currency(tot_inv),
472
+ _format_currency(tot_cur),
473
+ Text(_format_currency(tot_pnl), style=f"bold {pnl_color}"),
474
+ Text(_format_pnl_pct(pnl_pct), style=f"bold {pnl_color}"),
475
+ Text(_format_currency(tot_day), style=day_color),
476
+ )
477
+
478
+ # Total Row at the bottom of the table
479
+ if accounts_data:
480
+ grand_inv = sum(float(a.get("total_investment", 0.0)) for a in accounts_data)
481
+ grand_cur = sum(float(a.get("total_current_value", 0.0)) for a in accounts_data)
482
+ grand_pnl = grand_cur - grand_inv
483
+ grand_day = sum(float(a.get("total_day_change", 0.0)) for a in accounts_data)
484
+ grand_pnl_pct = (grand_pnl / grand_inv * 100.0) if grand_inv > 0 else 0.0
485
+
486
+ pnl_color = _pnl_style(grand_pnl)
487
+ day_color = _pnl_style(grand_day)
488
+
489
+ table.add_section()
490
+ table.add_row(
491
+ Text("Total", style="bold #e6edf3"),
492
+ Text(_format_currency(grand_inv), style="bold #e6edf3"),
493
+ Text(_format_currency(grand_cur), style="bold #e6edf3"),
494
+ Text(_format_currency(grand_pnl), style=f"bold {pnl_color}"),
495
+ Text(_format_pnl_pct(grand_pnl_pct), style=f"bold {pnl_color}"),
496
+ Text(_format_currency(grand_day), style=f"bold {day_color}"),
497
+ )
498
+
499
+ return table
500
+
501
+
502
+ def _build_account_deep_holdings_table(holdings: list[dict]) -> Table:
503
+ """Build a detailed constituents Table for an account's holdings."""
504
+ table = Table(
505
+ show_header=True,
506
+ header_style="bold #58a6ff",
507
+ border_style="#30363d",
508
+ row_styles=["", "dim"],
509
+ pad_edge=True,
510
+ expand=False,
511
+ )
512
+ table.add_column("Symbol", style="bold #e6edf3", max_width=32, no_wrap=True, overflow="ellipsis")
513
+ table.add_column("Qty", justify="right", style="#79c0ff", no_wrap=True)
514
+ table.add_column("Avg", justify="right", no_wrap=True)
515
+ table.add_column("LTP", justify="right", no_wrap=True)
516
+ table.add_column("Invested", justify="right", no_wrap=True)
517
+ table.add_column("Current", justify="right", no_wrap=True)
518
+ table.add_column("Total P&L", justify="right", no_wrap=True)
519
+ table.add_column("P&L %", justify="right", no_wrap=True)
520
+
521
+ # Sort holdings alphabetically by tradingsymbol
522
+ sorted_holdings = sorted(holdings, key=lambda h: str(h.get("tradingsymbol", "")))
523
+
524
+ for h in sorted_holdings:
525
+ raw_sym = str(h.get("tradingsymbol", "?"))
526
+ sym = _clean_instrument_name(raw_sym)
527
+ is_mf = (h.get("exchange") == "MF" or h.get("instrument_type") == "MF")
528
+ sym_style = "italic #79c0ff" if is_mf else "bold #e6edf3"
529
+
530
+ qty_val = float(h.get("quantity", 0))
531
+ qty_fmt = f"{int(qty_val)}" if qty_val.is_integer() else f"{qty_val:,.2f}"
532
+
533
+ avg = float(h.get("average_price", 0.0))
534
+ ltp = float(h.get("last_price", avg))
535
+ inv = float(h.get("investment_value", qty_val * avg))
536
+ cur = float(h.get("current_value", qty_val * ltp))
537
+ pnl = cur - inv
538
+ pnl_pct = (pnl / inv * 100.0) if inv > 0 else 0.0
539
+
540
+ pnl_color = _pnl_style(pnl)
541
+
542
+ table.add_row(
543
+ Text(sym, style=sym_style),
544
+ qty_fmt,
545
+ f"{avg:,.2f}",
546
+ f"{ltp:,.2f}",
547
+ _format_currency(inv),
548
+ _format_currency(cur),
549
+ Text(_format_currency(pnl), style=f"bold {pnl_color}"),
550
+ Text(_format_pnl_pct(pnl_pct), style=f"bold {pnl_color}"),
551
+ )
552
+
553
+ # Total Row for multiple constituents
554
+ if len(sorted_holdings) > 1:
555
+ tot_qty = sum(float(h.get("quantity", 0)) for h in sorted_holdings)
556
+ tot_qty_str = f"{int(tot_qty)}" if tot_qty.is_integer() else f"{tot_qty:,.2f}"
557
+ tot_inv = sum(float(h.get("investment_value", float(h.get("quantity", 0)) * float(h.get("average_price", 0.0)))) for h in sorted_holdings)
558
+ tot_cur = sum(float(h.get("current_value", float(h.get("quantity", 0)) * float(h.get("last_price", 0.0)))) for h in sorted_holdings)
559
+ tot_pnl = tot_cur - tot_inv
560
+ tot_pnl_pct = (tot_pnl / tot_inv * 100.0) if tot_inv > 0 else 0.0
561
+
562
+ pnl_color = _pnl_style(tot_pnl)
563
+
564
+ table.add_section()
565
+ table.add_row(
566
+ Text("Total", style="bold #e6edf3"),
567
+ Text(tot_qty_str, style="bold #79c0ff"),
568
+ Text("-", style="#8b949e", justify="right"),
569
+ Text("-", style="#8b949e", justify="right"),
570
+ Text(_format_currency(tot_inv), style="bold #e6edf3"),
571
+ Text(_format_currency(tot_cur), style="bold #e6edf3"),
572
+ Text(_format_currency(tot_pnl), style=f"bold {pnl_color}"),
573
+ Text(_format_pnl_pct(tot_pnl_pct), style=f"bold {pnl_color}"),
574
+ )
575
+
576
+ return table
577
+
578
+
579
+ def display_holdings(holdings_data: dict, deep: bool = False, target_account: Optional[str] = None) -> None:
580
+ """Print a Rich table of stock holdings across accounts.
581
+
582
+ Args:
583
+ holdings_data: Dict returned by client.get_holdings().
584
+ deep: If True, show full constituent stock breakdowns.
585
+ target_account: Optional account name or ID to filter single-account deep-dive.
586
+ """
587
+ accounts = holdings_data.get("accounts", [])
588
+ if not accounts:
589
+ display_info("No holdings data available.")
590
+ return
591
+
592
+ # Filter by account if requested
593
+ if target_account:
594
+ target_norm = target_account.strip().lower()
595
+ if target_norm.isdigit():
596
+ idx = int(target_norm) - 1
597
+ if 0 <= idx < len(accounts):
598
+ accounts = [accounts[idx]]
599
+ else:
600
+ display_error(f"Invalid account index: {target_account}")
601
+ return
602
+ else:
603
+ accounts = [a for a in accounts if a.get("name", "").lower() == target_norm or a.get("api_key", "").lower() == target_norm]
604
+ if not accounts:
605
+ display_error(f"Account '{target_account}' not found.")
606
+ return
607
+ deep = True # Single account drill-down defaults to deep view
608
+
609
+ if not deep:
610
+ # Summary View
611
+ table = _build_holdings_summary_table(accounts)
612
+ console.print(table)
613
+ console.print()
614
+ else:
615
+ # Deep View: Per-account constituent tables
616
+ for acct in accounts:
617
+ name = acct.get("name", "Unknown")
618
+ broker = str(acct.get("broker", "zerodha")).title()
619
+ holdings = acct.get("holdings", [])
620
+ tot_cur = float(acct.get("total_current_value", 0.0))
621
+ tot_inv = float(acct.get("total_investment", 0.0))
622
+ tot_pnl = tot_cur - tot_inv
623
+ pnl_pct = (tot_pnl / tot_inv * 100.0) if tot_inv > 0 else 0.0
624
+ pnl_color = _pnl_style(tot_pnl)
625
+
626
+ header_text = Text.assemble(
627
+ (f" {name} ({broker}) ", "bold #e6edf3"),
628
+ (" │ ", "#8b949e"),
629
+ ("Invested: ", "#8b949e"),
630
+ (f"{_format_currency(tot_inv)} ", "bold"),
631
+ (" │ ", "#8b949e"),
632
+ ("Current: ", "#8b949e"),
633
+ (f"{_format_currency(tot_cur)} ", "bold"),
634
+ (" │ ", "#8b949e"),
635
+ ("P&L: ", "bold"),
636
+ (f"{_format_currency(tot_pnl)} ({_format_pnl_pct(pnl_pct)})", f"bold {pnl_color}"),
637
+ )
638
+
639
+ if not holdings:
640
+ panel = Panel(
641
+ Text(" No stock holdings in this account.", style="#8b949e italic"),
642
+ title=header_text,
643
+ border_style="#30363d",
644
+ padding=(1, 2),
645
+ )
646
+ else:
647
+ table = _build_account_deep_holdings_table(holdings)
648
+ panel = Panel(
649
+ table,
650
+ title=header_text,
651
+ border_style=pnl_color,
652
+ padding=(0, 1),
653
+ )
654
+ console.print(panel)
655
+ console.print()
656
+
657
+
658
+ def render_holdings_to_string(
659
+ holdings_data: dict,
660
+ width: int = 100,
661
+ deep: bool = False,
662
+ target_account: Optional[str] = None
663
+ ) -> str:
664
+ """Render holdings to an ANSI string for the live session TUI."""
665
+ from io import StringIO
666
+ from rich.console import Console
667
+
668
+ string_io = StringIO()
669
+ capture_console = Console(
670
+ file=string_io,
671
+ width=max(width, 100),
672
+ force_terminal=True,
673
+ color_system="truecolor",
674
+ )
675
+ accounts = holdings_data.get("accounts", [])
676
+ if not accounts:
677
+ return "No holdings data available."
678
+
679
+ if target_account:
680
+ target_norm = target_account.strip().lower()
681
+ if target_norm.isdigit():
682
+ idx = int(target_norm) - 1
683
+ if 0 <= idx < len(accounts):
684
+ accounts = [accounts[idx]]
685
+ else:
686
+ filtered = [a for a in accounts if a.get("name", "").lower() == target_norm or a.get("api_key", "").lower() == target_norm]
687
+ if filtered:
688
+ accounts = filtered
689
+ deep = True
690
+
691
+ if not deep:
692
+ table = _build_holdings_summary_table(accounts)
693
+ capture_console.print(table)
694
+ else:
695
+ for acct in accounts:
696
+ name = acct.get("name", "Unknown")
697
+ broker = str(acct.get("broker", "zerodha")).title()
698
+ holdings = acct.get("holdings", [])
699
+ tot_cur = float(acct.get("total_current_value", 0.0))
700
+ tot_inv = float(acct.get("total_investment", 0.0))
701
+ tot_pnl = tot_cur - tot_inv
702
+ pnl_pct = (tot_pnl / tot_inv * 100.0) if tot_inv > 0 else 0.0
703
+ pnl_color = _pnl_style(tot_pnl)
704
+
705
+ header_text = Text.assemble(
706
+ (f" {name} ({broker}) ", "bold #e6edf3"),
707
+ (" │ ", "#8b949e"),
708
+ ("Invested: ", "#8b949e"),
709
+ (f"{_format_currency(tot_inv)} ", "bold"),
710
+ (" │ ", "#8b949e"),
711
+ ("Current: ", "#8b949e"),
712
+ (f"{_format_currency(tot_cur)} ", "bold"),
713
+ (" │ ", "#8b949e"),
714
+ ("P&L: ", "bold"),
715
+ (f"{_format_currency(tot_pnl)} ({_format_pnl_pct(pnl_pct)})", f"bold {pnl_color}"),
716
+ )
717
+
718
+ if not holdings:
719
+ panel = Panel(
720
+ Text(" No stock holdings in this account.", style="#8b949e italic"),
721
+ title=header_text,
722
+ border_style="#30363d",
723
+ padding=(0, 1),
724
+ )
725
+ else:
726
+ table = _build_account_deep_holdings_table(holdings)
727
+ panel = Panel(
728
+ table,
729
+ title=header_text,
730
+ border_style=pnl_color,
731
+ padding=(0, 1),
732
+ )
733
+ capture_console.print(panel)
734
+
735
+ return string_io.getvalue()
736
+
737
+
407
738
  # ── Simple messages ────────────────────────────────────────────────
408
739
 
409
740
  def display_error(message: str) -> None:
@@ -4,7 +4,8 @@ from cli.parser import (
4
4
  AccountSelectCommand, PlaceOrderCommand, ExitCommand,
5
5
  StatusCommand, PositionsCommand, OrdersCommand,
6
6
  CancelOrderCommand, ModifyOrderCommand, MarketDepthCommand,
7
- ConditionalOrderCommand, CancelConditionalOrderCommand, ConditionalOrdersViewCommand
7
+ ConditionalOrderCommand, CancelConditionalOrderCommand, ConditionalOrdersViewCommand,
8
+ CancelScheduledOrderCommand, ScheduledOrdersViewCommand
8
9
  )
9
10
 
10
11
  class ExecutionContext:
@@ -450,4 +451,18 @@ async def execute_command(cmd: Any, context: ExecutionContext) -> dict:
450
451
  "message": "Conditional orders viewer is active in live TUI dashboard ('kcli live')."
451
452
  }
452
453
 
454
+ # 13. Cancel Scheduled Order Command
455
+ elif isinstance(cmd, CancelScheduledOrderCommand):
456
+ return {
457
+ "status": "executed",
458
+ "message": f"Cancelled scheduled orders targeting '{cmd.target}'."
459
+ }
460
+
461
+ # 14. Scheduled Orders View Command
462
+ elif isinstance(cmd, ScheduledOrdersViewCommand):
463
+ return {
464
+ "status": "executed",
465
+ "message": "Scheduled orders viewer is active in live TUI dashboard ('kcli live')."
466
+ }
467
+
453
468
  raise TypeError(f"Execution not implemented for command type '{type(cmd)}'.")
@@ -1009,6 +1009,109 @@ class KiteAccountManager(BaseBrokerManager):
1009
1009
  logger.warning("get_margins failed for api_key=%s…: %s", api_key[:8], exc)
1010
1010
  return {"net": None, "cash": None, "collateral": None}
1011
1011
 
1012
+ def get_holdings(self, api_key: str) -> list[dict[str, Any]]:
1013
+ """Fetch stock/equity and mutual fund holdings for an account using normalised schema.
1014
+
1015
+ Normalised schema:
1016
+ tradingsymbol, exchange, isin, quantity, t1_quantity, collateral_quantity,
1017
+ average_price, last_price, close_price, pnl, pnl_pct,
1018
+ day_change, day_change_pct, investment_value, current_value, instrument_type
1019
+ """
1020
+ kite = self._clients.get(api_key)
1021
+ if not kite or not self.is_authenticated(api_key):
1022
+ return []
1023
+
1024
+ result = []
1025
+
1026
+ # 1. Fetch equity/CNC holdings (including free demat, T1, and pledged/collateral)
1027
+ try:
1028
+ raw_holdings = kite.holdings()
1029
+ for h in raw_holdings:
1030
+ qty = float(h.get("quantity") or 0.0)
1031
+ t1_qty = float(h.get("t1_quantity") or 0.0)
1032
+ collateral_qty = float(h.get("collateral_quantity") or 0.0)
1033
+ total_qty = qty + t1_qty + collateral_qty
1034
+ if total_qty == 0:
1035
+ continue
1036
+ avg_price = float(h.get("average_price") or 0.0)
1037
+ ltp = float(h.get("last_price") or avg_price)
1038
+ close_price = float(h.get("close_price") or ltp)
1039
+
1040
+ investment_val = total_qty * avg_price
1041
+ current_val = total_qty * ltp
1042
+ pnl = current_val - investment_val
1043
+ pnl_pct = (pnl / investment_val * 100.0) if investment_val > 0 else 0.0
1044
+
1045
+ # Day change
1046
+ day_change = (ltp - close_price) * total_qty
1047
+ day_change_pct = ((ltp - close_price) / close_price * 100.0) if close_price > 0 else float(h.get("day_change_percentage") or 0.0)
1048
+
1049
+ result.append({
1050
+ "tradingsymbol": str(h.get("tradingsymbol") or ""),
1051
+ "exchange": str(h.get("exchange") or "NSE"),
1052
+ "isin": str(h.get("isin") or ""),
1053
+ "quantity": int(total_qty) if total_qty.is_integer() else total_qty,
1054
+ "t1_quantity": int(t1_qty) if t1_qty.is_integer() else t1_qty,
1055
+ "collateral_quantity": int(collateral_qty) if collateral_qty.is_integer() else collateral_qty,
1056
+ "average_price": avg_price,
1057
+ "last_price": ltp,
1058
+ "close_price": close_price,
1059
+ "pnl": pnl,
1060
+ "pnl_pct": pnl_pct,
1061
+ "day_change": day_change,
1062
+ "day_change_pct": day_change_pct,
1063
+ "investment_value": investment_val,
1064
+ "current_value": current_val,
1065
+ "instrument_token": h.get("instrument_token"),
1066
+ "instrument_type": "EQ",
1067
+ })
1068
+ except Exception as exc:
1069
+ logger.warning("kite.holdings failed for api_key=%s…: %s", api_key[:8], exc)
1070
+
1071
+ # 2. Fetch Mutual Fund holdings (Coin / BSE StAR MF)
1072
+ try:
1073
+ if hasattr(kite, "mf_holdings"):
1074
+ raw_mf_holdings = kite.mf_holdings()
1075
+ for mf in raw_mf_holdings:
1076
+ qty = float(mf.get("quantity") or 0.0)
1077
+ if qty == 0:
1078
+ continue
1079
+ avg_nav = float(mf.get("average_price") or 0.0)
1080
+ last_nav = float(mf.get("last_price") or avg_nav)
1081
+ fund_name = str(mf.get("fund") or mf.get("tradingsymbol") or "Mutual Fund")
1082
+ isin = str(mf.get("isin") or "")
1083
+
1084
+ investment_val = qty * avg_nav
1085
+ current_val = qty * last_nav
1086
+ pnl = current_val - investment_val
1087
+ pnl_pct = (pnl / investment_val * 100.0) if investment_val > 0 else 0.0
1088
+ day_change = float(mf.get("day_change") or 0.0)
1089
+ day_change_pct = float(mf.get("day_change_percentage") or 0.0)
1090
+
1091
+ result.append({
1092
+ "tradingsymbol": fund_name,
1093
+ "exchange": "MF",
1094
+ "isin": isin,
1095
+ "quantity": int(qty) if qty.is_integer() else qty,
1096
+ "t1_quantity": 0,
1097
+ "collateral_quantity": 0,
1098
+ "average_price": avg_nav,
1099
+ "last_price": last_nav,
1100
+ "close_price": last_nav,
1101
+ "pnl": pnl,
1102
+ "pnl_pct": pnl_pct,
1103
+ "day_change": day_change,
1104
+ "day_change_pct": day_change_pct,
1105
+ "investment_value": investment_val,
1106
+ "current_value": current_val,
1107
+ "instrument_token": None,
1108
+ "instrument_type": "MF",
1109
+ })
1110
+ except Exception as exc:
1111
+ logger.debug("kite.mf_holdings failed or not supported for api_key=%s…: %s", api_key[:8], exc)
1112
+
1113
+ return result
1114
+
1012
1115
  def get_nfo_lot_sizes(self) -> dict[str, int]:
1013
1116
  """Return the cached NFO tradingsymbol → lot_size map.
1014
1117