kitecli 0.2.6__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.6 → kitecli-0.3.1}/PKG-INFO +1 -1
- kitecli-0.3.1/cli/__init__.py +2 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/api_client.py +24 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/base_manager.py +14 -0
- kitecli-0.3.1/cli/charges.py +92 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/config.py +1 -0
- {kitecli-0.2.6 → 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.6 → kitecli-0.3.1}/cli/kite_manager.py +78 -9
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/kotak_manager.py +97 -29
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/live_session.py +1158 -211
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/main.py +10 -1
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/parser.py +1 -1
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/recorder.py +205 -9
- {kitecli-0.2.6 → kitecli-0.3.1}/kitecli.egg-info/PKG-INFO +1 -1
- {kitecli-0.2.6 → kitecli-0.3.1}/kitecli.egg-info/SOURCES.txt +3 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/pyproject.toml +1 -1
- {kitecli-0.2.6 → kitecli-0.3.1}/tests/test_multi_broker.py +59 -1
- {kitecli-0.2.6 → kitecli-0.3.1}/tests/test_ui.py +38 -5
- kitecli-0.2.6/cli/__init__.py +0 -11
- {kitecli-0.2.6 → kitecli-0.3.1}/README.md +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/advisor.py +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/display.py +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/nli.py +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/cli/telegram_bot.py +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/kitecli.egg-info/dependency_links.txt +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/kitecli.egg-info/entry_points.txt +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/kitecli.egg-info/requires.txt +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/kitecli.egg-info/top_level.txt +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/setup.cfg +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/tests/test_nli.py +0 -0
- {kitecli-0.2.6 → kitecli-0.3.1}/tests/test_parser.py +0 -0
- {kitecli-0.2.6 → 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
|
+
}
|
|
@@ -1039,22 +1039,36 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
1039
1039
|
return {}
|
|
1040
1040
|
|
|
1041
1041
|
def get_market_indices(self) -> dict[str, Any]:
|
|
1042
|
-
# 1. Try Kite first
|
|
1042
|
+
# 1. Try Kite ohlc first for last_price and yesterday's close
|
|
1043
1043
|
for api_key in self.get_all_api_keys():
|
|
1044
1044
|
if self.is_authenticated(api_key):
|
|
1045
1045
|
kite = self._clients.get(api_key)
|
|
1046
1046
|
if kite:
|
|
1047
1047
|
try:
|
|
1048
|
-
data = kite.
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1048
|
+
data = kite.ohlc(["NSE:NIFTY 50", "BSE:SENSEX", "NSE:INDIA VIX"])
|
|
1049
|
+
|
|
1050
|
+
def parse_item(item_data):
|
|
1051
|
+
if not isinstance(item_data, dict):
|
|
1052
|
+
return None, None
|
|
1053
|
+
last = item_data.get("last_price")
|
|
1054
|
+
ohlc = item_data.get("ohlc", {})
|
|
1055
|
+
close = ohlc.get("close") if isinstance(ohlc, dict) else None
|
|
1056
|
+
change = (last - close) if (last is not None and close is not None) else None
|
|
1057
|
+
return last, change
|
|
1058
|
+
|
|
1059
|
+
nifty_last, nifty_change = parse_item(data.get("NSE:NIFTY 50"))
|
|
1060
|
+
sensex_last, sensex_change = parse_item(data.get("BSE:SENSEX"))
|
|
1061
|
+
vix_last, vix_change = parse_item(data.get("NSE:INDIA VIX"))
|
|
1062
|
+
|
|
1063
|
+
if nifty_last or sensex_last or vix_last:
|
|
1053
1064
|
return {
|
|
1054
1065
|
"status": "success",
|
|
1055
|
-
"nifty":
|
|
1056
|
-
"
|
|
1057
|
-
"
|
|
1066
|
+
"nifty": nifty_last,
|
|
1067
|
+
"nifty_change": nifty_change,
|
|
1068
|
+
"sensex": sensex_last,
|
|
1069
|
+
"sensex_change": sensex_change,
|
|
1070
|
+
"vix": vix_last,
|
|
1071
|
+
"vix_change": vix_change,
|
|
1058
1072
|
}
|
|
1059
1073
|
except Exception as exc:
|
|
1060
1074
|
logger.warning("Kite indices fetch failed for api_key=%s…: %s", api_key[:8], exc)
|
|
@@ -1089,3 +1103,58 @@ class KiteAccountManager(BaseBrokerManager):
|
|
|
1089
1103
|
logger.warning("Failed to fetch LTP/tokens from Zerodha: %s", exc)
|
|
1090
1104
|
return {}
|
|
1091
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
|
+
|