diff --git a/.env.example b/.env.example index 33d2b99d..47bfc5af 100644 --- a/.env.example +++ b/.env.example @@ -15,12 +15,6 @@ DANGER_MODE="false" # at the same brokerage with a comma, then separate account credentials with a colon # BROKER=BROKER_USERNAME:BROKER_PASSWORD,OTHER_BROKER_USERNAME:OTHER_BROKER_PASSWORD -# Ally (CURRENTLY DISABLED) -# ALLY=ALLY_CONSUMER_KEY:ALLY_CONSUMER_SECRET:ALLY_OAUTH_TOKEN:ALLY_OAUTH_SECRET -# ALLY_ACCOUNT_NUMBERS=ALLY_ACCOUNT_NUMBER_1:ALLY_ACCOUNT_NUMBER_2 -ALLY= -ALLY_ACCOUNT_NUMBERS= - # Fidelity # FIDELITY=FIDELITY_USERNAME:FIDELITY_PASSWORD FIDELITY= diff --git a/Dockerfile b/Dockerfile index ae0c35db..bbea096d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,6 @@ RUN playwright install && \ # Grab needed files COPY ./autoRSA.py . -COPY ./allyAPI.py . COPY ./fidelityAPI.py . COPY ./robinhoodAPI.py . COPY ./schwabAPI.py . diff --git a/README.md b/README.md index 2d32cbdd..fab6f6f8 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,39 @@ -# AutoRSA Discord Bot and CLI Tool -A CLI tool and Discord bot to buy and sell the same amount of stocks across multiple accounts! +# ✨ AutoRSA ✨ +## Discord Bot and CLI Tool +A CLI tool and Discord bot to buy, sell, and monitor holdings across multiple accounts! -## What is RSA? -RSA stands for "Reverse Split Arbitrage." This is a strategy where you buy the same amount of stocks in multiple accounts across multiple brokers right before a stock performs a reverse split. Once the stock splits and your fractional share is rounded up to a full share, you profit! + + + + -This project will allow you to maximize your profits by being able to easily manage multiple accounts across different brokerages, buying and selling as needed. +## ❓ What is RSA? ❓ +You already know what Reverse Split Arbitrage is, that's not why you're here. If you do know what it is, then you know why a tool like this would be valuable. If you're a big player, even more so... -## Discord Bot Installation +## πŸ€” How Does It Work? πŸ€” +This program uses APIs to interface with your brokerages. When available, official APIs are always used. If an official API is not available, then a third-party API is used. As a last resort, Selenium or Playwright Stealth are used to automate the browser. + +## πŸ€– Discord Bot Installation πŸ€– To create your Discord bot and get your `DISCORD_TOKEN`, follow this [guide](guides/discordBot.md). -### Docker +### 🐳 Docker 🐳 1. Create a `.env` file for your brokerage variables using [.env.example](.env.example) as a template, and add your bot using `DISCORD_TOKEN` and `DISCORD_CHANNEL` 2. Using the provided [docker-compose.yml](docker-compose.yml) file, run the command `docker compose up -d` inside the project directory. 3. The bot should appear online (You can also do `!ping` to check). -### Always Running Python Script +### πŸƒβ€β™‚οΈ Always Running Python Script πŸƒβ€β™€οΈ Make sure python3-pip is installed 1. Clone this repository and cd into it 2. Run `pip install -r requirements.txt` 3. Create a `.env` file for your brokerage variables using [.env.example](.env.example) as a template, and add your bot using `DISCORD_TOKEN` and `DISCORD_CHANNEL` 4. Run `python autoRSA.py` (See below for more command explanations) -## CLI Tool Installation +## πŸ’» CLI Tool Installation πŸ’» 1. Clone this repository and cd into it 2. Run `pip install -r requirements.txt` 3. Create a `.env` file for your brokerage variables using [.env.example](.env.example) as a template. 4. Run the script using `python autoRSA.py` plus the command you want to run (See below for more command explanations) -## Usage +## πŸ‘€ Usage πŸ‘€ If running as a Discord bot, append `!rsa` to the beginning of each command. If running from the CLI Tool, append `python autoRSA.py` to the beginning of each command. @@ -34,21 +41,21 @@ To buy and sell stocks, use this command: ` ` -For example, to buy 1 STAF in all accounts: +For example, to buy 1 AAPL in all accounts: -`buy 1 STAF all false` +`buy 1 AAPL all false` For a dry run of the above command in Robinhood only: -`buy 1 STAF robinhood true` +`buy 1 AAPL robinhood true` -For a real run on Ally and Robinhood, but not Schwab: +For a real run on Fidelity and Robinhood, but not Schwab: -`buy 1 STAF ally,robinhood not schwab false` +`buy 1 AAPL fidelity,robinhood not schwab false` -For a real run on Ally and Robinhood but not Schwab buying both STAF and AREB: +For a real run on Fidelity and Robinhood but not Schwab buying both AAPL and GOOG: -`buy 1 STAF,AREB ally,robinhood not schwab false` +`buy 1 AAPL,GOOG fidelity,robinhood not schwab false` To check your account holdings: @@ -62,7 +69,9 @@ For help: `!help` (without appending `!rsa`) -### Parameters +Note: There are two special keywords you can use when specifying accounts: `all` and `day1`. `all` will use every account that you have set up. `day1` will use "day 1" brokers, which are Robinhood, Schwab, Tastytrade, and Tradier. This is useful for brokers that provide quick turnaround times, hence the nickname "day 1". + +### βš™οΈ Parameters βš™οΈ - ``: string, "buy" or "sell" - ``: integer, Amount to buy or sell. - ``: string, The stock ticker to buy or sell. Separate multiple tickers with commas and no spaces. @@ -70,15 +79,12 @@ For help: - ``: string proceeding `not`, What brokerages to exclude from command. Separate multiple brokerages with commas and no spaces. - ``: boolean, Whether to run in `dry` mode (in which no transactions are made. Useful for testing). Set to `True`, `False`, or just write `dry` for`True`. Defaults to `True`, so if you want to run a real transaction, you must set this explicitly. -### Testing your Login Credentials -To test your login credentials, run `python testLogin.py`. This will print all your `.env` variables and attempt to log in to each brokerage. If you get an error, check your `.env` variables and try again. This prints everything in plain text, so don't share the output with anyone! - -### Guides +### πŸ—ΊοΈ Guides πŸ—ΊοΈ More detailed guides for some of the difficult setups: - [Discord Bot Setup](guides/discordBot.md) - [Schwab 2FA Setup](guides/schwabSetup.md) -## Contributing +## 🀝 Contributing 🀝 Found or fixed a bug? Have a feature request? Want to add support for a new brokerage? Feel free to open an issue or pull request! Is someone selling a ripoff of this bot? (Looking at you OSU freshmen). Get it from here and contribute to open source! @@ -87,30 +93,12 @@ Like what you see? Feel free to support me on Ko-Fi! [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/X8X6LFCI0) -## DISCLAIMER +## 😳 DISCLAIMER 😳 DISCLAIMER: I am not a financial advisor and not affiliated with any of the brokerages listed below. Use this tool at your own risk. I am not responsible for any losses or damages you may incur by using this project. This tool is provided as-is with no warranty. -## Supported brokerages: - -All brokers: separate account credentials with a colon (":"). For example, `ALLY_USERNAME:ALLY_PASSWORD`. Separate multiple logins with the same broker with a comma (","). For example, `ALLY_USERNAME:ALLY_PASSWORD,ALLY_USERNAME2:ALLY_PASSWORD2`. - -### Ally -**Ally has disabled their API, so Ally is currently unsupported.** - -Made using [PyAlly](https://github.com/alienbrett/PyAlly). Go give them a ⭐ - -Required `.env` variables: -- `ALLY_CONSUMER_KEY` -- `ALLY_CONSUMER_SECRET` -- `ALLY_OAUTH_TOKEN` -- `ALLY_OAUTH_SECRET` -- `ALLY_ACCOUNT_NUMBERS` - -`.env` file format: -- `ALLY=ALLY_CONSUMER_KEY:ALLY_CONSUMER_SECRET:ALLY_OAUTH_TOKEN:ALLY_OAUTH_SECRET` -- `ALLY_ACCOUNT_NUMBERS=ALLY_ACCOUNT_NUMBER1:ALLY_ACCOUNT_NUMBER2` +## πŸ‘ Supported brokerages πŸ‘ -To get these, follow [these instructions](https://alienbrett.github.io/PyAlly/installing.html#get-the-library). +All brokers: separate account credentials with a colon (":"). For example, `SCHWAB_USERNAME:SCHWAB_PASSWORD`. Separate multiple logins with the same broker with a comma (","). For example, `SCHWAB_USERNAME:SCHWAB_PASSWORD,SCHWAB_USERNAME2:SCHWAB_PASSWORD2`. ### Fidelity Made by yours truly using Selenium (and many hours of web scraping). @@ -176,9 +164,13 @@ Required `.env` variables: `.env` file format: - `TASTYTRADE=TASTYTRADE_USERNAME:TASTYTRADE_PASSWORD` -### Maybe future brokerages +### πŸ€·β€β™‚οΈ Maybe future brokerages πŸ€·β€β™€οΈ +#### Ally +Ally disabled their official API, so all Ally packages don't work. I am attempting to reverse engineer their API, which you can track [here](https://github.com/NelsonDane/ally-api). Once I get it working, I will add it to this project. #### Chase -I will be signing up for a Chase account soon, and I have heard that it is possible, so I will be looking into it soon. +Chase doesn't have an official API, so it would have to be added using Selenium. +#### Firstrade +In progress on the `develop-firstrade` branch. Stay tuned. #### Vanguard Will be added using Selenium just like Fidelity. I found this [vanguard-api](https://github.com/rikonor/vanguard-api), but it failed when I ran it. #### SoFi @@ -187,6 +179,6 @@ Login requires SMS 2fa, and I'm not sure how to do that automatically. In progress on [develop-webull](https://github.com/NelsonDane/auto-rsa/pull/61). Stay tuned. #### Public Same as Webull and SoFi. -### Never working brokerages +### πŸ‘Ž Never working brokerages πŸ‘Ž #### Stash Why. diff --git a/allyAPI.py b/allyAPI.py deleted file mode 100644 index 758ded4b..00000000 --- a/allyAPI.py +++ /dev/null @@ -1,241 +0,0 @@ -# Nelson Dane -# Ally API - -import os -import traceback - -import ally -import requests -from dotenv import load_dotenv - -from helperAPI import Brokerage, printAndDiscord, printHoldings, stockOrder - - -# Initialize Ally -def ally_init(ALLY_EXTERNAL=None, ALLY_ACCOUNT_NUMBERS_EXTERNAL=None): - # Disable Ally API - print("Ally has disabled their API, so Ally is currently unavailable.") - return None - # Initialize .env file - load_dotenv() - # Import Ally account - if ( - not os.getenv("ALLY") - or not os.getenv("ALLY_ACCOUNT_NUMBERS") - and ALLY_EXTERNAL is None - and ALLY_ACCOUNT_NUMBERS_EXTERNAL is None - ): - print("Ally not found, skipping...") - return None - accounts = ( - os.environ["ALLY"].strip().split(",") - if ALLY_EXTERNAL is None - else ALLY_EXTERNAL.strip().split(",") - ) - account_nbrs_list = ( - os.environ["ALLY_ACCOUNT_NUMBERS"].strip().split(",") - if ALLY_ACCOUNT_NUMBERS_EXTERNAL is None - else ALLY_ACCOUNT_NUMBERS_EXTERNAL.strip().split(",") - ) - params_list = [] - for account in accounts: - name = f"Ally {accounts.index(account) + 1}" - account = account.split(":") - for nbr in account_nbrs_list: - for num in nbr.split(":"): - if len(account) != 4: - print( - f"{name}: Too many parameters for Ally account, please see README.md and .env.example, skipping..." - ) - return None - params = { - "ALLY_CONSUMER_KEY": account[0], - "ALLY_CONSUMER_SECRET": account[1], - "ALLY_OAUTH_TOKEN": account[2], - "ALLY_OAUTH_SECRET": account[3], - "ALLY_ACCOUNT_NBR": num, - } - params_list.append(params) - # Initialize Ally account - ally_obj = Brokerage("Ally") - for account in accounts: - print(f"Logging in to {name}...") - for nbr in account_nbrs_list: - for index, num in enumerate(nbr.split(":")): - try: - a = ally.Ally(params=params_list[index]) - except requests.exceptions.HTTPError as e: - print(f"{name}: Error logging in: {e}") - return None - # Ally needs a different object for each account number - ally_obj.set_logged_in_object(name, a, num) - ally_obj.set_account_number(name, num) - print("Logged in to Ally!") - return ally_obj - - -# Function to get the current account holdings -def ally_holdings(ao: Brokerage, loop=None): - # Disable Ally API - printAndDiscord( - "Ally has disabled their API, so Ally is currently unavailable.", loop - ) - return - for key in ao.get_account_numbers(): - account_numbers = ao.get_account_numbers(key) - for account in account_numbers: - obj: ally.Ally = ao.get_logged_in_objects(key, account) - try: - # Get account holdings - ab = obj.balances() - a_value = ab["accountvalue"].values - for value in a_value: - ao.set_account_totals(key, account, value) - # Print account stock holdings - ah = obj.holdings() - # Test if holdings is empty - if len(ah.index) > 0: - account_symbols = (ah["sym"].values).tolist() - qty = (ah["qty"].values).tolist() - current_price = (ah["marketvalue"].values).tolist() - for i, symbol in enumerate(account_symbols): - ao.set_holdings(key, account, symbol, qty[i], current_price[i]) - except Exception as e: - printAndDiscord(f"{key}: Error getting account holdings: {e}", loop) - print(traceback.format_exc()) - continue - printHoldings(ao, loop) - - -# Function to buy/sell stock on Ally -def ally_transaction( - ao: Brokerage, - orderObj: stockOrder, - loop=None, - RETRY=False, - account_retry=None, -): - print() - print("==============================") - print("Ally") - print("==============================") - print() - # Disable Ally API - printAndDiscord( - "Ally has disabled their API, so Ally is currently unavailable.", loop - ) - return - # Set the action - price = ally.Order.Market() - if isinstance(orderObj.get_price(), (int, float)): - print(f"Limit order at: ${float(orderObj.get_price())}") - price = ally.Order.Limit(limpx=float(orderObj.get_price())) - for s in orderObj.get_stocks(): - for key in ao.get_account_numbers(): - printAndDiscord( - f"{key}: {orderObj.get_action()}ing {orderObj.get_amount()} of {s}", - loop, - ) - for account in ao.get_account_numbers(key): - if not RETRY: - obj: ally.Ally = ao.get_logged_in_objects(key, account) - else: - obj: ally.Ally = ao - account = account_retry - try: - # Create order - o = ally.Order.Order( - buysell=orderObj.get_action(), - symbol=s, - price=price, - time=orderObj.get_time(), - qty=orderObj.get_amount(), - ) - # Print order preview - print(f"{key} {account}: {str(o)}") - # Submit order - o.orderid - if not orderObj.get_dry(): - obj.submit(o, preview=False) - else: - printAndDiscord( - f"{key} {account}: Running in DRY mode. " - + f"Trasaction would've been: {orderObj.get_action()} {orderObj.get_amount()} of {s}", - loop, - ) - # Print order status - if o.orderid: - printAndDiscord( - f"{key} {account}: Order {o.orderid} submitted", loop - ) - else: - printAndDiscord(f"{key} {account}: Order not submitted", loop) - if RETRY: - return - except Exception as e: - ally_call_error = ( - "Error: For your security, certain symbols may only be traded " - + "by speaking to an Ally Invest registered representative. " - + "Please call 1-855-880-2559 if you need further assistance with this order." - ) - if ( - "500 server error: internal server error for url:" - in str(e).lower() - ): - # If selling too soon, then an error is thrown - if orderObj.get_action() == "sell": - printAndDiscord(ally_call_error, loop) - # If the message comes up while buying, then - # try again with a limit order - elif orderObj.get_action() == "buy" and not RETRY: - printAndDiscord( - f"{key} {account}: Error placing market buy, trying again with limit order...", - loop, - ) - # Need to get stock price (compare bid, ask, and last) - try: - # Get stock values - quotes = obj.quote( - s, - fields=["bid", "ask", "last"], - ) - # Add 1 cent to the highest value of the 3 above - new_price = ( - max( - [ - float(quotes["last"][0]), - float(quotes["bid"][0]), - float(quotes["ask"][0]), - ] - ) - ) + 0.01 - # Set new price - orderObj.set_price(new_price) - # Run function again with limit order - ally_transaction( - ao, - orderObj, - loop, - RETRY=True, - account_retry=account, - ) - except Exception as ex: - printAndDiscord( - f"{key} {account}: Failed to place limit order: {ex}", - loop, - ) - else: - printAndDiscord( - f"{key} {account}: Error placing limit order: {e}", - loop, - ) - # If price is not a string then it must've failed a limit order - elif not isinstance(orderObj.get_price(), str): - printAndDiscord( - f"{key} {account}: Error placing limit order: {e}", - loop, - ) - else: - printAndDiscord( - f"{key} {account}: Error submitting order: {e}", loop - ) diff --git a/autoRSA.py b/autoRSA.py index 0688471b..ccc30363 100644 --- a/autoRSA.py +++ b/autoRSA.py @@ -3,7 +3,6 @@ # Import libraries import os -import re import sys import traceback @@ -13,7 +12,6 @@ from dotenv import load_dotenv # Custom API libraries - from allyAPI import * from fidelityAPI import * from helperAPI import killDriver, stockOrder, updater from robinhoodAPI import * @@ -30,7 +28,8 @@ # Global variables -SUPPORTED_BROKERS = ["ally", "fidelity", "robinhood", "schwab", "tastytrade", "tradier"] +SUPPORTED_BROKERS = ["fidelity", "robinhood", "schwab", "tastytrade", "tradier"] +DAY1_BROKERS = ["robinhood", "schwab", "tastytrade", "tradier"] DISCORD_BOT = False DOCKER_MODE = False DANGER_MODE = False @@ -52,32 +51,30 @@ def fun_run(orderObj: stockOrder, command, loop=None): for broker in orderObj.get_brokers(): if broker in orderObj.get_notbrokers(): continue + broker = nicknames(broker) fun_name = broker + command try: - orderObj.order_validate(preLogin=True) - if nicknames(broker) == "ally": - printAndDiscord( - "Ally has disabled their API, so Ally is currently unavailable. Skipping...", - loop, - ) + # Initialize broker if command == "_init": - if nicknames(broker) == "fidelity": + if broker.lower() == "fidelity": # Fidelity requires docker mode argument orderObj.set_logged_in( - globals()[fun_name](DOCKER=DOCKER_MODE), nicknames(broker) + globals()[fun_name](DOCKER=DOCKER_MODE), broker ) else: - orderObj.set_logged_in(globals()[fun_name](), nicknames(broker)) - # Holdings and transaction - elif orderObj.get_logged_in(nicknames(broker)) is None: + orderObj.set_logged_in(globals()[fun_name](), broker) + # Verify broker is logged in + orderObj.order_validate(preLogin=False) + logged_in_broker = orderObj.get_logged_in(broker) + if logged_in_broker is None: print(f"Error: {broker} not logged in, skipping...") - elif command == "_holdings": - orderObj.order_validate(preLogin=False) - globals()[fun_name](orderObj.get_logged_in(nicknames(broker)), loop) + continue + # Get holdings or complete transaction + if command == "_holdings": + globals()[fun_name](logged_in_broker, loop) elif command == "_transaction": - orderObj.order_validate(preLogin=False) globals()[fun_name]( - orderObj.get_logged_in(nicknames(broker)), + logged_in_broker, orderObj, loop, ) @@ -90,81 +87,71 @@ def fun_run(orderObj: stockOrder, command, loop=None): print(f"Error: {command} is not a valid command") -# Regex function to check if stock ticker is valid -def isStockTicker(symbol): - pattern = r"^[A-Z]{1,5}$" # Regex pattern for stock tickers - return re.match(pattern, symbol) - - # Parse input arguments and update the order object -def argParser(args: str): +def argParser(args: list) -> stockOrder: orderObj = stockOrder() - for arg in args: - arg = arg.lower() - # Exclusions - if arg == "not": - next_arg = nicknames(args[args.index(arg) + 1]).split(",") - for broker in next_arg: - if nicknames(broker) in SUPPORTED_BROKERS: - orderObj.set_notbrokers(nicknames(broker)) - elif arg in ["buy", "sell"]: - orderObj.set_action(arg) - elif arg.isnumeric(): - orderObj.set_amount(arg) - elif arg == "false": - orderObj.set_dry(False) - # If first item of list is a broker, it must be a list of brokers - elif nicknames(arg.split(",")[0]) in SUPPORTED_BROKERS: - for broker in arg.split(","): - # Add broker if it is valid and not in notbrokers - if ( - nicknames(broker) in SUPPORTED_BROKERS - and nicknames(broker) not in orderObj.get_notbrokers() - ): - orderObj.set_brokers(nicknames(broker)) - elif arg == "all": - if "all" not in orderObj.get_brokers() and orderObj.get_brokers() == []: - orderObj.set_brokers(SUPPORTED_BROKERS) - elif arg == "holdings": - orderObj.set_holdings(True) - # If first item of list is a stock, it must be a list of stocks - elif ( - isStockTicker(arg.split(",")[0].upper()) - and arg.lower() != "dry" - and orderObj.get_stocks() == [] - ): - for stock in arg.split(","): - orderObj.set_stock(stock.upper()) + # If first argument is holdings, set holdings to true + if args[0].lower() == "holdings": + orderObj.set_holdings(True) + # Next argument is brokers + if args[1].lower() == "all": + orderObj.set_brokers(SUPPORTED_BROKERS) + elif args[1].lower() == "day1": + orderObj.set_brokers(DAY1_BROKERS) + else: + for broker in args[1].split(","): + orderObj.set_brokers(nicknames(broker)) + return orderObj + # Otherwise: action, amount, stock, broker, (optional) not broker, (optional) dry + orderObj.set_action(args[0].lower()) + orderObj.set_amount(args[1]) + for stock in args[2].split(","): + orderObj.set_stock(stock.upper()) + # Next argument is a broker, set broker + if args[3].lower() == "all": + orderObj.set_brokers(SUPPORTED_BROKERS) + elif args[3].lower() == "day1": + orderObj.set_brokers(DAY1_BROKERS) + else: + for broker in args[3].split(","): + if nicknames(broker) in SUPPORTED_BROKERS: + orderObj.set_brokers(nicknames(broker)) + # If next argument is not, set not broker + if len(args) > 4 and args[4].lower() == "not": + for broker in args[5].split(","): + if nicknames(broker) in SUPPORTED_BROKERS: + orderObj.set_notbrokers(nicknames(broker)) + # If next argument is false, set dry to false + if args[-1].lower() == "false": + orderObj.set_dry(False) # Validate order object orderObj.order_validate(preLogin=True) return orderObj if __name__ == "__main__": + # Determine if ran from command line + if len(sys.argv) == 1: # If no arguments, do nothing + print("No arguments given, see README for usage") + sys.exit(1) # Check if danger mode is enabled if os.getenv("DANGER_MODE", "").lower() == "true": DANGER_MODE = True print("DANGER MODE ENABLED") print() - # Determine if ran from command line - if len(sys.argv) == 1: # If no arguments, do nothing - print("No arguments given, see README for usage") - sys.exit(1) - elif ( - len(sys.argv) == 2 and sys.argv[1].lower() == "docker" - ): # If docker argument, run docker bot + # If docker argument, run docker bot + if sys.argv[1].lower() == "docker": print("Running bot from docker") DOCKER_MODE = DISCORD_BOT = True - elif ( - len(sys.argv) == 2 and sys.argv[1].lower() == "discord" - ): # If discord argument, run discord bot, no docker, no prompt + # If discord argument, run discord bot, no docker, no prompt + elif sys.argv[1].lower() == "discord": updater() print("Running Discord bot from command line") DISCORD_BOT = True else: # If any other argument, run bot, no docker or discord bot updater() print("Running bot from command line") - cliOrderObj: stockOrder = argParser(sys.argv[1:]) + cliOrderObj = argParser(sys.argv[1:]) if not cliOrderObj.get_holdings(): print(f"Action: {cliOrderObj.get_action()}") print(f"Amount: {cliOrderObj.get_amount()}") @@ -199,6 +186,7 @@ def argParser(args: str): killDriver(cliOrderObj.get_logged_in(b)) sys.exit(0) + # If discord bot, run discord bot if DISCORD_BOT: # Get discord token and channel from .env file if not os.environ["DISCORD_TOKEN"]: @@ -225,7 +213,7 @@ async def on_ready(): print( "ERROR: Invalid channel ID, please check your DISCORD_CHANNEL in your .env file and try again" ) - os._exit(1) + os._exit(1) # Special exit code to restart docker container await channel.send("Discord bot is started...") # Bot ping-pong @@ -250,9 +238,7 @@ async def help(ctx): # Main RSA command @bot.command(name="rsa") async def rsa(ctx, *args): - discOrdObj: stockOrder = await bot.loop.run_in_executor( - None, argParser, args - ) + discOrdObj = await bot.loop.run_in_executor(None, argParser, args) loop = asyncio.get_event_loop() try: # Login to brokers @@ -281,7 +267,7 @@ async def restart(ctx): print() await ctx.send("Restarting...") await bot.close() - os._exit(0) + os._exit(0) # Special exit code to restart docker container # Catch bad commands @bot.event diff --git a/docker-compose.yml b/docker-compose.yml index 7a5debe9..21178947 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,6 @@ services: env_file: - .env tty: true - labels: - com.centurylinklabs.watchtower.enable: "true" watchtower: image: containrrr/watchtower @@ -17,6 +15,4 @@ services: container_name: watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock - labels: - com.centurylinklabs.watchtower.enable: "true" restart: unless-stopped diff --git a/helperAPI.py b/helperAPI.py index e761992b..98d9e5a4 100644 --- a/helperAPI.py +++ b/helperAPI.py @@ -21,45 +21,50 @@ class stockOrder: def __init__(self): - self.__action = None # Buy or sell - self.__amount = None # Amount of shares to buy/sell - self.__stock = [] # List of stock tickers to buy/sell - self.__time = "day" # Only supports day for now - self.__price = "market" # Default to market price - self.__brokers = [] # List of brokerages to use - self.__notbrokers = [] # List of brokerages to not use !ally - self.__dry = True # Dry run mode - self.__holdings = False # Get holdings from enabled brokerages - self.__logged_in = {} # Dict of logged in brokerage objects - - def set_action(self, action): + self.__action: str = None # Buy or sell + self.__amount: float = None # Amount of shares to buy/sell + self.__stock: list = [] # List of stock tickers to buy/sell + self.__time: str = "day" # Only supports day for now + self.__price: str = "market" # Default to market price + self.__brokers: list = [] # List of brokerages to use + self.__notbrokers: list = [] # List of brokerages to not use + self.__dry: bool = True # Dry run mode + self.__holdings: bool = False # Get holdings from enabled brokerages + self.__logged_in: dict = {} # Dict of logged in brokerage objects + + def set_action(self, action: str) -> None or ValueError: if action.lower() not in ["buy", "sell"]: raise ValueError("Action must be buy or sell") self.__action = action.lower() - def set_amount(self, amount): - # Only allow ints for now + def set_amount(self, amount: float) -> None or ValueError: + # Only allow floats try: - amount = int(amount) + amount = float(amount) except ValueError: - raise ValueError("Amount must be an integer") - if int(amount) < 1: - raise ValueError("Amount must be greater than 0") - self.__amount = int(amount) + raise ValueError(f"Amount ({amount}) must be a number") + self.__amount = amount - def set_stock(self, stock): + def set_stock(self, stock: str) -> None or ValueError: # Only allow strings for now if not isinstance(stock, str): raise ValueError("Stock must be a string") self.__stock.append(stock.upper()) - def set_time(self, time): + def set_time(self, time) -> None or NotImplementedError: raise NotImplementedError - def set_price(self, price): - self.__price = float(price) - - def set_brokers(self, brokers): + def set_price(self, price: str or float) -> None or ValueError: + # Only "market" or float + if not isinstance(price, (str, float)): + raise ValueError("Price must be a string or float") + if isinstance(price, float): + price = round(price, 2) + if isinstance(price, str): + price = price.lower() + self.__price = price + + def set_brokers(self, brokers: list) -> None or ValueError: # Only allow strings or lists if not isinstance(brokers, (str, list)): raise ValueError("Brokers must be a string or list") @@ -69,46 +74,56 @@ def set_brokers(self, brokers): else: self.__brokers.append(brokers.lower()) - def set_notbrokers(self, notbrokers): - # Only allow strings for now + def set_notbrokers(self, notbrokers: list) -> None or ValueError: + # Only allow strings or lists if not isinstance(notbrokers, str): raise ValueError("Not Brokers must be a string") - self.__notbrokers.append(notbrokers.lower()) + if isinstance(notbrokers, list): + for b in notbrokers: + self.__notbrokers.append(b.lower()) + else: + self.__notbrokers.append(notbrokers.lower()) - def set_dry(self, dry): + def set_dry(self, dry: bool) -> None or ValueError: + # Only allow bools + if not isinstance(dry, bool): + raise ValueError("Dry must be a boolean") self.__dry = dry - def set_holdings(self, holdings): + def set_holdings(self, holdings: bool) -> None or ValueError: + # Only allow bools + if not isinstance(holdings, bool): + raise ValueError("Holdings must be a boolean") self.__holdings = holdings - def set_logged_in(self, logged_in, broker): + def set_logged_in(self, logged_in, broker: str): self.__logged_in[broker] = logged_in - def get_action(self): + def get_action(self) -> str: return self.__action - def get_amount(self): + def get_amount(self) -> float: return self.__amount - def get_stocks(self): + def get_stocks(self) -> list: return self.__stock - def get_time(self): + def get_time(self) -> str: return self.__time - def get_price(self): + def get_price(self) -> str or float: return self.__price - def get_brokers(self): + def get_brokers(self) -> list: return self.__brokers - def get_notbrokers(self): + def get_notbrokers(self) -> list: return self.__notbrokers - def get_dry(self): + def get_dry(self) -> bool: return self.__dry - def get_holdings(self): + def get_holdings(self) -> bool: return self.__holdings def get_logged_in(self, broker=None): @@ -126,7 +141,7 @@ def alphabetize(self): self.__brokers.sort() self.__notbrokers.sort() - def order_validate(self, preLogin=False): + def order_validate(self, preLogin=False) -> None or ValueError: # Check for required fields (doesn't apply to holdings) if not self.__holdings: if self.__action is None: @@ -163,24 +178,30 @@ def __str__(self) -> str: class Brokerage: def __init__(self, name): - self.__name = name # Name of brokerage - self.__account_numbers = ( + self.__name: str = name # Name of brokerage + self.__account_numbers: dict = ( {} ) # Dictionary of account names and numbers under parent - self.__logged_in_objects = {} # Dictionary of logged in objects under parent - self.__holdings = {} # Dictionary of holdings under parent - self.__account_totals = {} # Dictionary of account totals - self.__account_types = {} # Dictionary of account types - - def set_name(self, name): + self.__logged_in_objects: dict = ( + {} + ) # Dictionary of logged in objects under parent + self.__holdings: dict = {} # Dictionary of holdings under parent + self.__account_totals: dict = {} # Dictionary of account totals + self.__account_types: dict = {} # Dictionary of account types + + def set_name(self, name: str): + if not isinstance(name, str): + raise ValueError("Name must be a string") self.__name = name - def set_account_number(self, parent_name, account_number): + def set_account_number(self, parent_name: str, account_number: str): if parent_name not in self.__account_numbers: self.__account_numbers[parent_name] = [] self.__account_numbers[parent_name].append(account_number) - def set_logged_in_object(self, parent_name, logged_in_object, account_name=None): + def set_logged_in_object( + self, parent_name: str, logged_in_object, account_name: str = None + ): if parent_name not in self.__logged_in_objects: self.__logged_in_objects[parent_name] = {} if account_name is None: @@ -188,7 +209,14 @@ def set_logged_in_object(self, parent_name, logged_in_object, account_name=None) else: self.__logged_in_objects[parent_name][account_name] = logged_in_object - def set_holdings(self, parent_name, account_name, stock, quantity, price): + def set_holdings( + self, + parent_name: str, + account_name: str, + stock: str, + quantity: float, + price: float, + ): quantity = 0 if quantity == "N/A" else quantity price = 0 if price == "N/A" else price if parent_name not in self.__holdings: @@ -201,7 +229,7 @@ def set_holdings(self, parent_name, account_name, stock, quantity, price): "total": round(float(quantity) * float(price), 2), } - def set_account_totals(self, parent_name, account_name, total): + def set_account_totals(self, parent_name: str, account_name: str, total: float): if isinstance(total, str): total = total.replace(",", "").replace("$", "").strip() if parent_name not in self.__account_totals: @@ -211,46 +239,50 @@ def set_account_totals(self, parent_name, account_name, total): self.__account_totals[parent_name].values() ) - def set_account_type(self, parent_name, account_name, account_type): + def set_account_type(self, parent_name: str, account_name: str, account_type: str): if parent_name not in self.__account_types: self.__account_types[parent_name] = {} self.__account_types[parent_name][account_name] = account_type - def get_name(self): + def get_name(self) -> str: return self.__name - def get_account_numbers(self, parent_name=None): + def get_account_numbers(self, parent_name: str = None) -> list or dict: if parent_name is None: return self.__account_numbers return self.__account_numbers.get(parent_name, []) - def get_logged_in_objects(self, parent_name=None, account_name=None): + def get_logged_in_objects( + self, parent_name: str = None, account_name: str = None + ) -> dict: if parent_name is None: return self.__logged_in_objects if account_name is None: return self.__logged_in_objects.get(parent_name, {}) return self.__logged_in_objects.get(parent_name, {}).get(account_name, {}) - def get_holdings(self, parent_name=None, account_name=None): + def get_holdings(self, parent_name: str = None, account_name: str = None) -> dict: if parent_name is None: return self.__holdings if account_name is None: return self.__holdings.get(parent_name, {}) return self.__holdings.get(parent_name, {}).get(account_name, {}) - def get_account_totals(self, parent_name=None, account_name=None): + def get_account_totals( + self, parent_name: str = None, account_name: str = None + ) -> dict: if parent_name is None: return self.__account_totals if account_name is None: return self.__account_totals.get(parent_name, {}) return self.__account_totals.get(parent_name, {}).get(account_name, 0) - def get_account_types(self, parent_name, account_name=None): + def get_account_types(self, parent_name: str, account_name: str = None) -> dict: if account_name is None: return self.__account_types.get(parent_name, {}) return self.__account_types.get(parent_name, {}).get(account_name, "") - def __str__(self): + def __str__(self) -> str: return textwrap.dedent( f""" Brokerage: {self.__name} @@ -396,6 +428,7 @@ async def processTasks(message): except Exception as e: print(f"Error Sending Message: {e}") break + sleep(0.5) def printAndDiscord(message, loop=None): diff --git a/requirements.txt b/requirements.txt index 3bdcb585..47db2013 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ asyncio==3.4.3 discord.py==2.3.2 GitPython==3.1.34 pandas==2.1.0 -pyally==1.1.2 pyotp==2.9.0 python-dotenv==1.0.0 requests==2.31.0 diff --git a/tastyAPI.py b/tastyAPI.py index e9859705..ab6a67bf 100644 --- a/tastyAPI.py +++ b/tastyAPI.py @@ -24,29 +24,6 @@ from helperAPI import Brokerage, printAndDiscord, printHoldings, stockOrder -def day_trade_check(tt: ProductionSession, acct: Account, cash_balance, loop=None): - try: - trading_status = acct.get_trading_status(tt) - day_trade_count = trading_status.day_trade_count - except Exception as e: - printAndDiscord( - f"Error getting day trade count for account {acct.account_number}: {e}", - loop=loop, - ) - return False - if ( - acct.margin_or_cash == "Margin" - and float(cash_balance) <= 25000 - and day_trade_count > 3 - ): - printAndDiscord( - f"Tastytrade account {acct.account_number}: day trade count is {day_trade_count}. More than 3 day trades will cause a strike on your account!", - loop=loop, - ) - return False - return True - - def order_setup(tt: ProductionSession, order_type, stock_price, stock, amount): symbol = Equity.get_equity(tt, stock) if order_type[2] == "Buy to Open": @@ -153,20 +130,55 @@ async def tastytrade_execute(tt_o: Brokerage, orderObj: stockOrder, loop=None): order_type = ["Market", "Credit", "Sell to Close"] # Set stock price stock_price = 0 - # Day trade check - # Day trade check - # removing this check until tastytrade api maintainer fixes enhanced_security_check bug - # cash_balance = float(acct.get_balances(obj).cash_balance) - # day_trade_check(obj, acct, cash_balance) - if True: - # Place order + # Skip day trade check for now + # Place order + new_order = order_setup( + obj, order_type, stock_price, s, orderObj.get_amount() + ) + placed_order = acct.place_order( + obj, new_order, dry_run=orderObj.get_dry() + ) + order_status = placed_order.order.status.value + # Check order status + if order_status in ["Received", "Routed"]: + message = f"{key} {acct.account_number}: {orderObj.get_action()} {orderObj.get_amount()} of {s} Order: {placed_order.order.id} Status: {order_status}" + if orderObj.get_dry(): + message = f"{key} Running in DRY mode. Transaction would've been: {orderObj.get_action()} {orderObj.get_amount()} of {s}" + printAndDiscord(message, loop=loop) + elif order_status == "Rejected": + # Retry with limit order + streamer = await DataStreamer.create(obj) + stock_limit = await streamer.oneshot(EventType.PROFILE, [s]) + stock_quote = await streamer.oneshot(EventType.QUOTE, [s]) + printAndDiscord( + f"{key} {acct.account_number} Error: {order_status} Trying Limit order...", + loop=loop, + ) + # Get limit price + if orderObj.get_action() == "buy": + stock_limit = D(stock_limit[0].highLimitPrice) + stock_price = ( + D(stock_quote[0].askPrice) + if stock_limit.is_nan() + else stock_limit + ) + order_type = ["Market", "Debit", "Buy to Open"] + elif orderObj.get_action() == "sell": + stock_limit = D(stock_limit[0].lowLimitPrice) + stock_price = ( + D(stock_quote[0].bidPrice) + if stock_limit.is_nan() + else stock_limit + ) + order_type = ["Market", "Credit", "Sell to Close"] + print(f"{s} limit price is: ${round(stock_price, 2)}") + # Retry order new_order = order_setup( obj, order_type, stock_price, s, orderObj.get_amount() ) placed_order = acct.place_order( obj, new_order, dry_run=orderObj.get_dry() ) - order_status = placed_order.order.status.value # Check order status if order_status in ["Received", "Routed"]: message = f"{key} {acct.account_number}: {orderObj.get_action()} {orderObj.get_amount()} of {s} Order: {placed_order.order.id} Status: {order_status}" @@ -174,51 +186,11 @@ async def tastytrade_execute(tt_o: Brokerage, orderObj: stockOrder, loop=None): message = f"{key} Running in DRY mode. Transaction would've been: {orderObj.get_action()} {orderObj.get_amount()} of {s}" printAndDiscord(message, loop=loop) elif order_status == "Rejected": - # Retry with limit order - streamer = await DataStreamer.create(obj) - stock_limit = await streamer.oneshot(EventType.PROFILE, [s]) - stock_quote = await streamer.oneshot(EventType.QUOTE, [s]) + # Only want this message if it fails both orders. printAndDiscord( - f"{key} {acct.account_number} Error: {order_status} Trying Limit order...", + f"{key} Error placing order: {placed_order.order.id} on account {acct.account_number}: {order_status}", loop=loop, ) - # Get limit price - if orderObj.get_action() == "buy": - stock_limit = D(stock_limit[0].highLimitPrice) - stock_price = ( - D(stock_quote[0].askPrice) - if stock_limit.is_nan() - else stock_limit - ) - order_type = ["Market", "Debit", "Buy to Open"] - elif orderObj.get_action() == "sell": - stock_limit = D(stock_limit[0].lowLimitPrice) - stock_price = ( - D(stock_quote[0].bidPrice) - if stock_limit.is_nan() - else stock_limit - ) - order_type = ["Market", "Credit", "Sell to Close"] - print(f"{s} limit price is: ${round(stock_price, 2)}") - # Retry order - new_order = order_setup( - obj, order_type, stock_price, s, orderObj.get_amount() - ) - placed_order = acct.place_order( - obj, new_order, dry_run=orderObj.get_dry() - ) - # Check order status - if order_status in ["Received", "Routed"]: - message = f"{key} {acct.account_number}: {orderObj.get_action()} {orderObj.get_amount()} of {s} Order: {placed_order.order.id} Status: {order_status}" - if orderObj.get_dry(): - message = f"{key} Running in DRY mode. Transaction would've been: {orderObj.get_action()} {orderObj.get_amount()} of {s}" - printAndDiscord(message, loop=loop) - elif order_status == "Rejected": - # Only want this message if it fails market and limit order. - printAndDiscord( - f"{key} Error placing order: {placed_order.order.id} on account {acct.account_number}: {order_status}", - loop=loop, - ) except TastytradeError as te: printAndDiscord( f"{key} {acct.account_number}: Error: {te}", loop=loop diff --git a/testLogin.py b/testLogin.py deleted file mode 100644 index 5348688f..00000000 --- a/testLogin.py +++ /dev/null @@ -1,84 +0,0 @@ -# Nelson Dane -# Script to check auto rsa logins -# Run this to make sure the accounts successfully log in - -# Standard libraries -import os - -from dotenv import load_dotenv - -# Custom API libraries -from allyAPI import ally_init -from fidelityAPI import fidelity_init -from robinhoodAPI import robinhood_init -from schwabAPI import schwab_init -from tastyAPI import tastytrade_init -from tradierAPI import tradier_init - -# Initialize .env file -load_dotenv() - -# Check for environment variables -# Discord -if os.environ.get("DISCORD_TOKEN") is None: - print("DISCORD_TOKEN not found") -else: - print(f"Discord token found {os.environ.get('DISCORD_TOKEN')}") -if os.environ.get("DISCORD_CHANNEL") is None: - print("DISCORD_CHANNEL not found") -else: - print(f"Discord channel found {os.environ.get('DISCORD_CHANNEL')}") -# Ally -if os.environ.get("ALLY") is None: - print("ALLY not found") -else: - print(f"ALLY found {os.environ.get('ALLY')}") -# Fidelity -if os.environ.get("FIDELITY") is None: - print("FIDELITY not found") -else: - print(f"FIDELITY found {os.environ.get('FIDELITY')}") -# Robinhood -if os.environ.get("ROBINHOOD") is None: - print("ROBINHOOD not found") -else: - print(f"ROBINHOOD found {os.environ.get('ROBINHOOD')}") -# Schwab -if os.environ.get("SCHWAB") is None: - print("SCHWAB not found") -else: - print(f"SCHWAB found {os.environ.get('SCHWAB')}") -# Tradier -if os.environ.get("TRADIER") is None: - print("TRADIER not found") -else: - print(f"TRADIER found {os.environ.get('TRADIER')}") -# Tastytrade -if os.environ.get("TASTYTRADE") is None: - print("TASTYTRADE not found") -else: - print(f"TASTYTRADE found {os.environ.get('TASTYTRADE')}") -print() - -# Check each account -print("==========================================================") -print("Checking Accounts...") -print("==========================================================") -print() -ally_init() -print() -fidelity_init() -print() -robinhood_init() -print() -schwab_init() -print() -tradier_init() -print() -tastytrade_init() -# Print results -print() -print("==========================================================") -print("All checks complete") -print("==========================================================") -print() diff --git a/tradierAPI.py b/tradierAPI.py index 2846b949..676fa064 100644 --- a/tradierAPI.py +++ b/tradierAPI.py @@ -165,6 +165,13 @@ def tradier_transaction(tradier_o: Brokerage, orderObj: stockOrder, loop=None): ) for account in tradier_o.get_account_numbers(key): obj: str = tradier_o.get_logged_in_objects(key) + # Tradier doesn't support fractional shares + if not orderObj.get_amount().is_integer(): + printAndDiscord( + f"Tradier account {account} Error: Fractional share {orderObj.get_amount()} not supported", + loop=loop, + ) + continue if not orderObj.get_dry(): data = { "class": "equity",