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",