Skip to content

Commit

Permalink
Merge pull request #150 from NelsonDane/develop-public
Browse files Browse the repository at this point in the history
Add Public.com Support
  • Loading branch information
NelsonDane committed Mar 22, 2024
2 parents e8be034 + 334bbef commit 515aad7
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 5 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ FIDELITY=
# FIRSTRADE=FIRSTRADE_USERNAME:FIRSTRADE_PASSWORD:FIRSTRADE_PIN
FIRSTRADE=

# Public
# PUBLIC_BROKER=PUBLIC_USERNAME:PUBLIC_PASSWORD
PUBLIC_BROKER=

# Robinhood
# If 2fa is enabled:
# ROBINHOOD=ROBINHOOD_USERNAME:ROBINHOOD_PASSWORD:ROBINHOOD_TOTP
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ COPY ./entrypoint.sh .
COPY ./fidelityAPI.py .
COPY ./firstradeAPI.py .
COPY ./helperAPI.py .
COPY ./publicAPI.py .
COPY ./robinhoodAPI.py .
COPY ./schwabAPI.py .
COPY ./tastyAPI.py .
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ Required `.env` variables:
`.env` file format:
- `FIRSTRADE=FIRSTRADE_USERNAME:FIRSTRADE_PASSWORD:FIRSTRADE_PIN`

