b53ef86ef8
git-subtree-dir: utils_v2 git-subtree-split: 7f273565196085feb05ee3328aa2e80d3d721fc3
1713 lines
67 KiB
Python
1713 lines
67 KiB
Python
"""
|
|
|
|
AUTHOR:
|
|
|
|
Khushal P Soonderji
|
|
|
|
DATE:
|
|
|
|
Friday, 27th Dec., 2024
|
|
|
|
OBJECTIVE:
|
|
|
|
To provide a way to interface with Zerodha's Kite API in an asynchronous manner. Zerodha provides a great client
|
|
library, but it works only in sync mode. Here we will try to build an asynchronous version of the same for
|
|
more advance use cases.
|
|
|
|
REFERENCES:
|
|
|
|
01. Official documentation: https://kite.trade/docs/connect/v3/
|
|
|
|
DOWNLOADS:
|
|
|
|
N/A
|
|
|
|
"""
|
|
|
|
|
|
# *****************************************************************************************************************
|
|
# ***** ****
|
|
# *** IMPORT ***
|
|
# ***** ****
|
|
# *****************************************************************************************************************
|
|
|
|
|
|
# To make sibling directories accessible for imports:
|
|
import sys
|
|
|
|
from oauthlib.uri_validate import segment
|
|
|
|
sys.path.append(".")
|
|
sys.path.append("..")
|
|
|
|
# System-level activities:
|
|
import io
|
|
import os
|
|
|
|
# My utils:
|
|
from utils_v2.string import json
|
|
from utils_v2.system import files
|
|
from utils_v2.security.hash import Hasher
|
|
from utils_v2.date_time import date_time
|
|
|
|
# Data models:
|
|
from utils_v2.trading.zerodha_kite.models.api_call import ZerodhaKiteApiResponse
|
|
from utils_v2.trading.zerodha_kite.models.auth_tokens import ZerodhaKiteAuthTokens
|
|
from utils_v2.trading.zerodha_kite.models.user_profile import ZerodhaKiteUserProfile
|
|
from utils_v2.trading.zerodha_kite.models.user_funds import ZerodhaKiteUserFunds
|
|
from utils_v2.trading.zerodha_kite.models.instruments import ZerodhaKiteInstrument
|
|
from utils_v2.trading.zerodha_kite.models.ticks import (
|
|
ZerodhaKiteTick,
|
|
ZerodhaKiteMarketDepth,
|
|
OneZerodhaKiteMarketDepth
|
|
)
|
|
|
|
# To make API calls:
|
|
import httpx
|
|
import websockets
|
|
|
|
# To work with binary data:
|
|
import struct
|
|
|
|
# To work with date and time:
|
|
import datetime
|
|
import pytz
|
|
|
|
# For working with datatypes:
|
|
from typing import Literal, List, Callable, Awaitable, Union, Any
|
|
|
|
# For working with tabulated data:
|
|
import pandas as pd
|
|
|
|
# For debugging:
|
|
from icecream import IceCreamDebugger
|
|
import inspect
|
|
|
|
# For asynchronous activities:
|
|
import asyncio
|
|
|
|
|
|
# *****************************************************************************************************************
|
|
# ***** ****
|
|
# *** MACROS / ONE-TIME INIT ***
|
|
# ***** ****
|
|
# *****************************************************************************************************************
|
|
|
|
|
|
# --- Nothing Yet
|
|
|
|
|
|
# *****************************************************************************************************************
|
|
# ***** ****
|
|
# *** VARIABLES ***
|
|
# ***** ****
|
|
# *****************************************************************************************************************
|
|
|
|
|
|
# --- Nothing Yet
|
|
|
|
|
|
# *****************************************************************************************************************
|
|
# ***** ****
|
|
# *** FUNCTIONS ***
|
|
# ***** ****
|
|
# *****************************************************************************************************************
|
|
|
|
|
|
# --- Nothing Yet
|
|
|
|
|
|
# *****************************************************************************************************************
|
|
# ***** ****
|
|
# *** CLASSES ***
|
|
# ***** ****
|
|
# *****************************************************************************************************************
|
|
|
|
|
|
class AsyncZerodhaKite:
|
|
|
|
# ┏┓┓ ┓┏
|
|
# ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏
|
|
# ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛
|
|
|
|
# Root URLs:
|
|
ROOT_API_URL = r"https://api.kite.trade"
|
|
ROOT_WEBSOCKET_URL = r"wss://ws.kite.trade"
|
|
|
|
# Error types. These are the strings sent by Zerodha in their responses when some error occurs. The field in which
|
|
# this value will be found is 'error_type'.
|
|
# Documentation:
|
|
# 01. https://kite.trade/docs/connect/v3/response-structure/
|
|
# 02. https://kite.trade/docs/connect/v3/exceptions/
|
|
ERROR_TYPE_TOKEN_EXCEPTION = "TokenException"
|
|
ERROR_TYPE_USER_EXCEPTION = "UserException"
|
|
ERROR_TYPE_ORDER_EXCEPTION = "OrderException"
|
|
ERROR_TYPE_INPUT_EXCEPTION = "InputException"
|
|
ERROR_TYPE_MARGIN_EXCEPTION = "MarginException"
|
|
ERROR_TYPE_HOLDING_EXCEPTION = "HoldingException"
|
|
ERROR_TYPE_NETWORK_EXCEPTION = "NetworkException"
|
|
ERROR_TYPE_DATA_EXCEPTION = "DataException"
|
|
ERROR_TYPE_GENERAL_EXCEPTION = "GeneralException"
|
|
|
|
# Products offered by Zerodha:
|
|
PRODUCT_MIS = "MIS"
|
|
PRODUCT_CNC = "CNC"
|
|
PRODUCT_NRML = "NRML"
|
|
PRODUCT_CO = "CO"
|
|
|
|
# Order types offered by Zerodha:
|
|
ORDER_TYPE_MARKET = "MARKET"
|
|
ORDER_TYPE_LIMIT = "LIMIT"
|
|
ORDER_TYPE_SLM = "SL-M"
|
|
ORDER_TYPE_SL = "SL"
|
|
|
|
# Varieties offered by Zerodha:
|
|
VARIETY_REGULAR = "regular"
|
|
VARIETY_CO = "co"
|
|
VARIETY_AMO = "amo"
|
|
VARIETY_ICEBERG = "iceberg"
|
|
VARIETY_AUCTION = "auction"
|
|
|
|
# Transaction types offered by Zerodha:
|
|
TRANSACTION_TYPE_BUY = "BUY"
|
|
TRANSACTION_TYPE_SELL = "SELL"
|
|
|
|
# Validity types offered by Zerodha:
|
|
VALIDITY_DAY = "DAY"
|
|
VALIDITY_IOC = "IOC"
|
|
VALIDITY_TTL = "TTL"
|
|
|
|
# Position types offered by Zerodha:
|
|
POSITION_TYPE_DAY = "day"
|
|
POSITION_TYPE_OVERNIGHT = "overnight"
|
|
|
|
# Exchanges supported by Zerodha:
|
|
EXCHANGE_NSE = "NSE"
|
|
EXCHANGE_BSE = "BSE"
|
|
EXCHANGE_NFO = "NFO"
|
|
EXCHANGE_CDS = "CDS"
|
|
EXCHANGE_BFO = "BFO"
|
|
EXCHANGE_MCX = "MCX"
|
|
EXCHANGE_BCD = "BCD"
|
|
EXCHANGE_INDICES = "INDICES"
|
|
EXCHANGE_MCXSX = "MCXSX"
|
|
EXCHANGE_BSECDS = "BSECDS"
|
|
|
|
# Needed for decoding binary tick updates:
|
|
EXCHANGE_NAME_TO_CODE_MAP = {
|
|
EXCHANGE_NSE: 1,
|
|
EXCHANGE_NFO: 2,
|
|
EXCHANGE_CDS: 3,
|
|
EXCHANGE_BSE: 4,
|
|
EXCHANGE_BFO: 5,
|
|
EXCHANGE_BCD: 6,
|
|
EXCHANGE_MCX: 7,
|
|
EXCHANGE_MCXSX: 8,
|
|
EXCHANGE_INDICES: 9,
|
|
EXCHANGE_BSECDS: 6, # ... Effectively the same as "BCD". Only for backward compatibility.
|
|
}
|
|
EXCHANGE_CODE_TO_NAME_MAP = {v: k for k, v in EXCHANGE_NAME_TO_CODE_MAP.items()}
|
|
|
|
# For datetime normalization:
|
|
EXCHANGE_TIMEZONE_MAP = {
|
|
EXCHANGE_NSE: "Asia/Kolkata",
|
|
EXCHANGE_NFO: "Asia/Kolkata",
|
|
EXCHANGE_CDS: "Asia/Kolkata",
|
|
EXCHANGE_BSE: "Asia/Kolkata",
|
|
EXCHANGE_BFO: "Asia/Kolkata",
|
|
EXCHANGE_BCD: "Asia/Kolkata",
|
|
EXCHANGE_MCX: "Asia/Kolkata",
|
|
EXCHANGE_MCXSX: "Asia/Kolkata",
|
|
EXCHANGE_INDICES: "Asia/Kolkata",
|
|
EXCHANGE_BSECDS: "Asia/Kolkata", # ... Effectively the same as "BCD". Only for backward compatibility.
|
|
}
|
|
|
|
# Margins segments offered by Zerodha:
|
|
MARGIN_EQUITY = "equity"
|
|
MARGIN_COMMODITY = "commodity"
|
|
|
|
# Order statuses indicated by Zerodha:
|
|
STATUS_COMPLETE = "COMPLETE"
|
|
STATUS_REJECTED = "REJECTED"
|
|
STATUS_CANCELLED = "CANCELLED"
|
|
|
|
# GTT order type offered by Zerodha:
|
|
GTT_TYPE_OCO = "two-leg"
|
|
GTT_TYPE_SINGLE = "single"
|
|
|
|
# GTT order status indicated by Zerodha:
|
|
GTT_STATUS_ACTIVE = "active"
|
|
GTT_STATUS_TRIGGERED = "triggered"
|
|
GTT_STATUS_DISABLED = "disabled"
|
|
GTT_STATUS_EXPIRED = "expired"
|
|
GTT_STATUS_CANCELLED = "cancelled"
|
|
GTT_STATUS_REJECTED = "rejected"
|
|
GTT_STATUS_DELETED = "deleted"
|
|
|
|
# Intervals (for historical data) supported by Zerodha:
|
|
INTERVAL_1_MIN = "minute"
|
|
INTERVAL_3_MIN = "3minute"
|
|
INTERVAL_5_MIN = "5minute"
|
|
INTERVAL_10_MIN = "10minute"
|
|
INTERVAL_15_MIN = "15minute"
|
|
INTERVAL_30_MIN = "30minute"
|
|
INTERVAL_1_HOUR = "60minute"
|
|
INTERVAL_1DAY = "day"
|
|
|
|
# User identifiers and access control:
|
|
_api_key = None
|
|
_api_secret = None
|
|
_request_token = None
|
|
_access_token = None
|
|
_checksum = None
|
|
_user_profile = None
|
|
|
|
# For the websocket:
|
|
_ws = None
|
|
_ws_first_connect = True
|
|
_ws_last_heartbeat = None
|
|
_ws_conn_semaphore = asyncio.Semaphore(1)
|
|
_ws_listen_semaphore = asyncio.Semaphore(1)
|
|
_ws_subscription_semaphore = asyncio.Semaphore(1)
|
|
_ws_listening_for_messages = False
|
|
_ws_listener_task = None
|
|
_ws_made_conn_attempts = 0
|
|
_ws_max_conn_attempts = 100
|
|
_ws_subscribed_list = {
|
|
# instrument_token: mode
|
|
}
|
|
|
|
# Available tick-streaming modes (on the websocket):
|
|
# DOCUMENTATION: https://kite.trade/docs/connect/v3/websocket/
|
|
MODE_FULL = "full" # ..... Received in 184 bytes.
|
|
MODE_QUOTE = "quote" # ... Received in 44 bytes.
|
|
MODE_LTP = "ltp" # ....... Received in 8 bytes.
|
|
|
|
# Websocket callbacks:
|
|
# GitHub: https://github.com/zerodha/pykiteconnect/blob/master/kiteconnect/ticker.py
|
|
_ws_on_connect = None # ........ When the very first connection happens.
|
|
_ws_on_no_connect = None # ..... When a connection attempt fails.
|
|
_ws_on_disconnect = None # ..... When an established connection is lost.
|
|
_ws_on_reconnect = None # ...... When a re-connection attempt succeeds.
|
|
_ws_on_no_reconnect = None # ... When all re-connection attempts fail.
|
|
_ws_on_data = None # ........... When any data (binary or text) is received.
|
|
_ws_on_ticks = None # .......... When ticks are received.
|
|
_ws_on_error = None # .......... When an error message is received.
|
|
_ws_on_order_update = None # ... When an order's update is received.
|
|
_ws_on_message = None # ........ When a general message is received.
|
|
|
|
# Exceptions:
|
|
REQUEST_TOKEN_MISSING_EXCEPTION = ValueError(r"ERR: Please set a request token first.")
|
|
ACCESS_TOKEN_MISSING_EXCEPTION = ValueError(r"ERR: No access token found. Please complete the login cycle.")
|
|
|
|
# ┏┓
|
|
# ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓
|
|
# ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: str,
|
|
api_secret: str,
|
|
http_client: httpx.AsyncClient = None,
|
|
on_token_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_user_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_order_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_input_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_margin_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_holding_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_network_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_data_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_general_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
on_undocumented_exception: Callable[[str, Union[ZerodhaKiteUserProfile, None], str, dict, ZerodhaKiteApiResponse], Awaitable] = None,
|
|
debug = True,
|
|
debug_prefix = "Kite | ",
|
|
debug_only_errors = True
|
|
):
|
|
|
|
"""
|
|
To initialize the instance of this Zerodha Kite connector.
|
|
NOTE: All exception-handling callbacks must be async functions. On the occurrence of the associated exception,
|
|
the callback will be called with the API key of the user, the profile of the user (or null), the error type,
|
|
the error JSON (dict), and the full API response.
|
|
:param api_key: The API key that identifies your app. Note that this doesn't change in the lifecycle of the app.
|
|
:param api_secret: The API secret to access your app. Note that this can be changed from the API portal. This is
|
|
meant to be kept more secure than the simple API key.
|
|
:param http_client: An instance of 'httpx' library's AsyncClient. If not given, one will be instantiated
|
|
internally. It is recommended that, for multi-bot use cases, you provide a common HTTP client from outside.
|
|
:param on_token_exception: Called when the access token has some problem. This could be due to a manual log-out,
|
|
natural expiry (timeout), or maybe because the user logged-in from elsewhere.
|
|
:param on_user_exception: Called when there is some error relating to the user's account.
|
|
:param on_order_exception: Called when there is an error is placing orders.
|
|
:param on_input_exception: Called when there are missing required fields, bad values, etc.
|
|
:param on_margin_exception: Called when there are insufficient funds for placing orders.
|
|
:param on_holding_exception: Called when there are insufficient holdings. Typically, when you try to sell and
|
|
instrument with insufficient held quantity.
|
|
:param on_network_exception: Called when there is a network error. Typically, when the API wasn't able to
|
|
communicate with the OMS (order management system).
|
|
:param on_data_exception: Called when there is an internal server error at Zerodha when their API was unable to
|
|
understand the response from the OMS.
|
|
:param on_general_exception: Called when there is an unclassified error.
|
|
:param on_undocumented_exception: Called when there is an error but that error isn't in Zerodha's official
|
|
documentation.
|
|
:param debug: Whether, or not, you would like to show debugging messages on the terminal.
|
|
:param debug_prefix: The prefix string to identify the debugging messages.
|
|
:param debug_only_errors: Whether you would like to show all debugging messages or just error messages.
|
|
"""
|
|
|
|
# Prepare the debugging utility:
|
|
self._debug_prefix = debug_prefix
|
|
self._printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True)
|
|
if not debug: self._printer.disable()
|
|
self._debug_only_errors = debug_only_errors
|
|
|
|
# Accept the input configuration:
|
|
self._api_key = api_key
|
|
self._api_secret = api_secret
|
|
|
|
# Accept the awaitables on various exceptions:
|
|
self._on_token_exception = on_token_exception
|
|
self._on_user_exception = on_user_exception
|
|
self._on_order_exception = on_order_exception
|
|
self._on_input_exception = on_input_exception
|
|
self._on_margin_exception = on_margin_exception
|
|
self._on_holding_exception = on_holding_exception
|
|
self._on_network_exception = on_network_exception
|
|
self._on_data_exception = on_data_exception
|
|
self._on_general_exception = on_general_exception
|
|
self._on_undocumented_exception = on_undocumented_exception
|
|
|
|
# Accept/create an HTTP client to work with:
|
|
self.__http_client = http_client or httpx.AsyncClient(
|
|
limits = httpx.Limits(
|
|
max_connections = 100, # ............ Maximum number of connections allowed in the pool.
|
|
max_keepalive_connections = 50, # ... Maximum number of connections that can be kept alive.
|
|
),
|
|
timeout = httpx.Timeout(
|
|
pool = 120.0, # .... Time to wait for a free connection from the pool.
|
|
connect = 5.0, # ... Time to wait for establishing a connection to the server.
|
|
write = 10.0, # .... Time to wait for sending data.
|
|
read = 120.0 # ..... Time to wait for receiving data.
|
|
)
|
|
)
|
|
|
|
def enable_debug(self):
|
|
self._printer.enable()
|
|
|
|
def disable_debug(self):
|
|
self._printer.disable()
|
|
|
|
def debug_only_errors(self):
|
|
self._debug_only_errors = True
|
|
|
|
def debug_everything(self):
|
|
self._debug_only_errors = False
|
|
|
|
# ┏┓ •
|
|
# ┃┃┏┓┏┓┏┓┏┓┏┓╋┓┏┓┏
|
|
# ┣┛┛ ┗┛┣┛┗ ┛ ┗┗┗ ┛
|
|
# ┛
|
|
|
|
@property
|
|
def access_token_valid(self):
|
|
return True if self._access_token is not None else False
|
|
|
|
@property
|
|
def access_token_expired(self):
|
|
return False if self._access_token is not None else True
|
|
|
|
# ┏┓ ┓┏ ┓┓•
|
|
# ┣ ┏┓┏┓┏┓┏┓ ┣┫┏┓┏┓┏┫┃┓┏┓┏┓
|
|
# ┗┛┛ ┛ ┗┛┛ ┛┗┗┻┛┗┗┻┗┗┛┗┗┫
|
|
# ┛
|
|
|
|
async def _handle_error_actions(
|
|
self,
|
|
api_response: ZerodhaKiteApiResponse
|
|
) -> None:
|
|
|
|
"""
|
|
To handle some common actions when we encounter errors.
|
|
DOCUMENTATION:
|
|
01. https://kite.trade/docs/connect/v3/response-structure/
|
|
02. https://kite.trade/docs/connect/v3/exceptions/
|
|
:param api_response: The full client response after making an API call.
|
|
:return: None
|
|
"""
|
|
|
|
# Get the error type and error JSON.
|
|
# If there is not error type, exit immediately:
|
|
await api_response.note_error()
|
|
error_type = api_response.errorType
|
|
if error_type is None: return
|
|
error_json = await api_response.get_json()
|
|
|
|
# Figure put which action has to be called:
|
|
error_to_action_map = {
|
|
self.ERROR_TYPE_TOKEN_EXCEPTION: self._on_token_exception,
|
|
self.ERROR_TYPE_USER_EXCEPTION: self._on_user_exception,
|
|
self.ERROR_TYPE_ORDER_EXCEPTION: self._on_order_exception,
|
|
self.ERROR_TYPE_INPUT_EXCEPTION: self._on_input_exception,
|
|
self.ERROR_TYPE_MARGIN_EXCEPTION: self._on_margin_exception,
|
|
self.ERROR_TYPE_HOLDING_EXCEPTION: self._on_holding_exception,
|
|
self.ERROR_TYPE_NETWORK_EXCEPTION: self._on_network_exception,
|
|
self.ERROR_TYPE_DATA_EXCEPTION: self._on_data_exception,
|
|
self.ERROR_TYPE_GENERAL_EXCEPTION: self._on_general_exception
|
|
}
|
|
action = error_to_action_map.get(error_type, self._on_undocumented_exception)
|
|
|
|
# Call the action:
|
|
if not self._debug_only_errors: self._printer(error_type, action)
|
|
if asyncio.iscoroutinefunction(self._on_token_exception):
|
|
try: await self._on_token_exception(self._api_key, self._user_profile, error_type, error_json, api_response)
|
|
except Exception as exception: self._printer(exception)
|
|
|
|
# ┏┓┏┓┳ ┏┓ ┓┓•
|
|
# ┣┫┃┃┃ ┃ ┏┓┃┃┓┏┓┏┓
|
|
# ┛┗┣┛┻ ┗┛┗┻┗┗┗┛┗┗┫
|
|
# ┛
|
|
|
|
async def __get(
|
|
self,
|
|
url: str,
|
|
headers: dict = None,
|
|
params: dict = None
|
|
) -> ZerodhaKiteApiResponse:
|
|
|
|
"""
|
|
To call an API using the GET method.
|
|
:param url: The URL to call.
|
|
:param headers: The headers to pass.
|
|
:param params: The params to send in the query string itself.
|
|
:return: A structured response that includes the raw response, the exception (if any), and so on.
|
|
"""
|
|
|
|
# Prepare the structure of the response:
|
|
api_response = ZerodhaKiteApiResponse(
|
|
action = inspect.stack()[1].function,
|
|
url = url,
|
|
method = "GET"
|
|
)
|
|
|
|
try:
|
|
|
|
# Make the API call:
|
|
response = await self.__http_client.get(
|
|
url = url,
|
|
headers = headers,
|
|
params = params
|
|
)
|
|
|
|
# Note down the results:
|
|
api_response.response = response
|
|
api_response.httpCode = response.status_code
|
|
api_response.message = response.reason_phrase
|
|
|
|
# Handle errors:
|
|
await self._handle_error_actions(api_response)
|
|
|
|
# If something goes wrong:
|
|
except Exception as exception:
|
|
api_response.exception = exception
|
|
api_response.message = str(exception)
|
|
self._printer(exception, api_response.url, api_response.method, headers, params)
|
|
|
|
# Done here:
|
|
return api_response
|
|
|
|
async def __post(
|
|
self,
|
|
url: str,
|
|
headers: dict = None,
|
|
json: dict = None,
|
|
data: dict = None,
|
|
params: dict = None,
|
|
content: str | bytes = None,
|
|
) -> ZerodhaKiteApiResponse:
|
|
|
|
"""
|
|
To call an API using the POST method.
|
|
:param url: The URL to call.
|
|
:param headers: The headers to pass.
|
|
:param json: The params to send in the JSON body.
|
|
:param data: The params to send in the form-data in the body.
|
|
:param params: The params to send in the query string itself.
|
|
:param content: The raw content to be sent in the body (typically as an octet-stream).
|
|
:return: A structured response that includes the raw response, the exception (if any), and so on.
|
|
"""
|
|
|
|
# Prepare the structure of the response:
|
|
api_response = ZerodhaKiteApiResponse(
|
|
action = inspect.stack()[1].function,
|
|
url = url,
|
|
method = "POST"
|
|
)
|
|
|
|
try:
|
|
|
|
# Make the API call:
|
|
response = await self.__http_client.post(
|
|
url = url,
|
|
headers = headers,
|
|
json = json,
|
|
data = data,
|
|
params = params,
|
|
content = content
|
|
)
|
|
|
|
# Note down the results:
|
|
api_response.response = response
|
|
api_response.httpCode = response.status_code
|
|
api_response.message = response.reason_phrase
|
|
|
|
# Handle errors:
|
|
await self._handle_error_actions(api_response)
|
|
|
|
# If something goes wrong:
|
|
except Exception as exception:
|
|
api_response.exception = exception
|
|
api_response.message = str(exception)
|
|
self._printer(exception, api_response.url, api_response.method, headers, json, data)
|
|
|
|
# Done here:
|
|
return api_response
|
|
|
|
async def __delete(
|
|
self,
|
|
url: str,
|
|
headers: dict = None,
|
|
params: dict = None,
|
|
) -> ZerodhaKiteApiResponse:
|
|
|
|
"""
|
|
To call an API using the DELETE method.
|
|
:param url: The URL to call.
|
|
:param headers: The headers to pass.
|
|
:param params: The params to send in the query string itself.
|
|
:return: A structured response that includes the raw response, the exception (if any), and so on.
|
|
"""
|
|
|
|
# Prepare the structure of the response:
|
|
api_response = ZerodhaKiteApiResponse(
|
|
action = inspect.stack()[1].function,
|
|
url = url,
|
|
method = "DELETE"
|
|
)
|
|
|
|
try:
|
|
|
|
# Make the API call:
|
|
response = await self.__http_client.delete(
|
|
url = url,
|
|
headers = headers,
|
|
params = params
|
|
)
|
|
|
|
# Note down the results:
|
|
api_response.response = response
|
|
api_response.httpCode = response.status_code
|
|
api_response.message = response.reason_phrase
|
|
|
|
# Handle errors:
|
|
await self._handle_error_actions(api_response)
|
|
|
|
# If something goes wrong:
|
|
except Exception as exception:
|
|
api_response.exception = exception
|
|
api_response.message = str(exception)
|
|
self._printer(exception, api_response.url, api_response.method, headers, params)
|
|
|
|
# Done here:
|
|
return api_response
|
|
|
|
# ┳┳ ┏┓ ┏┓ •
|
|
# ┃┃┏┏┓┏┓ ┣╋ ┗┓┏┓┏┏┓┏┓┏┓
|
|
# ┗┛┛┗ ┛ ┗┻ ┗┛┗ ┛┛┗┗┛┛┗
|
|
|
|
@property
|
|
def login_url(self) -> str:
|
|
|
|
"""
|
|
Generate the login URL that the user can use to log in to his Zerodha account for this app.
|
|
DOCUMENTATION:
|
|
01. https://kite.trade/docs/connect/v3/user/
|
|
:return: The login URL that the user must use.
|
|
"""
|
|
|
|
return f"https://kite.zerodha.com/connect/login?api_key={self._api_key}"
|
|
|
|
def set_request_token(
|
|
self,
|
|
request_token: str
|
|
) -> None:
|
|
|
|
"""
|
|
When the user authorizes the login flow, Zerodha's serve will send you a GET request on the callback URL that
|
|
you set on the PI portal for your app. This callback will have, among other things, a 'request_token'. The
|
|
request token is valid only for a very short period, and must be used to get a longer token called
|
|
'access_token' for actual activities. A checksum is needed for verification. Read about it in the official
|
|
documentation on Kite's API docs.
|
|
DOCUMENTATION:
|
|
01. https://kite.trade/docs/connect/v3/user/
|
|
:param request_token: The request token received from Zerodha when the user logs in.
|
|
:return: None.
|
|
"""
|
|
|
|
self._request_token = request_token
|
|
hasher = Hasher()
|
|
hasher.update(self._api_key + request_token + self._api_secret)
|
|
self._checksum = hasher.hexdigest()
|
|
|
|
async def generate_session(self) -> ZerodhaKiteAuthTokens | None:
|
|
|
|
"""
|
|
Once we have the 'request_token' from Zerodha's callback, we must generate a session by fetching an access
|
|
token. The access token will be used to perform most of the actual activities.
|
|
DOCUMENTATION:
|
|
01. https://kite.trade/docs/connect/v3/user/
|
|
:return: Either the auth-token model of Zerodha, or null if the process failed.
|
|
"""
|
|
|
|
# If we don't have a request token:
|
|
if not self._request_token: raise self.REQUEST_TOKEN_MISSING_EXCEPTION
|
|
|
|
# Start by assuming failure:
|
|
session = None
|
|
|
|
# Try to get a session from Zerodha:
|
|
client_response = await self.__post(
|
|
url = f"{self.ROOT_API_URL}/session/token",
|
|
headers = {"X-Kite-Version": "3"},
|
|
data = {
|
|
"api_key": self._api_key,
|
|
"request_token": self._request_token,
|
|
"checksum": self._checksum
|
|
}
|
|
)
|
|
|
|
# If the API call was successful, we have a valid session:
|
|
if client_response.success:
|
|
client_json = await client_response.get_json()
|
|
client_json = client_json["data"]
|
|
self.set_access_token(client_json["access_token"])
|
|
session = ZerodhaKiteAuthTokens(**client_json)
|
|
self._user_profile = ZerodhaKiteUserProfile(**client_json)
|
|
|
|
# Done here:
|
|
return session
|
|
|
|
def set_access_token(
|
|
self,
|
|
access_token: str
|
|
) -> None:
|
|
|
|
"""
|
|
Use this when you already have an access token from elsewhere. Maybe you had logged in earlier and stored the
|
|
granted access token in a database. Useful for other stateless deployments also.
|
|
:param access_token: The access token to do actual work like get ticks, and place orders.
|
|
:return: None
|
|
"""
|
|
|
|
self._access_token = access_token
|
|
|
|
async def logout(self) -> bool:
|
|
|
|
"""
|
|
When you want to log out from Zerodha Kite and destroy the access token so that it cannot be used anywhere.
|
|
:return: The structured API-call response.
|
|
"""
|
|
|
|
# If we don't have an access token:
|
|
if not self._access_token: raise self. ACCESS_TOKEN_MISSING_EXCEPTION
|
|
|
|
# Start by assuming failure:
|
|
success = False
|
|
|
|
# Try to get clear the session from Zerodha:
|
|
client_response = await self.__delete(
|
|
url = f"{self.ROOT_API_URL}/session/token",
|
|
headers = {"X-Kite-Version": "3"},
|
|
params = {
|
|
"api_key": self._api_key,
|
|
"access_token": self._access_token
|
|
}
|
|
)
|
|
|
|
# If the logout request was successful:
|
|
if client_response.success:
|
|
self._access_token = None
|
|
success = True
|
|
|
|
# Done here:
|
|
return success
|
|
|
|
async def get_user_profile(self) -> ZerodhaKiteUserProfile | None:
|
|
|
|
"""
|
|
This gives you the user's profile. The values are a subset of the values
|
|
:return: The structured response for the user profile.
|
|
"""
|
|
|
|
# If we already have the user's profile:
|
|
if self._user_profile is not None: return self._user_profile
|
|
|
|
# If we don't have an access token:
|
|
if not self._access_token: raise self.ACCESS_TOKEN_MISSING_EXCEPTION
|
|
|
|
# Start by assuming failure:
|
|
profile = None
|
|
|
|
# Make the API call:
|
|
client_response = await self.__get(
|
|
url = f"{self.ROOT_API_URL}/user/profile",
|
|
headers = {
|
|
"X-Kite-Version": "3",
|
|
"Authorization": f"token {self._api_key}:{self._access_token}"
|
|
}
|
|
)
|
|
|
|
# if the API call was successful:
|
|
if client_response.success:
|
|
profile_json = await client_response.get_json()
|
|
profile = ZerodhaKiteUserProfile(**profile_json["data"])
|
|
self._user_profile = profile
|
|
|
|
# Done here:
|
|
return profile
|
|
|
|
async def get_user_funds(self) -> ZerodhaKiteUserFunds | None:
|
|
|
|
"""
|
|
To get a detailed view of the funds/margins available in this user's account.
|
|
:return: A structured response that describes the status of the account's funds.
|
|
"""
|
|
|
|
# If we don't have an access token:
|
|
if not self._access_token: raise self.ACCESS_TOKEN_MISSING_EXCEPTION
|
|
|
|
# Start by assuming failure:
|
|
funds = None
|
|
|
|
# Make the API call:
|
|
client_response = await self.__get(
|
|
url = f"{self.ROOT_API_URL}/user/margins",
|
|
headers = {
|
|
"X-Kite-Version": "3",
|
|
"Authorization": f"token {self._api_key}:{self._access_token}"
|
|
}
|
|
)
|
|
|
|
# if the API call was successful:
|
|
if client_response.success:
|
|
funds_json = await client_response.get_json()
|
|
funds = ZerodhaKiteUserFunds(**funds_json["data"])
|
|
|
|
# Done here:
|
|
return funds
|
|
|
|
# ┏┓ ┏ ┓•
|
|
# ┃┃┏┓┏┓╋╋┏┓┃┓┏┓
|
|
# ┣┛┗┛┛ ┗┛┗┛┗┗┗┛
|
|
|
|
async def get_user_holdings(self):
|
|
|
|
"""
|
|
To get a list of holdings that this user has in his account.
|
|
:return: ??
|
|
"""
|
|
|
|
# If we don't have an access token:
|
|
if not self._access_token: raise self.ACCESS_TOKEN_MISSING_EXCEPTION
|
|
|
|
# Start by assuming failure:
|
|
holdings = None
|
|
|
|
# Make the API call:
|
|
client_response = await self.__get(
|
|
url = f"{self.ROOT_API_URL}/portfolio/holdings",
|
|
headers = {
|
|
"X-Kite-Version": "3",
|
|
"Authorization": f"token {self._api_key}:{self._access_token}"
|
|
}
|
|
)
|
|
|
|
# if the API call was successful:
|
|
if client_response.success:
|
|
holdings_json = await client_response.get_json()
|
|
print("HOLDINGS:", json.to_string(holdings_json))
|
|
# funds = ZerodhaKiteUserFunds(**funds_json["data"])
|
|
|
|
# Done here:
|
|
return holdings
|
|
|
|
async def get_user_positions(self):
|
|
|
|
"""
|
|
To get a list of live positions that this user has in his account.
|
|
:return: ??
|
|
"""
|
|
|
|
# If we don't have an access token:
|
|
if not self._access_token: raise self.ACCESS_TOKEN_MISSING_EXCEPTION
|
|
|
|
# Start by assuming failure:
|
|
positions = None
|
|
|
|
# Make the API call:
|
|
client_response = await self.__get(
|
|
url = f"{self.ROOT_API_URL}/portfolio/positions",
|
|
headers = {
|
|
"X-Kite-Version": "3",
|
|
"Authorization": f"token {self._api_key}:{self._access_token}"
|
|
}
|
|
)
|
|
|
|
# if the API call was successful:
|
|
if client_response.success:
|
|
positions_json = await client_response.get_json()
|
|
print("POSITIONS:", json.to_string(positions_json))
|
|
# funds = ZerodhaKiteUserFunds(**funds_json["data"])
|
|
|
|
# Done here:
|
|
return positions
|
|
|
|
# ┳
|
|
# ┃┏┓┏╋┏┓┓┏┏┳┓┏┓┏┓╋┏
|
|
# ┻┛┗┛┗┛ ┗┻┛┗┗┗ ┛┗┗┛
|
|
|
|
async def list_instruments(self) -> List[ZerodhaKiteInstrument]:
|
|
|
|
"""
|
|
Get the list of all the instruments supported by Zerodha.
|
|
:return: A list of structured instrument models.
|
|
"""
|
|
|
|
# If we don't have an access token:
|
|
if not self._access_token: raise self.ACCESS_TOKEN_MISSING_EXCEPTION
|
|
|
|
# Start by assuming failure:
|
|
instruments = []
|
|
|
|
# Make the API call:
|
|
client_response = await self.__http_client.get(
|
|
url = f"{self.ROOT_API_URL}/instruments",
|
|
headers = {
|
|
"X-Kite-Version": "3",
|
|
"Authorization": f"token {self._api_key}:{self._access_token}"
|
|
}
|
|
)
|
|
|
|
# if the API call was successful:
|
|
if client_response.status_code in [200]:
|
|
instruments = ZerodhaKiteInstrument.from_api_data(client_response.content)
|
|
|
|
# Done here:
|
|
return instruments
|
|
|
|
# ┓ ┏ ┓ ┓
|
|
# ┃┃┃┏┓┣┓┏┏┓┏┃┏┏┓╋
|
|
# ┗┻┛┗ ┗┛┛┗┛┗┛┗┗ ┗
|
|
|
|
@property
|
|
def websocket_is_connected(self):
|
|
|
|
"""
|
|
To check if we are connected to the websocket. As per the documentation of the library being used, you must
|
|
not call their 'status' property.
|
|
:return: True if connected, else False.
|
|
"""
|
|
|
|
if self._ws is None: return False
|
|
return True
|
|
|
|
@property
|
|
def websocket_latency(self) -> float | None:
|
|
|
|
"""
|
|
Useful for knowing the roundtrip time between your machine and Zerodha's server.
|
|
:return: The latency in seconds (if connected), else None.
|
|
"""
|
|
|
|
if self.websocket_is_connected: return self._ws.latency
|
|
return None
|
|
|
|
async def invoke_callback(self, callback, *args) -> None:
|
|
|
|
"""
|
|
Safely calls a callback.
|
|
:param callback: The asynchronous callable.
|
|
:param args: Any args to send to the callable.
|
|
:return: None.
|
|
"""
|
|
|
|
if callback is not None:
|
|
try: asyncio.create_task(callback(*args))
|
|
except Exception as exception: self._printer(callback.__name__, exception)
|
|
|
|
@staticmethod
|
|
def unpack_bytes(
|
|
binary_data: bytes,
|
|
start: int,
|
|
end: int,
|
|
byte_order: str = ">",
|
|
byte_format: str = "I"
|
|
) -> Any:
|
|
|
|
"""
|
|
To unpack binary data to the format of choice:
|
|
I --> Unsigned Int (32-bit)
|
|
H --> Unsigned Int (16-bit)
|
|
B --> Unsigned Int (8-bit)
|
|
i --> Signed Int (32-bit)
|
|
h --> Signed Int (16-bit)
|
|
b --> Signed Int (8-bit)
|
|
l --> Signed Long Int (32-bit)
|
|
q --> Signed Long Int (64-bit)
|
|
f --> Floating-Point (32-bit)
|
|
d --> Floating-Point (64-bit)
|
|
s --> String
|
|
p --> Pascal-Style String
|
|
c --> Char (8-bit)
|
|
? --> Bool (8-bit)
|
|
-----------------------------------------------
|
|
You can even select the byte-order:
|
|
= --> Native Byte Order
|
|
> --> Big-Endian (MSB-first)
|
|
< --> Little-Endian (LSB-first)
|
|
-----------------------------------------------
|
|
:param binary_data: The binary data
|
|
:param start: The starting index (inclusive).
|
|
:param end: The ending index (exclusive).
|
|
:param byte_order: The "endianness" of the data.
|
|
:param byte_format: The format in which to unpack
|
|
:return:
|
|
"""
|
|
|
|
return struct.unpack(byte_order + byte_format, binary_data[start:end])[0]
|
|
|
|
def split_packets(self, binary_data: bytes) -> List[bytes]:
|
|
|
|
"""
|
|
We split the full binary payload into individual packets here.
|
|
:param binary_data: The binary message (full) as it was received from Zerodha.
|
|
:return: A list of individual binary packets.
|
|
"""
|
|
|
|
# Ignore the heartbeats:
|
|
if len(binary_data) < 2: return []
|
|
|
|
# The first 2 bytes are the no. of packets that are in the message.
|
|
# Each 'packet' is the data of a tick:
|
|
packet_count = self.unpack_bytes(
|
|
binary_data = binary_data,
|
|
start = 0,
|
|
end = 2,
|
|
byte_order = ">",
|
|
byte_format = "H"
|
|
)
|
|
|
|
# Get the packets:
|
|
packets = []
|
|
offset = 2
|
|
for _ in range(packet_count):
|
|
packet_length = self.unpack_bytes(
|
|
binary_data = binary_data,
|
|
start = offset,
|
|
end = offset + 2,
|
|
byte_order = ">",
|
|
byte_format = "H"
|
|
)
|
|
packets.append(binary_data[offset + 2: offset + 2 + packet_length])
|
|
offset = offset + 2 + packet_length
|
|
|
|
# Done here:
|
|
return packets
|
|
|
|
def parse_exchange_ts(
|
|
self,
|
|
timestamp: float | int,
|
|
exchange: str,
|
|
to_timezone: str = date_time.TIMEZONE_UTC
|
|
) -> (datetime.datetime, str):
|
|
|
|
"""
|
|
Parses the raw timestamp and makes it timezone aware with the timezone reference of the mentioned exchange.
|
|
:param timestamp: The raw timestamp from Zerodha.
|
|
:param exchange: The name of the exchange to get an understanding of the timezone.
|
|
:param to_timezone: The timezone to which the output should be normalized.
|
|
:return: A tuple with the parsed datetime (in the tz mentioned in 'to_timezone') and the timezone string of the
|
|
exchange. Both can be null if parsing fails.
|
|
"""
|
|
|
|
# Start by assuming failure:
|
|
dt = tz = None
|
|
|
|
try:
|
|
|
|
# Parse the timestamp:
|
|
tz = self.EXCHANGE_TIMEZONE_MAP[exchange]
|
|
dt = datetime.datetime.fromtimestamp(timestamp)
|
|
dt = date_time.as_if_timezone(dt, timezone = tz)
|
|
dt = date_time.to_timezone(dt, timezone = to_timezone)
|
|
|
|
# In case of parsing failure:
|
|
except Exception as exception:
|
|
dt = tz = None
|
|
|
|
# Done here:
|
|
return dt, tz
|
|
|
|
def decode_ticks(self, binary_data: bytes) -> List[ZerodhaKiteTick]:
|
|
|
|
"""
|
|
To decode the binary payload of the ticks into a usable format.
|
|
DOCUMENTATION:
|
|
01. Official docs: https://kite.trade/docs/connect/v3/websocket/
|
|
02. GitHub: https://github.com/zerodha/pykiteconnect/blob/master/kiteconnect/ticker.py
|
|
:param binary_data: The full binary payload that contains multiple ticks in it.
|
|
:return: A list of structured ticks.
|
|
"""
|
|
|
|
# Split the full payload into individual packets:
|
|
packets = self.split_packets(binary_data = binary_data)
|
|
ticks = []
|
|
|
|
for packet in packets:
|
|
|
|
# Measure the size of the packet since
|
|
# it will decide how the data is decoded:
|
|
packet_size = len(packet)
|
|
|
|
# Extract the instrument's details:
|
|
instrument_token = self.unpack_bytes(
|
|
binary_data = packet,
|
|
start = 0,
|
|
end = 4,
|
|
byte_order = ">",
|
|
byte_format = "I"
|
|
)
|
|
exchange_code = instrument_token & 0xff
|
|
exchange_name = self.EXCHANGE_CODE_TO_NAME_MAP[exchange_code]
|
|
tradeable = False if exchange_name == self.EXCHANGE_INDICES else True
|
|
match exchange_name:
|
|
case self.EXCHANGE_CDS: divisor = 10_000_000.0
|
|
case self.EXCHANGE_BCD: divisor = 10_000.0
|
|
case _: divisor = 100.0
|
|
|
|
# Create an LTP Tick:
|
|
if packet_size == 8:
|
|
this_tick = ZerodhaKiteTick(
|
|
tickMode = self.MODE_LTP,
|
|
exchange = exchange_name,
|
|
instrumentToken = instrument_token,
|
|
tradeable = tradeable,
|
|
ltp = self.unpack_bytes(packet, start = 4, end = 8) / divisor
|
|
)
|
|
ticks.append(this_tick)
|
|
# print(json.to_string(this_tick.model_dump(), default=str))
|
|
|
|
# For Indices Quote and Full) mode:
|
|
elif packet_size == 28 or packet_size == 32:
|
|
|
|
# Compute basic values:
|
|
mode = self.MODE_QUOTE if len(packet) == 28 else self.MODE_FULL
|
|
prev_close = self.unpack_bytes(packet, start = 20, end = 24) / divisor
|
|
ltp = self.unpack_bytes(packet, start = 4, end = 8) / divisor
|
|
change = (ltp - prev_close)
|
|
pct_change = (change / prev_close) * 100.0
|
|
|
|
# Assume null for those things that will be given for only Full mode:
|
|
exchange_ts = None
|
|
exchange_tz = None
|
|
|
|
# If the Index tick is in Full mode:
|
|
if packet_size == 32:
|
|
exchange_ts, exchange_tz = self.parse_exchange_ts(
|
|
timestamp = self.unpack_bytes(packet, start = 28, end = 32),
|
|
exchange = exchange_name,
|
|
to_timezone = date_time.TIMEZONE_UTC
|
|
)
|
|
|
|
# Feed the model:
|
|
this_tick = ZerodhaKiteTick(
|
|
tickMode = mode,
|
|
exchange = exchange_name,
|
|
instrumentToken = instrument_token,
|
|
tradeable = tradeable,
|
|
prevClose = prev_close,
|
|
ltp = ltp,
|
|
o = self.unpack_bytes(packet, start = 16, end = 20) / divisor,
|
|
h = self.unpack_bytes(packet, start = 8, end = 12) / divisor,
|
|
l = self.unpack_bytes(packet, start = 12, end = 16) / divisor,
|
|
c = ltp,
|
|
chg = change,
|
|
pChg = pct_change,
|
|
exchgTs = exchange_ts,
|
|
exchgTz = exchange_tz,
|
|
)
|
|
ticks.append(this_tick)
|
|
|
|
# For Non-Index Quotes and Full packets:
|
|
elif packet_size == 44 or packet_size == 184:
|
|
|
|
# Compute basic values:
|
|
mode = self.MODE_QUOTE if len(packet) == 44 else self.MODE_FULL
|
|
prev_close = self.unpack_bytes(packet, start = 40, end = 44) / divisor
|
|
ltp = self.unpack_bytes(packet, start = 4, end = 8) / divisor
|
|
change = (ltp - prev_close)
|
|
pct_change = (change / prev_close) * 100.0
|
|
|
|
# Assume null for those things that will be given for only Full mode:
|
|
oi = None
|
|
oi_day_high = None
|
|
oi_day_low = None
|
|
depth = None
|
|
trade_ts = None
|
|
trade_tz = None
|
|
exchange_ts = None
|
|
exchange_tz = None
|
|
|
|
# If the tick is in Full mode:
|
|
if packet_size == 184:
|
|
oi = self.unpack_bytes(packet, start = 48, end = 52)
|
|
oi_day_high = self.unpack_bytes(packet, start = 52, end = 56)
|
|
oi_day_low = self.unpack_bytes(packet, start = 56, end = 60)
|
|
depth = {
|
|
"buy": [],
|
|
"sell": []
|
|
}
|
|
for i, p in enumerate(range(64, len(packet), 12)):
|
|
depth["sell" if i >= 5 else "buy"].append({
|
|
"quantity": self.unpack_bytes(packet, p, p + 4),
|
|
"price": self.unpack_bytes(packet, p + 4, p + 8) / divisor,
|
|
"orders": self.unpack_bytes(packet, p + 8, p + 10, byte_format = "H")
|
|
})
|
|
depth = ZerodhaKiteMarketDepth(**depth)
|
|
trade_ts, trade_tz = self.parse_exchange_ts(
|
|
timestamp = self.unpack_bytes(packet, start=44, end=48),
|
|
exchange = exchange_name,
|
|
to_timezone = date_time.TIMEZONE_UTC
|
|
)
|
|
exchange_ts, exchange_tz = self.parse_exchange_ts(
|
|
timestamp = self.unpack_bytes(packet, start=60, end=64),
|
|
exchange = exchange_name,
|
|
to_timezone = date_time.TIMEZONE_UTC
|
|
)
|
|
|
|
# Feed the model:
|
|
this_tick = ZerodhaKiteTick(
|
|
tickMode = mode,
|
|
exchange = exchange_name,
|
|
instrumentToken = instrument_token,
|
|
tradeable = tradeable,
|
|
prevClose = prev_close,
|
|
ltp = ltp,
|
|
qty = self.unpack_bytes(packet, start = 8, end = 12),
|
|
o = self.unpack_bytes(packet, start = 28, end = 32) / divisor,
|
|
h = self.unpack_bytes(packet, start = 32, end = 36) / divisor,
|
|
l = self.unpack_bytes(packet, start = 36, end = 40) / divisor,
|
|
c = ltp,
|
|
chg = change,
|
|
pChg = pct_change,
|
|
vwap = self.unpack_bytes(packet, start = 12, end = 16) / divisor,
|
|
totBuyQty = self.unpack_bytes(packet, start = 20, end = 24),
|
|
totSellQty = self.unpack_bytes(packet, start = 24, end = 28),
|
|
totVol = self.unpack_bytes(packet, start = 16, end = 20),
|
|
oi = oi,
|
|
oiDayHigh = oi_day_high,
|
|
oiDayLow = oi_day_low,
|
|
depth = depth,
|
|
tradeTs = trade_ts,
|
|
tradeTz = trade_tz,
|
|
exchgTs = exchange_ts,
|
|
exchgTz = exchange_tz
|
|
)
|
|
ticks.append(this_tick)
|
|
|
|
# Done here:
|
|
return ticks
|
|
|
|
async def _keep_listening_to_websocket(self) -> None:
|
|
|
|
"""
|
|
Go in an infinite loop and keep listening to messages (both, binary and text) from the websocket. Zerodha, as
|
|
per their documentation, will send tick updates exclusively in binary format. Some other kinds of messages can
|
|
also be sent by them in text format. These text messages include order updates, simple broker messages, and
|
|
error information.
|
|
DOCUMENTATION:
|
|
01. https://kite.trade/docs/connect/v3/websocket/
|
|
:return: None.
|
|
"""
|
|
|
|
# Create an action-map to call different functions when text messages come in:
|
|
text_message_action_map = {
|
|
"error": self._ws_on_error,
|
|
"order": self._ws_on_order_update,
|
|
"message": self._ws_on_message
|
|
}
|
|
|
|
# Keep listening while the websocket is alive:
|
|
while self.websocket_is_connected:
|
|
|
|
# Wait for a message from the server.
|
|
# Remember that this could be any of the above-mentioned messages:
|
|
message = None
|
|
try: message = await self._ws.recv()
|
|
except websockets.exceptions.ConnectionClosed as exception: pass
|
|
if message is None: continue
|
|
|
|
# Figure out what kind of message has come in:
|
|
is_binary = True if isinstance(message, bytes) else False
|
|
|
|
# Since some data has arrived, we trigger the general callback:
|
|
await self.invoke_callback(self._ws_on_data, self, message, is_binary)
|
|
|
|
# When the received data is just a set of bytes:
|
|
if is_binary:
|
|
|
|
# Binary tick updates:
|
|
if len(message) > 1:
|
|
ticks = self.decode_ticks(binary_data = message)
|
|
await self.invoke_callback(self._ws_on_ticks, self, ticks)
|
|
|
|
# Just a one-byte heartbeat to keep the connection alive.
|
|
# This can be safely ignored:
|
|
else: pass
|
|
|
|
# One of the text-format messages:
|
|
elif not is_binary:
|
|
message = json.from_string(message)
|
|
callback = text_message_action_map.get(message["type"])
|
|
await self.invoke_callback(callback, self, message)
|
|
|
|
# Invoke the disconnection callback:
|
|
await self.invoke_callback(self._ws_on_disconnect, self)
|
|
|
|
def set_websocket_callbacks(
|
|
self,
|
|
on_connect: Callable[["AsyncZerodhaKite"], Awaitable] = None,
|
|
on_no_connect: Callable[["AsyncZerodhaKite"], Awaitable] = None,
|
|
on_disconnect: Callable[["AsyncZerodhaKite"], Awaitable] = None,
|
|
on_reconnect: Callable[["AsyncZerodhaKite"], Awaitable] = None,
|
|
on_no_reconnect: Callable[["AsyncZerodhaKite"], Awaitable] = None,
|
|
on_data: Callable[["AsyncZerodhaKite", Union[bytes, str], bool], Awaitable] = None,
|
|
on_ticks: Callable[["AsyncZerodhaKite", List[ZerodhaKiteTick]], Awaitable] = None,
|
|
on_error: Callable[["AsyncZerodhaKite", dict], Awaitable] = None,
|
|
on_order_update: Callable[["AsyncZerodhaKite", dict], Awaitable] = None,
|
|
on_message: Callable[["AsyncZerodhaKite", dict], Awaitable] = None
|
|
) -> None:
|
|
|
|
"""
|
|
Just sets the callbacks for various websocket events. None of them are enforced as "mandatory".
|
|
:param on_connect: Triggered when the VERY FIRST connection happens.
|
|
:param on_no_connect: Triggered when ANY connection attempt fails.
|
|
:param on_disconnect: Triggered when an established connection is lost.
|
|
:param on_reconnect: Triggered when ANY re-connection attempt is successful.
|
|
:param on_no_reconnect: Triggered when NO MORE new re-connection attempts will be made.
|
|
:param on_data: Triggered when any kind of data is received (binary or text). If the data is of binary nature,
|
|
it will trigger the 'on_ticks' callback as well, and, if it is of text nature, it will trigger one of
|
|
'on_error', 'on_order_update', or 'on_message' callbacks. Be sure on how you will handle double-triggering.
|
|
:param on_ticks: Triggered when specifically tick data is received.
|
|
:param on_error: Triggered when an error message is received.
|
|
:param on_order_update: Triggered when an order update is received.
|
|
:param on_message: Triggered when any general message is received.
|
|
:return: None.
|
|
"""
|
|
|
|
# Accept the callbacks:
|
|
self._ws_on_connect = on_connect
|
|
self._ws_on_no_connect = on_no_connect
|
|
self._ws_on_disconnect = on_disconnect
|
|
self._ws_on_reconnect = on_reconnect
|
|
self._ws_on_no_reconnect = on_no_reconnect
|
|
self._ws_on_data = on_data
|
|
self._ws_on_ticks = on_ticks
|
|
self._ws_on_error = on_error
|
|
self._ws_on_order_update = on_order_update
|
|
self._ws_on_message = on_message
|
|
|
|
async def connect_websocket(
|
|
self,
|
|
ping_interval: float = 20.0,
|
|
ping_timeout: float = 20.0,
|
|
) -> None:
|
|
|
|
"""
|
|
To connect to the websocket for live-streaming of data/alerts from Zerodha. This method may also be invoked for
|
|
attempting to re-connect to the websocket. This method will invoke callbacks like 'on_connect', 'on_reconnect',
|
|
and 'on_no_connect' when applicable so do ensure that you set them up before calling this method. You may change
|
|
callbacks on the fly as long as their inputs match the specifications.
|
|
:return: None.
|
|
"""
|
|
|
|
# Ensure that no other async process does this concurrently,
|
|
# and do this only if the websocket is not already connected:
|
|
async with self._ws_conn_semaphore:
|
|
if not self.websocket_is_connected:
|
|
|
|
try:
|
|
|
|
# Try to connect to the websocket:
|
|
self._ws = await websockets.connect(
|
|
uri = f"{self.ROOT_WEBSOCKET_URL}?api_key={self._api_key}&access_token={self._access_token}",
|
|
ping_interval = ping_interval,
|
|
ping_timeout = ping_timeout
|
|
)
|
|
|
|
# Trigger the appropriate callback:
|
|
task = None
|
|
if self._ws_first_connect: task = self.invoke_callback(self._ws_on_connect, self)
|
|
else: task = self.invoke_callback(self._ws_on_reconnect)
|
|
if task: asyncio.create_task(task)
|
|
|
|
# Start listening for messages:
|
|
if self._ws_listener_task is None:
|
|
self._ws_listener_task = asyncio.create_task(self._keep_listening_to_websocket())
|
|
|
|
# Mark the flag that indicates first connection:
|
|
self._ws_first_connect = False
|
|
|
|
# In case of any connection failure:
|
|
except Exception as exception:
|
|
self._ws = None
|
|
await self.invoke_callback(self._ws_on_no_connect, self)
|
|
|
|
async def disconnect_websocket(self) -> None:
|
|
|
|
"""
|
|
Manually disconnect from the websocket.
|
|
:return: None.
|
|
"""
|
|
|
|
# Ensure that the connection is not being modified:
|
|
async with self._ws_conn_semaphore:
|
|
|
|
# Disconnect only if you are currently connected:
|
|
if self.websocket_is_connected:
|
|
|
|
# Kill the message-listening task:
|
|
if self._ws_listener_task:
|
|
self._ws_listener_task.cancel()
|
|
self._ws_listener_task = None
|
|
|
|
# Close the websocket connection:
|
|
await self._ws.close(code = 1000, reason = "See you later :)")
|
|
self._ws = None
|
|
|
|
# Since this is a manual close, prevent the
|
|
# auto-reconnection mechanism from connecting right back:
|
|
self._ws_made_conn_attempts = self._ws_max_conn_attempts + 1
|
|
|
|
# Invoke the on-disconnect callback:
|
|
await self.invoke_callback(self._ws_on_disconnect, self)
|
|
|
|
async def run_websocket(
|
|
self,
|
|
max_reconnect_attempts: int = 100,
|
|
backoff_seconds: float = 1.0,
|
|
backoff_multiplier: float = 1.0,
|
|
max_backoff_seconds: float = 5.0,
|
|
ping_interval: float = 5.0,
|
|
ping_timeout: float = 5.0,
|
|
custom_task: Callable[["AsyncZerodhaKite"], Awaitable] = None
|
|
) -> None:
|
|
|
|
"""
|
|
Start a websocket loop where the websocket is listening to all incoming messages and invoking associated
|
|
callbacks. If there is some other async task that you need to run simultaneously, you may pass it in through the
|
|
'custom_task' variable, but it may come at a cost to the performance. If you need a fully customised loop, use
|
|
the 'connect_websocket' method instead of this. That method doesn't have the auto-reconnect loop.
|
|
:param max_reconnect_attempts: The absolute maximum no. of connection attempts to make (whether successful or
|
|
not). After this the code will break out of the loop.
|
|
:param backoff_seconds: The no. of seconds to wait before trying to reconnect to the websocket.
|
|
:param backoff_multiplier: The amount by which to vary the backoff seconds parameter.
|
|
:param max_backoff_seconds: The absolute maximum seconds to wait for the next reconnection attempt.
|
|
:param ping_interval: The keep-alive interval.
|
|
:param ping_timeout: The timeout for the keep-alive signal.
|
|
:param custom_task: Any custom async task that you'd like to run in the background. Use this wisely, it can
|
|
cause a significant performance loss.
|
|
:return: None.
|
|
"""
|
|
|
|
# Accept the inputs:
|
|
self._ws_max_conn_attempts = max_reconnect_attempts
|
|
|
|
# Spawn the custom task:
|
|
asyncio.create_task(custom_task(self))
|
|
|
|
# Stay in the loop:
|
|
self._ws_made_conn_attempts = 0
|
|
while self._ws_made_conn_attempts <= self._ws_max_conn_attempts:
|
|
if not self.websocket_is_connected:
|
|
self._ws_made_conn_attempts += 1
|
|
await self.connect_websocket(
|
|
ping_interval = ping_interval,
|
|
ping_timeout = ping_timeout
|
|
)
|
|
await asyncio.sleep(min(backoff_seconds, max_backoff_seconds))
|
|
backoff_seconds *= backoff_multiplier
|
|
else: await asyncio.sleep(1.0)
|
|
|
|
# When the connection attempts are exhausted:
|
|
await self.invoke_callback(self._ws_on_no_reconnect, self)
|
|
|
|
async def subscribe_ticks(
|
|
self,
|
|
instrument_tokens: int | List[int]
|
|
):
|
|
|
|
"""
|
|
To subscribe to instruments for their tick-by-tick updates. This can be done at any time; even when already
|
|
connected and listening to ticks for other instruments. Note that Zerodha DOES NOT immediately give confirmation
|
|
of whether, or not, a particular instrument was subscribed-to successfully. Any intimation is given only in
|
|
text messages of the type "error".
|
|
DOCUMENTATION:
|
|
01. https://kite.trade/docs/connect/v3/websocket/
|
|
:param instrument_tokens: One or more instrument token whose live ticks are desired.
|
|
:return: None.
|
|
"""
|
|
|
|
# Ensure that the websocket is connected:
|
|
if self.websocket_is_connected:
|
|
|
|
# Ensure that no other async process is modifying
|
|
# the subscriptions at the same time:
|
|
async with self._ws_subscription_semaphore:
|
|
|
|
# Zerodha needs the instrument tokens as a list
|
|
# even if you just want to subscribe to one:
|
|
if not isinstance(instrument_tokens, list):
|
|
instrument_tokens = [instrument_tokens]
|
|
|
|
# Construct the message:
|
|
message_json = json.to_string(
|
|
{
|
|
"a": "subscribe",
|
|
"v": instrument_tokens
|
|
},
|
|
no_space = True
|
|
)
|
|
|
|
# Send the message:
|
|
await self._ws.send(message_json.encode(), text = True)
|
|
|
|
# Make a note of your subscriptions:
|
|
for token in instrument_tokens:
|
|
self._ws_subscribed_list[token] = self.MODE_QUOTE
|
|
|
|
async def unsubscribe_ticks(
|
|
self,
|
|
instrument_tokens: int | List[int]
|
|
):
|
|
|
|
"""
|
|
To unsubscribe from instruments for their tick-by-tick updates. This can be done at any time; even when already
|
|
connected and listening to ticks for any instruments. Note that Zerodha DOES NOT immediately give confirmation
|
|
of whether, or not, a particular instrument was unsubscribed-from successfully. Any intimation is given only in
|
|
text messages of the type "error".
|
|
DOCUMENTATION:
|
|
01. https://kite.trade/docs/connect/v3/websocket/
|
|
:param instrument_tokens: One or more instrument token whose live ticks are desired.
|
|
:return: None.
|
|
"""
|
|
|
|
# Ensure that the websocket is connected:
|
|
if self.websocket_is_connected:
|
|
|
|
# Ensure that no other async process is modifying
|
|
# the subscriptions at the same time:
|
|
async with self._ws_subscription_semaphore:
|
|
|
|
# Zerodha needs the instrument tokens as a list
|
|
# even if you just want to subscribe to one:
|
|
if not isinstance(instrument_tokens, list):
|
|
instrument_tokens = [instrument_tokens]
|
|
|
|
# Construct the message:
|
|
message_json = json.to_string(
|
|
{
|
|
"a": "unsubscribe",
|
|
"v": instrument_tokens
|
|
},
|
|
no_space = True
|
|
)
|
|
|
|
# Send the message:
|
|
await self._ws.send(message_json.encode(), text = True)
|
|
|
|
# Make a note of changes to your subscriptions:
|
|
for token in instrument_tokens:
|
|
self._ws_subscribed_list.pop(token, None)
|
|
|
|
async def set_mode_for_ticks(
|
|
self,
|
|
mode: Literal["ltp", "quote", "full"],
|
|
instrument_tokens: int | List[int]
|
|
):
|
|
|
|
"""
|
|
To set the mode for the instruments you have subscribed to.
|
|
DOCUMENTATION:
|
|
01. https://kite.trade/docs/connect/v3/websocket/
|
|
:param mode: The mode in which you want to receive data for the instruments.
|
|
:param instrument_tokens: One or more instrument token whose live ticks are desired.
|
|
:return: None.
|
|
"""
|
|
|
|
# Ensure that the websocket is connected:
|
|
if self.websocket_is_connected:
|
|
|
|
# Ensure that no other async process is modifying
|
|
# the subscriptions at the same time:
|
|
async with self._ws_subscription_semaphore:
|
|
|
|
# Zerodha needs the instrument tokens as a list
|
|
# even if you just want to subscribe to one:
|
|
if not isinstance(instrument_tokens, list):
|
|
instrument_tokens = [instrument_tokens]
|
|
|
|
# Filter out the instruments that you haven't subscribed to:
|
|
subscribed_tokens = self._ws_subscribed_list.keys()
|
|
instrument_tokens = [i for i in instrument_tokens if i in subscribed_tokens]
|
|
|
|
# Construct the message:
|
|
message_json = json.to_string(
|
|
{
|
|
"a": "mode",
|
|
"v": [mode, instrument_tokens]
|
|
},
|
|
no_space = True
|
|
)
|
|
|
|
# Send the message:
|
|
await self._ws.send(message_json.encode(), text = True)
|
|
|
|
# Make a note of changes to your subscriptions:
|
|
for token in instrument_tokens:
|
|
self._ws_subscribed_list[token] = mode
|
|
|
|
|
|
# *****************************************************************************************************************
|
|
# ***** ****
|
|
# *** MAIN PROGRAM ***
|
|
# ***** ****
|
|
# *****************************************************************************************************************
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
async def on_exception(
|
|
api_key: str,
|
|
user_profile: ZerodhaKiteUserProfile | None,
|
|
error_type: str,
|
|
error_json: dict,
|
|
api_response: ZerodhaKiteApiResponse
|
|
):
|
|
|
|
print("\n\n---\n\n")
|
|
print("EXCEPTION!")
|
|
print("USER:", user_profile)
|
|
print("API-KEY:", api_key)
|
|
print("ERROR-TYPE:", error_type)
|
|
print("ERROR:", error_json)
|
|
print("URL:", f"[{api_response.method}] {api_response.url}")
|
|
|
|
async def on_ticks(client: AsyncZerodhaKite, ticks):
|
|
for tick in ticks:
|
|
print("TICK:", tick.model_dump())
|
|
|
|
# Read the credentials:
|
|
creds = json.from_file(r"../../../../creds/zerodha/api.json")
|
|
|
|
# Create the client:
|
|
my_kite = AsyncZerodhaKite(
|
|
api_key = creds["apiKey"],
|
|
api_secret = creds["apiSecret"],
|
|
on_token_exception = on_exception,
|
|
on_user_exception = on_exception,
|
|
on_order_exception = on_exception,
|
|
on_input_exception = on_exception,
|
|
on_margin_exception = on_exception,
|
|
on_holding_exception = on_exception,
|
|
on_network_exception = on_exception,
|
|
on_data_exception = on_exception,
|
|
on_general_exception = on_exception,
|
|
on_undocumented_exception = on_exception,
|
|
)
|
|
|
|
# async def on_connect(client: AsyncZerodhaKite):
|
|
# print("ON CONNECT!")
|
|
# while not client.websocket_is_connected: await asyncio.sleep(0.1)
|
|
# inst_tok = [408065, 884737]
|
|
# await client.subscribe_ticks(inst_tok)
|
|
# await client.set_mode_for_ticks(client.MODE_FULL, inst_tok)
|
|
#
|
|
# async def no_connect(client: AsyncZerodhaKite):
|
|
# print("NOOOOO CONNECT :(")
|
|
#
|
|
# async def no_reconnect_bhopli(client: AsyncZerodhaKite):
|
|
# print("NOOOOO REEE-CONNECT")
|
|
#
|
|
# async def reconnect(client: AsyncZerodhaKite):
|
|
# print("RE-CONNECT")
|
|
#
|
|
# async def on_disconnect(client: AsyncZerodhaKite):
|
|
# print("DISCONNECTED!")
|
|
#
|
|
# async def custom_task(client: AsyncZerodhaKite):
|
|
# for _ in range(60):
|
|
# await asyncio.sleep(1.0)
|
|
# print(f"WS LATENCY ({_}):", my_kite.websocket_latency)
|
|
# # await asyncio.sleep(60.0)
|
|
# await client.disconnect_websocket()
|
|
|
|
async def main():
|
|
|
|
# Login flow:
|
|
print("LOGIN URL:", my_kite.login_url)
|
|
my_kite.set_request_token(input("Request Token: "))
|
|
auth_tokens = await my_kite.generate_session()
|
|
print(auth_tokens)
|
|
|
|
# In case you had logged-in and already have the access token:
|
|
my_kite.set_access_token(access_token = creds["accessToken"])
|
|
|
|
# # Logout:
|
|
# api_response = await my_kite.logout()
|
|
# print(api_response.to_markdown())
|
|
|
|
# # User info:
|
|
# user_profile = await my_kite.get_user_profile()
|
|
# if user_profile: print(json.to_string(user_profile.model_dump()))
|
|
# user_funds = await my_kite.get_user_funds()
|
|
# if user_funds: print(json.to_string(user_funds.model_dump()))
|
|
|
|
# # Portfolio info:
|
|
# user_holdings = await my_kite.get_user_holdings()
|
|
# # if user_holdings: print(json.to_string(user_holdings.model_dump()))
|
|
# user_positions = await my_kite.get_user_positions()
|
|
# # if user_positions: print(json.to_string(user_positions.model_dump()))
|
|
|
|
# Instruments:
|
|
# instruments_list = await my_kite.list_instruments()
|
|
# print(instruments_list)
|
|
# print("COUNT:", len(instruments_list))
|
|
|
|
# my_kite.set_websocket_callbacks(
|
|
# on_connect = on_connect,
|
|
# on_reconnect = reconnect,
|
|
# on_no_connect = no_connect,
|
|
# on_ticks = on_ticks,
|
|
# on_no_reconnect = no_reconnect_bhopli,
|
|
# on_disconnect = on_disconnect
|
|
# )
|
|
# await my_kite.run_websocket(
|
|
# max_reconnect_attempts = 50,
|
|
# backoff_seconds = 1.0,
|
|
# backoff_multiplier = 1.1,
|
|
# max_backoff_seconds = 5.0,
|
|
# custom_task = custom_task,
|
|
# )
|
|
|
|
|
|
asyncio.run(main())
|