├── logs └── .gitkeep ├── gmail ├── creds │ └── .gitkeep └── __init__.py ├── .gitignore ├── Pipfile ├── assets ├── timeformatter.py ├── exception_handler.py ├── pushsafer.py ├── multifilehandler.py └── helper_functions.py ├── config.env.example ├── mongo └── __init__.py ├── main.py ├── api_trader ├── tasks.py ├── order_builder.py └── __init__.py ├── tdameritrade └── __init__.py ├── README.md └── Pipfile.lock /logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /gmail/creds/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.env 2 | .vscode 3 | *__pycache__ 4 | *.txt 5 | .VSCodeCounter 6 | token.json 7 | credentials.json 8 | *.log 9 | test.py 10 | oldrm.md -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pylint = "*" 8 | autopep8 = "*" 9 | 10 | [packages] 11 | google-api-python-client = "*" 12 | google-auth-httplib2 = "*" 13 | google-auth-oauthlib = "*" 14 | python-dotenv = "*" 15 | pymongo = "*" 16 | dnspython = "*" 17 | requests = "*" 18 | pytz = "*" 19 | psutil = "*" 20 | certifi = "*" 21 | 22 | [requires] 23 | python_version = "3.8" 24 | -------------------------------------------------------------------------------- /assets/timeformatter.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | 4 | class Formatter(logging.Formatter): 5 | 6 | """override logging.Formatter to use an naive datetime object""" 7 | 8 | def formatTime(self, record, datefmt=None): 9 | 10 | dt = datetime.utcnow() 11 | 12 | if datefmt: 13 | 14 | s = dt.strftime(datefmt) 15 | 16 | else: 17 | 18 | try: 19 | 20 | s = dt.isoformat(timespec='milliseconds') 21 | 22 | except TypeError: 23 | 24 | s = dt.isoformat() 25 | 26 | return s -------------------------------------------------------------------------------- /assets/exception_handler.py: -------------------------------------------------------------------------------- 1 | # EXCEPTION HANDLER DECORATOR FOR HANDLER EXCEPTIONS AND LOGGING THEM 2 | from assets.helper_functions import modifiedAccountID 3 | import traceback 4 | 5 | 6 | def exception_handler(func): 7 | 8 | def wrapper(self, *args, **kwargs): 9 | 10 | logger = self.logger 11 | 12 | try: 13 | 14 | return func(self, *args, **kwargs) 15 | 16 | except Exception as e: 17 | 18 | msg = f"{self.user['Name']} - {modifiedAccountID(self.account_id)} - {traceback.format_exc()}" 19 | 20 | logger.error(msg) 21 | 22 | return wrapper 23 | -------------------------------------------------------------------------------- /config.env.example: -------------------------------------------------------------------------------- 1 | 2 | ####################### THE FILENAME WILL NEED TO BE CHANGED FROM config.env.example TO config.env TO ALLOW THE BOT TO USE THE ENVIRONMENTAL VARIABLES ############################### 3 | 4 | # MONGO # 5 | MONGO_URI = *enter mongo uri here* 6 | 7 | # PUSHSAFER # 8 | PUSH_API_KEY = *enter pushsafer api key here* 9 | 10 | # RUN LIVE TRADER. IF SET TO TRUE, LIVE TRADER WILL RUN AND PAPER TRADER WILL NOT RUN. IF SET TO FALSE, ONLY PAPER TRADER WILL RUN # 11 | RUN_LIVE_TRADER = *True/False if you want to live trade* 12 | 13 | # PYTZ TIMEZONE # 14 | TIMEZONE = *find your timezone here: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones* 15 | 16 | # TASKS # 17 | RUN_TASKS = *True/False if you want to run tasks* 18 | 19 | # ORDERS # 20 | BUY_PRICE = *bidPrice, askPrice, lastPrice. Must be askPrice for OCO - This is case sensitive* 21 | SELL_PRICE = *bidPrice, askPrice, lastPrice - This is case sensitive* 22 | 23 | # OCO # 24 | TAKE_PROFIT_PERCENTAGE = *percentage to be used to calculate take profit i.e 1.1 equals 10% above entry price* 25 | STOP_LOSS_PERCENTAGE = *percentage to be used to calculate take profit i.e 0.9 equals 10% below entry price* -------------------------------------------------------------------------------- /assets/pushsafer.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | from dotenv import load_dotenv 3 | import requests 4 | import os 5 | from pathlib import Path 6 | 7 | THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) 8 | 9 | path = Path(THIS_FOLDER) 10 | 11 | load_dotenv(dotenv_path=f"{path.parent}/config.env") 12 | 13 | PUSH_API_KEY = os.getenv('PUSH_API_KEY') 14 | 15 | 16 | class PushNotification: 17 | 18 | def __init__(self, device_id, logger): 19 | 20 | self.url = 'https://www.pushsafer.com/api' 21 | 22 | self.post_fields = { 23 | "t": "TOS Trading Bot", 24 | "m": None, 25 | "s": 0, 26 | "v": 1, 27 | "i": 1, 28 | "c": "#E94B3C", 29 | "d": device_id, 30 | "ut": "TOS Trading Bot", 31 | "k": PUSH_API_KEY, 32 | } 33 | 34 | self.logger = logger 35 | 36 | def send(self, notification): 37 | """ METHOD SENDS PUSH NOTIFICATION TO USER 38 | 39 | Args: 40 | notification ([str]): MESSAGE TO BE SENT 41 | """ 42 | 43 | try: 44 | 45 | # RESPONSE: {'status': 1, 'success': 'message transmitted', 'available': 983, 'message_ids': '18265430:34011'} 46 | 47 | self.post_fields["m"] = notification 48 | 49 | response = requests.post(self.url, self.post_fields) 50 | 51 | if response.json()["success"] == 'message transmitted': 52 | 53 | self.logger.info(f"Push Sent!\n") 54 | 55 | else: 56 | 57 | self.logger.warning(f"Push Failed!\n") 58 | 59 | except ValueError: 60 | 61 | pass 62 | 63 | except KeyError: 64 | 65 | pass 66 | 67 | except Exception as e: 68 | 69 | self.logger.error(e) 70 | -------------------------------------------------------------------------------- /mongo/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pymongo import MongoClient 3 | from dotenv import load_dotenv 4 | import os 5 | import certifi 6 | ca = certifi.where() 7 | 8 | THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) 9 | 10 | path = Path(THIS_FOLDER) 11 | 12 | load_dotenv(dotenv_path=f"{path.parent}/config.env") 13 | 14 | MONGO_URI = os.getenv('MONGO_URI') 15 | RUN_LIVE_TRADER = True if os.getenv('RUN_LIVE_TRADER') == "True" else False 16 | 17 | 18 | class MongoDB: 19 | 20 | def __init__(self, logger): 21 | 22 | self.logger = logger 23 | 24 | def connect(self): 25 | 26 | try: 27 | 28 | self.logger.info("CONNECTING TO MONGO...", extra={'log': False}) 29 | 30 | if MONGO_URI != None: 31 | 32 | self.client = MongoClient( 33 | MONGO_URI, authSource="admin", tlsCAFile=ca) 34 | 35 | # SIMPLE TEST OF CONNECTION BEFORE CONTINUING 36 | self.client.server_info() 37 | 38 | self.db = self.client["Api_Trader"] 39 | 40 | self.users = self.db["users"] 41 | 42 | self.strategies = self.db["strategies"] 43 | 44 | self.open_positions = self.db["open_positions"] 45 | 46 | self.closed_positions = self.db["closed_positions"] 47 | 48 | self.rejected = self.db["rejected"] 49 | 50 | self.canceled = self.db["canceled"] 51 | 52 | self.queue = self.db["queue"] 53 | 54 | self.forbidden = self.db["forbidden"] 55 | 56 | self.logger.info("CONNECTED TO MONGO!\n", extra={'log': False}) 57 | 58 | return True 59 | 60 | else: 61 | 62 | raise Exception("MISSING MONGO URI") 63 | 64 | except Exception as e: 65 | 66 | self.logger.error(f"FAILED TO CONNECT TO MONGO! - {e}") 67 | 68 | return False 69 | -------------------------------------------------------------------------------- /assets/multifilehandler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import RotatingFileHandler 3 | import os 4 | 5 | # ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', 6 | # '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'args', 'created', 'exc_info', 'exc_text', 'filename', 'funcName', 'getMessage', 'levelname', 'levelno', 'lineno', 'module', 'msecs', 'msg', 'name', 'pathname', 'process', 'processName', 'relativeCreated', 'stack_info', 'thread', 'threadName'] 7 | 8 | 9 | class MultiFileHandler(RotatingFileHandler): 10 | 11 | def __init__(self, filename, mode, encoding=None, delay=0): 12 | 13 | RotatingFileHandler.__init__( 14 | self, filename, mode, maxBytes=10000, backupCount=1, encoding=None, delay=0) 15 | 16 | self.path = f"{os.path.abspath(os.path.dirname(__file__))}/logs".replace( 17 | "assets/", "") 18 | 19 | def emit(self, record): 20 | 21 | if "log" in dir(record) and not record.log: 22 | 23 | return 24 | 25 | self.change_file(record.levelname) 26 | 27 | logging.FileHandler.emit(self, record) 28 | 29 | def change_file(self, levelname): 30 | 31 | file_id = None 32 | 33 | if levelname == "WARNING": 34 | 35 | file_id = f"{self.path}/warning.log" 36 | 37 | elif levelname == "ERROR": 38 | 39 | file_id = f"{self.path}/error.log" 40 | 41 | elif levelname == "DEBUG": 42 | 43 | file_id = f"{self.path}/debug.log" 44 | 45 | elif levelname == "INFO": 46 | 47 | file_id = f"{self.path}/info.log" 48 | 49 | if file_id is not None: 50 | 51 | self.stream.close() 52 | 53 | self.baseFilename = file_id 54 | 55 | self.stream = self._open() 56 | -------------------------------------------------------------------------------- /assets/helper_functions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from dotenv import load_dotenv 3 | from pathlib import Path 4 | import os 5 | import pytz 6 | 7 | THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) 8 | 9 | path = Path(THIS_FOLDER) 10 | 11 | load_dotenv(dotenv_path=f"{path.parent}/config.env") 12 | 13 | TIMEZONE = os.getenv('TIMEZONE') 14 | 15 | 16 | def getDatetime(): 17 | """ function obtains the datetime based on timezone using the pytz library. 18 | 19 | Returns: 20 | [Datetime Object]: [formated datetime object] 21 | """ 22 | 23 | dt = datetime.now(tz=pytz.UTC).replace(microsecond=0) 24 | 25 | dt = dt.astimezone(pytz.timezone(TIMEZONE)) 26 | 27 | return datetime.strptime(dt.strftime( 28 | "%Y-%m-%d %H:%M:%S"), "%Y-%m-%d %H:%M:%S") 29 | 30 | 31 | def getUTCDatetime(): 32 | """ function obtains the utc datetime. will use this for all timestamps with the bot. GET FEEDBACK FROM DISCORD GROUP ON THIS BEFORE PUBLISH. 33 | 34 | Returns: 35 | [Datetime Object]: [formated datetime object] 36 | """ 37 | 38 | dt = datetime.utcnow().replace(microsecond=0) 39 | 40 | return dt.isoformat() 41 | 42 | 43 | def selectSleep(): 44 | """ 45 | PRE-MARKET(0400 - 0930 ET): 1 SECOND 46 | MARKET OPEN(0930 - 1600 ET): 1 SECOND 47 | AFTER MARKET(1600 - 2000 ET): 1 SECOND 48 | 49 | WEEKENDS: 60 SECONDS 50 | WEEKDAYS(2000 - 0400 ET): 60 SECONDS 51 | 52 | EVERYTHING WILL BE BASED OFF CENTRAL TIME 53 | 54 | OBJECTIVE IS TO FREE UP UNNECESSARY SERVER USAGE 55 | """ 56 | 57 | dt = getDatetime() 58 | 59 | day = dt.strftime("%a") 60 | 61 | tm = dt.strftime("%H:%M:%S") 62 | 63 | weekends = ["Sat", "Sun"] 64 | 65 | # IF CURRENT TIME GREATER THAN 8PM AND LESS THAN 4AM, OR DAY IS WEEKEND, THEN RETURN 60 SECONDS 66 | if tm > "20:00" or tm < "04:00" or day in weekends: 67 | 68 | return 60 69 | 70 | # ELSE RETURN 1 SECOND 71 | return 1 72 | 73 | 74 | def modifiedAccountID(account_id): 75 | 76 | return '*' * (len(str(account_id)) - 4) + str(account_id)[-4:] 77 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # imports 2 | import time 3 | import logging 4 | import os 5 | 6 | from api_trader import ApiTrader 7 | from tdameritrade import TDAmeritrade 8 | from gmail import Gmail 9 | from mongo import MongoDB 10 | 11 | from assets.pushsafer import PushNotification 12 | from assets.exception_handler import exception_handler 13 | from assets.helper_functions import selectSleep 14 | from assets.timeformatter import Formatter 15 | from assets.multifilehandler import MultiFileHandler 16 | 17 | 18 | class Main: 19 | 20 | def connectAll(self): 21 | """ METHOD INITIALIZES LOGGER, MONGO, GMAIL, PAPERTRADER. 22 | """ 23 | 24 | # INSTANTIATE LOGGER 25 | file_handler = MultiFileHandler( 26 | filename=f'{os.path.abspath(os.path.dirname(__file__))}/logs/error.log', mode='a') 27 | 28 | formatter = Formatter('%(asctime)s [%(levelname)s] %(message)s') 29 | 30 | file_handler.setFormatter(formatter) 31 | 32 | ch = logging.StreamHandler() 33 | 34 | ch.setLevel(level="INFO") 35 | 36 | ch.setFormatter(formatter) 37 | 38 | self.logger = logging.getLogger(__name__) 39 | 40 | self.logger.setLevel(level="INFO") 41 | 42 | self.logger.addHandler(file_handler) 43 | 44 | self.logger.addHandler(ch) 45 | 46 | # CONNECT TO MONGO 47 | self.mongo = MongoDB(self.logger) 48 | 49 | mongo_connected = self.mongo.connect() 50 | 51 | # CONNECT TO GMAIL API 52 | self.gmail = Gmail(self.logger) 53 | 54 | gmail_connected = self.gmail.connect() 55 | 56 | if mongo_connected and gmail_connected: 57 | 58 | self.traders = {} 59 | 60 | self.accounts = [] 61 | 62 | self.not_connected = [] 63 | 64 | return True 65 | 66 | return False 67 | 68 | @exception_handler 69 | def setupTraders(self): 70 | """ METHOD GETS ALL USERS ACCOUNTS FROM MONGO AND CREATES LIVE TRADER INSTANCES FOR THOSE ACCOUNTS. 71 | IF ACCOUNT INSTANCE ALREADY IN SELF.TRADERS DICT, THEN ACCOUNT INSTANCE WILL NOT BE CREATED AGAIN. 72 | """ 73 | # GET ALL USERS ACCOUNTS 74 | users = self.mongo.users.find({}) 75 | 76 | for user in users: 77 | 78 | try: 79 | 80 | for account_id in user["Accounts"].keys(): 81 | 82 | if account_id not in self.traders and account_id not in self.not_connected: 83 | 84 | push_notification = PushNotification( 85 | user["deviceID"], self.logger) 86 | 87 | tdameritrade = TDAmeritrade( 88 | self.mongo, user, account_id, self.logger, push_notification) 89 | 90 | connected = tdameritrade.initialConnect() 91 | 92 | if connected: 93 | 94 | obj = ApiTrader(user, self.mongo, push_notification, self.logger, int( 95 | account_id), tdameritrade) 96 | 97 | self.traders[account_id] = obj 98 | 99 | time.sleep(0.1) 100 | 101 | else: 102 | 103 | self.not_connected.append(account_id) 104 | 105 | self.accounts.append(account_id) 106 | 107 | except Exception as e: 108 | 109 | logging.error(e) 110 | 111 | @exception_handler 112 | def run(self): 113 | """ METHOD RUNS THE TWO METHODS ABOVE AND THEN RUNS LIVE TRADER METHOD RUNTRADER FOR EACH INSTANCE. 114 | """ 115 | 116 | self.setupTraders() 117 | 118 | trade_data = self.gmail.getEmails() 119 | 120 | for api_trader in self.traders.values(): 121 | 122 | api_trader.runTrader(trade_data) 123 | 124 | 125 | if __name__ == "__main__": 126 | """ START OF SCRIPT. 127 | INITIALIZES MAIN CLASS AND STARTS RUN METHOD ON WHILE LOOP WITH A DYNAMIC SLEEP TIME. 128 | """ 129 | 130 | main = Main() 131 | 132 | connected = main.connectAll() 133 | 134 | while connected: 135 | 136 | main.run() 137 | 138 | time.sleep(selectSleep()) 139 | -------------------------------------------------------------------------------- /api_trader/tasks.py: -------------------------------------------------------------------------------- 1 | 2 | # imports 3 | import time 4 | from dotenv import load_dotenv 5 | from pathlib import Path 6 | import os 7 | 8 | from assets.exception_handler import exception_handler 9 | from assets.helper_functions import getDatetime, selectSleep, modifiedAccountID 10 | 11 | THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) 12 | 13 | path = Path(THIS_FOLDER) 14 | 15 | load_dotenv(dotenv_path=f"{path.parent}/config.env") 16 | 17 | TAKE_PROFIT_PERCENTAGE = float(os.getenv('TAKE_PROFIT_PERCENTAGE')) 18 | STOP_LOSS_PERCENTAGE = float(os.getenv('STOP_LOSS_PERCENTAGE')) 19 | 20 | 21 | class Tasks: 22 | 23 | # THE TASKS CLASS IS USED FOR HANDLING ADDITIONAL TASKS OUTSIDE OF THE LIVE TRADER. 24 | # YOU CAN ADD METHODS THAT STORE PROFIT LOSS DATA TO MONGO, SELL OUT POSITIONS AT END OF DAY, ECT. 25 | # YOU CAN CREATE WHATEVER TASKS YOU WANT FOR THE BOT. 26 | # YOU CAN USE THE DISCORD CHANNEL NAMED TASKS IF YOU ANY HELP. 27 | 28 | def __init__(self): 29 | 30 | self.isAlive = True 31 | 32 | @exception_handler 33 | def checkOCOpapertriggers(self): 34 | 35 | for position in self.mongo.open_positions.find({"Trader": self.user["Name"]}): 36 | 37 | symbol = position["Symbol"] 38 | 39 | asset_type = position["Asset_Type"] 40 | 41 | resp = self.tdameritrade.getQuote( 42 | symbol if asset_type == "EQUITY" else position["Pre_Symbol"]) 43 | 44 | price = float(resp[symbol if asset_type == "EQUITY" else position["Pre_Symbol"]]["askPrice"]) 45 | 46 | if price <= (position["Entry_Price"] * STOP_LOSS_PERCENTAGE) or price >= (position["Entry_Price"] * TAKE_PROFIT_PERCENTAGE): 47 | # CLOSE POSITION 48 | pass 49 | 50 | @exception_handler 51 | def checkOCOtriggers(self): 52 | """ Checks OCO triggers (stop loss/ take profit) to see if either one has filled. If so, then close position in mongo like normal. 53 | 54 | """ 55 | 56 | open_positions = self.open_positions.find( 57 | {"Trader": self.user["Name"], "Order_Type": "OCO"}) 58 | 59 | for position in open_positions: 60 | 61 | childOrderStrategies = position["childOrderStrategies"] 62 | 63 | for order_id in childOrderStrategies.keys(): 64 | 65 | spec_order = self.tdameritrade.getSpecificOrder(order_id) 66 | 67 | new_status = spec_order["status"] 68 | 69 | if new_status == "FILLED": 70 | 71 | self.pushOrder(position, spec_order) 72 | 73 | elif new_status == "CANCELED" or new_status == "REJECTED": 74 | 75 | other = { 76 | "Symbol": position["Symbol"], 77 | "Order_Type": position["Order_Type"], 78 | "Order_Status": new_status, 79 | "Strategy": position["Strategy"], 80 | "Trader": self.user["Name"], 81 | "Date": getDatetime(), 82 | "Account_ID": self.account_id 83 | } 84 | 85 | self.rejected.insert_one( 86 | other) if new_status == "REJECTED" else self.canceled.insert_one(other) 87 | 88 | self.logger.info( 89 | f"{new_status.upper()} ORDER For {position['Symbol']} - TRADER: {self.user['Name']} - ACCOUNT ID: {modifiedAccountID(self.account_id)}") 90 | 91 | else: 92 | 93 | self.open_positions.update_one({"Trader": self.user["Name"], "Symbol": position["Symbol"], "Strategy": position["Strategy"]}, { 94 | "$set": {f"childOrderStrategies.{order_id}.Order_Status": new_status}}) 95 | 96 | @exception_handler 97 | def extractOCOchildren(self, spec_order): 98 | """This method extracts oco children order ids and then sends it to be stored in mongo open positions. 99 | Data will be used by checkOCOtriggers with order ids to see if stop loss or take profit has been triggered. 100 | 101 | """ 102 | 103 | oco_children = { 104 | "childOrderStrategies": {} 105 | } 106 | 107 | childOrderStrategies = spec_order["childOrderStrategies"][0]["childOrderStrategies"] 108 | 109 | for child in childOrderStrategies: 110 | 111 | oco_children["childOrderStrategies"][child["orderId"]] = { 112 | "Side": child["orderLegCollection"][0]["instruction"], 113 | "Exit_Price": child["stopPrice"] if "stopPrice" in child else child["price"], 114 | "Exit_Type": "STOP LOSS" if "stopPrice" in child else "TAKE PROFIT", 115 | "Order_Status": child["status"] 116 | } 117 | 118 | return oco_children 119 | 120 | @exception_handler 121 | def addNewStrategy(self, strategy, asset_type): 122 | """ METHOD UPDATES STRATEGIES OBJECT IN MONGODB WITH NEW STRATEGIES. 123 | 124 | Args: 125 | strategy ([str]): STRATEGY NAME 126 | """ 127 | 128 | obj = {"Active": True, 129 | "Order_Type": "STANDARD", 130 | "Asset_Type": asset_type, 131 | "Position_Size": 500, 132 | "Position_Type": "LONG", 133 | "Account_ID": self.account_id, 134 | "Strategy": strategy, 135 | } 136 | 137 | # IF STRATEGY NOT IN STRATEGIES COLLECTION IN MONGO, THEN ADD IT 138 | 139 | self.strategies.update( 140 | {"Strategy": strategy}, 141 | {"$setOnInsert": obj}, 142 | upsert=True 143 | ) 144 | 145 | def runTasks(self): 146 | """ METHOD RUNS TASKS ON WHILE LOOP EVERY 5 - 60 SECONDS DEPENDING. 147 | """ 148 | 149 | self.logger.info( 150 | f"STARTING TASKS FOR {self.user['Name']} ({modifiedAccountID(self.account_id)})", extra={'log': False}) 151 | 152 | while self.isAlive: 153 | 154 | try: 155 | 156 | # RUN TASKS #################################################### 157 | self.checkOCOtriggers() 158 | 159 | ############################################################## 160 | 161 | except KeyError: 162 | 163 | self.isAlive = False 164 | 165 | except Exception as e: 166 | 167 | self.logger.error( 168 | f"ACCOUNT ID: {modifiedAccountID(self.account_id)} - TRADER: {self.user['Name']} - {e}") 169 | 170 | finally: 171 | 172 | time.sleep(selectSleep()) 173 | 174 | self.logger.warning( 175 | f"TASK STOPPED FOR ACCOUNT ID {modifiedAccountID(self.account_id)}") 176 | -------------------------------------------------------------------------------- /api_trader/order_builder.py: -------------------------------------------------------------------------------- 1 | # imports 2 | from assets.helper_functions import getDatetime 3 | from dotenv import load_dotenv 4 | from pathlib import Path 5 | import os 6 | 7 | THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) 8 | 9 | path = Path(THIS_FOLDER) 10 | 11 | load_dotenv(dotenv_path=f"{path.parent}/config.env") 12 | 13 | BUY_PRICE = os.getenv('BUY_PRICE') 14 | SELL_PRICE = os.getenv('SELL_PRICE') 15 | TAKE_PROFIT_PERCENTAGE = float(os.getenv('TAKE_PROFIT_PERCENTAGE')) 16 | STOP_LOSS_PERCENTAGE = float(os.getenv('STOP_LOSS_PERCENTAGE')) 17 | 18 | 19 | class OrderBuilder: 20 | 21 | def __init__(self): 22 | 23 | self.order = { 24 | "orderType": "LIMIT", 25 | "price": None, 26 | "session": None, 27 | "duration": None, 28 | "orderStrategyType": "SINGLE", 29 | "orderLegCollection": [ 30 | { 31 | "instruction": None, 32 | "quantity": None, 33 | "instrument": { 34 | "symbol": None, 35 | "assetType": None, 36 | } 37 | } 38 | ] 39 | } 40 | 41 | self.obj = { 42 | "Symbol": None, 43 | "Qty": None, 44 | "Position_Size": None, 45 | "Strategy": None, 46 | "Trader": self.user["Name"], 47 | "Order_ID": None, 48 | "Order_Status": None, 49 | "Side": None, 50 | "Asset_Type": None, 51 | "Account_ID": self.account_id, 52 | "Position_Type": None, 53 | "Direction": None 54 | } 55 | 56 | def standardOrder(self, trade_data, strategy_object, direction, OCOorder=False): 57 | 58 | symbol = trade_data["Symbol"] 59 | 60 | side = trade_data["Side"] 61 | 62 | strategy = trade_data["Strategy"] 63 | 64 | asset_type = "OPTION" if "Pre_Symbol" in trade_data else "EQUITY" 65 | 66 | # TDA ORDER OBJECT 67 | self.order["session"] = "NORMAL" 68 | 69 | self.order["duration"] = "GOOD_TILL_CANCEL" if asset_type == "EQUITY" else "DAY" 70 | 71 | self.order["orderLegCollection"][0]["instruction"] = side 72 | 73 | self.order["orderLegCollection"][0]["instrument"]["symbol"] = symbol if asset_type == "EQUITY" else trade_data["Pre_Symbol"] 74 | 75 | self.order["orderLegCollection"][0]["instrument"]["assetType"] = asset_type 76 | ############################################################## 77 | 78 | # MONGO OBJECT 79 | self.obj["Symbol"] = symbol 80 | 81 | self.obj["Strategy"] = strategy 82 | 83 | self.obj["Side"] = side 84 | 85 | self.obj["Asset_Type"] = asset_type 86 | 87 | self.obj["Position_Type"] = strategy_object["Position_Type"] 88 | 89 | self.obj["Order_Type"] = strategy_object["Order_Type"] 90 | 91 | self.obj["Direction"] = direction 92 | ############################################################## 93 | 94 | # IF OPTION 95 | if asset_type == "OPTION": 96 | 97 | self.obj["Pre_Symbol"] = trade_data["Pre_Symbol"] 98 | 99 | self.obj["Exp_Date"] = trade_data["Exp_Date"] 100 | 101 | self.obj["Option_Type"] = trade_data["Option_Type"] 102 | 103 | self.order["orderLegCollection"][0]["instrument"]["putCall"] = trade_data["Option_Type"] 104 | 105 | # GET QUOTE FOR SYMBOL 106 | resp = self.tdameritrade.getQuote( 107 | symbol if asset_type == "EQUITY" else trade_data["Pre_Symbol"]) 108 | 109 | price = float(resp[symbol if asset_type == "EQUITY" else trade_data["Pre_Symbol"]][BUY_PRICE]) if side in ["BUY", "BUY_TO_OPEN", "BUY_TO_CLOSE"] else float( 110 | resp[symbol if asset_type == "EQUITY" else trade_data["Pre_Symbol"]][SELL_PRICE]) 111 | 112 | # OCO ORDER NEEDS TO USE ASK PRICE FOR ISSUE WITH THE ORDER BEING TERMINATED UPON BEING PLACED 113 | if OCOorder: 114 | 115 | price = float(resp[symbol if asset_type == "EQUITY" else trade_data["Pre_Symbol"]][SELL_PRICE]) 116 | 117 | self.order["price"] = round( 118 | price, 2) if price >= 1 else round(price, 4) 119 | 120 | # IF OPENING A POSITION 121 | if direction == "OPEN POSITION": 122 | 123 | position_size = int(strategy_object["Position_Size"]) 124 | 125 | shares = int( 126 | position_size/price) if asset_type == "EQUITY" else int((position_size / 100)/price) 127 | 128 | if strategy_object["Active"] and shares > 0: 129 | 130 | self.order["orderLegCollection"][0]["quantity"] = shares 131 | 132 | self.obj["Qty"] = shares 133 | 134 | self.obj["Position_Size"] = position_size 135 | 136 | self.obj["Entry_Price"] = price 137 | 138 | self.obj["Entry_Date"] = getDatetime() 139 | 140 | else: 141 | 142 | self.logger.warning( 143 | f"{side} ORDER STOPPED: STRATEGY STATUS - {strategy_object['Active']} SHARES - {shares}") 144 | 145 | return None, None 146 | 147 | # IF CLOSING A POSITION 148 | elif direction == "CLOSE POSITION": 149 | 150 | self.order["orderLegCollection"][0]["quantity"] = trade_data["Qty"] 151 | 152 | self.obj["Entry_Price"] = trade_data["Entry_Price"] 153 | 154 | self.obj["Entry_Date"] = trade_data["Entry_Date"] 155 | 156 | self.obj["Exit_Price"] = price 157 | 158 | self.obj["Exit_Date"] = getDatetime() 159 | 160 | self.obj["Qty"] = trade_data["Qty"] 161 | 162 | self.obj["Position_Size"] = trade_data["Position_Size"] 163 | ############################################################################ 164 | 165 | return self.order, self.obj 166 | 167 | def OCOorder(self, trade_data, strategy_object, direction): 168 | 169 | order, obj = self.standardOrder( 170 | trade_data, strategy_object, direction, OCOorder=True) 171 | 172 | asset_type = "OPTION" if "Pre_Symbol" in trade_data else "EQUITY" 173 | 174 | side = trade_data["Side"] 175 | 176 | # GET THE INVERSE OF THE SIDE 177 | ##################################### 178 | if side == "BUY_TO_OPEN": 179 | 180 | instruction = "SELL_TO_CLOSE" 181 | 182 | elif side == "BUY": 183 | 184 | instruction = "SELL" 185 | 186 | elif side == "SELL": 187 | 188 | instruction = "BUY" 189 | 190 | elif side == "SELL_TO_OPEN": 191 | 192 | instruction = "BUY_TO_CLOSE" 193 | ##################################### 194 | 195 | order["orderStrategyType"] = "TRIGGER" 196 | 197 | order["childOrderStrategies"] = [ 198 | { 199 | "orderStrategyType": "OCO", 200 | "childOrderStrategies": [ 201 | { 202 | "orderStrategyType": "SINGLE", 203 | "session": "NORMAL", 204 | "duration": "GOOD_TILL_CANCEL", 205 | "orderType": "LIMIT", 206 | "price": round( 207 | order["price"] * TAKE_PROFIT_PERCENTAGE, 2) if order["price"] * TAKE_PROFIT_PERCENTAGE >= 1 else round(order["price"] * TAKE_PROFIT_PERCENTAGE, 4), 208 | "orderLegCollection": [ 209 | { 210 | "instruction": instruction, 211 | "quantity": obj["Qty"], 212 | "instrument": { 213 | "assetType": asset_type, 214 | "symbol": trade_data["Symbol"] if asset_type == "EQUITY" else trade_data["Pre_Symbol"] 215 | } 216 | } 217 | ] 218 | }, 219 | { 220 | "orderStrategyType": "SINGLE", 221 | "session": "NORMAL", 222 | "duration": "GOOD_TILL_CANCEL", 223 | "orderType": "STOP", 224 | "stopPrice": round(order["price"] * STOP_LOSS_PERCENTAGE, 2) if order["price"] * STOP_LOSS_PERCENTAGE >= 1 else round(order["price"] * STOP_LOSS_PERCENTAGE, 4), 225 | "orderLegCollection": [ 226 | { 227 | "instruction": instruction, 228 | "quantity": obj["Qty"], 229 | "instrument": { 230 | "assetType": asset_type, 231 | "symbol": trade_data["Symbol"] if asset_type == "EQUITY" else trade_data["Pre_Symbol"] 232 | } 233 | } 234 | ] 235 | } 236 | ] 237 | } 238 | ] 239 | 240 | return order, obj 241 | -------------------------------------------------------------------------------- /gmail/__init__.py: -------------------------------------------------------------------------------- 1 | ################################################################################## 2 | # GMAIL CLASS #################################################################### 3 | # Handles email auth and messages ################################################ 4 | 5 | # imports 6 | from google.auth.transport.requests import Request 7 | from google_auth_oauthlib.flow import InstalledAppFlow 8 | from googleapiclient.discovery import build 9 | from google.oauth2.credentials import Credentials 10 | import os.path 11 | import os 12 | from datetime import datetime 13 | 14 | THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) 15 | 16 | 17 | class Gmail: 18 | 19 | def __init__(self, logger): 20 | 21 | self.logger = logger 22 | 23 | self.SCOPES = ["https://mail.google.com/"] 24 | 25 | self.creds = None 26 | 27 | self.service = None 28 | 29 | self.token_file = f"{THIS_FOLDER}/creds/token.json" 30 | 31 | self.creds_file = f"{THIS_FOLDER}/creds/credentials.json" 32 | 33 | def connect(self): 34 | """ METHOD SETS ATTRIBUTES AND CONNECTS TO GMAIL API 35 | 36 | Args: 37 | mongo ([object]): MONGODB OBJECT 38 | logger ([object]): LOGGER OBJECT 39 | """ 40 | 41 | try: 42 | 43 | self.logger.info("CONNECTING TO GMAIL...", extra={'log': False}) 44 | 45 | if os.path.exists(self.token_file): 46 | 47 | with open(self.token_file, 'r') as token: 48 | 49 | self.creds = Credentials.from_authorized_user_file( 50 | self.token_file, self.SCOPES) 51 | 52 | if not self.creds: 53 | 54 | flow = InstalledAppFlow.from_client_secrets_file( 55 | self.creds_file, self.SCOPES) 56 | 57 | self.creds = flow.run_local_server(port=0) 58 | 59 | elif self.creds and self.creds.expired and self.creds.refresh_token: 60 | 61 | self.creds.refresh(Request()) 62 | 63 | if self.creds != None: 64 | 65 | # Save the credentials for the next run 66 | with open(self.token_file, 'w') as token: 67 | 68 | token.write(self.creds.to_json()) 69 | 70 | self.service = build('gmail', 'v1', credentials=self.creds) 71 | 72 | self.logger.info("CONNECTED TO GMAIL!\n", extra={'log': False}) 73 | 74 | return True 75 | 76 | else: 77 | 78 | raise Exception("Creds Not Found!") 79 | 80 | except Exception as e: 81 | 82 | self.logger.error( 83 | f"FAILED TO CONNECT TO GMAIL! - {e}\n", extra={'log': False}) 84 | 85 | return False 86 | 87 | def handleOption(self, symbol): 88 | 89 | symbol = symbol.replace(".", "", 1).strip() 90 | 91 | ending_index = 0 92 | 93 | int_found = False 94 | 95 | for index, char in enumerate(symbol): 96 | 97 | try: 98 | 99 | int(char) 100 | 101 | int_found = True 102 | 103 | except: 104 | 105 | if not int_found: 106 | 107 | ending_index = index 108 | 109 | exp = symbol[ending_index + 1:] 110 | 111 | year = exp[:2] 112 | 113 | month = exp[2:4] 114 | 115 | day = exp[4:6] 116 | 117 | option_type = "CALL" if "C" in exp else "PUT" 118 | 119 | # .AA201211C5.5 120 | 121 | # AA_121120C5.5 122 | 123 | pre_symbol = f"{symbol[:ending_index + 1]}_{month}{day}{year}{exp[6:]}" 124 | 125 | return symbol[:ending_index + 1], pre_symbol, datetime.strptime(f"{year}-{month}-{day}", "%y-%m-%d"), option_type 126 | 127 | def extractSymbolsFromEmails(self, payloads): 128 | """ METHOD TAKES SUBJECT LINES OF THE EMAILS WITH THE SYMBOLS AND SCANNER NAMES AND EXTRACTS THE NEEDED THE INFO FROM THEM. 129 | NEEDED INFO: Symbol, Strategy, Side(Buy/Sell), Account ID 130 | 131 | Args: 132 | payloads ([list]): LIST OF EMAIL CONTENT 133 | 134 | Returns: 135 | [dict]: LIST OF EXTRACTED EMAIL CONTENT 136 | """ 137 | 138 | trade_data = [] 139 | 140 | # Alert: New Symbol: ABC was added to LinRegEMA_v2, BUY 141 | # Alert: New Symbol: ABC was added to LinRegEMA_v2, BUY 142 | 143 | for payload in payloads: 144 | 145 | try: 146 | 147 | seperate = payload.split(":") 148 | 149 | if len(seperate) > 1: 150 | 151 | contains = ["were added to", "was added to"] 152 | 153 | for i in contains: 154 | 155 | if i in seperate[2]: 156 | 157 | sep = seperate[2].split(i) 158 | 159 | symbols = sep[0].strip().split(",") 160 | 161 | strategy, side = sep[1].strip().split(",") 162 | 163 | for symbol in symbols: 164 | 165 | if strategy != "" and side != "": 166 | 167 | obj = { 168 | "Symbol": symbol.strip(), 169 | "Side": side.replace(".", " ").upper().strip(), 170 | "Strategy": strategy.replace(".", " ").upper().strip(), 171 | "Asset_Type": "EQUITY" 172 | } 173 | 174 | # IF THIS IS AN OPTION 175 | if "." in symbol: 176 | 177 | symbol, pre_symbol, exp_date, option_type = self.handleOption( 178 | symbol) 179 | 180 | obj["Symbol"] = symbol 181 | 182 | obj["Pre_Symbol"] = pre_symbol 183 | 184 | obj["Exp_Date"] = exp_date 185 | 186 | obj["Option_Type"] = option_type 187 | 188 | obj["Asset_Type"] = "OPTION" 189 | 190 | # CHECK TO SEE IF ASSET TYPE AND SIDE ARE A LOGICAL MATCH 191 | if side.replace(".", " ").upper().strip() in ["SELL", "BUY"] and obj["Asset_Type"] == "EQUITY" or side.replace(".", " ").upper().strip() in ["SELL_TO_CLOSE", "SELL_TO_OPEN", "BUY_TO_CLOSE", "BUY_TO_OPEN"] and obj["Asset_Type"] == "OPTION": 192 | 193 | trade_data.append(obj) 194 | 195 | else: 196 | 197 | self.logger.warning( 198 | f"{__class__.__name__} - ILLOGICAL MATCH - SIDE: {side.upper().strip()} / ASSET TYPE: {obj['Asset_Type']}") 199 | 200 | else: 201 | 202 | self.logger.warning( 203 | f"{__class__.__name__} - MISSING FIELDS FOR STRATEGY {strategy}") 204 | 205 | break 206 | 207 | self.logger.info( 208 | f"New Email: {payload}", extra={'log': False}) 209 | 210 | except IndexError: 211 | 212 | pass 213 | 214 | except ValueError: 215 | 216 | self.logger.warning( 217 | f"{__class__.__name__} - Email Format Issue: {payload}") 218 | 219 | except Exception as e: 220 | 221 | self.logger.error(f"{__class__.__name__} - {e}") 222 | 223 | return trade_data 224 | 225 | def getEmails(self): 226 | """ METHOD RETRIEVES EMAILS FROM INBOX, ADDS EMAIL TO TRASH FOLDER, AND ADD THEIR CONTENT TO payloads LIST TO BE EXTRACTED. 227 | 228 | Returns: 229 | [dict]: LIST RETURNED FROM extractSymbolsFromEmails METHOD 230 | """ 231 | 232 | payloads = [] 233 | 234 | try: 235 | 236 | # GETS LIST OF ALL EMAILS 237 | results = self.service.users().messages().list(userId='me').execute() 238 | 239 | if results['resultSizeEstimate'] != 0: 240 | 241 | # {'id': '173da9a232284f0f', 'threadId': '173da9a232284f0f'} 242 | for message in results["messages"]: 243 | 244 | result = self.service.users().messages().get( 245 | id=message["id"], userId="me", format="metadata").execute() 246 | 247 | for payload in result['payload']["headers"]: 248 | 249 | if payload["name"] == "Subject": 250 | 251 | payloads.append(payload["value"].strip()) 252 | 253 | # MOVE EMAIL TO TRASH FOLDER 254 | self.service.users().messages().trash( 255 | userId='me', id=message["id"]).execute() 256 | 257 | except Exception as e: 258 | 259 | self.logger.error(f"{__class__.__name__} - {e}") 260 | 261 | finally: 262 | 263 | return self.extractSymbolsFromEmails(payloads) 264 | -------------------------------------------------------------------------------- /tdameritrade/__init__.py: -------------------------------------------------------------------------------- 1 | # imports 2 | from datetime import datetime, timedelta 3 | import urllib.parse as up 4 | import time 5 | import requests 6 | from assets.helper_functions import modifiedAccountID 7 | from assets.exception_handler import exception_handler 8 | 9 | 10 | class TDAmeritrade: 11 | 12 | def __init__(self, mongo, user, account_id, logger, push_notification): 13 | 14 | self.user = user 15 | 16 | self.account_id = account_id 17 | 18 | self.logger = logger 19 | 20 | self.users = mongo.users 21 | 22 | self.push_notification = push_notification 23 | 24 | self.no_go_token_sent = False 25 | 26 | self.client_id = self.user["ClientID"] 27 | 28 | self.header = {} 29 | 30 | self.terminate = False 31 | 32 | self.invalid_count = 0 33 | 34 | @exception_handler 35 | def initialConnect(self): 36 | 37 | self.logger.info( 38 | f"CONNECTING {self.user['Name']} TO TDAMERITRADE ({modifiedAccountID(self.account_id)})", extra={'log': False}) 39 | 40 | isValid = self.checkTokenValidity() 41 | 42 | if isValid: 43 | 44 | self.logger.info( 45 | f"CONNECTED {self.user['Name']} TO TDAMERITRADE ({modifiedAccountID(self.account_id)})", extra={'log': False}) 46 | 47 | return True 48 | 49 | else: 50 | 51 | self.logger.error( 52 | f"FAILED TO CONNECT {self.user['Name']} TO TDAMERITRADE ({self.account_id})", extra={'log': False}) 53 | 54 | return False 55 | 56 | @exception_handler 57 | def checkTokenValidity(self): 58 | """ METHOD CHECKS IF ACCESS TOKEN IS VALID 59 | 60 | Returns: 61 | [boolean]: TRUE IF SUCCESSFUL, FALSE IF ERROR 62 | """ 63 | 64 | # GET USER DATA 65 | user = self.users.find_one({"Name": self.user["Name"]}) 66 | 67 | # ADD EXISTING TOKEN TO HEADER 68 | self.header.update({ 69 | "Authorization": f"Bearer {user['Accounts'][self.account_id]['access_token']}"}) 70 | 71 | # CHECK IF ACCESS TOKEN NEEDS UPDATED 72 | age_sec = round( 73 | time.time() - user["Accounts"][self.account_id]["created_at"]) 74 | 75 | if age_sec >= user["Accounts"][self.account_id]['expires_in'] - 60: 76 | 77 | token = self.getNewTokens(user["Accounts"][self.account_id]) 78 | 79 | if token: 80 | 81 | # ADD NEW TOKEN DATA TO USER DATA IN DB 82 | self.users.update_one({"Name": self.user["Name"]}, { 83 | "$set": {f"Accounts.{self.account_id}.expires_in": token['expires_in'], f"Accounts.{self.account_id}.access_token": token["access_token"], f"Accounts.{self.account_id}.created_at": time.time()}}) 84 | 85 | self.header.update({ 86 | "Authorization": f"Bearer {token['access_token']}"}) 87 | 88 | else: 89 | 90 | return False 91 | 92 | # CHECK IF REFRESH TOKEN NEEDS UPDATED 93 | now = datetime.strptime(datetime.strftime( 94 | datetime.now().replace(microsecond=0), "%Y-%m-%d"), "%Y-%m-%d") 95 | 96 | refresh_exp = datetime.strptime( 97 | user["Accounts"][self.account_id]["refresh_exp_date"], "%Y-%m-%d") 98 | 99 | days_left = (refresh_exp - now).total_seconds() / 60 / 60 / 24 100 | 101 | if days_left <= 5: 102 | 103 | token = self.getNewTokens( 104 | user["Accounts"][self.account_id], refresh_type="Refresh Token") 105 | 106 | if token: 107 | 108 | # ADD NEW TOKEN DATA TO USER DATA IN DB 109 | self.users.update_one({"Name": self.user["Name"]}, { 110 | "$set": {f"{self.account_id}.refresh_token": token['refresh_token'], f"{self.account_id}.refresh_exp_date": (datetime.now().replace( 111 | microsecond=0) + timedelta(days=90)).strftime("%Y-%m-%d")}}) 112 | 113 | self.header.update({ 114 | "Authorization": f"Bearer {token['access_token']}"}) 115 | 116 | else: 117 | 118 | return False 119 | 120 | return True 121 | 122 | @exception_handler 123 | def getNewTokens(self, token, refresh_type="Access Token"): 124 | """ METHOD GETS NEW ACCESS TOKEN, OR NEW REFRESH TOKEN IF NEEDED. 125 | 126 | Args: 127 | token ([dict]): TOKEN DATA (ACCESS TOKEN, REFRESH TOKEN, EXP DATES) 128 | refresh_type (str, optional): CAN BE EITHER Access Token OR Refresh Token. Defaults to "Access Token". 129 | 130 | Raises: 131 | Exception: IF RESPONSE STATUS CODE IS NOT 200 132 | 133 | Returns: 134 | [json]: NEW TOKEN DATA 135 | """ 136 | 137 | data = {'grant_type': 'refresh_token', 138 | 'refresh_token': token["refresh_token"], 139 | 'client_id': self.client_id} 140 | 141 | if refresh_type == "Refresh Token": 142 | 143 | data["access_type"] = "offline" 144 | 145 | # print(f"REFRESHING TOKEN: {data} - TRADER: {self.user['Name']} - REFRESH TYPE: {refresh_type} - ACCOUNT ID: {self.account_id}") 146 | 147 | resp = requests.post('https://api.tdameritrade.com/v1/oauth2/token', 148 | headers={ 149 | 'Content-Type': 'application/x-www-form-urlencoded'}, 150 | data=data) 151 | 152 | if resp.status_code != 200: 153 | 154 | if not self.no_go_token_sent: 155 | 156 | msg = f"ERROR WITH GETTING NEW TOKENS - {resp.json()} - TRADER: {self.user['Name']} - REFRESH TYPE: {refresh_type} - ACCOUNT ID: {modifiedAccountID(self.account_id)}" 157 | 158 | self.logger.error(msg) 159 | 160 | self.push_notification.send(msg) 161 | 162 | self.no_go_token_sent = True 163 | 164 | self.invalid_count += 1 165 | 166 | if self.invalid_count == 5: 167 | 168 | self.terminate = True 169 | 170 | msg = f"{__class__.__name__} - {self.user['Name']} - TDAMERITRADE INSTANCE TERMINATED - {resp.json()} - Refresh Type: {refresh_type} {modifiedAccountID(self.account_id)}" 171 | 172 | self.logger.error(msg) 173 | 174 | self.push_notification.send(msg) 175 | 176 | return 177 | 178 | self.no_go_token_sent = False 179 | 180 | self.invalid_count = 0 181 | 182 | self.terminate = False 183 | 184 | return resp.json() 185 | 186 | @exception_handler 187 | def sendRequest(self, url, method="GET", data=None): 188 | """ METHOD SENDS ALL REQUESTS FOR METHODS BELOW. 189 | 190 | Args: 191 | url ([str]): URL for the particular API 192 | method (str, optional): GET, POST, PUT, DELETE. Defaults to "GET". 193 | data ([dict], optional): ONLY IF POST REQUEST. Defaults to None. 194 | 195 | Returns: 196 | [json]: RESPONSE DATA 197 | """ 198 | 199 | isValid = self.checkTokenValidity() 200 | 201 | if isValid: 202 | 203 | if method == "GET": 204 | 205 | resp = requests.get(url, headers=self.header) 206 | 207 | return resp.json() 208 | 209 | elif method == "POST": 210 | 211 | resp = requests.post(url, headers=self.header, json=data) 212 | 213 | return resp 214 | 215 | elif method == "PATCH": 216 | 217 | resp = requests.patch(url, headers=self.header, json=data) 218 | 219 | return resp 220 | 221 | elif method == "PUT": 222 | 223 | resp = requests.put(url, headers=self.header, json=data) 224 | 225 | return resp 226 | 227 | elif method == "DELETE": 228 | 229 | resp = requests.delete(url, headers=self.header) 230 | 231 | return resp 232 | 233 | else: 234 | 235 | return 236 | 237 | def getAccount(self): 238 | """ METHOD GET ACCOUNT DATA 239 | 240 | Returns: 241 | [json]: ACCOUNT DATA 242 | """ 243 | 244 | fields = up.quote("positions,orders") 245 | 246 | url = f"https://api.tdameritrade.com/v1/accounts/{self.account_id}?fields={fields}" 247 | 248 | return self.sendRequest(url) 249 | 250 | def placeTDAOrder(self, data): 251 | """ METHOD PLACES ORDER 252 | 253 | Args: 254 | data ([dict]): ORDER DATA 255 | 256 | Returns: 257 | [json]: ORDER RESPONSE INFO. USED TO RETRIEVE ORDER ID. 258 | """ 259 | 260 | url = f"https://api.tdameritrade.com/v1/accounts/{self.account_id}/orders" 261 | 262 | return self.sendRequest(url, method="POST", data=data) 263 | 264 | def getBuyingPower(self): 265 | """ METHOD GETS BUYING POWER 266 | 267 | Returns: 268 | [json]: BUYING POWER 269 | """ 270 | 271 | account = self.getAccount() 272 | 273 | buying_power = account["securitiesAccount"]["initialBalances"]["cashAvailableForTrading"] 274 | 275 | return float(buying_power) 276 | 277 | def getQuote(self, symbol): 278 | """ METHOD GETS MOST RECENT QUOTE FOR STOCK 279 | 280 | Args: 281 | symbol ([str]): STOCK SYMBOL 282 | 283 | Returns: 284 | [json]: STOCK QUOTE 285 | """ 286 | 287 | url = f"https://api.tdameritrade.com/v1/marketdata/{symbol}/quotes" 288 | 289 | return self.sendRequest(url) 290 | 291 | def getQuotes(self, symbols): 292 | """ METHOD GETS STOCK QUOTES FOR MULTIPLE STOCK IN ONE CALL. 293 | 294 | Args: 295 | symbols ([list]): LIST OF SYMBOLS 296 | 297 | Returns: 298 | [json]: ALL SYMBOLS STOCK DATA 299 | """ 300 | 301 | join_ = ",".join(symbols) 302 | 303 | seperated_values = up.quote(join_) 304 | 305 | url = f"https://api.tdameritrade.com/v1/marketdata/quotes?symbol={seperated_values}" 306 | 307 | return self.sendRequest(url) 308 | 309 | def getSpecificOrder(self, id): 310 | """ METHOD GETS A SPECIFIC ORDER INFO 311 | 312 | Args: 313 | id ([int]): ORDER ID FOR ORDER 314 | 315 | Returns: 316 | [json]: ORDER DATA 317 | """ 318 | 319 | url = f"https://api.tdameritrade.com/v1/accounts/{self.account_id}/orders/{id}" 320 | 321 | return self.sendRequest(url) 322 | 323 | def cancelOrder(self, id): 324 | """ METHOD CANCELS ORDER 325 | 326 | Args: 327 | id ([int]): ORDER ID FOR ORDER 328 | 329 | Returns: 330 | [json]: RESPONSE. LOOKING FOR STATUS CODE 200,201 331 | """ 332 | 333 | url = f"https://api.tdameritrade.com/v1/accounts/{self.account_id}/orders/{id}" 334 | 335 | return self.sendRequest(url, method="DELETE") 336 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Trading Bot w/ Thinkorswim 2 | 3 | ## Description 4 | 5 | - This automated trading bot utilizes TDAmeritrades API, Thinkorswim Alert System, Gmail API , and MongoDB to place trades, both Equity and Options, dynamically. _**This bot works for LONG and SHORT positions**_ 6 | 7 | ## Table Of Contents 8 | 9 | - [How it works](#how-it-works) 10 | 11 | - [Getting Started](#getting-started) 12 | 13 | - [Dependencies](#dependencies) 14 | - [Thinkorswim](#thinkorswim) 15 | - [TDA API Tokens](#tda-tokens) 16 | - [Gmail](#gmail) 17 | - [MongoDB](#mongo) 18 | - [Pushsafer](#pushsafer) 19 | 20 | - [Discrepencies](#discrepencies) 21 | 22 | - [What I Use and Costs](#what-i-use-and-costs) 23 | 24 | - [Code Counter](#code-counter) 25 | 26 | - [Final Thoughts and Support](#final-thoughts-and-support) 27 | 28 | ## How it works (in a nutshell) 29 | 30 | ### **Thinkorswim** 31 | 32 | 1. Develop strategies in Thinkorswim. 33 | 2. Create a scanner for your strategy. (Scanner name will have specific format needed) 34 | 3. Set your scanner to send alerts to your non-personal gmail. 35 | 4. When a symbol is populated into the scanner, an alert is triggered and sent to gmail. 36 | 37 | ### **Trading Bot (Python)** 38 | 39 | 1. Continuously scrapes email inbox looking for alerts. 40 | 2. Once found, bot will extract needed information and will place a trade if warranted. 41 | 42 | --- 43 | 44 | - You can only buy a symbol once per strategy, but you can buy the same symbol on multiple strategies. 45 | 46 | - For Example: 47 | 48 | 1. You place a buy order for AAPL with the strategy name MyRSIStrategy. Once the order is placed and filled, it is pushed to mongo. 49 | 2. If another alert is triggered for AAPL with the strategy name of MyRSIStrategy, the bot will reject it because it's already an open position. 50 | 3. Once the position is removed via a sell order, then AAPL with the strategy name of MyRSIStrategy can be bought again. 51 | 52 | - This bot is setup for both Standard orders and OCO orders. 53 | 54 | 1. Standard Orders - basic buy and sell order flow. 55 | 2. OCO orders - single entry price with two exit prices (Stop Loss/Take Profit) 56 | 57 | - For the OCO orders, the bot uses a task to check your TDA account to see if any OCO exits have triggered. 58 | 59 | - **ATTENTION** - The bot is designed to either paper trade or live trade, but not at the same time. You can do one or the other. This can be changed by the value set for the "Account_Position" field located in your account object stored in the users collection in mongo. The options for this field are "Paper" and "Live". These are case sensitive. By default when the account is created, it is set to "Paper" as a safety precaution for the user. 60 | 61 | --- 62 | 63 | ## Getting Started 64 | 65 | ### **DEPENDENCIES** 66 | 67 | --- 68 | 69 | > [dev-packages] 70 | 71 | - pylint 72 | - autopep8 73 | 74 | > [packages] 75 | 76 | - google-api-python-client = "\*" 77 | - google-auth-httplib2 = "\*" 78 | - google-auth-oauthlib = "\*" 79 | - python-dotenv = "\*" 80 | - pymongo = "\*" 81 | - dnspython = "\*" 82 | - requests = "\*" 83 | - pytz = "\*" 84 | - psutil = "\*" 85 | - certifi = "\*" 86 | 87 | > [venv] 88 | 89 | - pipenv 90 | 91 | > [requires] 92 | 93 | - python_version = "3.8" 94 | 95 | ### **THINKORSWIM** 96 | 97 | --- 98 | 99 | 1. Create a strategy that you want to use in the bot. 100 | 2. Create a scanner and name it using the format below: 101 | 102 | - STRATEGY, SIDE 103 | 104 | - Example: ![Scanner Name Format](https://tos-python-trading-bot.s3.us-east-2.amazonaws.com/img/Scanner_Name_Format.PNG) 105 | 106 | 1. REVA is the strategy name example. 107 | 2. BUY is the side. Can be BUY, BUY_TO_OPEN, BUY_TO_CLOSE, SELL, SELL_TO_CLOSE, SELL_TO_OPEN 108 | 109 | *** 110 | 111 | - _**ATTENTION**_ - Your scanner names must have the same strategy names for the buy and sell scanners, or the bot will not be able to trade correctly. 112 | - Example: 113 | 114 | - MyRSIStrategy, BUY 115 | - MyRSIStrategy, SELL 116 | 117 | --- 118 | 119 | 3. You will need to offset the scanner logic to prevent premature alerts from firing. This is due to the fact of the current candle constantly repainting and meeting/not meeting criteria. 120 | 121 | - This is how an entry strategy in the charts may look. 122 | 123 | - ![Chart Strategy Example](https://tos-python-trading-bot.s3.us-east-2.amazonaws.com/img/Chart_Strategy.PNG) 124 | 125 | *** 126 | 127 | - This is how the scanner should look for the exact same entry strategy. 128 | 129 | - ![Scanner Strategy Example](https://tos-python-trading-bot.s3.us-east-2.amazonaws.com/img/Scanner_Strategy.PNG) 130 | 131 | - The only thing that changed was that [1] was added to offset the scanner by one and to look at the previous candle. 132 | 133 | --- 134 | 135 | 4. Set up the alert for the scanner. View images below: 136 | 137 | - ![Create Alert Screen 1](https://tos-python-trading-bot.s3.us-east-2.amazonaws.com/img/Create_Alert_Screen.PNG) 138 | - Set Event dropdown to "A symbol is added" 139 | 140 | - ![Create Alert Screen 1](https://tos-python-trading-bot.s3.us-east-2.amazonaws.com/img/Create_Alert_Screen2.PNG) 141 | - Check the box that says "Send an e-mail to all specified e-mail addresses" 142 | 143 | - ![Create Alert Screen 1](https://tos-python-trading-bot.s3.us-east-2.amazonaws.com/img/Create_Alert_Screen3.PNG) 144 | - Check the radio button thats says "A message for every change" 145 | 146 | --- 147 | 148 | 5. You should now start to receive alerts to your specified gmail account. 149 | 150 | --- 151 | 152 | ### **TDAMERITRADE API TOKENS** 153 | 154 | - You will need an access token and refresh token for each account you wish to use. 155 | - This will allow you to connect to your TDA account through the API. 156 | - Here is my [repo](https://github.com/TreyThomas93/TDA-Token) to help you to get these tokens and save them to your mongo database, in your users collection. 157 | 158 | ### **GMAIL** 159 | 160 | - First off, it is best to create an additional and seperate Gmail account and not your personal account. 161 | 162 | - Make sure that you are in the account that will be used to receive alerts from Thinkorswim. 163 | - _Step by Step (Follow this to setup Gmail API):_ 164 | 165 | 1. https://developers.google.com/gmail/api/quickstart/python 166 | 2. https://developers.google.com/workspace/guides/create-project 167 | 3. https://developers.google.com/workspace/guides/create-credentials 168 | 4. After you obtain your credentials file, make sure you rename it to credentials.json and store it in the creds folding within the gmail package in the program. 169 | 5. Run the program and you will go through the OAuth process. Once complete, a token.json file will be stored in your creds folder. 170 | 6. If you get an access_denied during the OAuth process, try this: https://stackoverflow.com/questions/65184355/error-403-access-denied-from-google-authentication-web-api-despite-google-acc 171 | 172 | - _ATTENTION:_ Be advised that while your gmail api app that you create during the above process is in TESTING mode, the tokens will expire after 7 days. https://stackoverflow.com/questions/66058279/token-has-been-expired-or-revoked-google-oauth2-refresh-token-gets-expired-i 173 | 174 | - You will need to set this in production mode to avoid this. Simply skip the SCOPES section of the setup process. 175 | 176 | ### **MONGODB** 177 | 178 | --- 179 | 180 | - Create a MongoDB [account](https://www.mongodb.com/), create a cluster, and create one database with the following names: 181 | 182 | 1. Api_Trader 183 | 184 | - The Api_Trader will contain all live and paper data. Each document contains a field called Account_Position which will tell the bot if its for paper trading or live trading. 185 | 186 | - You will need the mongo URI to be able to connect pymongo in the program. Store this URI in a config.env file within your mongo package in your code. 187 | 188 | > #### _ApiTrader_ 189 | 190 | - The collections you will find in the Api_Trader database will be the following: 191 | 192 | 1. users 193 | 2. queue 194 | 3. open_positions 195 | 4. closed_positions 196 | 5. rejected 197 | 6. canceled 198 | 7. strategies 199 | 200 | - The users collection stores all users and their individial data, such as name and accounts. 201 | 202 | - The queue collection stores non-filled orders that are working or queued, until either cancelled or filled. 203 | 204 | - The open_positions collection stores all open positions and is used to help determine if an order is warranted. 205 | 206 | - The closed_positions collection stores all closed positions after a trade has completed. 207 | 208 | - The rejected collection stores all rejected orders. 209 | 210 | - The canceled collection stores all canceled orders. 211 | 212 | - The strategies collection stores all strategies that have been used with the bot. Here is an example of a strategy object stored in mongo: `{"Active": True, "Order_Type": "STANDARD", "Asset_Type": asset_type, "Position_Size": 500, "Position_Type": "LONG", "Trader": self.user["Name"], "Strategy": strategy, }` 213 | 214 | - **FYI** - You are able to add more collections for additional tasks that you so wish to use with the bot. Mongo will automatically add a collection if it doesnt exist when the bot needs to use it so you dont need to manually create it. 215 | 216 | ### **PUSHSAFER** 217 | 218 | --- 219 | 220 | - Pushsafer allows you to send and receive push notifications to your phone from the program. 221 | 222 | - This is handy for knowing in real time when trades are placed. 223 | 224 | - The first thing you will need to do is register: 225 | https://www.pushsafer.com/ 226 | 227 | - Once registered, read the docs on how to register and connect to devices. There is an Android and IOS app for this. 228 | 229 | - You will also need to pay for API calls, which is about $1 for 1,000 calls. 230 | 231 | - You will also need to store your api key in your code in a config.env file. 232 | 233 | ### **DISCREPENCIES** 234 | 235 | --- 236 | 237 | - This program is not perfect. I am not liable for any profits or losses. 238 | - There are several factors that could play into the program not working correctly. Some examples below: 239 | 240 | 1. TDAmeritrades API is buggy at times, and you may lose connection, or not get correct responses after making requests. 241 | 2. Thinkorswim scanners update every 3-5 minutes, and sometimes symbols wont populate at a timely rate. I've seen some to where it took 20-30 minutes to finally send an alert. 242 | 3. Gmail servers could go down aswell. That has happened in the past, but not very common. 243 | 4. And depending on who you have hosting your server for the program, that is also subject to go down sometimes, either for maintenance or for other reasons. 244 | 5. As for refreshing the refresh token, I have been running into issues when renewing it. The TDA API site says the refresh token will expire after 90 days, but for some reason It won't allow you to always renew it and may give you an "invalid grant" error, so you may have to play around with it or even recreate everything using this [repo](https://github.com/TreyThomas93/TDA-Token). Just make sure you set it to existing user in the script so it can update your account. 245 | 246 | - The program is very indirect, and lots of factors play into how well it performs. For the most part, it does a great job. 247 | 248 | ### **WHAT I USED AND COSTS** 249 | 250 | > SERVER FOR HOSTING PROGRAM 251 | 252 | - PythonAnywhere -- $7 / month 253 | 254 | > DATABASE 255 | 256 | - MongoDB Atlas -- Approx. $25 / month. 257 | - I currently use the M5 tier. You may be able to do the M2 tier. If you wont be using the web app then you don't need a higher level tier. 258 | 259 | ![Mongo Tiers](https://tos-python-trading-bot.s3.us-east-2.amazonaws.com/img/cluster-tier.png) 260 | 261 | > NOTIFICATION SYSTEM 262 | 263 | - PushSafer -- Less than $5 / month 264 | 265 | ### **CODE COUNTER** 266 | 267 | --- 268 | 269 | - Total : 15 files, 1768 codes, 271 comments, 849 blanks, all 2888 lines 270 | 271 | ## Languages 272 | 273 | | language | files | code | comment | blank | total | 274 | | :------- | ----: | ---: | ------: | ----: | ----: | 275 | | Python | 12 | 982 | 271 | 719 | 1,972 | 276 | | JSON | 1 | 576 | 0 | 1 | 577 | 277 | | Markdown | 1 | 190 | 0 | 125 | 315 | 278 | | toml | 1 | 20 | 0 | 4 | 24 | 279 | 280 | ## Directories 281 | 282 | | path | files | code | comment | blank | total | 283 | | :----------- | ----: | ----: | ------: | ----: | ----: | 284 | | . | 15 | 1,768 | 271 | 849 | 2,888 | 285 | | api_trader | 3 | 492 | 102 | 306 | 900 | 286 | | assets | 5 | 114 | 34 | 100 | 248 | 287 | | gmail | 1 | 122 | 34 | 107 | 263 | 288 | | mongo | 1 | 36 | 1 | 30 | 67 | 289 | | tdameritrade | 1 | 138 | 85 | 113 | 336 | 290 | 291 | ### **FINAL THOUGHTS** 292 | 293 | --- 294 | 295 | - This is in continous development, with hopes to make this program as good as it can possibly get. I know this README might not do it justice with giving you all the information you may need, and you most likely will have questions. Therefore, don't hesitate to contact me either via Github or email. As for you all, I would like your input on how to improve this, and I also heavily encourage you to fork the code and send me your improvements. I appreciate all the support! Thanks, Trey. 296 | 297 | - > _DISCORD GROUP_ - I have created a Discord group to allow for a more interactive enviroment that will allow for all of us to answer questions and talk about the program. Discord Group 298 | 299 | - If you like backtesting with Thinkorswim, here's a [repo](https://github.com/TreyThomas93/TOS-Auto-Export) of mine that may help you export strategy reports alot faster. 300 | 301 | - Also, If you like what I have to offer, please support me here! 302 | 303 | 304 | -------------------------------------------------------------------------------- /api_trader/__init__.py: -------------------------------------------------------------------------------- 1 | from assets.helper_functions import getDatetime, modifiedAccountID 2 | from api_trader.tasks import Tasks 3 | from threading import Thread 4 | from assets.exception_handler import exception_handler 5 | from api_trader.order_builder import OrderBuilder 6 | from dotenv import load_dotenv 7 | from pathlib import Path 8 | import os 9 | from pymongo.errors import WriteError, WriteConcernError 10 | import traceback 11 | import time 12 | from random import randint 13 | 14 | 15 | THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) 16 | 17 | path = Path(THIS_FOLDER) 18 | 19 | load_dotenv(dotenv_path=f"{path.parent}/config.env") 20 | 21 | RUN_TASKS = True if os.getenv('RUN_TASKS') == "True" else False 22 | 23 | 24 | class ApiTrader(Tasks, OrderBuilder): 25 | 26 | def __init__(self, user, mongo, push, logger, account_id, tdameritrade): 27 | """ 28 | Args: 29 | user ([dict]): [USER DATA FOR CURRENT INSTANCE] 30 | mongo ([object]): [MONGO OBJECT CONNECTING TO DB] 31 | push ([object]): [PUSH OBJECT FOR PUSH NOTIFICATIONS] 32 | logger ([object]): [LOGGER OBJECT FOR LOGGING] 33 | account_id ([str]): [USER ACCOUNT ID FOR TDAMERITRADE] 34 | asset_type ([str]): [ACCOUNT ASSET TYPE (EQUITY, OPTIONS)] 35 | """ 36 | 37 | self.RUN_LIVE_TRADER = True if user["Accounts"][str( 38 | account_id)]["Account_Position"] == "Live" else False 39 | 40 | self.tdameritrade = tdameritrade 41 | 42 | self.mongo = mongo 43 | 44 | self.account_id = account_id 45 | 46 | self.user = user 47 | 48 | self.users = mongo.users 49 | 50 | self.push = push 51 | 52 | self.open_positions = mongo.open_positions 53 | 54 | self.closed_positions = mongo.closed_positions 55 | 56 | self.strategies = mongo.strategies 57 | 58 | self.rejected = mongo.rejected 59 | 60 | self.canceled = mongo.canceled 61 | 62 | self.queue = mongo.queue 63 | 64 | self.logger = logger 65 | 66 | self.no_ids_list = [] 67 | 68 | OrderBuilder.__init__(self) 69 | 70 | Tasks.__init__(self) 71 | 72 | # If user wants to run tasks 73 | if RUN_TASKS: 74 | 75 | Thread(target=self.runTasks, daemon=True).start() 76 | 77 | else: 78 | 79 | self.logger.info( 80 | f"NOT RUNNING TASKS FOR {self.user['Name']} ({modifiedAccountID(self.account_id)})\n", extra={'log': False}) 81 | 82 | self.logger.info( 83 | f"RUNNING {user['Accounts'][str(account_id)]['Account_Position'].upper()} TRADER ({modifiedAccountID(self.account_id)})\n") 84 | 85 | # STEP ONE 86 | @exception_handler 87 | def sendOrder(self, trade_data, strategy_object, direction): 88 | 89 | symbol = trade_data["Symbol"] 90 | 91 | strategy = trade_data["Strategy"] 92 | 93 | side = trade_data["Side"] 94 | 95 | order_type = strategy_object["Order_Type"] 96 | 97 | if order_type == "STANDARD": 98 | 99 | order, obj = self.standardOrder( 100 | trade_data, strategy_object, direction) 101 | 102 | elif order_type == "OCO": 103 | 104 | order, obj = self.OCOorder(trade_data, strategy_object, direction) 105 | 106 | if order == None and obj == None: 107 | 108 | return 109 | 110 | # PLACE ORDER IF LIVE TRADER ################################################ 111 | if self.RUN_LIVE_TRADER: 112 | 113 | resp = self.tdameritrade.placeTDAOrder(order) 114 | 115 | status_code = resp.status_code 116 | 117 | if status_code not in [200, 201]: 118 | 119 | other = { 120 | "Symbol": symbol, 121 | "Order_Type": side, 122 | "Order_Status": "REJECTED", 123 | "Strategy": strategy, 124 | "Trader": self.user["Name"], 125 | "Date": getDatetime(), 126 | "Account_ID": self.account_id 127 | } 128 | 129 | self.logger.info( 130 | f"{symbol} Rejected For {self.user['Name']} ({modifiedAccountID(self.account_id)}) - Reason: {(resp.json())['error']} ") 131 | 132 | self.rejected.insert_one(other) 133 | 134 | return 135 | 136 | # GETS ORDER ID FROM RESPONSE HEADERS LOCATION 137 | obj["Order_ID"] = int( 138 | (resp.headers["Location"]).split("/")[-1].strip()) 139 | 140 | obj["Account_Position"] = "Live" 141 | 142 | else: 143 | 144 | obj["Order_ID"] = -1*randint(100_000_000, 999_999_999) 145 | 146 | obj["Account_Position"] = "Paper" 147 | 148 | obj["Order_Status"] = "QUEUED" 149 | 150 | self.queueOrder(obj) 151 | 152 | response_msg = f"{'Live Trade' if self.RUN_LIVE_TRADER else 'Paper Trade'}: {side} Order for Symbol {symbol} ({modifiedAccountID(self.account_id)})" 153 | 154 | self.logger.info(response_msg) 155 | 156 | # STEP TWO 157 | @exception_handler 158 | def queueOrder(self, order): 159 | """ METHOD FOR QUEUEING ORDER TO QUEUE COLLECTION IN MONGODB 160 | 161 | Args: 162 | order ([dict]): [ORDER DATA TO BE PLACED IN QUEUE COLLECTION] 163 | """ 164 | # ADD TO QUEUE WITHOUT ORDER ID AND STATUS 165 | self.queue.update_one( 166 | {"Trader": self.user["Name"], "Symbol": order["Symbol"], "Strategy": order["Strategy"]}, {"$set": order}, upsert=True) 167 | 168 | # STEP THREE 169 | @exception_handler 170 | def updateStatus(self): 171 | """ METHOD QUERIES THE QUEUED ORDERS AND USES THE ORDER ID TO QUERY TDAMERITRADES ORDERS FOR ACCOUNT TO CHECK THE ORDERS CURRENT STATUS. 172 | INITIALLY WHEN ORDER IS PLACED, THE ORDER STATUS ON TDAMERITRADES END IS SET TO WORKING OR QUEUED. THREE OUTCOMES THAT I AM LOOKING FOR ARE 173 | FILLED, CANCELED, REJECTED. 174 | 175 | IF FILLED, THEN QUEUED ORDER IS REMOVED FROM QUEUE AND THE pushOrder METHOD IS CALLED. 176 | 177 | IF REJECTED OR CANCELED, THEN QUEUED ORDER IS REMOVED FROM QUEUE AND SENT TO OTHER COLLECTION IN MONGODB. 178 | 179 | IF ORDER ID NOT FOUND, THEN ASSUME ORDER FILLED AND MARK AS ASSUMED DATA. ELSE MARK AS RELIABLE DATA. 180 | """ 181 | 182 | queued_orders = self.queue.find({"Trader": self.user["Name"], "Order_ID": { 183 | "$ne": None}, "Account_ID": self.account_id}) 184 | 185 | for queue_order in queued_orders: 186 | 187 | spec_order = self.tdameritrade.getSpecificOrder( 188 | queue_order["Order_ID"]) 189 | 190 | # ORDER ID NOT FOUND. ASSUME REMOVED OR PAPER TRADING 191 | if "error" in spec_order: 192 | 193 | custom = { 194 | "price": queue_order["Entry_Price"] if queue_order["Direction"] == "OPEN POSITION" else queue_order["Exit_Price"], 195 | "shares": queue_order["Qty"] 196 | } 197 | 198 | # IF RUNNING LIVE TRADER, THEN ASSUME DATA 199 | if self.RUN_LIVE_TRADER: 200 | 201 | data_integrity = "Assumed" 202 | 203 | self.logger.warning( 204 | f"Order ID Not Found. Moving {queue_order['Symbol']} {queue_order['Order_Type']} Order To {queue_order['Direction']} Positions ({modifiedAccountID(self.account_id)})") 205 | 206 | else: 207 | 208 | data_integrity = "Reliable" 209 | 210 | self.logger.info( 211 | f"Paper Trader - Sending Queue Order To PushOrder ({modifiedAccountID(self.account_id)})") 212 | 213 | self.pushOrder(queue_order, custom, data_integrity) 214 | 215 | continue 216 | 217 | new_status = spec_order["status"] 218 | 219 | order_type = queue_order["Order_Type"] 220 | 221 | # CHECK IF QUEUE ORDER ID EQUALS TDA ORDER ID 222 | if queue_order["Order_ID"] == spec_order["orderId"]: 223 | 224 | if new_status == "FILLED": 225 | 226 | # CHECK IF OCO ORDER AND THEN GET THE CHILDREN 227 | if queue_order["Order_Type"] == "OCO": 228 | 229 | queue_order = {**queue_order, ** 230 | self.extractOCOchildren(spec_order)} 231 | 232 | self.pushOrder(queue_order, spec_order) 233 | 234 | elif new_status == "CANCELED" or new_status == "REJECTED": 235 | 236 | # REMOVE FROM QUEUE 237 | self.queue.delete_one({"Trader": self.user["Name"], "Symbol": queue_order["Symbol"], 238 | "Strategy": queue_order["Strategy"], "Account_ID": self.account_id}) 239 | 240 | other = { 241 | "Symbol": queue_order["Symbol"], 242 | "Order_Type": order_type, 243 | "Order_Status": new_status, 244 | "Strategy": queue_order["Strategy"], 245 | "Trader": self.user["Name"], 246 | "Date": getDatetime(), 247 | "Account_ID": self.account_id 248 | } 249 | 250 | self.rejected.insert_one( 251 | other) if new_status == "REJECTED" else self.canceled.insert_one(other) 252 | 253 | self.logger.info( 254 | f"{new_status.upper()} Order For {queue_order['Symbol']} ({modifiedAccountID(self.account_id)})") 255 | 256 | else: 257 | 258 | self.queue.update_one({"Trader": self.user["Name"], "Symbol": queue_order["Symbol"], "Strategy": queue_order["Strategy"]}, { 259 | "$set": {"Order_Status": new_status}}) 260 | 261 | # STEP FOUR 262 | @exception_handler 263 | def pushOrder(self, queue_order, spec_order, data_integrity="Reliable"): 264 | """ METHOD PUSHES ORDER TO EITHER OPEN POSITIONS OR CLOSED POSITIONS COLLECTION IN MONGODB. 265 | IF BUY ORDER, THEN PUSHES TO OPEN POSITIONS. 266 | IF SELL ORDER, THEN PUSHES TO CLOSED POSITIONS. 267 | 268 | Args: 269 | queue_order ([dict]): [QUEUE ORDER DATA FROM QUEUE] 270 | spec_order ([dict(json)]): [ORDER DATA FROM TDAMERITRADE] 271 | """ 272 | 273 | symbol = queue_order["Symbol"] 274 | 275 | if "orderActivityCollection" in spec_order: 276 | 277 | price = spec_order["orderActivityCollection"][0]["executionLegs"][0]["price"] 278 | 279 | shares = int(spec_order["quantity"]) 280 | 281 | else: 282 | 283 | price = spec_order["price"] 284 | 285 | shares = int(queue_order["Qty"]) 286 | 287 | price = round(price, 2) if price >= 1 else round(price, 4) 288 | 289 | strategy = queue_order["Strategy"] 290 | 291 | side = queue_order["Side"] 292 | 293 | account_id = queue_order["Account_ID"] 294 | 295 | position_size = queue_order["Position_Size"] 296 | 297 | asset_type = queue_order["Asset_Type"] 298 | 299 | position_type = queue_order["Position_Type"] 300 | 301 | direction = queue_order["Direction"] 302 | 303 | account_position = queue_order["Account_Position"] 304 | 305 | order_type = queue_order["Order_Type"] 306 | 307 | obj = { 308 | "Symbol": symbol, 309 | "Strategy": strategy, 310 | "Position_Size": position_size, 311 | "Position_Type": position_type, 312 | "Data_Integrity": data_integrity, 313 | "Trader": self.user["Name"], 314 | "Account_ID": account_id, 315 | "Asset_Type": asset_type, 316 | "Account_Position": account_position, 317 | "Order_Type": order_type 318 | } 319 | 320 | if asset_type == "OPTION": 321 | 322 | obj["Pre_Symbol"] = queue_order["Pre_Symbol"] 323 | 324 | obj["Exp_Date"] = queue_order["Exp_Date"] 325 | 326 | obj["Option_Type"] = queue_order["Option_Type"] 327 | 328 | collection_insert = None 329 | 330 | message_to_push = None 331 | 332 | if direction == "OPEN POSITION": 333 | 334 | obj["Qty"] = shares 335 | 336 | obj["Entry_Price"] = price 337 | 338 | obj["Entry_Date"] = getDatetime() 339 | 340 | collection_insert = self.open_positions.insert_one 341 | 342 | message_to_push = f">>>> \n Side: {side} \n Symbol: {symbol} \n Qty: {shares} \n Price: ${price} \n Strategy: {strategy} \n Asset Type: {asset_type} \n Date: {getDatetime()} \n Trader: {self.user['Name']} \n Account Position: {'Live Trade' if self.RUN_LIVE_TRADER else 'Paper Trade'}" 343 | 344 | elif direction == "CLOSE POSITION": 345 | 346 | position = self.open_positions.find_one( 347 | {"Trader": self.user["Name"], "Symbol": symbol, "Strategy": strategy}) 348 | 349 | obj["Qty"] = position["Qty"] 350 | 351 | obj["Entry_Price"] = position["Entry_Price"] 352 | 353 | obj["Entry_Date"] = position["Entry_Date"] 354 | 355 | obj["Exit_Price"] = price 356 | 357 | obj["Exit_Date"] = getDatetime() 358 | 359 | exit_price = round(price * position["Qty"], 2) 360 | 361 | entry_price = round( 362 | position["Entry_Price"] * position["Qty"], 2) 363 | 364 | collection_insert = self.closed_positions.insert_one 365 | 366 | message_to_push = f"____ \n Side: {side} \n Symbol: {symbol} \n Qty: {position['Qty']} \n Entry Price: ${position['Entry_Price']} \n Entry Date: {position['Entry_Date']} \n Exit Price: ${price} \n Exit Date: {getDatetime()} \n Strategy: {strategy} \n Asset Type: {asset_type} \n Trader: {self.user['Name']} \n Account Position: {'Live Trade' if self.RUN_LIVE_TRADER else 'Paper Trade'}" 367 | 368 | # REMOVE FROM OPEN POSITIONS 369 | is_removed = self.open_positions.delete_one( 370 | {"Trader": self.user["Name"], "Symbol": symbol, "Strategy": strategy}) 371 | 372 | try: 373 | 374 | if int(is_removed.deleted_count) == 0: 375 | 376 | self.logger.error( 377 | f"INITIAL FAIL OF DELETING OPEN POSITION FOR SYMBOL {symbol} - {self.user['Name']} ({modifiedAccountID(self.account_id)})") 378 | 379 | self.open_positions.delete_one( 380 | {"Trader": self.user["Name"], "Symbol": symbol, "Strategy": strategy}) 381 | 382 | except Exception: 383 | 384 | msg = f"{self.user['Name']} - {modifiedAccountID(self.account_id)} - {traceback.format_exc()}" 385 | 386 | self.logger.error(msg) 387 | 388 | # PUSH OBJECT TO MONGO. IF WRITE ERROR THEN ONE RETRY WILL OCCUR. IF YOU SEE THIS ERROR, THEN YOU MUST CONFIRM THE PUSH OCCURED. 389 | try: 390 | 391 | collection_insert(obj) 392 | 393 | except WriteConcernError as e: 394 | 395 | self.logger.error( 396 | f"INITIAL FAIL OF INSERTING OPEN POSITION FOR SYMBOL {symbol} - DATE/TIME: {getDatetime()} - DATA: {obj} - {e}") 397 | 398 | collection_insert(obj) 399 | 400 | except WriteError as e: 401 | 402 | self.logger.error( 403 | f"INITIAL FAIL OF INSERTING OPEN POSITION FOR SYMBOL {symbol} - DATE/TIME: {getDatetime()} - DATA: {obj} - {e}") 404 | 405 | collection_insert(obj) 406 | 407 | except Exception: 408 | 409 | msg = f"{self.user['Name']} - {modifiedAccountID(self.account_id)} - {traceback.format_exc()}" 410 | 411 | self.logger.error(msg) 412 | 413 | self.logger.info( 414 | f"Pushing {side} Order For {symbol} To {'Open Positions' if direction == 'OPEN POSITION' else 'Closed Positions'} ({modifiedAccountID(self.account_id)})") 415 | 416 | # REMOVE FROM QUEUE 417 | self.queue.delete_one({"Trader": self.user["Name"], "Symbol": symbol, 418 | "Strategy": strategy, "Account_ID": self.account_id}) 419 | 420 | self.push.send(message_to_push) 421 | 422 | # RUN TRADER 423 | @exception_handler 424 | def runTrader(self, trade_data): 425 | """ METHOD RUNS ON A FOR LOOP ITERATING OVER THE TRADE DATA AND MAKING DECISIONS ON WHAT NEEDS TO BUY OR SELL. 426 | 427 | Args: 428 | trade_data ([list]): CONSISTS OF TWO DICTS TOP LEVEL, AND THEIR VALUES AS LISTS CONTAINING ALL THE TRADE DATA FOR EACH STOCK. 429 | """ 430 | 431 | # UPDATE ALL ORDER STATUS'S 432 | self.updateStatus() 433 | 434 | # UPDATE USER ATTRIBUTE 435 | self.user = self.mongo.users.find_one({"Name": self.user["Name"]}) 436 | 437 | # FORBIDDEN SYMBOLS 438 | forbidden_symbols = self.mongo.forbidden.find({"Account_ID": str(self.account_id)}) 439 | 440 | for row in trade_data: 441 | 442 | strategy = row["Strategy"] 443 | 444 | symbol = row["Symbol"] 445 | 446 | asset_type = row["Asset_Type"] 447 | 448 | side = row["Side"] 449 | 450 | # CHECK OPEN POSITIONS AND QUEUE 451 | open_position = self.open_positions.find_one( 452 | {"Trader": self.user["Name"], "Symbol": symbol, "Strategy": strategy, "Account_ID": self.account_id}) 453 | 454 | queued = self.queue.find_one( 455 | {"Trader": self.user["Name"], "Symbol": symbol, "Strategy": strategy, "Account_ID": self.account_id}) 456 | 457 | strategy_object = self.strategies.find_one( 458 | {"Strategy": strategy, "Account_ID": self.account_id}) 459 | 460 | if not strategy_object: 461 | 462 | self.addNewStrategy(strategy, asset_type) 463 | 464 | strategy_object = self.strategies.find_one( 465 | {"Account_ID": self.account_id, "Strategy": strategy}) 466 | 467 | position_type = strategy_object["Position_Type"] 468 | 469 | row["Position_Type"] = position_type 470 | 471 | if not queued: 472 | 473 | direction = None 474 | 475 | # IS THERE AN OPEN POSITION ALREADY IN MONGO FOR THIS SYMBOL/STRATEGY COMBO 476 | if open_position: 477 | 478 | direction = "CLOSE POSITION" 479 | 480 | # NEED TO COVER SHORT 481 | if side == "BUY" and position_type == "SHORT": 482 | 483 | pass 484 | 485 | # NEED TO SELL LONG 486 | elif side == "SELL" and position_type == "LONG": 487 | 488 | pass 489 | 490 | # NEED TO SELL LONG OPTION 491 | elif side == "SELL_TO_CLOSE" and position_type == "LONG": 492 | 493 | pass 494 | 495 | # NEED TO COVER SHORT OPTION 496 | elif side == "BUY_TO_CLOSE" and position_type == "SHORT": 497 | 498 | pass 499 | 500 | else: 501 | 502 | continue 503 | 504 | elif not open_position and symbol not in forbidden_symbols: 505 | 506 | direction = "OPEN POSITION" 507 | 508 | # NEED TO GO LONG 509 | if side == "BUY" and position_type == "LONG": 510 | 511 | pass 512 | 513 | # NEED TO GO SHORT 514 | elif side == "SELL" and position_type == "SHORT": 515 | 516 | pass 517 | 518 | # NEED TO GO SHORT OPTION 519 | elif side == "SELL_TO_OPEN" and position_type == "SHORT": 520 | 521 | pass 522 | 523 | # NEED TO GO LONG OPTION 524 | elif side == "BUY_TO_OPEN" and position_type == "LONG": 525 | 526 | pass 527 | 528 | else: 529 | 530 | continue 531 | 532 | if direction != None: 533 | 534 | self.sendOrder(row if not open_position else { 535 | **row, **open_position}, strategy_object, direction) 536 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "9d65c61234dfbf792ba8dbc6feac103cea022afa80c97ec42efdb9759e7f6b56" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "cachetools": { 20 | "hashes": [ 21 | "sha256:6a94c6402995a99c3970cc7e4884bb60b4a8639938157eeed436098bf9831757", 22 | "sha256:f9f17d2aec496a9aa6b76f53e3b614c965223c061982d434d160f930c698a9db" 23 | ], 24 | "markers": "python_version ~= '3.7'", 25 | "version": "==5.2.0" 26 | }, 27 | "certifi": { 28 | "hashes": [ 29 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 30 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 31 | ], 32 | "index": "pypi", 33 | "version": "==2021.10.8" 34 | }, 35 | "charset-normalizer": { 36 | "hashes": [ 37 | "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", 38 | "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" 39 | ], 40 | "markers": "python_version >= '3'", 41 | "version": "==2.0.12" 42 | }, 43 | "dnspython": { 44 | "hashes": [ 45 | "sha256:95d12f6ef0317118d2a1a6fc49aac65ffec7eb8087474158f42f26a639135216", 46 | "sha256:e4a87f0b573201a0f3727fa18a516b055fd1107e0e5477cded4a2de497df1dd4" 47 | ], 48 | "index": "pypi", 49 | "version": "==2.1.0" 50 | }, 51 | "google-api-core": { 52 | "hashes": [ 53 | "sha256:06f7244c640322b508b125903bb5701bebabce8832f85aba9335ec00b3d02edc", 54 | "sha256:93c6a91ccac79079ac6bbf8b74ee75db970cc899278b97d53bc012f35908cf50" 55 | ], 56 | "markers": "python_version >= '3.6'", 57 | "version": "==2.8.2" 58 | }, 59 | "google-api-python-client": { 60 | "hashes": [ 61 | "sha256:54e60f20acc3a5ac48e37b3d86bf5191fd6a1acb0f1efe76c47ed6b90f8c5a50", 62 | "sha256:d46418a296f8ee309b2044791aeffae512cb1a9d9bfb3def2bfb37058f01c645" 63 | ], 64 | "index": "pypi", 65 | "version": "==2.30.0" 66 | }, 67 | "google-auth": { 68 | "hashes": [ 69 | "sha256:516e6623038b81430dd062a1a25ecd24f173d7c15cdf4e48a9e78bc87e97aeec", 70 | "sha256:53bdc0c2b4e25895575779caef4cfb3a6bdff1b7b32dc38a654d71aba35bb5f8" 71 | ], 72 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 73 | "version": "==2.11.1" 74 | }, 75 | "google-auth-httplib2": { 76 | "hashes": [ 77 | "sha256:31e49c36c6b5643b57e82617cb3e021e3e1d2df9da63af67252c02fa9c1f4a10", 78 | "sha256:a07c39fd632becacd3f07718dfd6021bf396978f03ad3ce4321d060015cc30ac" 79 | ], 80 | "index": "pypi", 81 | "version": "==0.1.0" 82 | }, 83 | "google-auth-oauthlib": { 84 | "hashes": [ 85 | "sha256:3f2a6e802eebbb6fb736a370fbf3b055edcb6b52878bf2f26330b5e041316c73", 86 | "sha256:a90a072f6993f2c327067bf65270046384cda5a8ecb20b94ea9a687f1f233a7a" 87 | ], 88 | "index": "pypi", 89 | "version": "==0.4.6" 90 | }, 91 | "googleapis-common-protos": { 92 | "hashes": [ 93 | "sha256:8eb2cbc91b69feaf23e32452a7ae60e791e09967d81d4fcc7fc388182d1bd394", 94 | "sha256:c25873c47279387cfdcbdafa36149887901d36202cb645a0e4f29686bf6e4417" 95 | ], 96 | "markers": "python_version >= '3.7'", 97 | "version": "==1.56.4" 98 | }, 99 | "httplib2": { 100 | "hashes": [ 101 | "sha256:58a98e45b4b1a48273073f905d2961666ecf0fbac4250ea5b47aef259eb5c585", 102 | "sha256:8b6a905cb1c79eefd03f8669fd993c36dc341f7c558f056cb5a33b5c2f458543" 103 | ], 104 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 105 | "version": "==0.20.4" 106 | }, 107 | "idna": { 108 | "hashes": [ 109 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 110 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 111 | ], 112 | "markers": "python_version >= '3'", 113 | "version": "==3.4" 114 | }, 115 | "oauthlib": { 116 | "hashes": [ 117 | "sha256:1565237372795bf6ee3e5aba5e2a85bd5a65d0e2aa5c628b9a97b7d7a0da3721", 118 | "sha256:88e912ca1ad915e1dcc1c06fc9259d19de8deacd6fd17cc2df266decc2e49066" 119 | ], 120 | "markers": "python_version >= '3.6'", 121 | "version": "==3.2.1" 122 | }, 123 | "protobuf": { 124 | "hashes": [ 125 | "sha256:1867f93b06a183f87696871bb8d1e99ee71dbb69d468ce1f0cc8bf3d30f982f3", 126 | "sha256:3c4160b601220627f7e91154e572baf5e161a9c3f445a8242d536ee3d0b7b17c", 127 | "sha256:4ee2af7051d3b10c8a4fe6fd1a2c69f201fea36aeee7086cf202a692e1b99ee1", 128 | "sha256:5266c36cc0af3bb3dbf44f199d225b33da66a9a5c3bdc2b14865ad10eddf0e37", 129 | "sha256:5470f892961af464ae6eaf0f3099e2c1190ae8c7f36f174b89491281341f79ca", 130 | "sha256:66d14b5b90090353efe75c9fb1bf65ef7267383034688d255b500822e37d5c2f", 131 | "sha256:67efb5d20618020aa9596e17bfc37ca068c28ec0c1507d9507f73c93d46c9855", 132 | "sha256:696e6cfab94cc15a14946f2bf72719dced087d437adbd994fff34f38986628bc", 133 | "sha256:6a02172b9650f819d01fb8e224fc69b0706458fc1ab4f1c669281243c71c1a5e", 134 | "sha256:6eca9ae238ba615d702387a2ddea635d535d769994a9968c09a4ca920c487ab9", 135 | "sha256:950abd6c00e7b51f87ae8b18a0ce4d69fea217f62f171426e77de5061f6d9850", 136 | "sha256:9e1d74032f56ff25f417cfe84c8147047732e5059137ca42efad20cbbd25f5e0", 137 | "sha256:9e42b1cf2ecd8a1bd161239e693f22035ba99905ae6d7efeac8a0546c7ec1a27", 138 | "sha256:9f957ef53e872d58a0afd3bf6d80d48535d28c99b40e75e6634cbc33ea42fd54", 139 | "sha256:a89aa0c042e61e11ade320b802d6db4ee5391d8d973e46d3a48172c1597789f8", 140 | "sha256:c0f80876a8ff0ae7064084ed094eb86497bd5a3812e6fc96a05318b92301674e", 141 | "sha256:c44e3282cff74ad18c7e8a0375f407f69ee50c2116364b44492a196293e08b21", 142 | "sha256:d249519ba5ecf5dd6b18150c9b6bcde510b273714b696f3923ff8308fc11ae49", 143 | "sha256:d3973a2d58aefc7d1230725c2447ce7f86a71cbc094b86a77c6ee1505ac7cdb1", 144 | "sha256:dca2284378a5f2a86ffed35c6ac147d14c48b525eefcd1083e5a9ce28dfa8657", 145 | "sha256:e63b0b3c42e51c94add62b010366cd4979cb6d5f06158bcae8faac4c294f91e1", 146 | "sha256:f2b599a21c9a32e171ec29a2ac54e03297736c578698e11b099d031f79da114b", 147 | "sha256:f2bde37667b18c2b5280df83bc799204394a5d2d774e4deaf9de0eb741df6833", 148 | "sha256:f4f909f4dde413dec435a44b0894956d55bb928ded7d6e3c726556ca4c796e84", 149 | "sha256:f976234e20ab2785f54224bcdafa027674e23663b132fa3ca0caa291a6cfbde7", 150 | "sha256:f9cebda093c2f6bfed88f1c17cdade09d4d96096421b344026feee236532d4de" 151 | ], 152 | "index": "pypi", 153 | "version": "==3.19.5" 154 | }, 155 | "psutil": { 156 | "hashes": [ 157 | "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64", 158 | "sha256:02b8292609b1f7fcb34173b25e48d0da8667bc85f81d7476584d889c6e0f2131", 159 | "sha256:0ae6f386d8d297177fd288be6e8d1afc05966878704dad9847719650e44fc49c", 160 | "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6", 161 | "sha256:0dd4465a039d343925cdc29023bb6960ccf4e74a65ad53e768403746a9207023", 162 | "sha256:12d844996d6c2b1d3881cfa6fa201fd635971869a9da945cf6756105af73d2df", 163 | "sha256:1bff0d07e76114ec24ee32e7f7f8d0c4b0514b3fae93e3d2aaafd65d22502394", 164 | "sha256:245b5509968ac0bd179287d91210cd3f37add77dad385ef238b275bad35fa1c4", 165 | "sha256:28ff7c95293ae74bf1ca1a79e8805fcde005c18a122ca983abf676ea3466362b", 166 | "sha256:36b3b6c9e2a34b7d7fbae330a85bf72c30b1c827a4366a07443fc4b6270449e2", 167 | "sha256:52de075468cd394ac98c66f9ca33b2f54ae1d9bff1ef6b67a212ee8f639ec06d", 168 | "sha256:5da29e394bdedd9144c7331192e20c1f79283fb03b06e6abd3a8ae45ffecee65", 169 | "sha256:61f05864b42fedc0771d6d8e49c35f07efd209ade09a5afe6a5059e7bb7bf83d", 170 | "sha256:6223d07a1ae93f86451d0198a0c361032c4c93ebd4bf6d25e2fb3edfad9571ef", 171 | "sha256:6323d5d845c2785efb20aded4726636546b26d3b577aded22492908f7c1bdda7", 172 | "sha256:6ffe81843131ee0ffa02c317186ed1e759a145267d54fdef1bc4ea5f5931ab60", 173 | "sha256:74f2d0be88db96ada78756cb3a3e1b107ce8ab79f65aa885f76d7664e56928f6", 174 | "sha256:74fb2557d1430fff18ff0d72613c5ca30c45cdbfcddd6a5773e9fc1fe9364be8", 175 | "sha256:90d4091c2d30ddd0a03e0b97e6a33a48628469b99585e2ad6bf21f17423b112b", 176 | "sha256:90f31c34d25b1b3ed6c40cdd34ff122b1887a825297c017e4cbd6796dd8b672d", 177 | "sha256:99de3e8739258b3c3e8669cb9757c9a861b2a25ad0955f8e53ac662d66de61ac", 178 | "sha256:c6a5fd10ce6b6344e616cf01cc5b849fa8103fbb5ba507b6b2dee4c11e84c935", 179 | "sha256:ce8b867423291cb65cfc6d9c4955ee9bfc1e21fe03bb50e177f2b957f1c2469d", 180 | "sha256:d225cd8319aa1d3c85bf195c4e07d17d3cd68636b8fc97e6cf198f782f99af28", 181 | "sha256:ea313bb02e5e25224e518e4352af4bf5e062755160f77e4b1767dd5ccb65f876", 182 | "sha256:ea372bcc129394485824ae3e3ddabe67dc0b118d262c568b4d2602a7070afdb0", 183 | "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3", 184 | "sha256:fcc01e900c1d7bee2a37e5d6e4f9194760a93597c97fee89c4ae51701de03563" 185 | ], 186 | "index": "pypi", 187 | "version": "==5.8.0" 188 | }, 189 | "pyasn1": { 190 | "hashes": [ 191 | "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", 192 | "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", 193 | "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", 194 | "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", 195 | "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", 196 | "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", 197 | "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", 198 | "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", 199 | "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", 200 | "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", 201 | "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", 202 | "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", 203 | "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" 204 | ], 205 | "version": "==0.4.8" 206 | }, 207 | "pyasn1-modules": { 208 | "hashes": [ 209 | "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", 210 | "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", 211 | "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", 212 | "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", 213 | "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", 214 | "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", 215 | "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", 216 | "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", 217 | "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", 218 | "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", 219 | "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", 220 | "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", 221 | "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" 222 | ], 223 | "version": "==0.2.8" 224 | }, 225 | "pymongo": { 226 | "hashes": [ 227 | "sha256:02e0c088f189ca69fac094cb5f851b43bbbd7cec42114495777d4d8f297f7f8a", 228 | "sha256:138248c542051eb462f88b50b0267bd5286d6661064bab06faa0ef6ac30cdb4b", 229 | "sha256:13a7c6d055af58a1e9c505e736da8b6a2e95ccc8cec10b008143f7a536e5de8a", 230 | "sha256:13d74bf3435c1e58d8fafccc0d5e87f246ae2c6e9cbef4b35e32a1c3759e354f", 231 | "sha256:15dae01341571d0af51526b7a21648ca575e9375e16ba045c9860848dfa8952f", 232 | "sha256:17238115e6d37f5423b046cb829f1ca02c4ea7edb163f5b8b88e0c975dc3fec9", 233 | "sha256:180b405e17b90a877ea5dbc5efe7f4c171af4c89323148e100c0f12cedb86f12", 234 | "sha256:1821ce4e5a293313947fd017bbd2d2535aa6309680fa29b33d0442d15da296ec", 235 | "sha256:1a7b138a04fdd17849930dc8bf664002e17db38448850bfb96d200c9c5a8b3a1", 236 | "sha256:1c4e51a3b69789b6f468a8e881a13f2d1e8f5e99e41f80fd44845e6ec0f701e1", 237 | "sha256:1d55982e5335925c55e2b87467043866ce72bd30ea7e7e3eeed6ec3d95a806d4", 238 | "sha256:1fa6f08ddb6975371777f97592d35c771e713ee2250e55618148a5e57e260aff", 239 | "sha256:2174d3279b8e2b6d7613b338f684cd78ff7adf1e7ec5b7b7bde5609a129c9898", 240 | "sha256:2462a68f6675da548e333fa299d8e9807e00f95a4d198cfe9194d7be69f40c9b", 241 | "sha256:25fd76deabe9ea37c8360c362b32f702cc095a208dd1c5328189938ca7685847", 242 | "sha256:287c2a0063267c1458c4ddf528b44063ce7f376a6436eea5bccd7f625bbc3b5e", 243 | "sha256:2d3abe548a280b49269c7907d5b71199882510c484d680a5ea7860f30c4a695f", 244 | "sha256:2fa101bb23619120673899694a65b094364269e597f551a87c4bdae3a474d726", 245 | "sha256:2fda3b3fb5c0d159195ab834b322a23808f1b059bcc7e475765abeddee6a2529", 246 | "sha256:303531649fa45f96b694054c1aa02f79bda32ef57affe42c5c339336717eed74", 247 | "sha256:36806ee53a85c3ba73939652f2ced2961e6a77cfbae385cd83f2e24cd97964b7", 248 | "sha256:37a63da5ee623acdf98e6d511171c8a5827a6106b0712c18af4441ef4f11e6be", 249 | "sha256:3a2fcbd04273a509fa85285d9eccf17ab65ce440bd4f5e5a58c978e563cd9e9a", 250 | "sha256:3b40e36d3036bfe69ba63ec8e746a390721f75467085a0384b528e1dda532c69", 251 | "sha256:4168b6c425d783e81723fc3dc382d374a228ff29530436a472a36d9f27593e73", 252 | "sha256:444c00ebc20f2f9dc62e34f7dc9453dc2f5f5a72419c8dccad6e26d546c35712", 253 | "sha256:45d6b47d70ed44e3c40bef618ed61866c48176e7e5dff80d06d8b1a6192e8584", 254 | "sha256:460bdaa3f65ddb5b7474ae08589a1763b5da1a78b8348351b9ba1c63b459d67d", 255 | "sha256:47ed77f62c8417a86f9ad158b803f3459a636386cb9d3d4e9e7d6a82d051f907", 256 | "sha256:48722e91981bb22a16b0431ea01da3e1cc5b96805634d3b8d3c2a5315c1ce7f1", 257 | "sha256:49b0d92724d3fce1174fd30b0b428595072d5c6b14d6203e46a9ea347ae7b439", 258 | "sha256:4a2d73a9281faefb273a5448f6d25f44ebd311ada9eb79b6801ae890508fe231", 259 | "sha256:4f4bc64fe9cbd70d46f519f1e88c9e4677f7af18ab9cd4942abce2bcfa7549c3", 260 | "sha256:5067c04d3b19c820faac6342854d887ade58e8d38c3db79b68c2a102bbb100e7", 261 | "sha256:51437c77030bed72d57d8a61e22758e3c389b13fea7787c808030002bb05ca39", 262 | "sha256:515e4708d6567901ffc06476a38abe2c9093733f52638235d9f149579c1d3de0", 263 | "sha256:5183b698d6542219e4135de583b57bc6286bd37df7f645b688278eb919bfa785", 264 | "sha256:56feb80ea1f5334ccab9bd16a5161571ab70392e51fcc752fb8a1dc67125f663", 265 | "sha256:573e2387d0686976642142c50740dfc4d3494cc627e2a7d22782b99f70879055", 266 | "sha256:58a67b3800476232f9989e533d0244060309451b436d46670a53e6d189f1a7e7", 267 | "sha256:5e3833c001a04aa06a28c6fd9628256862a654c09b0f81c07734b5629bc014ab", 268 | "sha256:5f5fe59328838fa28958cc06ecf94be585726b97d637012f168bc3c7abe4fd81", 269 | "sha256:6235bf2157aa46e53568ed79b70603aa8874baa202d5d1de82fa0eb917696e73", 270 | "sha256:63be03f7ae1e15e72a234637ec7941ef229c7ab252c9ff6af48bba1e5418961c", 271 | "sha256:65f159c445761cab04b665fc448b3fc008aebc98e54fdcbfd1aff195ef1b1408", 272 | "sha256:67e0b2ad3692f6d0335ae231a40de55ec395b6c2e971ad6f55b162244d1ec542", 273 | "sha256:68409171ab2aa7ccd6e8e839233e4b8ddeec246383c9a3698614e814739356f9", 274 | "sha256:6a96c04ce39d66df60d9ce89f4c254c4967bc7d9e2e2c52adc58f47be826ee96", 275 | "sha256:6ead0126fb4424c6c6a4fdc603d699a9db7c03cdb8eac374c352a75fec8a820a", 276 | "sha256:6eb6789f26c398c383225e1313c8e75a7d290d323b8eaf65f3f3ddd0eb8a5a3c", 277 | "sha256:6f07888e3b73c0dfa46f12d098760494f5f23fd66923a6615edfe486e6a7649c", 278 | "sha256:6f0f0a10f128ea0898e607d351ebfabf70941494fc94e87f12c76e2894d8e6c4", 279 | "sha256:704879b6a54c45ad76cea7c6789c1ae7185050acea7afd15b58318fa1932ed45", 280 | "sha256:7117bfd8827cfe550f65a3c399dcd6e02226197a91c6d11a3540c3e8efc686d6", 281 | "sha256:712de1876608fd5d76abc3fc8ec55077278dd5044073fbe9492631c9a2c58351", 282 | "sha256:75c7ef67b4b8ec070e7a4740764f6c03ec9246b59d95e2ae45c029d41cb9efa1", 283 | "sha256:77dddf596fb065de29fb39992fbc81301f7fd0003be649b7fa7448c77ca53bed", 284 | "sha256:7abc87e45b572eb6d17a50422e69a9e5d6f13e691e821fe2312df512500faa50", 285 | "sha256:7d8cdd2f070c71366e64990653522cce84b08dc26ab0d1fa19aa8d14ee0cf9ba", 286 | "sha256:81ce5f871f5d8e82615c8bd0b34b68a9650204c8b1a04ce7890d58c98eb66e39", 287 | "sha256:837cdef094f39c6f4a2967abc646a412999c2540fbf5d3cce1dd3b671f4b876c", 288 | "sha256:849e641cfed05c75d772f9e9018f42c5fbd00655d43d52da1b9c56346fd3e4cc", 289 | "sha256:87114b995506e7584cf3daf891e419b5f6e7e383e7df6267494da3a76312aa22", 290 | "sha256:87db421c9eb915b8d9a9a13c5b2ee338350e36ee83e26ff0adfc48abc5db3ac3", 291 | "sha256:8851544168703fb519e95556e3b463fca4beeef7ed3f731d81a68c8268515d9d", 292 | "sha256:891f541c7ed29b95799da0cd249ae1db1842777b564e8205a197b038c5df6135", 293 | "sha256:8f87f53c9cd89010ae45490ec2c963ff18b31f5f290dc08b04151709589fe8d9", 294 | "sha256:9641be893ccce7d192a0094efd0a0d9f1783a1ebf314b4128f8a27bfadb8a77c", 295 | "sha256:979e34db4f3dc5710c18db437aaf282f691092b352e708cb2afd4df287698c76", 296 | "sha256:9b62d84478f471fdb0dcea3876acff38f146bd23cbdbed15074fb4622064ec2e", 297 | "sha256:a472ca3d43d33e596ff5836c6cc71c3e61be33f44fe1cfdab4a1100f4af60333", 298 | "sha256:a5dbeeea6a375fbd79448b48a54c46fc9351611a03ef8398d2a40b684ce46194", 299 | "sha256:a7430f3987d232e782304c109be1d0e6fff46ca6405cb2479e4d8d08cd29541e", 300 | "sha256:a81e52dbf95f236a0c89a5abcd2b6e1331da0c0312f471c73fae76c79d2acf6b", 301 | "sha256:aa434534cc91f51a85e3099dc257ee8034b3d2be77f2ca58fb335a686e3a681f", 302 | "sha256:ab27d6d7d41a66d9e54269a290d27cd5c74f08e9add0054a754b4821026c4f42", 303 | "sha256:adb37bf22d25a51b84d989a2a5c770d4514ac590201eea1cb50ce8c9c5257f1d", 304 | "sha256:afb16330ab6efbbf995375ad94e970fa2f89bb46bd10d854b7047620fdb0d67d", 305 | "sha256:b1b06038c9940a49c73db0aeb0f6809b308e198da1326171768cf68d843af521", 306 | "sha256:b1e6d1cf4bd6552b5f519432cce1530c09e6b0aab98d44803b991f7e880bd332", 307 | "sha256:bf2d9d62178bb5c05e77d40becf89c309b1966fbcfb5c306238f81bf1ec2d6a2", 308 | "sha256:bfd073fea04061019a103a288847846b5ef40dfa2f73b940ed61e399ca95314f", 309 | "sha256:c04e84ccf590933a266180286d8b6a5fc844078a5d934432628301bd8b5f9ca7", 310 | "sha256:c0947d7be30335cb4c3d5d0983d8ebc8294ae52503cf1d596c926f7e7183900b", 311 | "sha256:c2a17752f97a942bdb4ff4a0516a67c5ade1658ebe1ab2edacdec0b42e39fa75", 312 | "sha256:c4653830375ab019b86d218c749ad38908b74182b2863d09936aa8d7f990d30e", 313 | "sha256:c660fd1e4a4b52f79f7d134a3d31d452948477b7f46ff5061074a534c5805ba6", 314 | "sha256:cb48ff6cc6109190e1ccf8ea1fc71cc244c9185813ce7d1c415dce991cfb8709", 315 | "sha256:cef2675004d85d85a4ccc24730b73a99931547368d18ceeed1259a2d9fcddbc1", 316 | "sha256:d1b98539b0de822b6f717498e59ae3e5ae2e7f564370ab513e6d0c060753e447", 317 | "sha256:d6c6989c10008ac70c2bb2ad2b940fcfe883712746c89f7e3308c14c213a70d7", 318 | "sha256:db3efec9dcecd96555d752215797816da40315d61878f90ca39c8e269791bf17", 319 | "sha256:dc4749c230a71b34db50ac2481d9008bb17b67c92671c443c3b40e192fbea78e", 320 | "sha256:dcf906c1f7a33e4222e4bff18da1554d69323bc4dd95fe867a6fa80709ee5f93", 321 | "sha256:e2bccadbe313b11704160aaba5eec95d2da1aa663f02f41d2d1520d02bbbdcd5", 322 | "sha256:e30cce3cc86d6082c8596b3fbee0d4f54bc4d337a4fa1bf536920e2e319e24f0", 323 | "sha256:e5d6428b8b422ba5205140e8be11722fa7292a0bedaa8bc80fb34c92eb19ba45", 324 | "sha256:e841695b5dbea38909ab2dbf17e91e9a823412d8d88d1ef77f1b94a7bc551c0f", 325 | "sha256:eb65ec0255a0fccc47c87d44e505ef5180bfd71690bd5f84161b1f23949fb209", 326 | "sha256:ed20ec5a01c43254f6047c5d8124b70d28e39f128c8ad960b437644fe94e1827", 327 | "sha256:ed751a20840a31242e7bea566fcf93ba75bc11b33afe2777bbf46069c1af5094", 328 | "sha256:ef8b927813c27c3bdfc82c55682d7767403bcdadfd9f9c0fc49f4be4553a877b", 329 | "sha256:f43cacda46fc188f998e6d308afe1c61ff41dcb300949f4cbf731e9a0a5eb2d3", 330 | "sha256:f44bea60fd2178d7153deef9621c4b526a93939da30010bba24d3408a98b0f79", 331 | "sha256:fcc021530b7c71069132fe4846d95a3cdd74d143adc2f7e398d5fabf610f111c", 332 | "sha256:fe16517b275031d61261a4e3941c411fb7c46a9cd012f02381b56e7907cc9e06", 333 | "sha256:fe3ae4294d593da54862f0140fdcc89d1aeeb94258ca97f094119ed7f0e5882d" 334 | ], 335 | "index": "pypi", 336 | "version": "==3.12.1" 337 | }, 338 | "pyparsing": { 339 | "hashes": [ 340 | "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", 341 | "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" 342 | ], 343 | "markers": "python_version >= '3.1'", 344 | "version": "==3.0.9" 345 | }, 346 | "python-dotenv": { 347 | "hashes": [ 348 | "sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8", 349 | "sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a" 350 | ], 351 | "index": "pypi", 352 | "version": "==0.19.1" 353 | }, 354 | "pytz": { 355 | "hashes": [ 356 | "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c", 357 | "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326" 358 | ], 359 | "index": "pypi", 360 | "version": "==2021.3" 361 | }, 362 | "requests": { 363 | "hashes": [ 364 | "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", 365 | "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" 366 | ], 367 | "index": "pypi", 368 | "version": "==2.26.0" 369 | }, 370 | "requests-oauthlib": { 371 | "hashes": [ 372 | "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", 373 | "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a" 374 | ], 375 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 376 | "version": "==1.3.1" 377 | }, 378 | "rsa": { 379 | "hashes": [ 380 | "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", 381 | "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21" 382 | ], 383 | "markers": "python_version >= '3.6'", 384 | "version": "==4.9" 385 | }, 386 | "six": { 387 | "hashes": [ 388 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 389 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 390 | ], 391 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 392 | "version": "==1.16.0" 393 | }, 394 | "uritemplate": { 395 | "hashes": [ 396 | "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0", 397 | "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e" 398 | ], 399 | "markers": "python_version >= '3.6'", 400 | "version": "==4.1.1" 401 | }, 402 | "urllib3": { 403 | "hashes": [ 404 | "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e", 405 | "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997" 406 | ], 407 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' and python_version < '4'", 408 | "version": "==1.26.12" 409 | } 410 | }, 411 | "develop": { 412 | "astroid": { 413 | "hashes": [ 414 | "sha256:5f6f75e45f15290e73b56f9dfde95b4bf96382284cde406ef4203e928335a495", 415 | "sha256:cd8326b424c971e7d87678609cf6275d22028afd37d6ac59c16d47f1245882f6" 416 | ], 417 | "markers": "python_version ~= '3.6'", 418 | "version": "==2.8.6" 419 | }, 420 | "autopep8": { 421 | "hashes": [ 422 | "sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979", 423 | "sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f" 424 | ], 425 | "index": "pypi", 426 | "version": "==1.6.0" 427 | }, 428 | "isort": { 429 | "hashes": [ 430 | "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7", 431 | "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951" 432 | ], 433 | "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", 434 | "version": "==5.10.1" 435 | }, 436 | "lazy-object-proxy": { 437 | "hashes": [ 438 | "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7", 439 | "sha256:07fa44286cda977bd4803b656ffc1c9b7e3bc7dff7d34263446aec8f8c96f88a", 440 | "sha256:12f3bb77efe1367b2515f8cb4790a11cffae889148ad33adad07b9b55e0ab22c", 441 | "sha256:2052837718516a94940867e16b1bb10edb069ab475c3ad84fd1e1a6dd2c0fcfc", 442 | "sha256:2130db8ed69a48a3440103d4a520b89d8a9405f1b06e2cc81640509e8bf6548f", 443 | "sha256:39b0e26725c5023757fc1ab2a89ef9d7ab23b84f9251e28f9cc114d5b59c1b09", 444 | "sha256:46ff647e76f106bb444b4533bb4153c7370cdf52efc62ccfc1a28bdb3cc95442", 445 | "sha256:4dca6244e4121c74cc20542c2ca39e5c4a5027c81d112bfb893cf0790f96f57e", 446 | "sha256:553b0f0d8dbf21890dd66edd771f9b1b5f51bd912fa5f26de4449bfc5af5e029", 447 | "sha256:677ea950bef409b47e51e733283544ac3d660b709cfce7b187f5ace137960d61", 448 | "sha256:6a24357267aa976abab660b1d47a34aaf07259a0c3859a34e536f1ee6e76b5bb", 449 | "sha256:6a6e94c7b02641d1311228a102607ecd576f70734dc3d5e22610111aeacba8a0", 450 | "sha256:6aff3fe5de0831867092e017cf67e2750c6a1c7d88d84d2481bd84a2e019ec35", 451 | "sha256:6ecbb350991d6434e1388bee761ece3260e5228952b1f0c46ffc800eb313ff42", 452 | "sha256:7096a5e0c1115ec82641afbdd70451a144558ea5cf564a896294e346eb611be1", 453 | "sha256:70ed0c2b380eb6248abdef3cd425fc52f0abd92d2b07ce26359fcbc399f636ad", 454 | "sha256:8561da8b3dd22d696244d6d0d5330618c993a215070f473b699e00cf1f3f6443", 455 | "sha256:85b232e791f2229a4f55840ed54706110c80c0a210d076eee093f2b2e33e1bfd", 456 | "sha256:898322f8d078f2654d275124a8dd19b079080ae977033b713f677afcfc88e2b9", 457 | "sha256:8f3953eb575b45480db6568306893f0bd9d8dfeeebd46812aa09ca9579595148", 458 | "sha256:91ba172fc5b03978764d1df5144b4ba4ab13290d7bab7a50f12d8117f8630c38", 459 | "sha256:9d166602b525bf54ac994cf833c385bfcc341b364e3ee71e3bf5a1336e677b55", 460 | "sha256:a57d51ed2997e97f3b8e3500c984db50a554bb5db56c50b5dab1b41339b37e36", 461 | "sha256:b9e89b87c707dd769c4ea91f7a31538888aad05c116a59820f28d59b3ebfe25a", 462 | "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b", 463 | "sha256:c19814163728941bb871240d45c4c30d33b8a2e85972c44d4e63dd7107faba44", 464 | "sha256:c4ce15276a1a14549d7e81c243b887293904ad2d94ad767f42df91e75fd7b5b6", 465 | "sha256:c7a683c37a8a24f6428c28c561c80d5f4fd316ddcf0c7cab999b15ab3f5c5c69", 466 | "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4", 467 | "sha256:d66906d5785da8e0be7360912e99c9188b70f52c422f9fc18223347235691a84", 468 | "sha256:dd7ed7429dbb6c494aa9bc4e09d94b778a3579be699f9d67da7e6804c422d3de", 469 | "sha256:df2631f9d67259dc9620d831384ed7732a198eb434eadf69aea95ad18c587a28", 470 | "sha256:e368b7f7eac182a59ff1f81d5f3802161932a41dc1b1cc45c1f757dc876b5d2c", 471 | "sha256:e40f2013d96d30217a51eeb1db28c9ac41e9d0ee915ef9d00da639c5b63f01a1", 472 | "sha256:f769457a639403073968d118bc70110e7dce294688009f5c24ab78800ae56dc8", 473 | "sha256:fccdf7c2c5821a8cbd0a9440a456f5050492f2270bd54e94360cac663398739b", 474 | "sha256:fd45683c3caddf83abbb1249b653a266e7069a09f486daa8863fb0e7496a9fdb" 475 | ], 476 | "markers": "python_version >= '3.6'", 477 | "version": "==1.7.1" 478 | }, 479 | "mccabe": { 480 | "hashes": [ 481 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 482 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 483 | ], 484 | "version": "==0.6.1" 485 | }, 486 | "platformdirs": { 487 | "hashes": [ 488 | "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788", 489 | "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19" 490 | ], 491 | "markers": "python_version >= '3.7'", 492 | "version": "==2.5.2" 493 | }, 494 | "pycodestyle": { 495 | "hashes": [ 496 | "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785", 497 | "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b" 498 | ], 499 | "markers": "python_version >= '3.6'", 500 | "version": "==2.9.1" 501 | }, 502 | "pylint": { 503 | "hashes": [ 504 | "sha256:0f358e221c45cbd4dad2a1e4b883e75d28acdcccd29d40c76eb72b307269b126", 505 | "sha256:2c9843fff1a88ca0ad98a256806c82c5a8f86086e7ccbdb93297d86c3f90c436" 506 | ], 507 | "index": "pypi", 508 | "version": "==2.11.1" 509 | }, 510 | "setuptools": { 511 | "hashes": [ 512 | "sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82", 513 | "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57" 514 | ], 515 | "markers": "python_version >= '3.7'", 516 | "version": "==65.3.0" 517 | }, 518 | "toml": { 519 | "hashes": [ 520 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 521 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 522 | ], 523 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 524 | "version": "==0.10.2" 525 | }, 526 | "typing-extensions": { 527 | "hashes": [ 528 | "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", 529 | "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" 530 | ], 531 | "markers": "python_version < '3.10'", 532 | "version": "==4.3.0" 533 | }, 534 | "wrapt": { 535 | "hashes": [ 536 | "sha256:086218a72ec7d986a3eddb7707c8c4526d677c7b35e355875a0fe2918b059179", 537 | "sha256:0877fe981fd76b183711d767500e6b3111378ed2043c145e21816ee589d91096", 538 | "sha256:0a017a667d1f7411816e4bf214646d0ad5b1da2c1ea13dec6c162736ff25a374", 539 | "sha256:0cb23d36ed03bf46b894cfec777eec754146d68429c30431c99ef28482b5c1df", 540 | "sha256:1fea9cd438686e6682271d36f3481a9f3636195578bab9ca3382e2f5f01fc185", 541 | "sha256:220a869982ea9023e163ba915077816ca439489de6d2c09089b219f4e11b6785", 542 | "sha256:25b1b1d5df495d82be1c9d2fad408f7ce5ca8a38085e2da41bb63c914baadff7", 543 | "sha256:2dded5496e8f1592ec27079b28b6ad2a1ef0b9296d270f77b8e4a3a796cf6909", 544 | "sha256:2ebdde19cd3c8cdf8df3fc165bc7827334bc4e353465048b36f7deeae8ee0918", 545 | "sha256:43e69ffe47e3609a6aec0fe723001c60c65305784d964f5007d5b4fb1bc6bf33", 546 | "sha256:46f7f3af321a573fc0c3586612db4decb7eb37172af1bc6173d81f5b66c2e068", 547 | "sha256:47f0a183743e7f71f29e4e21574ad3fa95676136f45b91afcf83f6a050914829", 548 | "sha256:498e6217523111d07cd67e87a791f5e9ee769f9241fcf8a379696e25806965af", 549 | "sha256:4b9c458732450ec42578b5642ac53e312092acf8c0bfce140ada5ca1ac556f79", 550 | "sha256:51799ca950cfee9396a87f4a1240622ac38973b6df5ef7a41e7f0b98797099ce", 551 | "sha256:5601f44a0f38fed36cc07db004f0eedeaadbdcec90e4e90509480e7e6060a5bc", 552 | "sha256:5f223101f21cfd41deec8ce3889dc59f88a59b409db028c469c9b20cfeefbe36", 553 | "sha256:610f5f83dd1e0ad40254c306f4764fcdc846641f120c3cf424ff57a19d5f7ade", 554 | "sha256:6a03d9917aee887690aa3f1747ce634e610f6db6f6b332b35c2dd89412912bca", 555 | "sha256:705e2af1f7be4707e49ced9153f8d72131090e52be9278b5dbb1498c749a1e32", 556 | "sha256:766b32c762e07e26f50d8a3468e3b4228b3736c805018e4b0ec8cc01ecd88125", 557 | "sha256:77416e6b17926d953b5c666a3cb718d5945df63ecf922af0ee576206d7033b5e", 558 | "sha256:778fd096ee96890c10ce96187c76b3e99b2da44e08c9e24d5652f356873f6709", 559 | "sha256:78dea98c81915bbf510eb6a3c9c24915e4660302937b9ae05a0947164248020f", 560 | "sha256:7dd215e4e8514004c8d810a73e342c536547038fb130205ec4bba9f5de35d45b", 561 | "sha256:7dde79d007cd6dfa65afe404766057c2409316135cb892be4b1c768e3f3a11cb", 562 | "sha256:81bd7c90d28a4b2e1df135bfbd7c23aee3050078ca6441bead44c42483f9ebfb", 563 | "sha256:85148f4225287b6a0665eef08a178c15097366d46b210574a658c1ff5b377489", 564 | "sha256:865c0b50003616f05858b22174c40ffc27a38e67359fa1495605f96125f76640", 565 | "sha256:87883690cae293541e08ba2da22cacaae0a092e0ed56bbba8d018cc486fbafbb", 566 | "sha256:8aab36778fa9bba1a8f06a4919556f9f8c7b33102bd71b3ab307bb3fecb21851", 567 | "sha256:8c73c1a2ec7c98d7eaded149f6d225a692caa1bd7b2401a14125446e9e90410d", 568 | "sha256:936503cb0a6ed28dbfa87e8fcd0a56458822144e9d11a49ccee6d9a8adb2ac44", 569 | "sha256:944b180f61f5e36c0634d3202ba8509b986b5fbaf57db3e94df11abee244ba13", 570 | "sha256:96b81ae75591a795d8c90edc0bfaab44d3d41ffc1aae4d994c5aa21d9b8e19a2", 571 | "sha256:981da26722bebb9247a0601e2922cedf8bb7a600e89c852d063313102de6f2cb", 572 | "sha256:ae9de71eb60940e58207f8e71fe113c639da42adb02fb2bcbcaccc1ccecd092b", 573 | "sha256:b73d4b78807bd299b38e4598b8e7bd34ed55d480160d2e7fdaabd9931afa65f9", 574 | "sha256:d4a5f6146cfa5c7ba0134249665acd322a70d1ea61732723c7d3e8cc0fa80755", 575 | "sha256:dd91006848eb55af2159375134d724032a2d1d13bcc6f81cd8d3ed9f2b8e846c", 576 | "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a", 577 | "sha256:e6906d6f48437dfd80464f7d7af1740eadc572b9f7a4301e7dd3d65db285cacf", 578 | "sha256:e92d0d4fa68ea0c02d39f1e2f9cb5bc4b4a71e8c442207433d8db47ee79d7aa3", 579 | "sha256:e94b7d9deaa4cc7bac9198a58a7240aaf87fe56c6277ee25fa5b3aa1edebd229", 580 | "sha256:ea3e746e29d4000cd98d572f3ee2a6050a4f784bb536f4ac1f035987fc1ed83e", 581 | "sha256:ec7e20258ecc5174029a0f391e1b948bf2906cd64c198a9b8b281b811cbc04de", 582 | "sha256:ec9465dd69d5657b5d2fa6133b3e1e989ae27d29471a672416fd729b429eb554", 583 | "sha256:f122ccd12fdc69628786d0c947bdd9cb2733be8f800d88b5a37c57f1f1d73c10", 584 | "sha256:f99c0489258086308aad4ae57da9e8ecf9e1f3f30fa35d5e170b4d4896554d80", 585 | "sha256:f9c51d9af9abb899bd34ace878fbec8bf357b3194a10c4e8e0a25512826ef056", 586 | "sha256:fd76c47f20984b43d93de9a82011bb6e5f8325df6c9ed4d8310029a55fa361ea" 587 | ], 588 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 589 | "version": "==1.13.3" 590 | } 591 | } 592 | } 593 | --------------------------------------------------------------------------------