├── 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: 
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 | - 
124 |
125 | ***
126 |
127 | - This is how the scanner should look for the exact same entry strategy.
128 |
129 | - 
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 | - 
138 | - Set Event dropdown to "A symbol is added"
139 |
140 | - 
141 | - Check the box that says "Send an e-mail to all specified e-mail addresses"
142 |
143 | - 
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 | 
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 |
--------------------------------------------------------------------------------