kitecli 0.2.9__tar.gz → 0.3.1__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 (34) hide show
  1. {kitecli-0.2.9 → kitecli-0.3.1}/PKG-INFO +1 -1
  2. kitecli-0.3.1/cli/__init__.py +2 -0
  3. {kitecli-0.2.9 → kitecli-0.3.1}/cli/api_client.py +24 -0
  4. {kitecli-0.2.9 → kitecli-0.3.1}/cli/base_manager.py +14 -0
  5. kitecli-0.3.1/cli/charges.py +92 -0
  6. {kitecli-0.2.9 → kitecli-0.3.1}/cli/config.py +1 -0
  7. {kitecli-0.2.9 → kitecli-0.3.1}/cli/executor.py +13 -0
  8. kitecli-0.3.1/cli/greeks.py +160 -0
  9. kitecli-0.3.1/cli/indicators.py +205 -0
  10. {kitecli-0.2.9 → kitecli-0.3.1}/cli/kite_manager.py +55 -0
  11. {kitecli-0.2.9 → kitecli-0.3.1}/cli/kotak_manager.py +46 -2
  12. {kitecli-0.2.9 → kitecli-0.3.1}/cli/live_session.py +1049 -192
  13. {kitecli-0.2.9 → kitecli-0.3.1}/cli/main.py +10 -1
  14. {kitecli-0.2.9 → kitecli-0.3.1}/cli/parser.py +1 -1
  15. {kitecli-0.2.9 → kitecli-0.3.1}/cli/recorder.py +205 -9
  16. {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/PKG-INFO +1 -1
  17. {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/SOURCES.txt +3 -0
  18. {kitecli-0.2.9 → kitecli-0.3.1}/pyproject.toml +1 -1
  19. {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_multi_broker.py +59 -1
  20. {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_ui.py +38 -5
  21. kitecli-0.2.9/cli/__init__.py +0 -11
  22. {kitecli-0.2.9 → kitecli-0.3.1}/README.md +0 -0
  23. {kitecli-0.2.9 → kitecli-0.3.1}/cli/advisor.py +0 -0
  24. {kitecli-0.2.9 → kitecli-0.3.1}/cli/display.py +0 -0
  25. {kitecli-0.2.9 → kitecli-0.3.1}/cli/nli.py +0 -0
  26. {kitecli-0.2.9 → kitecli-0.3.1}/cli/telegram_bot.py +0 -0
  27. {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/dependency_links.txt +0 -0
  28. {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/entry_points.txt +0 -0
  29. {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/requires.txt +0 -0
  30. {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/top_level.txt +0 -0
  31. {kitecli-0.2.9 → kitecli-0.3.1}/setup.cfg +0 -0
  32. {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_nli.py +0 -0
  33. {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_parser.py +0 -0
  34. {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_telegram.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitecli
3
- Version: 0.2.9
3
+ Version: 0.3.1
4
4
  Summary: KiteCLI — Multi-account, multi-broker trading positions viewer (Zerodha + Kotak Neo)
5
5
  Author: KiteCLI Team
6
6
  License: MIT
@@ -0,0 +1,2 @@
1
+ # KiteCLI - Kite Connect CLI
2
+
@@ -261,6 +261,30 @@ class KCLIClient:
261
261
  except Exception as exc:
262
262
  raise KCLIClientError(str(exc)) from exc
263
263
 
264
+ def get_order_margin(
265
+ self,
266
+ api_key: str,
267
+ tradingsymbol: str,
268
+ transaction_type: str,
269
+ quantity: int,
270
+ price: float | None = None,
271
+ product: str = "NRML",
272
+ exchange: str = "NFO",
273
+ order_type: str = "LIMIT",
274
+ ) -> dict:
275
+ """Calculate margin required for a proposed order on a specific account."""
276
+ mgr = _manager_for(api_key)
277
+ return mgr.get_order_margin(
278
+ account_key=api_key,
279
+ tradingsymbol=tradingsymbol,
280
+ transaction_type=transaction_type,
281
+ quantity=quantity,
282
+ price=price,
283
+ product=product,
284
+ exchange=exchange,
285
+ order_type=order_type,
286
+ )
287
+
264
288
  def get_positions(self, api_keys: list[str]) -> dict:
265
289
  """Fetch open positions for the given accounts in parallel."""
266
290
  keys = api_keys or [a.get("api_key") for a in self._accounts if a.get("api_key")]
@@ -143,3 +143,17 @@ class BaseBrokerManager(ABC):
143
143
  def get_market_indices(self) -> dict[str, Any]:
144
144
  """Return Nifty / Sensex / India VIX snapshot. Default: not supported."""
145
145
  return {"status": "error", "message": "not supported by this broker"}
146
+
147
+ def get_order_margin(
148
+ self,
149
+ account_key: str,
150
+ tradingsymbol: str,
151
+ transaction_type: str,
152
+ quantity: int,
153
+ price: float | None = None,
154
+ product: str = "NRML",
155
+ exchange: str = "NFO",
156
+ order_type: str = "LIMIT",
157
+ ) -> dict[str, Any]:
158
+ """Calculate margin required for a proposed order. Default: fallback estimation."""
159
+ return {"status": "error", "message": "not supported by this broker"}
@@ -0,0 +1,92 @@
1
+ """Zerodha & Statutory Charges Calculation Engine for Indian F&O & Equity Trades.
2
+
3
+ Official Rates from https://zerodha.com/charges/#charges-explained:
4
+ - Brokerage: Flat ₹20 per executed order (or 0.03% whichever is lower).
5
+ - STT/CTT: 0.15% on sell side (on premium value).
6
+ - Transaction charges: NSE/NFO Options = 0.03553% (on premium), BSE/BFO Options = 0.0325% (on premium).
7
+ - GST: 18% on (brokerage + SEBI charges + transaction charges).
8
+ - SEBI charges: ₹10 / crore (0.0001%).
9
+ - Stamp charges: 0.003% or ₹300 / crore on buy side.
10
+ - IPFT fee: ₹50 / crore (0.0005%).
11
+ """
12
+
13
+ from typing import Any
14
+
15
+
16
+ def calculate_option_charges(
17
+ buy_price: float,
18
+ sell_price: float,
19
+ qty: int,
20
+ buy_orders_count: int = 1,
21
+ sell_orders_count: int = 1,
22
+ exchange: str = "NSE",
23
+ ) -> dict[str, float]:
24
+ """Calculate exact statutory charges and taxes for an F&O option contract trade per Zerodha official rates.
25
+
26
+ Args:
27
+ buy_price: Average buy price of matched legs.
28
+ sell_price: Average sell price of matched legs.
29
+ qty: Matched quantity.
30
+ buy_orders_count: Number of buy orders executed.
31
+ sell_orders_count: Number of sell orders executed.
32
+ exchange: Exchange name ('NSE', 'NFO', 'BSE', 'BFO').
33
+
34
+ Returns:
35
+ Dictionary containing breakdown of brokerage, STT, exchange fees, SEBI fees,
36
+ GST, stamp duty, IPFT, total charges, and net P&L.
37
+ """
38
+ buy_val = max(0.0, buy_price * qty)
39
+ sell_val = max(0.0, sell_price * qty)
40
+ total_val = buy_val + sell_val
41
+
42
+ exch_str = str(exchange or "NSE").upper()
43
+ is_nse = exch_str in ["NSE", "NFO"]
44
+
45
+ # 1. Brokerage: Flat ₹20 per executed order
46
+ brokerage = float((buy_orders_count + sell_orders_count) * 20.0)
47
+
48
+ # 2. STT: 0.15% on Sell side premium (Zerodha official rate)
49
+ stt = round(sell_val * 0.0015, 2)
50
+
51
+ # 3. Transaction charges: NSE/NFO Options = 0.03553% on premium, BSE/BFO Options = 0.0325% on premium
52
+ exch_rate = 0.0003553 if is_nse else 0.000325
53
+ exch_fee = round(total_val * exch_rate, 2)
54
+
55
+ # 4. SEBI turnover fee: ₹10 / crore (0.0001%)
56
+ sebi_fee = round(total_val * 0.000001, 2)
57
+
58
+ # 5. IPFT fee: ₹50 / crore (0.000005%) for NSE/NFO
59
+ ipft_fee = round(total_val * 0.000005, 2) if is_nse else 0.0
60
+
61
+ # 6. GST: 18% on (Brokerage + Exchange Fee + SEBI Fee)
62
+ gst = round((brokerage + exch_fee + sebi_fee) * 0.18, 2)
63
+
64
+ # 7. Stamp duty: 0.003% (₹300/crore) on Buy side premium
65
+ stamp_duty = round(buy_val * 0.00003, 2)
66
+
67
+ total_charges = round(brokerage + stt + exch_fee + sebi_fee + ipft_fee + gst + stamp_duty, 2)
68
+
69
+ return {
70
+ "brokerage": brokerage,
71
+ "stt": stt,
72
+ "exch_fee": exch_fee,
73
+ "sebi_fee": sebi_fee,
74
+ "ipft_fee": ipft_fee,
75
+ "gst": gst,
76
+ "stamp_duty": stamp_duty,
77
+ "total_charges": total_charges,
78
+ }
79
+
80
+
81
+ def fetch_broker_charges(client: Any, order_params_list: list[dict[str, Any]]) -> float | None:
82
+ """Fetch official charges from Zerodha API if available."""
83
+ try:
84
+ if hasattr(client, "get_order_charges"):
85
+ resp = client.get_order_charges(order_params_list)
86
+ if isinstance(resp, list):
87
+ return sum(float(item.get("total", 0.0)) for item in resp)
88
+ elif isinstance(resp, dict):
89
+ return float(resp.get("total", 0.0))
90
+ except Exception:
91
+ pass
92
+ return None
@@ -21,6 +21,7 @@ sessions_lock = threading.RLock()
21
21
  logger = logging.getLogger(__name__)
22
22
 
23
23
  DEFAULT_CONFIG = {
24
+ "record_executed_order_context": False,
24
25
  "accounts": [
25
26
  {
26
27
  # --- Zerodha account (default) ---
@@ -247,6 +247,19 @@ async def execute_command(cmd: Any, context: ExecutionContext) -> dict:
247
247
  "message": "\n".join(output_lines)
248
248
  }
249
249
 
250
+ # 5.5 Status Command
251
+ elif isinstance(cmd, StatusCommand):
252
+ res = context.client.get_status()
253
+ output_lines = ["🔌 Account Connection & Health Status:"]
254
+ for acct in res.get("accounts", []):
255
+ auth_icon = "🟢 Active" if acct.get("authenticated") else "🔴 Inactive"
256
+ broker_name = acct.get("broker", "Zerodha").title()
257
+ output_lines.append(f" • {acct.get('name', 'Account')} ({broker_name}): {auth_icon}")
258
+ return {
259
+ "status": "executed",
260
+ "message": "\n".join(output_lines)
261
+ }
262
+
250
263
  # 6. Orders Command
251
264
  elif isinstance(cmd, OrdersCommand):
252
265
  api_keys = [context.selected_account_key] if context.selected_account_key != "ALL" else None
@@ -0,0 +1,160 @@
1
+ import calendar
2
+ import math
3
+ import re
4
+ from datetime import date, datetime, timedelta
5
+ from typing import Any, Dict, Optional
6
+
7
+ MONTH_MAP = {
8
+ "JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6,
9
+ "JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12,
10
+ }
11
+ MONTH_CODE_MAP = {
12
+ "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6,
13
+ "7": 7, "8": 8, "9": 9, "O": 10, "N": 11, "D": 12,
14
+ }
15
+
16
+ # Standard Normal Cumulative Distribution Function
17
+ def _norm_cdf(x: float) -> float:
18
+ return (1.0 + math.erf(x / math.sqrt(2.0))) / 2.0
19
+
20
+
21
+ # Standard Normal Probability Density Function
22
+ def _norm_pdf(x: float) -> float:
23
+ return math.exp(-0.5 * x * x) / math.sqrt(2.0 * math.pi)
24
+
25
+
26
+ def parse_nfo_symbol(symbol: str) -> Optional[Dict[str, Any]]:
27
+ """Parse NFO option tradingsymbol into strike, option_type, and estimated expiry date."""
28
+ symbol = symbol.strip().upper()
29
+ if not (symbol.endswith("CE") or symbol.endswith("PE")):
30
+ return None
31
+
32
+ opt_type = "PE" if symbol.endswith("PE") else "CE"
33
+ core = symbol[:-2]
34
+
35
+ # Pattern A: Monthly (e.g. NIFTY26JUL22500 or BANKNIFTY26AUG50000)
36
+ m_monthly = re.match(r"^([A-Z]+)(\d{2})([A-Z]{3})(\d+(?:\.\d+)?)$", core)
37
+ if m_monthly:
38
+ underlying, yy, mon_str, strike_str = m_monthly.groups()
39
+ year = 2000 + int(yy)
40
+ month = MONTH_MAP.get(mon_str, date.today().month)
41
+ strike = float(strike_str)
42
+ cal = calendar.monthcalendar(year, month)
43
+ thursdays = [week[calendar.THURSDAY] for week in cal if week[calendar.THURSDAY] != 0]
44
+ expiry_day = thursdays[-1] if thursdays else 28
45
+ exp_date = date(year, month, expiry_day)
46
+ return {"underlying": underlying, "option_type": opt_type, "strike": strike, "expiry_date": exp_date}
47
+
48
+ # Pattern B: Weekly (e.g. NIFTY2672522500 -> YY=26, M=7, DD=25, STRIKE=22500)
49
+ m_weekly = re.match(r"^([A-Z]+)(\d{2})([1-9OND])(\d{2})(\d+(?:\.\d+)?)$", core)
50
+ if m_weekly:
51
+ underlying, yy, m_code, dd_str, strike_str = m_weekly.groups()
52
+ year = 2000 + int(yy)
53
+ month = MONTH_CODE_MAP.get(m_code, date.today().month)
54
+ day = int(dd_str)
55
+ strike = float(strike_str)
56
+ try:
57
+ exp_date = date(year, month, day)
58
+ except ValueError:
59
+ exp_date = date.today() + timedelta(days=7)
60
+ return {"underlying": underlying, "option_type": opt_type, "strike": strike, "expiry_date": exp_date}
61
+
62
+ # Fallback pattern if non-standard string:
63
+ nums = re.findall(r"\d+", core)
64
+ if nums:
65
+ strike = float(nums[-1])
66
+ exp_date = date.today() + timedelta(days=7)
67
+ return {"underlying": "NIFTY", "option_type": opt_type, "strike": strike, "expiry_date": exp_date}
68
+
69
+ return None
70
+
71
+
72
+ def calculate_greeks_for_symbol(symbol: str, spot_price: float, vix_pct: float = 15.0) -> Dict[str, Optional[float]]:
73
+ """Helper to calculate Option Greeks directly from a tradingsymbol."""
74
+ parsed = parse_nfo_symbol(symbol)
75
+ if not parsed:
76
+ return {"delta": None, "gamma": None, "theta": None, "vega": None, "iv": None}
77
+
78
+ return calculate_greeks(
79
+ spot_price=spot_price,
80
+ strike_price=parsed["strike"],
81
+ expiry_date=parsed["expiry_date"],
82
+ option_type=parsed["option_type"],
83
+ vix_pct=vix_pct,
84
+ )
85
+
86
+
87
+ def calculate_greeks(
88
+ spot_price: float,
89
+ strike_price: float,
90
+ expiry_date: str | datetime | date,
91
+ option_type: str,
92
+ vix_pct: float = 15.0,
93
+ risk_free_rate: float = 0.07,
94
+ ) -> Dict[str, Optional[float]]:
95
+ """Calculate Black-Scholes Option Greeks.
96
+
97
+ Args:
98
+ spot_price: Current spot price of underlying index/stock (e.g. 24500.0)
99
+ strike_price: Strike price of option (e.g. 24500.0)
100
+ expiry_date: Expiry date string (YYYY-MM-DD), date, or datetime object
101
+ option_type: 'CE' / 'CALL' or 'PE' / 'PUT'
102
+ vix_pct: Volatility percentage (e.g. 15.2 for 15.2% India VIX)
103
+ risk_free_rate: Annual risk-free interest rate (default 7% = 0.07)
104
+
105
+ Returns:
106
+ Dict containing delta, gamma, theta, vega, and iv.
107
+ """
108
+ if spot_price <= 0 or strike_price <= 0:
109
+ return {"delta": None, "gamma": None, "theta": None, "vega": None, "iv": None}
110
+
111
+ # Calculate time to expiry in years
112
+ if isinstance(expiry_date, str):
113
+ try:
114
+ exp_dt = datetime.strptime(expiry_date, "%Y-%m-%d")
115
+ except ValueError:
116
+ exp_dt = datetime.now() + timedelta(days=7)
117
+ elif isinstance(expiry_date, date) and not isinstance(expiry_date, datetime):
118
+ exp_dt = datetime.combine(expiry_date, datetime.min.time())
119
+ else:
120
+ exp_dt = expiry_date
121
+
122
+ now = datetime.now()
123
+ days_to_exp = max((exp_dt - now).total_seconds() / 86400.0, 0.25)
124
+ T = days_to_exp / 365.0
125
+ sigma = max(vix_pct / 100.0, 0.01)
126
+
127
+ # Black-Scholes d1 and d2 calculations
128
+ d1 = (math.log(spot_price / strike_price) + (risk_free_rate + 0.5 * sigma**2) * T) / (sigma * math.sqrt(T))
129
+ d2 = d1 - sigma * math.sqrt(T)
130
+
131
+ opt_type_upper = option_type.upper()
132
+ is_call = "CE" in opt_type_upper or "CALL" in opt_type_upper
133
+
134
+ # 1. Delta
135
+ if is_call:
136
+ delta = _norm_cdf(d1)
137
+ else:
138
+ delta = _norm_cdf(d1) - 1.0
139
+
140
+ # 2. Gamma
141
+ gamma = _norm_pdf(d1) / (spot_price * sigma * math.sqrt(T))
142
+
143
+ # 3. Theta (daily decay)
144
+ term1 = -(spot_price * _norm_pdf(d1) * sigma) / (2.0 * math.sqrt(T))
145
+ if is_call:
146
+ term2 = -risk_free_rate * strike_price * math.exp(-risk_free_rate * T) * _norm_cdf(d2)
147
+ else:
148
+ term2 = risk_free_rate * strike_price * math.exp(-risk_free_rate * T) * _norm_cdf(-d2)
149
+ theta = (term1 + term2) / 365.0
150
+
151
+ # 4. Vega (per 1% change in volatility)
152
+ vega = (spot_price * math.sqrt(T) * _norm_pdf(d1)) / 100.0
153
+
154
+ return {
155
+ "delta": round(delta, 4),
156
+ "gamma": round(gamma, 6),
157
+ "theta": round(theta, 2),
158
+ "vega": round(vega, 2),
159
+ "iv": round(vix_pct, 2),
160
+ }
@@ -0,0 +1,205 @@
1
+ """
2
+ Technical indicators and daily Nifty candle cache manager for KiteCLI.
3
+
4
+ Manages ~/.kcli/nifty_daily.json and calculates Daily EMA-9, SMA-20, SMA-50, and RSI-14.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from datetime import date, datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import pandas as pd
14
+
15
+ logger = logging.getLogger("kitecli.indicators")
16
+
17
+ CACHE_PATH = Path.home() / ".kcli" / "nifty_daily.json"
18
+
19
+
20
+ def get_cached_daily_closes() -> tuple[Optional[str], List[float]]:
21
+ """Read cached daily close prices from ~/.kcli/nifty_daily.json.
22
+
23
+ Returns:
24
+ (last_updated_date, daily_closes_list)
25
+ """
26
+ if not CACHE_PATH.exists():
27
+ return None, []
28
+
29
+ try:
30
+ with open(CACHE_PATH, "r", encoding="utf-8") as f:
31
+ data = json.load(f)
32
+ last_date = data.get("last_updated_date")
33
+ closes = data.get("daily_closes", [])
34
+ return last_date, closes
35
+ except Exception as exc:
36
+ logger.warning("Failed to read nifty_daily.json cache: %s", exc)
37
+ return None, []
38
+
39
+
40
+ def save_cached_daily_closes(daily_closes: List[float]) -> None:
41
+ """Save daily close prices to ~/.kcli/nifty_daily.json with today's date."""
42
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
43
+ today_str = date.today().isoformat()
44
+ try:
45
+ with open(CACHE_PATH, "w", encoding="utf-8") as f:
46
+ json.dump({
47
+ "last_updated_date": today_str,
48
+ "daily_closes": daily_closes,
49
+ }, f, indent=2)
50
+ logger.info("Saved %d daily closes to %s for date %s", len(daily_closes), CACHE_PATH, today_str)
51
+ except Exception as exc:
52
+ logger.error("Failed to save nifty_daily.json cache: %s", exc)
53
+
54
+
55
+ def fetch_or_get_daily_closes(client: Any) -> List[float]:
56
+ """Fetch 100 daily closing prices from Zerodha if missing or outdated, else return cached values."""
57
+ today_str = date.today().isoformat()
58
+ last_date, closes = get_cached_daily_closes()
59
+
60
+ if last_date == today_str and len(closes) >= 20:
61
+ logger.debug("Using cached daily closes for date %s (len=%d)", today_str, len(closes))
62
+ return closes
63
+
64
+ # First order of the day: fetch 100 daily candles from Zerodha REST API
65
+ logger.info("First completed order of the day (%s): fetching Nifty daily candles from Zerodha...", today_str)
66
+ try:
67
+ # NIFTY 50 instrument token = 256265
68
+ token = 256265
69
+ to_date = date.today()
70
+ from_date = to_date - timedelta(days=365)
71
+
72
+ # Retrieve a valid authenticated Zerodha client instance
73
+ kite = None
74
+ if hasattr(client, "_account_manager_map"):
75
+ for key, mgr in getattr(client, "_account_manager_map", {}).items():
76
+ if hasattr(mgr, "_clients") and key in mgr._clients:
77
+ kite = mgr._clients[key]
78
+ break
79
+ elif hasattr(client, "_clients"):
80
+ for key, client_inst in getattr(client, "_clients", {}).items():
81
+ kite = client_inst
82
+ break
83
+
84
+ if not kite:
85
+ try:
86
+ from cli.api_client import _account_manager_map, _kite_manager
87
+ for key, mgr in _account_manager_map.items():
88
+ if getattr(mgr, "broker_name", "") == "zerodha":
89
+ if hasattr(mgr, "_clients") and key in mgr._clients and mgr.is_authenticated(key):
90
+ kite = mgr._clients[key]
91
+ break
92
+ if not kite:
93
+ for key, client_inst in getattr(_kite_manager, "_clients", {}).items():
94
+ if _kite_manager.is_authenticated(key):
95
+ kite = client_inst
96
+ break
97
+ except Exception as exc:
98
+ logger.warning("Failed to locate Zerodha client: %s", exc)
99
+
100
+ if not kite:
101
+ logger.warning("No authenticated Zerodha client available to fetch daily candles.")
102
+ return closes
103
+
104
+ records = kite.historical_data(
105
+ instrument_token=token,
106
+ from_date=from_date.strftime("%Y-%m-%d"),
107
+ to_date=to_date.strftime("%Y-%m-%d"),
108
+ interval="day",
109
+ )
110
+ if records:
111
+ fetched_closes = [float(r["close"]) for r in records][-200:]
112
+ if fetched_closes:
113
+ save_cached_daily_closes(fetched_closes)
114
+ return fetched_closes
115
+ except Exception as exc:
116
+ logger.error("Failed to fetch Nifty daily historical candles: %s", exc)
117
+
118
+ return closes
119
+
120
+
121
+ def calculate_daily_indicators(
122
+ client: Any, live_nifty_spot: float
123
+ ) -> Dict[str, Optional[float]]:
124
+ """Compute Daily EMA-9, SMA-20, SMA-50, SMA-200, RSI-14, MACD, Bollinger Bands, Distance %, and Returns."""
125
+ empty_res = {
126
+ "nifty_ema_9": None, "nifty_sma_20": None, "nifty_sma_50": None, "nifty_sma_200": None, "nifty_rsi_14": None,
127
+ "nifty_macd_line": None, "nifty_macd_signal": None, "nifty_macd_hist": None,
128
+ "nifty_bb_upper": None, "nifty_bb_lower": None, "nifty_bb_pct_b": None, "nifty_bb_width": None,
129
+ "nifty_dist_sma_20_pct": None, "nifty_dist_sma_50_pct": None, "nifty_dist_sma_200_pct": None,
130
+ "nifty_ret_1d_pct": None, "nifty_ret_5d_pct": None, "nifty_ret_20d_pct": None,
131
+ }
132
+ if live_nifty_spot <= 0:
133
+ return empty_res
134
+
135
+ historical_closes = fetch_or_get_daily_closes(client)
136
+ if not historical_closes:
137
+ return empty_res
138
+
139
+ # Append live Nifty spot as today's forming daily close
140
+ all_closes = historical_closes + [live_nifty_spot]
141
+ df = pd.DataFrame({"close": all_closes})
142
+
143
+ # 1. EMAs & SMAs
144
+ df["ema_9"] = df["close"].ewm(span=9, adjust=False).mean()
145
+ df["ema_12"] = df["close"].ewm(span=12, adjust=False).mean()
146
+ df["ema_26"] = df["close"].ewm(span=26, adjust=False).mean()
147
+ df["sma_20"] = df["close"].rolling(window=min(20, len(df))).mean()
148
+ df["sma_50"] = df["close"].rolling(window=min(50, len(df))).mean()
149
+ df["sma_200"] = df["close"].rolling(window=min(200, len(df))).mean()
150
+
151
+ # 2. RSI 14
152
+ delta = df["close"].diff()
153
+ gain = (delta.where(delta > 0, 0.0)).rolling(window=14).mean()
154
+ loss = (-delta.where(delta < 0, 0.0)).rolling(window=14).mean()
155
+ rs = gain / loss
156
+ df["rsi_14"] = 100.0 - (100.0 / (1.0 + rs))
157
+
158
+ # 3. MACD (12, 26, 9)
159
+ df["macd_line"] = df["ema_12"] - df["ema_26"]
160
+ df["macd_signal"] = df["macd_line"].ewm(span=9, adjust=False).mean()
161
+ df["macd_hist"] = df["macd_line"] - df["macd_signal"]
162
+
163
+ # 4. Bollinger Bands (20, 2)
164
+ std_20 = df["close"].rolling(window=min(20, len(df))).std()
165
+ df["bb_upper"] = df["sma_20"] + (2.0 * std_20)
166
+ df["bb_lower"] = df["sma_20"] - (2.0 * std_20)
167
+ bb_range = df["bb_upper"] - df["bb_lower"]
168
+ df["bb_pct_b"] = (df["close"] - df["bb_lower"]) / bb_range.replace(0, 1.0)
169
+ df["bb_width"] = bb_range / df["sma_20"].replace(0, 1.0)
170
+
171
+ # 5. Normalized Distance % to Moving Averages (Stationary ML features)
172
+ df["dist_sma_20_pct"] = ((df["close"] - df["sma_20"]) / df["sma_20"].replace(0, 1.0)) * 100.0
173
+ df["dist_sma_50_pct"] = ((df["close"] - df["sma_50"]) / df["sma_50"].replace(0, 1.0)) * 100.0
174
+ df["dist_sma_200_pct"] = ((df["close"] - df["sma_200"]) / df["sma_200"].replace(0, 1.0)) * 100.0
175
+
176
+ # 6. Multi-period Percentage Returns
177
+ df["ret_1d_pct"] = df["close"].pct_change(1) * 100.0
178
+ df["ret_5d_pct"] = df["close"].pct_change(5) * 100.0
179
+ df["ret_20d_pct"] = df["close"].pct_change(20) * 100.0
180
+
181
+ latest = df.iloc[-1]
182
+
183
+ def _clean_val(v, decimals=2):
184
+ return round(float(v), decimals) if pd.notna(v) else None
185
+
186
+ return {
187
+ "nifty_ema_9": _clean_val(latest["ema_9"]),
188
+ "nifty_sma_20": _clean_val(latest["sma_20"]),
189
+ "nifty_sma_50": _clean_val(latest["sma_50"]),
190
+ "nifty_sma_200": _clean_val(latest["sma_200"]),
191
+ "nifty_rsi_14": _clean_val(latest["rsi_14"]),
192
+ "nifty_macd_line": _clean_val(latest["macd_line"]),
193
+ "nifty_macd_signal": _clean_val(latest["macd_signal"]),
194
+ "nifty_macd_hist": _clean_val(latest["macd_hist"]),
195
+ "nifty_bb_upper": _clean_val(latest["bb_upper"]),
196
+ "nifty_bb_lower": _clean_val(latest["bb_lower"]),
197
+ "nifty_bb_pct_b": _clean_val(latest["bb_pct_b"], 4),
198
+ "nifty_bb_width": _clean_val(latest["bb_width"], 4),
199
+ "nifty_dist_sma_20_pct": _clean_val(latest["dist_sma_20_pct"]),
200
+ "nifty_dist_sma_50_pct": _clean_val(latest["dist_sma_50_pct"]),
201
+ "nifty_dist_sma_200_pct": _clean_val(latest["dist_sma_200_pct"]),
202
+ "nifty_ret_1d_pct": _clean_val(latest["ret_1d_pct"]),
203
+ "nifty_ret_5d_pct": _clean_val(latest["ret_5d_pct"]),
204
+ "nifty_ret_20d_pct": _clean_val(latest["ret_20d_pct"]),
205
+ }
@@ -1103,3 +1103,58 @@ class KiteAccountManager(BaseBrokerManager):
1103
1103
  logger.warning("Failed to fetch LTP/tokens from Zerodha: %s", exc)
1104
1104
  return {}
1105
1105
 
1106
+ def get_order_margin(
1107
+ self,
1108
+ account_key: str,
1109
+ tradingsymbol: str,
1110
+ transaction_type: str,
1111
+ quantity: int,
1112
+ price: float | None = None,
1113
+ product: str = "NRML",
1114
+ exchange: str = "NFO",
1115
+ order_type: str = "LIMIT",
1116
+ ) -> dict[str, Any]:
1117
+ """Calculate margin required using Zerodha kite.order_margins()."""
1118
+ kite = self._clients.get(account_key)
1119
+ if not kite or not self.is_authenticated(account_key):
1120
+ return {"status": "error", "message": "Account not authenticated"}
1121
+
1122
+ try:
1123
+ p_val = price if price is not None else 0.0
1124
+ order_param = {
1125
+ "exchange": exchange,
1126
+ "tradingsymbol": tradingsymbol,
1127
+ "transaction_type": transaction_type.upper(),
1128
+ "variety": "regular",
1129
+ "product": product.upper(),
1130
+ "order_type": order_type.upper() if price is not None else "MARKET",
1131
+ "quantity": abs(quantity),
1132
+ "price": p_val,
1133
+ "trigger_price": 0,
1134
+ }
1135
+
1136
+ margin_resp = kite.order_margins([order_param])
1137
+ if isinstance(margin_resp, list) and len(margin_resp) > 0:
1138
+ m_info = margin_resp[0]
1139
+ total_margin = m_info.get("total")
1140
+ if total_margin is None:
1141
+ total_margin = m_info.get("margin_required", 0.0)
1142
+ return {
1143
+ "status": "success",
1144
+ "total": float(total_margin),
1145
+ "span": float(m_info.get("span", 0.0)),
1146
+ "exposure": float(m_info.get("exposure", 0.0)),
1147
+ "option_premium": float(m_info.get("option_premium", 0.0)),
1148
+ "detail": m_info,
1149
+ }
1150
+ return {"status": "error", "message": "Empty margin response from Zerodha"}
1151
+ except Exception as exc:
1152
+ logger.warning("Zerodha order_margins fetch failed for %s: %s", tradingsymbol, exc)
1153
+ if price and price > 0 and transaction_type.upper() == "BUY":
1154
+ return {
1155
+ "status": "success",
1156
+ "total": round(abs(quantity) * price, 2),
1157
+ "is_estimated": True,
1158
+ }
1159
+ return {"status": "error", "message": str(exc)}
1160
+
@@ -1026,7 +1026,7 @@ class KotakTicker:
1026
1026
  def _on_close(self, message: Any = None) -> None:
1027
1027
  logger.info("Kotak Neo WebSocket closed: %s", message or "Session closed")
1028
1028
  msg_str = str(message or "").lower()
1029
- is_auth_error = any(x in msg_str for x in ["session has been closed", "unauthorized", "invalid token"])
1029
+ is_auth_error = any(x in msg_str for x in ["unauthorized", "invalid token", "token expired", "100008"])
1030
1030
 
1031
1031
  if is_auth_error:
1032
1032
  logger.error("Kotak Neo WebSocket closed due to session expiry/auth failure: %s (mapped code=403)", message)
@@ -1042,7 +1042,8 @@ class KotakTicker:
1042
1042
 
1043
1043
  def _on_error(self, error: Any) -> None:
1044
1044
  error_str = str(error).lower()
1045
- code = 403 if ("unauthorized" in error_str or "auth" in error_str or "session" in error_str or "token" in error_str) else 0
1045
+ is_auth = any(x in error_str for x in ["unauthorized", "invalid token", "token expired", "100008"]) and not ("session has been closed" in error_str or "session closed" in error_str)
1046
+ code = 403 if is_auth else 0
1046
1047
  logger.error("Kotak Neo WebSocket error: %s (mapped code=%s)", error, code)
1047
1048
  if self.on_error:
1048
1049
  self.on_error(self, code, str(error))
@@ -1202,3 +1203,46 @@ class KotakTicker:
1202
1203
  if self.on_order_update:
1203
1204
  self.on_order_update(self, norm_data)
1204
1205
 
1206
+ def get_order_margin(
1207
+ self,
1208
+ account_key: str,
1209
+ tradingsymbol: str,
1210
+ transaction_type: str,
1211
+ quantity: int,
1212
+ price: float | None = None,
1213
+ product: str = "NRML",
1214
+ exchange: str = "NFO",
1215
+ order_type: str = "LIMIT",
1216
+ ) -> dict[str, Any]:
1217
+ """Calculate margin required for Kotak Neo orders."""
1218
+ client = self._clients.get(account_key)
1219
+ if not client or not self.is_authenticated(account_key):
1220
+ return {"status": "error", "message": "Kotak account not authenticated"}
1221
+
1222
+ try:
1223
+ p_val = price if price is not None else 0.0
1224
+ if hasattr(client, "margin_required") and callable(getattr(client, "margin_required")):
1225
+ margin_resp = client.margin_required(
1226
+ exchange_segment=exchange,
1227
+ price=str(p_val),
1228
+ product=product,
1229
+ quantity=str(abs(quantity)),
1230
+ token=tradingsymbol,
1231
+ transaction_type=transaction_type.upper(),
1232
+ )
1233
+ if isinstance(margin_resp, dict) and "margin" in margin_resp:
1234
+ return {
1235
+ "status": "success",
1236
+ "total": float(margin_resp.get("margin", 0.0)),
1237
+ }
1238
+ except Exception as exc:
1239
+ logger.debug("Kotak SDK margin_required call failed: %s", exc)
1240
+
1241
+ if price and price > 0:
1242
+ return {
1243
+ "status": "success",
1244
+ "total": round(abs(quantity) * price, 2),
1245
+ "is_estimated": True,
1246
+ }
1247
+ return {"status": "error", "message": "Kotak margin estimation unavailable"}
1248
+