### Public
Made by yours truly using using [public-invest-api](https://github.com/NelsonDane/public-invest-api). Consider giving me a ⭐

Required `.env` variables:
- `PUBLIC_USERNAME`
- `PUBLIC_PASSWORD`

`.env` file format:
- `PUBLIC_BROKER=PUBLIC_USERNAME:PUBLIC_PASSWORD`

Note: Because Windows already has a `PUBLIC` environment variable, you will need to use `PUBLIC_BROKER` instead.

### Robinhood
Made using [robin_stocks](https://github.com/jmfernandes/robin_stocks). Go give them a ⭐

Expand Down
26 changes: 21 additions & 5 deletions autoRSA.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from fidelityAPI import *
from firstradeAPI import *
from helperAPI import check_package_versions, stockOrder, updater
from publicAPI import *
from robinhoodAPI import *
from schwabAPI import *
from tastyAPI import *
Expand All @@ -35,13 +36,22 @@
SUPPORTED_BROKERS = [
"fidelity",
"firstrade",
"public",
"robinhood",
"schwab",
"tastytrade",
"tradier",
"webull",
]
DAY1_BROKERS = [
"firstrade",
"public",
"robinhood",
"schwab",
"tastytrade",
"tradier",
"webull",
]
DAY1_BROKERS = ["robinhood", "firstrade", "schwab", "tastytrade", "tradier", "webull"]
DISCORD_BOT = False
DOCKER_MODE = False
DANGER_MODE = False
Expand All @@ -64,7 +74,7 @@ def nicknames(broker):

# Runs the specified function for each broker in the list
# broker name + type of function
def fun_run(orderObj: stockOrder, command, loop=None):
def fun_run(orderObj: stockOrder, command, botObj=None, loop=None):
if command in ["_init", "_holdings", "_transaction"]:
for broker in orderObj.get_brokers():
if broker in orderObj.get_notbrokers():
Expand All @@ -79,6 +89,10 @@ def fun_run(orderObj: stockOrder, command, loop=None):
orderObj.set_logged_in(
globals()[fun_name](DOCKER=DOCKER_MODE), broker
)
elif broker.lower() == "public":
orderObj.set_logged_in(
globals()[fun_name](botObj=botObj, loop=loop), broker
)
else:
orderObj.set_logged_in(globals()[fun_name](), broker)
# Verify broker is logged in
Expand Down Expand Up @@ -264,17 +278,19 @@ async def rsa(ctx, *args):
event_loop = asyncio.get_event_loop()
try:
# Login to brokers
await bot.loop.run_in_executor(None, fun_run, discOrdObj, "_init")
await bot.loop.run_in_executor(
None, fun_run, discOrdObj, "_init", bot, event_loop
)
# Validate order object
discOrdObj.order_validate()
# Get holdings or complete transaction
if discOrdObj.get_holdings():
await bot.loop.run_in_executor(
None, fun_run, discOrdObj, "_holdings", event_loop
None, fun_run, discOrdObj, "_holdings", None, event_loop
)
else:
await bot.loop.run_in_executor(
None, fun_run, discOrdObj, "_transaction", event_loop
None, fun_run, discOrdObj, "_transaction", None, event_loop
)
except Exception as err:
print(traceback.format_exc())
Expand Down
4 changes: 4 additions & 0 deletions creds/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ services:
env_file:
- .env
tty: true
volumes:
- ./creds:/app/creds

watchtower:
# Auto update the auto-rsa container every hour
Expand Down
38 changes: 38 additions & 0 deletions helperAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import pkg_resources
import requests
from discord.ext import commands
from dotenv import load_dotenv
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromiumService
Expand Down Expand Up @@ -555,6 +556,43 @@ async def processQueue():
task_queue.task_done()


async def getSMSCodeDiscord(
botObj: commands.Bot, brokerName, code_len=6, timeout=60, loop=None
):
printAndDiscord(f"{brokerName} requires SMS code", loop)
printAndDiscord(
f"Please enter SMS code or type cancel within {timeout} seconds", loop
)
# Get SMS code from Discord
sms_code = None
while sms_code is None:
try:
code = await botObj.wait_for(
"message",
# Ignore bot messages and messages not in the correct channel
check=lambda m: m.author != botObj.user
and m.channel.id == int(os.getenv("DISCORD_CHANNEL")),
timeout=timeout,
)
except asyncio.TimeoutError:
printAndDiscord(
f"Timed out waiting for SMS code input for {brokerName}", loop
)
return None
if code.content.lower() == "cancel":
printAndDiscord(f"Cancelling SMS code for {brokerName}", loop)
return None
try:
sms_code = int(code.content)
except ValueError:
printAndDiscord("SMS code must be numbers only", loop)
continue
if len(code.content) != code_len:
printAndDiscord("SMS code must be 6 digits", loop)
continue
return sms_code


def maskString(string):
# Mask string (12345678 -> xxxx5678)
string = str(string)
Expand Down
144 changes: 144 additions & 0 deletions publicAPI.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import asyncio
import os
import traceback

from dotenv import load_dotenv
from public_invest_api import Public

from helperAPI import (
Brokerage,
getSMSCodeDiscord,
maskString,
printAndDiscord,
printHoldings,
stockOrder,
)


def public_init(PUBLIC_EXTERNAL=None, botObj=None, loop=None):
# Initialize .env file
load_dotenv()
# Import Public account
public_obj = Brokerage("Public")
if not os.getenv("PUBLIC_BROKER") and PUBLIC_EXTERNAL is None:
print("Public not found, skipping...")
return None
PUBLIC = (
os.environ["PUBLIC_BROKER"].strip().split(",")
if PUBLIC_EXTERNAL is None
else PUBLIC_EXTERNAL.strip().split(",")
)
# Log in to Public account
print("Logging in to Public...")
for index, account in enumerate(PUBLIC):
name = f"Public {index + 1}"
try:
account = account.split(":")
pb = Public(filename=f"public{index + 1}.pkl", path="./creds/")
try:
if botObj is None and loop is None:
# Login from CLI
pb.login(
username=account[0],
password=account[1],
wait_for_2fa=True,
)
else:
# Login from Discord and check for 2fa required message
pb.login(
username=account[0],
password=account[1],
wait_for_2fa=False,
)
except Exception as e:
if "2FA" in str(e) and botObj is not None and loop is not None:
# Sometimes codes take a long time to arrive
timeout = 300 # 5 minutes
sms_code = asyncio.run_coroutine_threadsafe(
getSMSCodeDiscord(botObj, name, timeout=timeout, loop=loop),
loop,
).result()
if sms_code is None:
raise Exception("No SMS code found")
pb.login(
username=account[0],
password=account[1],
wait_for_2fa=False,
code=sms_code,
)
else:
raise e
# Public only has one account
public_obj.set_logged_in_object(name, pb)
an = pb.get_account_number()
public_obj.set_account_number(name, an)
print(f"{name}: Found account {maskString(an)}")
atype = pb.get_account_type()
public_obj.set_account_type(name, an, atype)
cash = pb.get_account_cash()
public_obj.set_account_totals(name, an, cash)
except Exception as e:
print(f"Error logging in to Public: {e}")
print(traceback.format_exc())
continue
print("Logged in to Public!")
return public_obj


def public_holdings(pbo: Brokerage, loop=None):
for key in pbo.get_account_numbers():
for account in pbo.get_account_numbers(key):
obj: Public = pbo.get_logged_in_objects(key)
try:
# Get account holdings
positions = obj.get_positions()
if positions != []:
for holding in positions:
# Get symbol, quantity, and total value
sym = holding["instrument"]["symbol"]
qty = float(holding["quantity"])
current_price = obj.get_symbol_price(sym)
if current_price is None:
current_price = "N/A"
pbo.set_holdings(key, account, sym, qty, current_price)
except Exception as e:
printAndDiscord(f"{key}: Error getting account holdings: {e}", loop)
traceback.format_exc()
continue
printHoldings(pbo, loop)


def public_transaction(pbo: Brokerage, orderObj: stockOrder, loop=None):
print()
print("==============================")
print("Public")
print("==============================")
print()
for s in orderObj.get_stocks():
for key in pbo.get_account_numbers():
printAndDiscord(
f"{key}: {orderObj.get_action()}ing {orderObj.get_amount()} of {s}",
loop,
)
for account in pbo.get_account_numbers(key):
obj: Public = pbo.get_logged_in_objects(key)
print_account = maskString(account)
try:
order = obj.place_order(
symbol=s,
quantity=orderObj.get_amount(),
side=orderObj.get_action(),
order_type="market",
time_in_force="day",
is_dry_run=orderObj.get_dry(),
)
if order["success"] is True:
order = "Success"
printAndDiscord(
f"{key}: {orderObj.get_action()} {orderObj.get_amount()} of {s} in {print_account}: {order}",
loop,
)
except Exception as e:
printAndDiscord(f"{print_account}: Error placing order: {e}", loop)
traceback.print_exc()
continue
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ asyncio==3.4.3
discord.py==2.3.2
firstrade==0.0.14
GitPython==3.1.42
-e git+https://github.com/NelsonDane/public-invest-api.git@abc84ca23cbf765f2ce0a88484c6db3b4b8019b4#egg=public-invest-api
pyotp==2.9.0
python-dotenv==1.0.1
requests==2.31.0
Expand Down

0 comments on commit 515aad7

Please sign in to comment.