Skip to content

Commit

Permalink
place orders working
Browse files Browse the repository at this point in the history
  • Loading branch information
NelsonDane committed Apr 10, 2024
1 parent b8904d7 commit 351a5d7
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 5 deletions.
13 changes: 13 additions & 0 deletions .deepsource.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version = 1

[[analyzers]]
name = "python"

[analyzers.meta]
runtime_version = "3.x.x"

[[transformers]]
name = "black"

[[transformers]]
name = "isort"
24 changes: 24 additions & 0 deletions .github/workflows/pypi_publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Public to PyPI
name: Publish to PyPI
on:
push:
paths:
- "setup.py"

jobs:
build:
name: Build
runs-on: ubuntu-latest
environment:
name: "pypi"
url: https://pypi.org/p/fennel-invest-api
permissions:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and Publish
uses: lsst-sqre/build-and-publish-to-pypi@v2
with:
python-version: "3.11"
upload: ${{ github.ref == 'refs/heads/main' }}
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,71 @@
# fennel-invest-api
# Unofficial Fennel Invest API

This is an unofficial API for Fennel.com. It is a simple Python wrapper around the Fennel.com GraphQL API. It is not affiliated with Fennel.com in any way.

Fennel does everything via GraphQL, so yes, this is very slow.

This is still a work in progress, so it will have bugs and missing features. Please feel free to contribute!

## Installation

```bash
pip install fennel-invest-api
```

## Usage: Logging In

```python
from fennel_invest_api import Fennel

fennel = Fennel()
fennel.login(
email="[email protected]",
wait_for_2fa=True # When logging in for the first time, you need to wait for email 2FA
)
```

If you'd like to handle the 2FA yourself programmatically instead of waiting for `input()`, you can call it with `wait_for_2fa=False`, catch the 2FA exception, then call it again with the 2FA code:

```python
fennel.login(
email="[email protected]",
wait_for_2fa=False
code="123456" # Should be six-digit integer from email
)
```

## Usage: Get Stock Holdings
```python
positions = fennel.get_stock_holdings()
for position in positions:
print(position)
```

## Usage: Get Portfolio
```python
portfolio = fennel.get_portfolio_summary()
print(portfolio)
```

## Usage: Placing Orders
```python
order = fennel.place_order(
symbol="AAPL",
quantity=1,
side="buy", # Must be "buy" or "sell"
price="market" # Only market orders are supported for now
)
print(order)
```

## Contributing
Found or fixed a bug? Have a feature request? Feel free to open an issue or pull request!

Enjoying the project? Feel free to Sponsor me on GitHub or Ko-fi!

[![Sponsor](https://img.shields.io/badge/sponsor-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white)](https://github.com/sponsors/NelsonDane)
[![ko-fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white
)](https://ko-fi.com/X8X6LFCI0)

## DISCLAIMER
DISCLAIMER: I am not a financial advisor and not affiliated with Fennel.com. 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.
51 changes: 49 additions & 2 deletions fennel_invest_api/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ def retrieve_bearer_url(self):
def oauth_url(self):
return f"{self.accounts}/oauth/token"

def build_graphql_payload(self, query):
def build_graphql_payload(self, query, variables={}):
return {
"operationName": None,
"variables": {},
"variables": variables,
"query": query
}

Expand Down Expand Up @@ -65,6 +65,53 @@ def stock_holdings_query(self):
"""
return json.dumps(self.build_graphql_payload(query))

def is_market_open_query(self):
query = """
query MarketHours {
securityMarketInfo {
isOpen
}
}
"""
return json.dumps(self.build_graphql_payload(query))

def stock_search_query(self, symbol, count=5):
# idk what count is
query = """
query Search($query: String!, $count: Int) {
searchSearch {
searchSecurities(query: $query, count: $count) {
isin
}
}
}
"""
variables = {
"query": symbol,
"count": count
}
return json.dumps(self.build_graphql_payload(query, variables))

def stock_order_query(self, symbol, quantity, isin, side, priceRule):
query = """
mutation CreateOrder($order_details: OrderDetailsInput__!){
orderCreateOrder(order: $order_details)
}
"""
variables = {
"order_details": {
"quantity": quantity,
"symbol": symbol,
"isin": isin,
"side": side,
"priceRule": priceRule,
"timeInForce": "day",
"routingOption": "exchange_ats_sdp",
}
}
return json.dumps(self.build_graphql_payload(query, variables))


@staticmethod
def build_headers(Bearer, graphql=True):
headers = {
Expand Down
36 changes: 34 additions & 2 deletions fennel_invest_api/fennel.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ def login(self, email, wait_for_code=True, code=None):
# refresh_token() # Refresh token after login?
self._save_credentials()
return True


def refresh_token(self):
url = self.endpoints.oauth_url()
Expand Down Expand Up @@ -154,4 +153,37 @@ def get_stock_holdings(self):
response = self.session.post(self.endpoints.graphql, headers=headers, data=query)
if response.status_code != 200:
raise Exception(f"Stock Holdings Request failed with status code {response.status_code}: {response.text}")
return response.json()
response = response.json()
return response['data']['portfolio']['bulbs']

@check_login
def is_market_open(self):
query = self.endpoints.is_market_open_query()
headers = self.endpoints.build_headers(self.Bearer)
response = self.session.post(self.endpoints.graphql, headers=headers, data=query)
if response.status_code != 200:
raise Exception(f"Market Open Request failed with status code {response.status_code}: {response.text}")
response = response.json()
return response['data']['securityMarketInfo']['isOpen']

@check_login
def place_order(self, ticker, quantity, side, price="market"):
if side.lower() not in ["buy", "sell"]:
raise Exception("Side must be either 'buy' or 'sell'")
# Check if market is open
if not self.is_market_open():
raise Exception("Market is closed. Cannot place order.")
# Search for stock "isin"
query = self.endpoints.stock_search_query(ticker)
headers = self.endpoints.build_headers(self.Bearer)
search_response = self.session.post(self.endpoints.graphql, headers=headers, data=query)
if search_response.status_code != 200:
raise Exception(f"Stock Search Request failed with status code {search_response.status_code}: {search_response.text}")
search_response = search_response.json()
isin = search_response['data']['searchSearch']['searchSecurities'][0]['isin']
# Place order
query = self.endpoints.stock_order_query(ticker, quantity, isin, side, price)
order_response = self.session.post(self.endpoints.graphql, headers=headers, data=query)
if order_response.status_code != 200:
raise Exception(f"Order Request failed with status code {order_response.status_code}: {order_response.text}")
return order_response.json()
13 changes: 13 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from setuptools import setup

setup(
name="fennel_invest_api",
version="1.0.0",
description="Unofficial Fennel.com Invest API written in Python Requests",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
url="https://github.com/NelsonDane/fennel-invest-api",
author="Nelson Dane",
packages=["fennel_invest_api"],
install_requires=["requests", "python-dotenv"],
)

0 comments on commit 351a5d7

Please sign in to comment.