""" AUTHOR: Khushal P Soonderji DATE: Saturday, 21st Dec., 2024 OBJECTIVE: To handle all trading related behaviour for Zerodha's Kite platform. REFERENCES: N/A DOWNLOADS: N/A """ # ***************************************************************************************************************** # ***** **** # *** IMPORT *** # ***** **** # ***************************************************************************************************************** # To make sibling directories accessible for imports: import sys sys.path.append(".") sys.path.append("..") # My async utils: from utils_v2.string import json from utils_v2.database.async_mysql_v2 import AsyncMySQL from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Controllers: from controllers_v2.finstitutions.trading.base import TradingController # Models: from models.core.auth_token import CoreAuthTokenModel from utils_v2.trading.zerodha_kite.models.auth_tokens import ZerodhaKiteAuthTokens from models.api.finstitutions.trading.symbols.list import ( TradingSymbolListRequestData, TradingSymbolListBrokerResponse, TradingSymbol ) from models.finstitutions.trading.oauth import TradingOAuthCallbackResponse # To work with MongoDB: from bson.objectid import ObjectId # To work with datatypes: from typing import List, Any # To make HTTP requests: import httpx # To work with Zerodha's Kite platform: from kiteconnect import KiteConnect # To handle exceptions: from pydantic import ValidationError # ***************************************************************************************************************** # ***** **** # *** MACROS / ONE-TIME INIT *** # ***** **** # ***************************************************************************************************************** # --- Nothing Yet # ***************************************************************************************************************** # ***** **** # *** VARIABLES *** # ***** **** # ***************************************************************************************************************** # --- Nothing Yet # ***************************************************************************************************************** # ***** **** # *** FUNCTIONS *** # ***** **** # ***************************************************************************************************************** # --- Nothing Yet # ***************************************************************************************************************** # ***** **** # *** CLASSES *** # ***** **** # ***************************************************************************************************************** class ZerodhaKiteTradingController(TradingController): # ┏┓┓ ┓┏ # ┃ ┃┏┓┏┏ ┃┃┏┓┏┓┏ # ┗┛┗┗┻┛┛ ┗┛┗┻┛ ┛ CLIENT_NAME = "zerodhaKite" # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛ def __init__( self, cache: AsyncRedisCache = None, http_client: httpx.AsyncClient = None, alert_url: str = None, debug: bool = True, debug_prefix: str = "Zerodha Kite (C) | ", debug_only_errors: bool = True ): """ This is the foundational controller for Zerodha's Kite platform. :param cache: The object to use for caching results from database calls. :param http_client: The HTTP client :param debug: Whether, or not, you would like to print debugging messages: :param debug_prefix: The prefix to print with the debugging messages. :param debug_only_errors: Whether you would like to print only error messages or all messages. :return: None. """ # Declare the client: this_client = self.CLIENT_NAME # Prepare base filter: this_filter = {"client": this_client} # Invoke the parent's constructor: super().__init__( cache = cache, alert_url = alert_url, http_client = http_client, base_filter = this_filter, debug = debug, debug_prefix = debug_prefix, debug_only_errors = debug_only_errors ) # Init a variable in a parent: self._client = this_client # ┏┓ ┓ # ┣┫┓┏╋┣┓ # ┛┗┗┻┗┛┗ @staticmethod async def get_authorization_url( **kwargs ) -> str: """ To generate an authorization URL for this broker. :param kwargs: Any no. of things needed by your broker to generate the URL. :return: The authorization URL. """ return f"https://kite.zerodha.com/connect/login?api_key={kwargs['api_key']}" async def handle_authorization_callback( self, sql_conn: AsyncMySQL, mongo_data_conn: AsyncMongo, inbound_data: dict, client_user_id: str ) -> TradingOAuthCallbackResponse: """ When the end user interacts with Zerodha's APIs, Zerodha's servers issue a callback like this: http://127.0.0.1:5999/auth/callback?action=login&type=login&status=success&request_token=the-request-token We must use the request token to get the access token. The access token is the thing that we must hold onto for executing actual actions like subscribing to live market feed, placing trades, etc. NOTE: Please ensure that you set the 'Redirect URL' in the format as shown below: 01. http://127.0.0.1:5106/converse/finstitutions/trading/oauth/callback/zerodhaKite/ 02. https://api.thecaoffice.com/converse/finstitutions/trading/oauth/callback/zerodhaKite/ BACKWARD COMPATIBILITY: Earlier, we used to set the same API key in the callback URL as a query param like shown below: E.g.: http://127.0.0.1:5106/converse/finstitutions/trading/oauth/callback/zerodhaKite?api_key=user_api_key :param sql_conn: The database connection to use to perform this activity. :param mongo_data_conn: The database connection to use to perform this activity. :param inbound_data: The data that came in from the broker. This could be in the JSON body, query params, etc. :param client_user_id: How the trading client identifies this user. :return: A structured response to capture the process of callback handling. """ # Start by assuming failure: response = TradingOAuthCallbackResponse() zerodha_auth_token = None # Check if either the new system or the old system is being followed. # At least one is needed: api_key = inbound_data.get("api_key") if not api_key and not client_user_id: response.message = "Your callback URL hasn't been configured properly." return response # Get the token from the database: old_condition = mongo_data_conn.dict_to_dot_notation({"auth": {"apiKey": api_key}}) condition = mongo_data_conn.dict_to_dot_notation({"auth": {"userId": client_user_id}}) auth_token = await self.get_token_from_filter( mongo_data_conn = mongo_data_conn, filter_json = {"$or": [old_condition, condition]} ) # If not such auth token exists: if not auth_token: response.message = ( f"We couldn't find such an integration in our system. " "Please add this integration first and then try again." ) return response # Get the final access tokens set from Zerodha Kite: try: kite = KiteConnect(api_key = auth_token.auth["apiKey"]) session_data = kite.generate_session( request_token = inbound_data["request_token"], api_secret = auth_token.auth["apiSecret"] ) zerodha_auth_token = ZerodhaKiteAuthTokens(**session_data) except Exception as exception: response.message = f"Client exception: {exception}" response.exception = exception return response # Ensure that the client user id of the incoming callback and the one given in Zerodha's session data match: if ( client_user_id is not None and # ................ For backward compatibility. zerodha_auth_token.userId != client_user_id # ... New mechanism that verifies account match. ): response.message = ( f"We were expecting authorization for the account '{client_user_id}', " f"but Zerodha says the authorization was granted for the account '{zerodha_auth_token.userId}'. " "This could be because of a misconfigured callback URL." ) return response # Prepare the inputs to save to the database: auth_url = await self.get_authorization_url(api_key = auth_token.auth["apiKey"]) auth_token.token = zerodha_auth_token.model_dump() # Save the additional auth info to the database: success = await self.set_token( sql_conn = sql_conn, mongo_data_conn = mongo_data_conn, token_key = auth_token.key, auth_token = auth_token, token_notes = { "apiKey": auth_token.auth["apiKey"], "authUrl": auth_url }, display_name = zerodha_auth_token.userId, display_picture = zerodha_auth_token.displayPictureUrl ) # If saving the token fails: if not success: response.message = "Something went wrong towards the end of the authorization cycle." return response # Done here: response.success = True response.message = "Authorization cycle successfully completed." return response # ┏┳┓ ┓• ┏┓ ┓ ┓ # ┃ ┏┓┏┓┏┫┓┏┓┏┓ ┗┓┓┏┏┳┓┣┓┏┓┃┏ # ┻ ┛ ┗┻┗┻┗┛┗┗┫ ┗┛┗┫┛┗┗┗┛┗┛┗┛ # ┛ ┛ async def list_symbols( self, mongo_data_conn: AsyncMongo, auth_token: CoreAuthTokenModel, inbound_data: TradingSymbolListRequestData ) -> TradingSymbolListBrokerResponse: """ To get the list of tradeable symbols offered by Zerodha. :param mongo_data_conn: The database connection to use to perform this activity. :param auth_token: The token that has to be used to fetch the data. :param inbound_data: The data that came in with the APi call. :return: The structured response form the broker. """ # Start by assuming failure: symbol_list = TradingSymbolListBrokerResponse() try: # Fit the token into the model: zerodha_token = ZerodhaKiteAuthTokens(**auth_token.token) # Now create an instance of the Kite: kite = KiteConnect(api_key = auth_token.auth["apiKey"]) kite.set_access_token(zerodha_token.accessToken) # Now we retrieve the list of symbols for every kind of exchange: symbol_list.data = [] for exchange in inbound_data.exchanges: symbols_subset = kite.instruments(exchange = exchange) symbols_subset = [TradingSymbol.from_zerodha_kite(s) for s in symbols_subset] symbol_list.data += symbols_subset symbol_list.success = True symbol_list.message = "Symbol list retrieved successfully." # In case something goes wrong: except Exception as exception: symbol_list.data = None symbol_list.exception = exception if isinstance(exception, ValidationError): symbol_list.message = "Token error. Please log in to your Zerodha (Kite) account again." else: symbol_list.message = str(exception) # Done here: return symbol_list # ***************************************************************************************************************** # ***** **** # *** MAIN PROGRAM *** # ***** **** # ***************************************************************************************************************** if __name__ == "__main__": pass