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.
- {kitecli-0.3.3 → kitecli-0.4.0}/PKG-INFO +1 -1
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/api_client.py +67 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/base_manager.py +4 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/display.py +331 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/executor.py +16 -1
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/kite_manager.py +103 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/kotak_manager.py +123 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/live_session.py +890 -112
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/main.py +60 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/parser.py +216 -22
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/recorder.py +169 -2
- {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/PKG-INFO +1 -1
- {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/SOURCES.txt +2 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/pyproject.toml +1 -1
- kitecli-0.4.0/tests/test_command_matrix.py +401 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_multi_broker.py +126 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_parser.py +109 -0
- kitecli-0.4.0/tests/test_scheduled_orders.py +394 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_ui.py +68 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/README.md +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/__init__.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/advisor.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/charges.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/config.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/greeks.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/indicators.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/nli.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/cli/telegram_bot.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/dependency_links.txt +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/entry_points.txt +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/requires.txt +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/kitecli.egg-info/top_level.txt +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/setup.cfg +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_conditional_orders.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_nli.py +0 -0
- {kitecli-0.3.3 → kitecli-0.4.0}/tests/test_telegram.py +0 -0
|
@@ -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
|
|