Skip to content

Commit

Permalink
Webull Support (NelsonDane#61)
Browse files Browse the repository at this point in the history
* resolve conflicts

* add webull API

* Add to Docker and requirements.txt

* fix getting obj and check for login

* add webull to readme and add setup guide

* save account id

* get trade pin

* update webull

* update webull transaction

* reorder imports

* working webull orders

* wb holdings fix

* remove duplicate copies

* mask string update

* fix merge mistakes

* allow webull fractionals

* fix wb order amount

* support for wb roth

* update wb api

* style: format code with Black and isort (NelsonDane#138)

* style: format code with Black and isort

This commit fixes the style issues introduced in bc82b2f according to the output
from Black and isort.

Details: None

* split line

---------

Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
Co-authored-by: Nelson Dane <[email protected]>

* fix holdings with multi account

* style: format code with Black and isort

This commit fixes the style issues introduced in 4708a23 according to the output
from Black and isort.

Details: None

* no private member function calls

* add commit hash

* orders and holdings fix

* restore buy

* update wb

* print correct account numbers

* improve logged in checking

* style: format code with Black and isort

This commit fixes the style issues introduced in d9d99a2 according to the output
from Black and isort.

Details: None

* fix wb final print

* style: format code with Black and isort

This commit fixes the style issues introduced in bb7cefe according to the output
from Black and isort.

Details: NelsonDane#61

* fix account totals

* style: format code with Black and isort

This commit fixes the style issues introduced in aa7ea14 according to the output
from Black and isort.

Details: None

* update readme

* remove debug prints

---------

Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
  • Loading branch information
2 people authored and MaxxRK committed Aug 15, 2024
1 parent 39ff0be commit cdfb6e8
Show file tree
Hide file tree
Showing 7 changed files with 237 additions and 10 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ TRADIER=
# Tastytrade
# TASTYTRADE=TASTYTRADE_USERNAME:TASTYTRADE_PASSWORD
TASTYTRADE=

# Webull
# WEBULL=WEBULL_USERNAME:WEBULL_PASSWORD:WEBULL_DID:WEBULL_TRADING_PIN
WEBULL=
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ COPY ./helperAPI.py .
COPY ./publicAPI.py .
COPY ./robinhoodAPI.py .
COPY ./schwabAPI.py .
COPY ./tradierAPI.py .
COPY ./tastyAPI.py .
COPY ./tradierAPI.py .
COPY ./webullAPI.py .

#Temporary
COPY ./chaseinvest-api-0.1.0.tar.gz .
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,22 @@ Required `.env` variables:
`.env` file format:
- `TASTYTRADE=TASTYTRADE_USERNAME:TASTYTRADE_PASSWORD`

### Webull
Made using the [webull](https://github.com/tedchou12/webull) library. Go give them a ⭐

Required `.env` variables:
- `WEBULL_USERNAME`
- `WEBULL_PASSWORD`
- `WEBULL_DID`
- `WEBULL_TRADING_PIN`

`.env` file format:
- `WEBULL=WEBULL_USERNAME:WEBULL_PASSWORD:WEBULL_DID:WEBULL_TRADING_PIN`

Your `WEBULL_USERNAME` can be your email or phone number. If using a phone number, it must be formatted as: +1-XXXXXXXXXX or +86-XXXXXXXXXXX.

To get your Webull DID, follow this [guide](https://github.com/tedchou12/webull/wiki/Workaround-for-Login-%E2%80%90-Method-2).

### 🤷‍♂️ 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.
Expand Down
6 changes: 5 additions & 1 deletion autoRSA.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from schwabAPI import *
from tastyAPI import *
from tradierAPI import *
from webullAPI import *
except Exception as e:
print(f"Error importing libraries: {e}")
print(traceback.format_exc())
Expand All @@ -42,8 +43,9 @@
"schwab",
"tastytrade",
"tradier",
"webull",
]
DAY1_BROKERS = ["robinhood", "firstrade", "schwab", "tastytrade", "tradier"]
DAY1_BROKERS = ["chase", "robinhood", "firstrade", "schwab", "tastytrade", "tradier", "webull"]
DISCORD_BOT = False
DOCKER_MODE = False
DANGER_MODE = False
Expand All @@ -57,6 +59,8 @@ def nicknames(broker):
return "robinhood"
if broker == "tasty":
return "tastytrade"
if broker == "wb":
return "webull"
return broker


Expand Down
11 changes: 8 additions & 3 deletions helperAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,13 @@ def set_stock(self, stock: str) -> None or ValueError:
raise ValueError("Stock must be a string")
self.__stock.append(stock.upper())

def set_time(self, time) -> None or NotImplementedError:
raise NotImplementedError
def set_time(self, time):
# Only allow strings for now
if not isinstance(time, str):
raise ValueError("Time must be a string")
if time.lower() not in ["day", "gtc"]:
raise ValueError("Time must be day or gtc")
self.__time = time.lower()

def set_price(self, price: str or float) -> None or ValueError:
# Only "market" or float
Expand Down Expand Up @@ -229,7 +234,7 @@ def set_holdings(
if account_name not in self.__holdings[parent_name]:
self.__holdings[parent_name][account_name] = {}
self.__holdings[parent_name][account_name][stock] = {
"quantity": round(float(quantity), 2),
"quantity": float(quantity),
"price": round(float(price), 2),
"total": round(float(quantity) * float(price), 2),
}
Expand Down
6 changes: 1 addition & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,5 @@ schwab-api==0.3.10
selenium==4.18.1
setuptools==69.1.0
tastytrade==6.7
=======



>>>>>>> ad2f6e9 (Bump schwab-api from 0.3.8 to 0.3.9)
-e git+https://github.com/NelsonDane/webull.git@824442ea6a97f76d5a44cf4ea05aa2a0864ee4bc#egg=webull
webdriver-manager==4.0.1
201 changes: 201 additions & 0 deletions webullAPI.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# Nelson Dane
# Webull API

import os
import traceback

from dotenv import load_dotenv
from webull import webull

from helperAPI import Brokerage, maskString, printAndDiscord, printHoldings, stockOrder

MAX_WB_RETRIES = 3 # Number of times to retry logging in if not successful
MAX_WB_ACCOUNTS = 11 # Different account types


def place_order(obj: webull, account: str, orderObj: stockOrder, s: str):
obj.set_account_id(account)
order = obj.place_order(
stock=s,
action=orderObj.get_action().upper(),
orderType=orderObj.get_price().upper(),
quant=orderObj.get_amount(),
enforce=orderObj.get_time().upper(),
)
if order.get("success") is not None and not order["success"]:
print(f"{order['msg']} Code {order['code']}")
return False
return True


# Initialize Webull
def webull_init(WEBULL_EXTERNAL=None):
# Initialize .env file
load_dotenv()
# Import Webull account
wb_obj = Brokerage("Webull")
if not os.getenv("WEBULL") and WEBULL_EXTERNAL is None:
print("Webull not found, skipping...")
return None
accounts = (
os.environ["WEBULL"].strip().split(",")
if WEBULL_EXTERNAL is None
else WEBULL_EXTERNAL.strip().split(",")
)
for index, account in enumerate(accounts):
print("Logging in to Webull...")
name = f"Webull {index + 1}"
account = account.split(":")
if len(account) != 4:
print(
f"Invalid number of parameters for {name}, got {len(account)}, expected 4"
)
return None
try:
for i in range(MAX_WB_RETRIES):
wb = webull()
wb.set_did(account[2])
wb.login(account[0], account[1])
wb.get_trade_token(account[3])
id_test = wb.get_account_id(0)
if id_test is not None:
break
if i == MAX_WB_RETRIES - 1:
raise Exception(
f"Unable to log in to {name} after {i+1} tries. Check credentials."
)
wb_obj.set_logged_in_object(name, wb, "wb")
wb_obj.set_logged_in_object(name, account[3], "trading_pin")
# Get all accounts
for i in range(MAX_WB_ACCOUNTS):
id = wb.get_account_id(i)
if id is None:
break
# Webull uses a different internal account ID than displayed in app
ac = wb.get_account(v2=True)["accountSummaryVO"]
wb_obj.set_account_number(name, ac["accountNumber"])
print(maskString(ac["accountNumber"]))
wb_obj.set_logged_in_object(name, id, ac["accountNumber"])
wb_obj.set_account_type(
name, ac["accountNumber"], ac["accountTypeName"]
)
wb_obj.set_account_totals(
name, ac["accountNumber"], ac["netLiquidationValue"]
)
except Exception as e:
print(traceback.format_exc())
print(f"Error: Unable to log in to Webull: {e}")
return None
print("Logged in to Webull!")
return wb_obj


def webull_holdings(wbo: Brokerage, loop=None):
for key in wbo.get_account_numbers():
for account in wbo.get_account_numbers(key):
obj: webull = wbo.get_logged_in_objects(key, "wb")
internal_account = wbo.get_logged_in_objects(key, account)
try:
# Get account holdings
obj.set_account_id(internal_account)
positions = obj.get_positions()
if positions is None:
positions = obj.get_positions(v2=True)
# List of holdings dictionaries
if positions is not None and positions != []:
for item in positions:
if item.get("items") is not None:
item = item["items"][0]
sym = item["ticker"]["symbol"]
if sym == "":
sym = "Unknown"
if item.get("quantity") is not None:
qty = item["quantity"]
else:
qty = item["position"]
if float(qty) == 0:
continue
mv = round(float(item["marketValue"]) / float(qty), 2)
wbo.set_holdings(key, account, sym, qty, mv)
except Exception as e:
printAndDiscord(f"{key}: Error getting holdings: {e}", loop)
traceback.print_exc()
continue
printHoldings(wbo, loop=loop)


def webull_transaction(wbo: Brokerage, orderObj: stockOrder, loop=None):
print()
print("==============================")
print("Webull")
print("==============================")
print()
for s in orderObj.get_stocks():
for key in wbo.get_account_numbers():
printAndDiscord(
f"{key}: {orderObj.get_action()}ing {orderObj.get_amount()} of {s}",
loop,
)
for account in wbo.get_account_numbers(key):
print_account = maskString(account)
obj: webull = wbo.get_logged_in_objects(key, "wb")
internal_account = wbo.get_logged_in_objects(key, account)
if not orderObj.get_dry():
try:
if orderObj.get_price() == "market":
orderObj.set_price("MKT")
# If buy stock price < $1 or $0.10,
# buy 100/1000 shares and sell 100/1000 - amount
askList = obj.get_quote(s)["askList"]
bidList = obj.get_quote(s)["bidList"]
if askList == [] and bidList == []:
printAndDiscord(f"{key} {account}: {s} not found", loop)
continue
askPrice = float(askList[0]["price"]) if askList != [] else 0
bidPrice = float(bidList[0]["price"]) if bidList != [] else 0
if (
askPrice < 1 or bidPrice < 1
) and orderObj.get_action() == "buy":
big_amount = (
1000 if askPrice < 0.1 or bidPrice < 0.1 else 100
)
print(
f"Buying {big_amount} then selling {big_amount - orderObj.get_amount()} of {s}"
)
# Under $1, buy 100 shares and sell 100 - amount
old_amount = orderObj.get_amount()
orderObj.set_amount(big_amount)
buy_success = place_order(
obj, internal_account, orderObj, s
)
if not buy_success:
raise Exception(f"Error buying {big_amount} of {s}")
orderObj.set_amount(big_amount - old_amount)
orderObj.set_action("sell")
order = place_order(obj, internal_account, orderObj, s)
# Restore orderObj
orderObj.set_amount(old_amount)
orderObj.set_action("buy")
if not order:
raise Exception(
f"Error selling {big_amount - old_amount} of {s}"
)
else:
# Place normal order
order = place_order(obj, internal_account, orderObj, s)
if order:
printAndDiscord(
f"{key}: {orderObj.get_action()} {orderObj.get_amount()} of {s} in {print_account}: Success",
loop,
)
except Exception as e:
printAndDiscord(
f"{key} {print_account}: Error placing order: {e}", loop
)
print(traceback.format_exc())
continue
else:
printAndDiscord(
f"{key} {print_account}: Running in DRY mode. Transaction would've been: {orderObj.get_action()} {orderObj.get_amount()} of {s}",
loop,
)

0 comments on commit cdfb6e8

Please sign in to comment.