diff --git a/api/blueprints/finstitutions/trading/oauth/callback.py b/api/blueprints/finstitutions/trading/oauth/callback.py index a72d192..3a2c334 100644 --- a/api/blueprints/finstitutions/trading/oauth/callback.py +++ b/api/blueprints/finstitutions/trading/oauth/callback.py @@ -6,11 +6,11 @@ DATE: - Monday, 25th Nov., 2024 + Saturday, 21st Dec., 2024 OBJECTIVE: - To receive callbacks (webhooks). + To receive callbacks (webhooks) from stockbrokers for trading API integrations. REFERENCES: @@ -81,7 +81,7 @@ import asyncio # Related to Quart: -mail_oauth_callback_bp = Blueprint("mail_cb", __name__) +trading_oauth_callback_bp = Blueprint("trading_cb", __name__) # ***************************************************************************************************************** @@ -101,7 +101,7 @@ mail_oauth_callback_bp = Blueprint("mail_cb", __name__) # ***************************************************************************************************************** -@mail_oauth_callback_bp.record_once +@trading_oauth_callback_bp.record_once def init(blueprint_setup_state): # This gets called when the blueprint is registered. @@ -112,141 +112,14 @@ def init(blueprint_setup_state): # --------------------------------------------------------------------------------------------------------------------- -@AsyncLoggerContext.log_it( - api_version = "1.0.0", - project = constants.PROJECT_NAME, - log_type = constants.MODULE_NAME, - operation = "gmailClbk", - log_input = 2, - log_output = 1, - sensitive_keys = ["sessionToken", "X-Session-Token"] -) -async def handle_gmail_callback() -> render_template: - - """ - To handle the callbacks from GMail specifically. Refer to the individual comments to check what hap[pens at each - step of the process. - :return: A rendered template (HTML) of the final status of the authorization. - """ - - # Start by assuming failure: - tokens_saved = False - - # In case the user cancelled halfway through (on Google's screen): - if g.inbound_data.get("error") == "access_denied": return await render_template( - "/mail/oauth/oauth_cancelled_v2.html", - mail_client = g.mail_client.title() - ) - - # Generate the tokens from the callback. Google sends all the needed params in the callback as the URL's query - # params. We can simply use the exact URL that was hit to generate the tokens. In Quart (and Flask) this can be - # achieved by 'request.url' like this: - google_tokens = await current_app.gmail_client.get_authorization_tokens( - redirect_url = request.url, - scopes = None - ) - - if google_tokens: - - # Get the e-mail id that granted authorization. We will be comparing this to the e-mail id that had been given - # to us when the authorization was initiated. We don't mind any e-mail id being used, but we need them to be the - # same at both ends: - user_profile = await current_app.gmail_client.get_user_profile(tokens = google_tokens) - if user_profile.success: - google_tokens.email = user_profile.data["emailAddress"] - google_tokens.displayName = user_profile.data["displayName"] - google_tokens.displayPictureUrl = user_profile.data["displayPictureUrl"] - - # Here's where we do the checking of the e-mails, - # if they don't match, we reject the authorization: - auth_token = await current_app.mail_controller.get_token_from_key( - mongo_conn = current_app.data_mongo, - token_key = g.inbound_data["state"] - ) - if ( - (not auth_token) or - auth_token.clientUserId["email"] != str(google_tokens.email) - ): return await render_template( - "/mail/oauth/oauth_failure_v2.html", - mail_client = g.mail_client.title(), - failure_hint = ( - f"We were expecting authorization from '{auth_token.clientUserId['email']}', " - f"but got authorization from '{google_tokens.email}' instead." - ) - ) - - # We create standard labels that we will use: - labels = [ - { - "name": "TCAOFF", - "textColor": "#434343", - "backgroundColor": "#e7e7e7" - }, - { - "name": "CA-Doc", - "textColor": "#434343", - "backgroundColor": "#e7e7e7" - }, - { - "name": "CA-AI", - "textColor": "#434343", - "backgroundColor": "#e7e7e7" - } - ] - tasks = [ - current_app.gmail_client.create_label( - tokens = google_tokens, - label_name = label["name"], - label_visibility = "labelShow", - message_visibility = "show", - label_text_color = label["textColor"], - label_background_color = label["backgroundColor"] - ) for label in labels - ] - client_responses = await asyncio.gather(*tasks) - - # Add the labels to the tokens data: - client_response = await current_app.gmail_client.list_labels(tokens = google_tokens) - google_tokens.labels = client_response.data if client_response.success else None - - # Now that we have passed the check, - # we save the tokens to the database: - auth_token.clientUserId = google_tokens.client_user_id - auth_token.token = google_tokens.model_dump() - auth_token.status = "active" - tokens_saved = await current_app.mail_controller.set_token( - db_conn = current_app.sql_writer, - mongo_conn = current_app.data_mongo, - session_token = g.inbound_headers.get("X-Session-Token"), - token_key = g.inbound_data["state"], - auth_token = auth_token - ) - - # Return an HTML response for success: - if tokens_saved: return await render_template( - "/mail/oauth/oauth_success_v2.html", - mail_client = g.mail_client.title() - ) - - # Return an HTML response for failure: - else: return await render_template( - "/mail/oauth/oauth_failure_v2.html", - mail_client = g.mail_client.title(), - failure_hint = f"Unknown error. Please use log-id '{g.log_id}' to check with the support team." - ) - - -# --------------------------------------------------------------------------------------------------------------------- - - -@mail_oauth_callback_bp.route("/callback/", methods = ["POST", "GET"]) +@trading_oauth_callback_bp.route("oauth/callback/", methods = ["POST", "GET"]) @set_api_version(api_version = "1.0.0") @read_input(sanitize_headers = False, sanitize_data = False) @log_request_to_mongo( attr_name = "logs_mongo", project = constants.PROJECT_NAME, log_type = constants.MODULE_NAME, - operation = "gmailCllBckApi", + operation = "trdngOauthCllBckApi", log_input = True, log_output = True, sensitive_keys = None @@ -254,8 +127,8 @@ async def handle_gmail_callback() -> render_template: @log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @handle_cancelled_request() -async def mail_auth_callback( - mail_client: str = None, +async def trading_oauth_callback( + trading_client: str = None, inbound_headers: dict = None, inbound_data: dict = None, inbound_files: dict = None, @@ -263,8 +136,9 @@ async def mail_auth_callback( ): """ - Use this when authorizing access to someone's GMail account. This can be used to capture the authentication token. - :param mail_client: The mail company/brand that you want the authorization from. + This endpoint gets triggered by the stockbroker's servers to let you know when a user accepted or rejected an + authorization request. + :param trading_client: The name of the stockbroker that you have received the callback from. :param inbound_headers: auto-extracted by the decorators. :param inbound_data: auto-extracted by the decorators. :param inbound_files: auto-extracted by the decorators. @@ -272,45 +146,36 @@ async def mail_auth_callback( :return: A standard response structure. """ - # ┓┏ ┓┓ ┓┏ • ┓ ┓ - # ┣┫┏┓┏┓┏┫┃┏┓ ┃┃┏┓┏┓┓┏┓┣┓┃┏┓┏ - # ┛┗┗┻┛┗┗┻┗┗ ┗┛┗┻┛ ┗┗┻┗┛┗┗ ┛ + # Start by assuming failure: + success = None - # Here we make various variables available in the scope of the current request through 'g': - g.log_id = kwargs.get("log_id") - g.inbound_headers = inbound_headers - g.inbound_data = inbound_data - g.mail_client = mail_client + # ┏┓ ┏┓ ┓┓ ┓┏┓• + # ┣ ┏┓┏┓ ┏┛┏┓┏┓┏┓┏┫┣┓┏┓ ┃┫ ┓╋┏┓ + # ┻ ┗┛┛ ┗┛┗ ┛ ┗┛┗┻┛┗┗┻ ┛┗┛┗┗┗ - # ┓┏ ┓┓ ┏┓ ┓┓┓ ┓ - # ┣┫┏┓┏┓┏┫┃┏┓ ┃ ┏┓┃┃┣┓┏┓┏┃┏┏ - # ┛┗┗┻┛┗┗┻┗┗ ┗┛┗┻┗┗┗┛┗┻┗┛┗┛ - - try: - - if mail_client == "gmail": return await handle_gmail_callback() - - # If something goes wrong: - except Exception as exception: return await render_template( - "/mail/oauth/oauth_failure_v2.html", - mail_client = mail_client.title(), - failure_hint = ( - f"An internal server error occurred. " - f"Please use log-id '{g.log_id}' to check with the support team." + if trading_client == "zerodha": + success = await current_app.zerodha_kite_controller.handle_authorization_callback( + sql_conn = current_app.sql_writer, + mongo_data_conn = current_app.data_mongo, + inbound_data = inbound_data ) + + # ┳┓ + # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ + # ┛┗┗ ┛┣┛┗┛┛┗┛┗ + # ┛ + + # No valid client: + if success is None: return ResponseModel( + status_code = StatusCodes.FAILED, + http_code = HttpCodes.BAD_REQUEST, + message = f"Invalid/unimplemented client '{trading_client}'." ) - # ┓┏ ┓┓ ┳ ┓• ┓ ┏┓┓• - # ┣┫┏┓┏┓┏┫┃┏┓ ┃┏┓┓┏┏┓┃┓┏┫ ┃ ┃┓┏┓┏┓╋ - # ┛┗┗┻┛┗┗┻┗┗ ┻┛┗┗┛┗┻┗┗┗┻ ┗┛┗┗┗ ┛┗┗ - - return await render_template( - "/mail/oauth/oauth_failure_v2.html", - mail_client = mail_client.title(), - failure_hint = ( - f"Invalid client '{mail_client}' selected. " - f"Please use log-id '{g.log_id}' to check with the support team." - ) + # When the client was valid: + return ResponseModel( + status_code = StatusCodes.OK if success else StatusCodes.FAILED, + http_code = HttpCodes.SUCCESS if success else HttpCodes.INTERNAL_SERVER_ERROR, ) diff --git a/api/blueprints/finstitutions/trading/oauth/request.py b/api/blueprints/finstitutions/trading/oauth/request.py index b6d33b6..ad4be42 100644 --- a/api/blueprints/finstitutions/trading/oauth/request.py +++ b/api/blueprints/finstitutions/trading/oauth/request.py @@ -59,18 +59,15 @@ from utils_v2.api.async_quart import ( handle_cancelled_request ) -# GMail-related utils: -from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT - # Common: from shared import constants # Data Models: -from models.api.mail.oauth import ( - OAuthMailAuthorizationRequestHeaders, - OAuthMailAuthorizationRequestData -) from models.core.auth_token import CoreAuthTokenModel +from models.api.finstitutions.trading.auth import ( + TradingAuthRequestHeaders, + TradingAuthRequestData +) # For asynchronous activities: import asyncio @@ -131,13 +128,13 @@ def init(blueprint_setup_state): @log_chain_to_mongo(attr_name = "logs_mongo") @should_not_be_under_maintenance(attr_name = "is_under_maintenance") @validate_input( - header_validator = lambda x: OAuthMailAuthorizationRequestHeaders(**x).model_dump(), - data_validator = lambda x: OAuthMailAuthorizationRequestData(**x) + header_validator = lambda x: TradingAuthRequestHeaders(**x).model_dump(), + data_validator = lambda x: TradingAuthRequestData(**x) ) @handle_cancelled_request() async def request_oauth_authorization_url( - inbound_headers: dict | OAuthMailAuthorizationRequestHeaders = None, - inbound_data: dict | OAuthMailAuthorizationRequestData = None, + inbound_headers: dict | TradingAuthRequestHeaders = None, + inbound_data: dict | TradingAuthRequestData = None, inbound_files: dict = None, **kwargs ): @@ -168,48 +165,47 @@ async def request_oauth_authorization_url( # Start by assuming failure: auth_url = None - # ┏┓ ┏┳┓ ┓ ┓┏┓ - # ┃┓┏┓┏┓┏┓┏┓┏┓╋┏┓ ┃ ┏┓┃┏┏┓┏┓ ┃┫ ┏┓┓┏ - # ┗┛┗ ┛┗┗ ┛ ┗┻┗┗ ┻ ┗┛┛┗┗ ┛┗ ┛┗┛┗ ┗┫ - # ┛ + # ┏┓ ┏┓ ┓┓ ┓┏┓• + # ┣ ┏┓┏┓ ┏┛┏┓┏┓┏┓┏┫┣┓┏┓ ┃┫ ┓╋┏┓ + # ┻ ┗┛┛ ┗┛┗ ┛ ┗┛┗┻┛┗┗┻ ┛┗┛┗┗┗ - # Make a user identifier from the session info: - token_key = await current_app.mail_controller.generate_token_key( - db_conn = current_app.sql_writer, - mongo_conn = current_app.data_mongo, - auth_token = CoreAuthTokenModel( - serviceType = "email", - client = inbound_data.mailClient, - authType = "oauth", - user = kwargs["session_info"], - clientUserId = {"email": inbound_data.mailId}, - status = "pending", - syncFreq = inbound_data.syncFreq, - ), - session_token = inbound_headers["X-Session-Token"] - ) - if token_key is None: - return ResponseModel( - status_code = StatusCodes.FAILED, - http_code = HttpCodes.INTERNAL_SERVER_ERROR, - message = "Failed to generate token key." + # PLANNED FLOW FOR ZERODHA-KITE: + # Step 01.: (One time) The user will go to the integrations page and add his API Key and SPI Secret there. We store + # these values without verification. + # Step 02.: (Daily) The user will go to the investments tab and click on his Zerodha account, which will give him + # a URL that will take him to Zerodha's official site to log in. When he logs in, Zerodha will hit our + # callback URL and give us the authentication details. + + if inbound_data.client == "zerodhaKite": + + # Prepare the inputs: + auth_url = await current_app.zerodha_kite_controller.get_authorization_url(api_key = inbound_data.auth.apiKey) + + # Immediately save the details against that token id: + success = await current_app.zerodha_kite_controller.set_token_direct( + sql_conn = current_app.sql_writer, + mongo_data_conn = current_app.data_mongo, + auth_token = CoreAuthTokenModel( + serviceType = "stockTrading", + client = inbound_data.client, + authType = "oauth", + user = kwargs["session_info"], + clientUserId = { + "apiKey": inbound_data.auth.apiKey + }, + auth = inbound_data.auth.model_dump(), + status = "active", + syncFreq = 1500 + ), + token_notes = { + "apiKey": inbound_data.auth.apiKey, + "authUrl": auth_url + }, + session_token = inbound_headers["X-Session-Token"] ) - # ┏┓ ┏┓┳┳┓ •┓ - # ┣ ┏┓┏┓ ┃┓┃┃┃┏┓┓┃ - # ┻ ┗┛┛ ┗┛┛ ┗┗┻┗┗ - - if inbound_data.mailClient == "gmail": - - # Get the authorization URL: - auth_url = await current_app.gmail_client.get_authorization_url( - scopes = SCOPES_GMAIL_MAIL_MANAGEMENT, - state = str(token_key), - access_type = "offline", - approval_prompt = "force", - include_granted_scopes = "true", - user_email = inbound_data.mailId - ) + # Check if things were successful: + if not success: auth_url = None # ┳┓ # ┣┫┏┓┏┏┓┏┓┏┓┏┏┓ @@ -221,7 +217,7 @@ async def request_oauth_authorization_url( status_code = StatusCodes.OK if auth_url else StatusCodes.FAILED, http_code = HttpCodes.SUCCESS if auth_url else HttpCodes.INTERNAL_SERVER_ERROR, data = { - "client": inbound_data.mailClient, + "client": inbound_data.client, "authorizationUrl": auth_url } ) diff --git a/api/main.py b/api/main.py index abeba20..74c616b 100644 --- a/api/main.py +++ b/api/main.py @@ -75,9 +75,13 @@ from controllers.api.payment import PaymentController # Controllers V2: from controllers_v2.core.auth_token import CoreAuthTokenController -from controllers_v2.sms.all_sms import AllSMSController -from controllers_v2.sms.nimbus_sms_india import NimbusSMSIndiaController -from controllers_v2.sms.savvy_bulk_sms_kenya import SavvyBulkSMSKenyaController +# --- +from controllers_v2.message.sms.all_sms import AllSMSController +from controllers_v2.message.sms.nimbus_sms_india import NimbusSMSIndiaController +from controllers_v2.message.sms.savvy_bulk_sms_kenya import SavvyBulkSMSKenyaController +# --- +from controllers_v2.finstitutions.trading.all_trading import AllTradingController +from controllers_v2.finstitutions.trading.zerodha_kite import ZerodhaKiteTradingController # To make REST API calls: import httpx @@ -107,7 +111,7 @@ from api.blueprints.sms.tags import sms_update_tags_bp # Software Blueprints: from api.blueprints.software.auth import sw_auth_bp -# Payment Blueprints: +# Finstitutions / Payment Blueprints: from api.blueprints.finstitutions.payments.auth import pg_auth_bp from api.blueprints.finstitutions.payments.request import pg_request_bp from api.blueprints.finstitutions.payments.callback import pg_callback_bp @@ -115,6 +119,10 @@ from api.blueprints.finstitutions.payments.list import pg_list_bp from api.blueprints.finstitutions.payments.get import pg_get_bp from api.blueprints.finstitutions.payments.tags import pg_tags_update_bp +# Finstitutions / Trading Blueprints: +from api.blueprints.finstitutions.trading.oauth.request import trading_oauth_request_bp +from api.blueprints.finstitutions.trading.oauth.callback import trading_oauth_callback_bp + # AI Blueprints: from api.blueprints.ai.llm.invoke import llm_invoke_bp @@ -171,7 +179,7 @@ app.register_blueprint(sms_update_tags_bp, url_prefix = f"/{MODULE_BASE}/sms") # Software Blueprints: app.register_blueprint(sw_auth_bp, url_prefix = f"/{MODULE_BASE}/software") -# Payment Blueprints: +# Finstitutions / Payment Blueprints: app.register_blueprint(pg_auth_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") app.register_blueprint(pg_request_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") app.register_blueprint(pg_callback_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") @@ -179,6 +187,10 @@ app.register_blueprint(pg_list_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/p app.register_blueprint(pg_get_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") app.register_blueprint(pg_tags_update_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/payments") +# Finstitutions / Trading Blueprints: +app.register_blueprint(trading_oauth_request_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/trading") +app.register_blueprint(trading_oauth_callback_bp, url_prefix = f"/{MODULE_BASE}/finstitutions/trading") + # AI Blueprints: app.register_blueprint(llm_invoke_bp, url_prefix = f"/{MODULE_BASE}/ai") @@ -416,7 +428,7 @@ async def app_startup(**kwargs): debug = enable_debugging ) - # SMS Controllers: + # Messages / SMS Controllers: current_app.sms_controller = AllSMSController( cache = current_app.module_cache, http_client = current_app.http_client, @@ -436,6 +448,20 @@ async def app_startup(**kwargs): debug = enable_debugging ) + # Finstitutions / Trading Controllers: + current_app.trading_controller = AllTradingController( + cache = current_app.module_cache, + http_client = current_app.http_client, + alert_url = current_app.script_data["alerts"]["url"], + debug = enable_debugging + ) + current_app.zerodha_kite_controller = ZerodhaKiteTradingController( + cache = current_app.module_cache, + http_client = current_app.http_client, + alert_url = current_app.script_data["alerts"]["url"], + debug = enable_debugging + ) + # ┏┓ ┓ ┏┓┓• # ┃ ┏┓┏┓┏┓┏┓┏╋┏┓┏┓┏ ┏┓┏┓┏┫ ┃ ┃┓┏┓┏┓╋┏ # ┗┛┗┛┛┗┛┗┗ ┗┗┗┛┛ ┛ ┗┻┛┗┗┻ ┗┛┗┗┗ ┛┗┗┛ diff --git a/controllers_v2/core/auth_token.py b/controllers_v2/core/auth_token.py index 8fa0a1f..c348cea 100644 --- a/controllers_v2/core/auth_token.py +++ b/controllers_v2/core/auth_token.py @@ -103,6 +103,10 @@ class CoreAuthTokenController(CoreBaseModel): # For MongoDB: AUTH_COLLECTION = "_authTokens" + # Other variables: + _service_type = None + _client = None + # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ # ┗┛┗┛┛┗┛┗┛ ┗┻┗┗┗┛┛ @@ -166,6 +170,12 @@ class CoreAuthTokenController(CoreBaseModel): :return: An ObjectId to later store the granted tokens. """ + # Safety check for consistency: + if self._service_type is not None and self._service_type != auth_token.serviceType: + raise ValueError(f"Expected service type '{self._service_type}', got '{auth_token.serviceType}'") + if self._client is not None and self._client != auth_token.client: + raise ValueError(f"Expected service type '{self._client}', got '{auth_token.client}'") + # Note down the timestamp at which this event occurred: request_ts = date_time.get_current_utc_date_time(as_string = False) @@ -255,6 +265,12 @@ class CoreAuthTokenController(CoreBaseModel): :return: True if saved, False if failed. """ + # Safety check for consistency: + if self._service_type is not None and self._service_type != auth_token.serviceType: + raise ValueError(f"Expected service type '{self._service_type}', got '{auth_token.serviceType}'") + if self._client is not None and self._client != auth_token.client: + raise ValueError(f"Expected service type '{self._client}', got '{auth_token.client}'") + # Start by assuming failure: token_saved = False @@ -430,6 +446,33 @@ class CoreAuthTokenController(CoreBaseModel): # Done here: return CoreAuthTokenModel(**token) if token else None + async def get_token_from_filter( + self, + mongo_data_conn: AsyncMongo, + filter_json: dict + ) -> CoreAuthTokenModel | None: + + """ + To retrieve stored tokens from the database. One token at a time. + :param mongo_data_conn: The database connection (MongoDB) to use to perform the action. + :param filter_json: The filter conditions to use. + :return: The retrieved record that has the token, and information about the service and client if found, else + None when there is no matching record. + """ + + # Prepare the filter: + if self._base_filter: + for k, v in self._base_filter.items(): filter_json[k] = v + + # If there is some filtering possible, we fetch the token: + token = await mongo_data_conn.find_one( + collection = self.AUTH_COLLECTION, + filter = filter_json + ) + + # Done here: + return CoreAuthTokenModel(**token) if token else None + async def get_tokens_from_ids( self, mongo_data_conn: AsyncMongo, diff --git a/controllers_v2/finstitutions/trading/all_trading.py b/controllers_v2/finstitutions/trading/all_trading.py index 10fad96..78a6d47 100644 --- a/controllers_v2/finstitutions/trading/all_trading.py +++ b/controllers_v2/finstitutions/trading/all_trading.py @@ -40,7 +40,7 @@ from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Controllers: -from controllers_v2.core.auth_token import CoreAuthTokenController +from controllers_v2.finstitutions.trading.base import TradingController # Models: from models.core.auth_token import CoreAuthTokenModel @@ -89,7 +89,7 @@ import httpx # ***************************************************************************************************************** -class TradingController(CoreAuthTokenController): +class AllTradingController(TradingController): # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ @@ -100,37 +100,28 @@ class TradingController(CoreAuthTokenController): cache: AsyncRedisCache = None, http_client: httpx.AsyncClient = None, alert_url: str = None, - base_filter: dict = None, debug: bool = True, - debug_prefix: str = "Trading (C) | ", + debug_prefix: str = "All Trading (C) | ", debug_only_errors: bool = True ): """ - This is the foundational controller for all SMS services. This is built on top of the core message controller, - and, in turn, all individual SMS client controllers must be built on top of this. + This is the foundational controller for all trading services. Use this for any smaller common tasks where you + may not know the exact client beforehand. :param cache: The object to use for caching results from database calls. :param http_client: The HTTP client - :param base_filter: The basic filter that will be applied to all fetching/updating queries. WARNING: THE BASE - FILTER WILL ALWAYS BE APPLIED AUTOMATICALLY. SET THIS UP WISELY. :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. """ - # Prepare the combined base filter: - this_filter = {} - for k, v in (base_filter or {}).items(): this_filter[k] = v - this_filter["serviceType"] = "stockTrading" - # Invoke the parent's constructor: - CoreAuthTokenController.__init__( - self, + super().__init__( cache = cache, alert_url = alert_url, http_client = http_client, - base_filter = this_filter, + base_filter = None, debug = debug, debug_prefix = debug_prefix, debug_only_errors = debug_only_errors @@ -140,23 +131,7 @@ class TradingController(CoreAuthTokenController): # ┣┫┓┏╋┣┓ # ┛┗┗┻┗┛┗ - # async def login_url( - # self, - # mongo_data_conn: AsyncMongo, - # auth_token: CoreAuthTokenModel - # ) -> SMSSendOneResult: - # - # """ - # To send one SMS message through the third-party client. - # :param mongo_data_conn: The database connection to use to perform this task. - # :param auth_token: The auth token that will be used to send this message. - # :param client: The third-party SMS client to use to send this message. - # :param message: The actual message that needs to be sent. - # :param tags: Any tags to attach with this SMS for filtering when querying in the listing service. - # :return: The structured result of sending one message. - # """ - # - # pass + pass # ***************************************************************************************************************** diff --git a/controllers_v2/finstitutions/trading/base.py b/controllers_v2/finstitutions/trading/base.py index 6689e1e..7f9323d 100644 --- a/controllers_v2/finstitutions/trading/base.py +++ b/controllers_v2/finstitutions/trading/base.py @@ -6,11 +6,11 @@ DATE: - Thursday, 19th Dec., 2024 + Saturday, 21st Dec., 2024 OBJECTIVE: - To handle all SMS related behaviour from one place. + To handle all trading related behaviour from one place. REFERENCES: @@ -40,20 +40,10 @@ from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Controllers: -from controllers_v2.core.message import CoreMessageController +from controllers_v2.core.auth_token import CoreAuthTokenController # Models: from models.core.auth_token import CoreAuthTokenModel -from models.api.sms.send import ( - NimbusSMSIndiaMessage, - SavvyBulkSMSKenyaMessage, - SMSSendOneResult, - SMSSendManyResults -) - -# SMS clients: -from utils_v2.sms.india.nimbus.controllers.async_nimbus import AsyncNimbusSMS -from utils_v2.sms.kenya.savvy_bulk_sms.controllers.async_savvy_bulk_sms import AsyncSavvyBulkSMS # To work with datatypes: from typing import List, Any @@ -102,7 +92,7 @@ from abc import ABC, abstractmethod # ***************************************************************************************************************** -class SMSController(CoreMessageController, ABC): +class TradingController(CoreAuthTokenController, ABC): # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ @@ -115,13 +105,13 @@ class SMSController(CoreMessageController, ABC): alert_url: str = None, base_filter: dict = None, debug: bool = True, - debug_prefix: str = "SMS (C) | ", + debug_prefix: str = "Trading (C) | ", debug_only_errors: bool = True ): """ - This is the foundational controller for all SMS services. This is built on top of the core message controller, - and, in turn, all individual SMS client controllers must be built on top of this. + This is the foundational controller for all trading/stockbroking services. This is built on top of the + authorization model, and, in turn, the individual stockbroking clients should be built on top of this. :param cache: The object to use for caching results from database calls. :param http_client: The HTTP client :param base_filter: The basic filter that will be applied to all fetching/updating queries. WARNING: THE BASE @@ -132,70 +122,34 @@ class SMSController(CoreMessageController, ABC): :return: None. """ - # Prepare the combined base filter: - sms_filter = {} - for k, v in (base_filter or {}).items(): sms_filter[k] = v - sms_filter["serviceType"] = "sms" + # Declare the service type: + this_service_type = "stockTrading" + + # Prepare base filter: + this_filter = {} + for k, v in (base_filter or {}).items(): this_filter[k] = v + this_filter["serviceType"] = this_service_type # Invoke the parent's constructor: - CoreMessageController.__init__( + CoreAuthTokenController.__init__( self, cache = cache, alert_url = alert_url, http_client = http_client, - base_filter = sms_filter, + base_filter = this_filter, debug = debug, debug_prefix = debug_prefix, debug_only_errors = debug_only_errors ) - # ┏┓┳┳┓┏┓ ┏┓ ┓• - # ┗┓┃┃┃┗┓ ┗┓┏┓┏┓┏┫┓┏┓┏┓ - # ┗┛┛ ┗┗┛ ┗┛┗ ┛┗┗┻┗┛┗┗┫ - # ┛ + # Init a variable in a parent: + self._service_type = this_service_type - async def send_one_sms( - self, - mongo_data_conn: AsyncMongo, - auth_token: CoreAuthTokenModel, - client: AsyncNimbusSMS | AsyncSavvyBulkSMS, - message: NimbusSMSIndiaMessage, - tags: List[Any] - ) -> SMSSendOneResult: + # ┏┓ ┓ + # ┣┫┓┏╋┣┓ + # ┛┗┗┻┗┛┗ - """ - To send one SMS message through the third-party client. - :param mongo_data_conn: The database connection to use to perform this task. - :param auth_token: The auth token that will be used to send this message. - :param client: The third-party SMS client to use to send this message. - :param message: The actual message that needs to be sent. - :param tags: Any tags to attach with this SMS for filtering when querying in the listing service. - :return: The structured result of sending one message. - """ - - pass - - @abstractmethod - async def send_many_sms( - self, - mongo_data_conn: AsyncMongo, - auth_token: CoreAuthTokenModel, - messages: List[NimbusSMSIndiaMessage | SavvyBulkSMSKenyaMessage], - tags: List[Any] - ) -> SMSSendManyResults: - - """ - To send multiple SMS messages through the third-party client. - individual message, and then aggregates the results. - :param mongo_data_conn: The database connection to use to perform this task. - :param auth_token: The auth token that will be used to send this message. - :param messages: The list of messages to send out. - :param tags: Any tags to attach with these SMS for filtering when querying in the listing service. The same tags - will be applied to all messages. Do not call this method if you need to have different tags for all of them. - :return: The structured result of sending many SMS messages. - """ - - pass + pass # ***************************************************************************************************************** diff --git a/controllers_v2/finstitutions/trading/zerodha_kite.py b/controllers_v2/finstitutions/trading/zerodha_kite.py index c0ecb8d..8697022 100644 --- a/controllers_v2/finstitutions/trading/zerodha_kite.py +++ b/controllers_v2/finstitutions/trading/zerodha_kite.py @@ -10,7 +10,7 @@ OBJECTIVE: - To handle all trading related behaviour from one place. + To handle all trading related behaviour for Zerodha's Kite platform. REFERENCES: @@ -36,6 +36,8 @@ 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 @@ -44,6 +46,10 @@ 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 + +# To work with MongoDB: +from bson.objectid import ObjectId # To work with datatypes: from typing import List, Any @@ -51,6 +57,9 @@ from typing import List, Any # To make HTTP requests: import httpx +# To work with Zerodha's Kite platform: +from kiteconnect import KiteConnect + # ***************************************************************************************************************** # ***** **** @@ -89,7 +98,7 @@ import httpx # ***************************************************************************************************************** -class AllTradingController(TradingController): +class ZerodhaKiteTradingController(TradingController): # ┏┓ # ┃ ┏┓┏┓┏╋┏┓┓┏┏╋┏┓┏┓ @@ -101,12 +110,12 @@ class AllTradingController(TradingController): http_client: httpx.AsyncClient = None, alert_url: str = None, debug: bool = True, - debug_prefix: str = "All SMS (C) | ", + debug_prefix: str = "Zerodha kite (C) | ", debug_only_errors: bool = True ): + """ - This is the foundational controller for all trading services. Use this for any smaller common tasks where you - may not know the exact client beforehand. + 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: @@ -115,22 +124,110 @@ class AllTradingController(TradingController): :return: None. """ + # Declare the client: + this_client = "zerodhaKite" + + # 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 = None, + 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 + # ┏┓ ┓ # ┣┫┓┏╋┣┓ # ┛┗┗┻┗┛┗ - pass + @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 + ) -> bool: + + """ + 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' such that is passes back Kite's 'api_key' back through the + callback URL. This can be one by setting the value manually as a query param on the app's configuration + page. E.g.: http://127.0.0.1:5999/auth/callback?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. + :return: The model that hold the access tokens, or None if something failed. + """ + + # Start by assuming failure: + success = False + zerodha_auth_token = None + + # Get the token from the database: + auth_token = await self.get_token_from_filter( + mongo_data_conn = mongo_data_conn, + filter_json = mongo_data_conn.dict_to_dot_notation({ + "auth": { + "apiKey": inbound_data.get( + "api_key", + "Hint: Put the user's app's key in the query params of the 'Redirect URL'" + ) + } + }) + ) + + # If not such auth token exists: + if not auth_token: return success + + # Get the final access tokens set from Zerodha Kite: + 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) + + # 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 + } + ) + + # Done here: + return success # ***************************************************************************************************************** diff --git a/controllers_v2/sms/__init__.py b/controllers_v2/message/sms/__init__.py similarity index 100% rename from controllers_v2/sms/__init__.py rename to controllers_v2/message/sms/__init__.py diff --git a/controllers_v2/sms/all_sms.py b/controllers_v2/message/sms/all_sms.py similarity index 99% rename from controllers_v2/sms/all_sms.py rename to controllers_v2/message/sms/all_sms.py index ff41a66..23a8fb1 100644 --- a/controllers_v2/sms/all_sms.py +++ b/controllers_v2/message/sms/all_sms.py @@ -41,7 +41,7 @@ from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Controllers: -from controllers_v2.sms.base import SMSController +from controllers_v2.message.sms.base import SMSController # Models: from models.core.auth_token import CoreAuthTokenModel diff --git a/controllers_v2/sms/base.py b/controllers_v2/message/sms/base.py similarity index 100% rename from controllers_v2/sms/base.py rename to controllers_v2/message/sms/base.py diff --git a/controllers_v2/sms/nimbus_sms_india.py b/controllers_v2/message/sms/nimbus_sms_india.py similarity index 99% rename from controllers_v2/sms/nimbus_sms_india.py rename to controllers_v2/message/sms/nimbus_sms_india.py index 44e376f..654dc5d 100644 --- a/controllers_v2/sms/nimbus_sms_india.py +++ b/controllers_v2/message/sms/nimbus_sms_india.py @@ -42,7 +42,7 @@ from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Controllers: -from controllers_v2.sms.base import SMSController +from controllers_v2.message.sms.base import SMSController # Models: from models.core.auth_token import CoreAuthTokenModel diff --git a/controllers_v2/sms/savvy_bulk_sms_kenya.py b/controllers_v2/message/sms/savvy_bulk_sms_kenya.py similarity index 99% rename from controllers_v2/sms/savvy_bulk_sms_kenya.py rename to controllers_v2/message/sms/savvy_bulk_sms_kenya.py index 48aebfa..562f135 100644 --- a/controllers_v2/sms/savvy_bulk_sms_kenya.py +++ b/controllers_v2/message/sms/savvy_bulk_sms_kenya.py @@ -42,7 +42,7 @@ from utils_v2.database.async_mongo_v2 import AsyncMongo from utils_v2.cache.async_redis_cache_v2 import AsyncRedisCache # Controllers: -from controllers_v2.sms.base import SMSController +from controllers_v2.message.sms.base import SMSController # Models: from models.core.auth_token import CoreAuthTokenModel diff --git a/models/api/finstitutions/trading/auth.py b/models/api/finstitutions/trading/auth.py index 39c45ec..dd51f6b 100644 --- a/models/api/finstitutions/trading/auth.py +++ b/models/api/finstitutions/trading/auth.py @@ -75,25 +75,21 @@ REGEX_SESSION_TOKEN = r"^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9] # ***************************************************************************************************************** -class SafaricomMPesaExpressAuth(BaseModel): +class ZerodhaKiteAuth(BaseModel): - consumerKey: str = Field( - description = "the app's consumer key given by safaricom; found in 'my apps'", + apiKey: str = Field( + description = ( + "the api key of your kite connect app; " + "this remains constant throughout the life of the app" + ), frozen = True ) - consumerSecret: str = Field( - description = "the app's consumer secret given by safaricom; found in 'my apps'", - frozen = True - ) - - businessShortCode: str = Field( - description = "your app's business short code; found in 'my apps'", - frozen = True - ) - - appPasskey: str = Field( - description = "your app's passkey; taken from human representative", + apiSecret: str = Field( + description = ( + "the api secret of your kite connect app; " + "this can change if you think the security of your app has been compromised" + ), frozen = True ) @@ -109,7 +105,7 @@ class SafaricomMPesaExpressAuth(BaseModel): # --------------------------------------------------------------------------------------------------------------------- -class PGAuthRequestHeaders(BaseModel): +class TradingAuthRequestHeaders(BaseModel): sessionToken: str = Field( description = "the session token of the user who is requesting the service", @@ -133,10 +129,10 @@ class PGAuthRequestHeaders(BaseModel): # --------------------------------------------------------------------------------------------------------------------- -class PGAuthRequestData(BaseModel): +class TradingAuthRequestData(BaseModel): - client: Literal["safaricomMPesaExpress"] = Field(alias = "client") - auth: Union[SafaricomMPesaExpressAuth] + client: Literal["zerodhaKite"] = Field(alias = "client") + auth: Union[ZerodhaKiteAuth] # ┏┓ ┏• # ┃ ┏┓┏┓╋┓┏┓ @@ -155,7 +151,7 @@ class PGAuthRequestData(BaseModel): client = values.client auth = values.auth harmony_map = { - "safaricomMPesaExpress": SafaricomMPesaExpressAuth + "zerodhaKite": ZerodhaKiteAuth } if not isinstance(auth, harmony_map[client]): raise ValueError(f"incorrect 'auth' for selected client '{client}'") diff --git a/models/core/auth_token.py b/models/core/auth_token.py index 860f02b..23fee2e 100644 --- a/models/core/auth_token.py +++ b/models/core/auth_token.py @@ -95,7 +95,11 @@ class CoreAuthTokenModel(BaseModel): default_factory = lambda: ObjectId() ) - serviceType: Literal["software", "email", "sms", "chat", "paymentGateway"] = Field( + serviceType: Literal[ + "email", "sms", "chat", # ............. Message + "paymentGateway", "stockTrading", # ... Finstitutions + "software", # ......................... God knows + ] = Field( description = "the kind of service this message was sent/received from", frozen = True ) @@ -105,6 +109,7 @@ class CoreAuthTokenModel(BaseModel): "telegram", "whatsapp", # .................. Chat Clients "nimbusSmsIndia", "savvyBulkSmsKenya", # ... SMS Clients "razorpay", "safaricomMPesaExpress", # ..... Payment Gateways + "zerodhaKite", # ........................... Stock Brokers "theCaOfficeAi" # .......................... Software ] = Field( description = "the third-part client that was used", diff --git a/playground/socketio/tick_simulator.py b/playground/socketio/tick_simulator.py index 67d39f7..064df3e 100644 --- a/playground/socketio/tick_simulator.py +++ b/playground/socketio/tick_simulator.py @@ -6,16 +6,15 @@ DATE: - Create: Saturday, 18th May, 2022 - Update: Thursday, 22nd Aug. 2024 + Saturday, 21st Dec. 2024 OBJECTIVE: - To provide an easy way to work with '.json' data and files. + To simulate stock market updates to test on SocketIO. REFERENCES: - 1) https://www.w3schools.com/python/python_json.asp + N/A DOWNLOADS: @@ -38,12 +37,27 @@ sys.path.append("..") # System-level activities: import io +import os -# To work with the JSON standard: -import json +# My utils: +from utils_v2.string import json -# To work with files: -from utils_v2.system import files +# For pseudo-random simulations: +import random + +# To work with SocketIO +import socketio +from aiohttp import web + +# To make HTTP calls: +import httpx + +# To work with date and time: +import datetime +import time + +# For asynchronous behaviour: +import asyncio # ***************************************************************************************************************** @@ -53,7 +67,64 @@ from utils_v2.system import files # ***************************************************************************************************************** -# --- Nothing Yet +# For SocketIO: +# Create a Socket.IO server instance +sio = socketio.AsyncServer(cors_allowed_origins = "*") +app = web.Application() +sio.attach(app) + +# A list of stocks to simulate: +SYMBOL_TO_PRICE_MAP = { + "HDFCBANK": { + "prevClose": 1_763.95, + "ltp": 1_771.50, + "totVol": 55_96_931, + "buyVol": 16_79_079, + "sellVol": 39_17_852, + }, + "RELIANCE": { + "prevClose": 1_213.35, + "ltp": 1_205.30, + "totVol": 7_34_568, + "buyVol": 1_04_873, + "sellVol": 6_29_695, + }, + "INFY": { + "prevClose": 1_925.70, + "ltp": 1_922.15, + "totVol": 5_54_108, + "buyVol": 2_61_593, + "sellVol": 2_92_515, + }, + "TCS": { + "prevClose": 4_203.50, + "ltp": 4_170.30, + "totVol": 7_24_932, + "buyVol": 1_34_666, + "sellVol": 5_90_266, + }, + "HINDUNILVR": { + "prevClose": 2_312.95, + "ltp": 2_333.90, + "totVol": 5_04_533, + "buyVol": 9_252, + "sellVol": 4_95_281, + }, + "ITC": { + "prevClose": 463.20, + "ltp": 464.65, + "totVol": 7_07_905, + "buyVol": 3_27_422, + "sellVol": 3_80_483, + }, + "KOTAKBANK": { + "prevClose": 1_751.65, + "ltp": 1_743.55, + "totVol": 4_49_104, + "buyVol": 2_47_489, + "sellVol": 2_01_615, + } +} # ***************************************************************************************************************** @@ -73,121 +144,181 @@ from utils_v2.system import files # ***************************************************************************************************************** -def from_string(json_data): - - """ - Decodes a JSON string to a pythonic variable like a dict. - :param json_data: The JSON string to decode. - :return: The decoded pythonic variable. - """ - - python_data = json.loads(json_data) - return python_data - - -# --------------------------------------------------------------------------------------------------------------------- - - -def to_string( - python_data, - indent = 4, - default = None, - separators = None, - no_space = False -): - - """ - Converts the given pythonic data to a JSON string. - :param python_data: The input data like a dict. - :param indent: The tab-width for pretty presentation. - :param default: The function to use on something that cannot be directly parsed into a JSON string. - :param separators: Custom separators to use. - :param no_space: If you want a dense JSON string that saves memory by not using spaces or tabs or line-breaks. Not - good for human readability, very good for saving memory. WARNING: THIS OVERRIDES EVERY OTHER PARAMETER EXCEPT - 'default'. - :return: The JSON string representation of the input pythonic data. - """ - - if no_space: - json_data = json.dumps( - python_data, - default = default, - separators = (',', ':') +@sio.event +async def connect(sid, environ): + print(f"Client {sid} connected") + async with httpx.AsyncClient() as client: + try: await client.post( + url = r"https://api.thecaoffice.com/converse/tech/alert/chat/backend", + json = { + "type": "info", + "chatClient": "telegram", + "chatId": "-4206946032", + # "chatId": "1275560043", + "message": f"*SocketIO Connected!*\n👍 SID: {sid}" + } ) + except: pass - else: - json_data = json.dumps( - python_data, - indent = indent, - default = default, - separators = separators + +# --------------------------------------------------------------------------------------------------------------------- + + +@sio.event +async def disconnect(sid): + print(f"Client {sid} disconnected") + async with httpx.AsyncClient() as client: + try: await client.post( + url = r"https://api.thecaoffice.com/converse/tech/alert/chat/backend", + json = { + "type": "info", + "chatClient": "telegram", + "chatId": "-4206946032", + # "chatId": "1275560043", + "message": f"*SocketIO Disconnected!*\n❌ SID: {sid}" + } ) + except: pass - return json_data + +def round_tick(price): + return round(price * 20) / 20 # --------------------------------------------------------------------------------------------------------------------- -def from_file(file): +def simulate_one_stock(symbol, price): - """ - Reads a JSON file and returns it as a pythonic variable like a dict. - :param file: The path to the file on the disk or a file held in RAM as a BytesIO object. - :return: The decoded pythonic variable. - """ + global SYMBOL_TO_PRICE_MAP - if isinstance(file, io.BytesIO): - file.seek(0) - json_data = file.getvalue() - else: json_data = files.read_file(file) - python_data = from_string(json_data) - return python_data + # Simulate a change in the price: + pos_bias = [1] * 10 + no_bias = [0] * 1 + neg_bias = [-1] * 10 + bias = random.choice(pos_bias + no_bias + neg_bias) + change_factor = random.random() / 100.0 + change = price * change_factor * bias + ltp = round_tick(price + change) + + # Simulate the volume. + # Assume a trade qty. worth 1L to 10L rupees: + traded_amt = random.uniform(1_00_000, 10_00_000) + ltq = int(traded_amt / price) + SYMBOL_TO_PRICE_MAP[symbol]["totVol"] += ltq + if bias >= 0: SYMBOL_TO_PRICE_MAP[symbol]["buyVol"] += ltq + else: SYMBOL_TO_PRICE_MAP[symbol]["sellVol"] += ltq + + # Create the basic JSON payload: + stock_json = { + "symbol": symbol, + "last_traded_quantity": ltq, + "average_traded_price": round_tick(price + (bias * price * (random.random() / 100.0))), + "volume_traded": SYMBOL_TO_PRICE_MAP[symbol]["totVol"], + "total_buy_quantity": SYMBOL_TO_PRICE_MAP[symbol]["buyVol"], + "total_sell_quantity": SYMBOL_TO_PRICE_MAP[symbol]["sellVol"], + "ohlc": { + "open": round_tick(price + (price * 0.005)), + "high": round_tick(price + (price * 0.015)), + "low": round_tick(price - (price * 0.015)), + "close": ltp + }, + "change": ((ltp - SYMBOL_TO_PRICE_MAP[symbol]["prevClose"]) / SYMBOL_TO_PRICE_MAP[symbol]["prevClose"]) * 100, + "last_trade_time": (datetime.datetime.now() - datetime.timedelta(seconds = random.uniform(0.0, 2.5))).strftime("%Y-%m-%d %H:%M:%S"), + "oi": 0, + "oi_day_high": 0, + "oi_day_low": 0, + "exchange_timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "depth": { + "buy": [ + { + "quantity": random.randint(0, 100), + "price": round(ltp - 0.05, 2), + "orders": random.randint(0, 10) + }, + { + "quantity": random.randint(0, 100), + "price": round(ltp - 0.10, 2), + "orders": random.randint(0, 10) + }, + { + "quantity": random.randint(0, 100), + "price": round(ltp - 0.15, 2), + "orders": random.randint(0, 10) + }, + { + "quantity": random.randint(0, 100), + "price": round(ltp - 0.20, 2), + "orders": random.randint(0, 10) + }, + { + "quantity": random.randint(0, 100), + "price": round(ltp - 0.25, 2), + "orders": random.randint(0, 10) + } + ], + "sell": [ + { + "quantity": random.randint(0, 100), + "price": round(ltp + 0.05, 2), + "orders": random.randint(0, 10) + }, + { + "quantity": random.randint(0, 100), + "price": round(ltp + 0.10, 2), + "orders": random.randint(0, 10) + }, + { + "quantity": random.randint(0, 100), + "price": round(ltp + 0.15, 2), + "orders": random.randint(0, 10) + }, + { + "quantity": random.randint(0, 100), + "price": round(ltp + 0.20, 2), + "orders": random.randint(0, 10) + }, + { + "quantity": random.randint(0, 100), + "price": round(ltp + 0.25, 2), + "orders": random.randint(0, 10) + } + ] + } + } + + # Done here: + return stock_json # --------------------------------------------------------------------------------------------------------------------- -def to_file( - file, - python_data, - indent = 4, - default = None, - separators = None, - no_space = False -): +def simulate_ticks_once(): - """ + # Pick a no. of stocks to simulate: + count = random.randint(1, len(SYMBOL_TO_PRICE_MAP)) + symbols = random.sample(list(SYMBOL_TO_PRICE_MAP.keys()), count) - :param file: Either a path to a file on disk, or a buffer in RAM in the form of a BytesIO object. - :param python_data: The pythonic data to be converted to the JSON string. - :param indent: The tab-width for pretty presentation. - :param default: The function to use on something that cannot be directly parsed into a JSON string. - :param separators: Custom separators to use. - :param no_space: If you want a dense JSON string that saves memory by not using spaces or tabs or line-breaks. Not - good for human readability, very good for saving memory. WARNING: THIS OVERRIDES EVERY OTHER PARAMETER EXCEPT - 'default'. - :return: True/False if a path was given, else the same BytesIO object with the written JSON data. - """ + # Create the tick JSON: + tick_json = [ + simulate_one_stock( + symbol = symbol, + price = SYMBOL_TO_PRICE_MAP[symbol]["ltp"] + ) for symbol in symbols + ] - json_data = to_string( - python_data, - indent = indent, - default = default, - separators = separators, - no_space = no_space - ) + # Done here: + return tick_json - if isinstance(file, io.BytesIO): - file.write(json_data.encode("utf-8")) - file.seek(0) - return file - else: - try: - files.write_file(file, json_data, mode = "w") - return True - except: return False +# --------------------------------------------------------------------------------------------------------------------- + + +async def broadcast_random_data(): + while True: + await sio.emit("ticks", simulate_ticks_once()) + await asyncio.sleep(random.uniform(0.15, 1.0)) # ***************************************************************************************************************** @@ -199,4 +330,20 @@ def to_file( if __name__ == "__main__": - pass + async def server(): + + # Start broadcasting random data in the background + asyncio.create_task(broadcast_random_data()) + + # Run the web server + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", 5000) + print("Server running on http://0.0.0.0:5000") + await site.start() + + # Keep the server running + while True: + await asyncio.sleep(3600) + + asyncio.run(server())