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.
- {kitecli-0.2.9 → kitecli-0.3.1}/PKG-INFO +1 -1
- kitecli-0.3.1/cli/__init__.py +2 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/api_client.py +24 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/base_manager.py +14 -0
- kitecli-0.3.1/cli/charges.py +92 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/config.py +1 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/executor.py +13 -0
- kitecli-0.3.1/cli/greeks.py +160 -0
- kitecli-0.3.1/cli/indicators.py +205 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/kite_manager.py +55 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/kotak_manager.py +46 -2
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/live_session.py +1049 -192
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/main.py +10 -1
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/parser.py +1 -1
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/recorder.py +205 -9
- {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/PKG-INFO +1 -1
- {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/SOURCES.txt +3 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/pyproject.toml +1 -1
- {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_multi_broker.py +59 -1
- {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_ui.py +38 -5
- kitecli-0.2.9/cli/__init__.py +0 -11
- {kitecli-0.2.9 → kitecli-0.3.1}/README.md +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/advisor.py +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/display.py +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/nli.py +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/cli/telegram_bot.py +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/dependency_links.txt +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/entry_points.txt +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/requires.txt +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/kitecli.egg-info/top_level.txt +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/setup.cfg +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_nli.py +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_parser.py +0 -0
- {kitecli-0.2.9 → kitecli-0.3.1}/tests/test_telegram.py +0 -0
|
@@ -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
|
|
@@ -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 ["
|
|
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
|
-
|
|
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
|
+
|