From 735ed448d4f2c9c9a2fd96f43ff58f3b0f9cb74d Mon Sep 17 00:00:00 2001 From: hinedy Date: Thu, 20 Oct 2022 04:16:42 +0200 Subject: [PATCH] first commit --- app.py | 240 ++++++++++++++++++ finance.db | Bin 0 -> 20480 bytes finance.zip | Bin 0 -> 8630 bytes .../2029240f6d1128be89ddc32729463129 | Bin 0 -> 9 bytes .../b40c21b292afe27c0b3163683abd2fb4 | Bin 0 -> 31 bytes helpers.py | 64 +++++ images/hfinance-logo.png | Bin 0 -> 5138 bytes key.txt | 1 + requirements.txt | 6 + static/favicon.ico | Bin 0 -> 15406 bytes static/styles.css | 23 ++ templates/apology.html | 10 + templates/buy.html | 14 + templates/history.html | 29 +++ templates/index.html | 45 ++++ templates/layout.html | 70 +++++ templates/login.html | 17 ++ templates/quote.html | 13 + templates/quoted.html | 10 + templates/register.html | 18 ++ templates/sell.html | 19 ++ 21 files changed, 579 insertions(+) create mode 100644 app.py create mode 100644 finance.db create mode 100644 finance.zip create mode 100644 flask_session/2029240f6d1128be89ddc32729463129 create mode 100644 flask_session/b40c21b292afe27c0b3163683abd2fb4 create mode 100644 helpers.py create mode 100644 images/hfinance-logo.png create mode 100644 key.txt create mode 100644 requirements.txt create mode 100644 static/favicon.ico create mode 100644 static/styles.css create mode 100644 templates/apology.html create mode 100644 templates/buy.html create mode 100644 templates/history.html create mode 100644 templates/index.html create mode 100644 templates/layout.html create mode 100644 templates/login.html create mode 100644 templates/quote.html create mode 100644 templates/quoted.html create mode 100644 templates/register.html create mode 100644 templates/sell.html diff --git a/app.py b/app.py new file mode 100644 index 0000000..c21d4b7 --- /dev/null +++ b/app.py @@ -0,0 +1,240 @@ +import os + +from cs50 import SQL +from flask import Flask, flash, redirect, render_template, request, session +from flask_session import Session +from tempfile import mkdtemp +from werkzeug.security import check_password_hash, generate_password_hash + +from helpers import apology, login_required, lookup, usd + +# Configure application +app = Flask(__name__) + +# Ensure templates are auto-reloaded +app.config["TEMPLATES_AUTO_RELOAD"] = True + +# Custom filter +app.jinja_env.filters["usd"] = usd + +# Configure session to use filesystem (instead of signed cookies) +app.config["SESSION_PERMANENT"] = False +app.config["SESSION_TYPE"] = "filesystem" +Session(app) + +# Configure CS50 Library to use SQLite database +db = SQL("sqlite:///finance.db") + +# Make sure API key is set +if not os.environ.get("API_KEY"): + raise RuntimeError("API_KEY not set") + + +@app.after_request +def after_request(response): + """Ensure responses aren't cached""" + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Expires"] = 0 + response.headers["Pragma"] = "no-cache" + return response + + +@app.route("/") +@login_required +def index(): + """Show portfolio of stocks""" + userID = session["user_id"] + symbols = db.execute("SELECT DISTINCT symbol FROM transactions WHERE user_id == ?", userID) + assets = [] + rows = db.execute("SELECT cash from users WHERE id = ?", userID ) + current_cash = rows[0]["cash"] + total = current_cash + for symbol in symbols: + + shares_bought = db.execute("SELECT SUM(shares) FROM transactions WHERE user_id == :userID AND symbol == :symbol AND type == 'buy'", userID = userID, symbol = symbol['symbol']) + shares_sold = db.execute("SELECT SUM(shares) FROM transactions WHERE user_id == :userID AND symbol == :symbol AND type == 'sell'", userID = userID, symbol = symbol['symbol']) + if shares_sold[0]['SUM(shares)'] is None: + shares_sold[0]['SUM(shares)'] = 0 + net_shares = shares_bought[0]['SUM(shares)'] - shares_sold[0]['SUM(shares)'] + + if not net_shares == 0: + assets.append({"symbol": symbol["symbol"], "name" : lookup(symbol["symbol"])['name'] , "shares" : net_shares , "price" : lookup(symbol["symbol"])['price'] }) + for asset in assets: + total = total + (asset['price'] * asset['shares']) + + return render_template("index.html" , current_cash = current_cash, assets = assets , total = total) + + +@app.route("/buy", methods=["GET", "POST"]) +@login_required +def buy(): + """Buy shares of stock""" + userID = session["user_id"] + if request.method == "POST": + symbol = request.form.get("symbol") + if not lookup(symbol): + return apology("invalid symbol") + if not request.form.get("shares") or not request.form.get("shares").isnumeric(): + return apology("please specify shares", 400) + else: + price = lookup(symbol)["price"] + stock = lookup(symbol)["name"] + + shares = int(request.form.get("shares")) + rows = db.execute("SELECT cash from users WHERE id = ?", userID ) + current_cash = rows[0]["cash"] + if current_cash - (shares * price) < 0: + return apology("not enough funds") + else: + # make transaction + current_cash = current_cash - (shares * price) + db.execute("UPDATE users SET cash= :current_cash WHERE id= :userID" , current_cash = current_cash, userID= userID) + db.execute("CREATE TABLE IF NOT EXISTS transactions (transaction_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_id TEXT NOT NULL, stock TEXT NOT NULL, symbol TEXT NOT NULL, price NUMERIC NOT NULL, shares INTEGER NOT NULL, type TEXT NOT NULL, time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id));") + db.execute("INSERT INTO transactions (user_id, stock, symbol, price, shares, type) VALUES(?, ?, ?, ?, ?, ?)", userID, stock, symbol, usd(price), shares, 'buy') + return redirect("/") + + else: + return render_template("buy.html") + + + +@app.route("/history") +@login_required +def history(): + """Show history of transactions""" + userID = session["user_id"] + transactions = db.execute("SELECT symbol, shares, type, price, time FROM transactions WHERE user_id == ?", userID) + + + return render_template("history.html", transactions = transactions) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + """Log user in""" + + # Forget any user_id + session.clear() + + # User reached route via POST (as by submitting a form via POST) + if request.method == "POST": + + # Ensure username was submitted + if not request.form.get("username"): + return apology("must provide username", 403) + + # Ensure password was submitted + elif not request.form.get("password"): + return apology("must provide password", 403) + + # Query database for username + rows = db.execute("SELECT * FROM users WHERE username = ?", request.form.get("username")) + + # Ensure username exists and password is correct + if len(rows) != 1 or not check_password_hash(rows[0]["hash"], request.form.get("password")): + return apology("invalid username and/or password", 403) + + # Remember which user has logged in + session["user_id"] = rows[0]["id"] + + # Redirect user to home page + return redirect("/") + + # User reached route via GET (as by clicking a link or via redirect) + else: + return render_template("login.html") + + +@app.route("/logout") +def logout(): + """Log user out""" + + # Forget any user_id + session.clear() + + # Redirect user to login form + return redirect("/") + + +@app.route("/quote", methods=["GET", "POST"]) +@login_required +def quote(): + """Get stock quote.""" + if request.method == "POST": + symbol = request.form.get("symbol") + if not lookup(symbol): + return apology("invalid symbol") + else: + return render_template("quoted.html", quote = lookup(symbol)) + else: + return render_template("quote.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + """Register user""" + # Forget any user_id + session.clear() + + # User reached route via POST (as by submitting a form via POST) + if request.method == "POST": + name = request.form.get("username") + rows = db.execute("SELECT * FROM users WHERE username = ?", request.form.get("username")) + if not name or len(rows) > 0: + return apology("Invalid username", 400) + # Ensure password was submitted + + elif not request.form.get("password"): + return apology("must provide password", 400) + elif not request.form.get("password") == request.form.get("confirmation"): + return apology("password doesn't match", 400) + + hash = generate_password_hash(request.form.get("password")) + db.execute("INSERT INTO users (username, hash) VALUES(?, ?)", name , hash ) + + return redirect("/") + + # User reached route via GET (as by clicking a link or via redirect) + else: + return render_template("register.html") + + + + + +@app.route("/sell", methods=["GET", "POST"]) +@login_required +def sell(): + + """Sell shares of stock""" + userID = session["user_id"] + + + if request.method == "POST": + symbol = request.form.get("symbol") + shares_bought = db.execute("SELECT SUM(shares) FROM transactions WHERE user_id == :userID AND symbol == :symbol AND type == 'buy'", userID = userID, symbol = symbol) + shares_sold = db.execute("SELECT SUM(shares) FROM transactions WHERE user_id == :userID AND symbol == :symbol AND type == 'sell'", userID = userID, symbol = symbol) + # rows = db.execute("SELECT SUM(shares) - (SELECT SUM(shares) FROM transactions WHERE user_id == :userID AND symbol == :symbol AND type == 'sell') AS net_shares FROM transactions WHERE user_id == :userID AND symbol == :symbol AND type == 'buy'", userID = userID, symbol = symbol) + if shares_sold[0]['SUM(shares)'] is None: + shares_sold[0]['SUM(shares)'] = 0 + net_shares = shares_bought[0]['SUM(shares)'] - shares_sold[0]['SUM(shares)'] + shares_to_sell = int(request.form.get("shares")) + if not symbol: + return apology("INVALID SYMBOL") + elif shares_to_sell < 0 or (net_shares - shares_to_sell) < 0: + return apology("invalid number of shares") + else: + price = lookup(symbol)["price"] + stock = lookup(symbol)["name"] + difference = int(shares_to_sell) * price + cash_list = db.execute("SELECT cash from users WHERE id = ?", userID ) + current_cash = cash_list[0]["cash"] + current_cash = current_cash + difference + db.execute("UPDATE users SET cash= :current_cash WHERE id= :userID" , current_cash = current_cash, userID= userID) + db.execute("INSERT INTO transactions (user_id, stock, symbol, price, shares, type) VALUES(?, ?, ?, ?, ?, ?)", userID, stock, symbol, usd(price), shares_to_sell, 'sell') + return redirect("/") + + else: + + user_stocks = db.execute("SELECT DISTINCT symbol FROM transactions WHERE user_id == ?", userID ) + return render_template("sell.html", options = user_stocks) diff --git a/finance.db b/finance.db new file mode 100644 index 0000000000000000000000000000000000000000..cdf2d19cb95c185197dd7b810f544c9222176257 GIT binary patch literal 20480 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 literal 0 HcmV?d00001 diff --git a/flask_session/2029240f6d1128be89ddc32729463129 b/flask_session/2029240f6d1128be89ddc32729463129 new file mode 100644 index 0000000000000000000000000000000000000000..8b04914a5e6ad4858df0019a6abe09326d3863de GIT binary patch literal 9 QcmZQzU|?uq^=8xq00XW800000 literal 0 HcmV?d00001 diff --git a/flask_session/b40c21b292afe27c0b3163683abd2fb4 b/flask_session/b40c21b292afe27c0b3163683abd2fb4 new file mode 100644 index 0000000000000000000000000000000000000000..654c6db7eef9779e06b07b6ea8fe0e981ef30c47 GIT binary patch literal 31 icmccJQ<>brI#qxH0&1u9u$LC67R6_#O!4L@)&l^U?+Jwf literal 0 HcmV?d00001 diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..27a6caf --- /dev/null +++ b/helpers.py @@ -0,0 +1,64 @@ +import os +import requests +import urllib.parse + +from flask import redirect, render_template, request, session +from functools import wraps + + +def apology(message, code=400): + """Render message as an apology to user.""" + def escape(s): + """ + Escape special characters. + + https://github.com/jacebrowning/memegen#special-characters + """ + for old, new in [("-", "--"), (" ", "-"), ("_", "__"), ("?", "~q"), + ("%", "~p"), ("#", "~h"), ("/", "~s"), ("\"", "''")]: + s = s.replace(old, new) + return s + return render_template("apology.html", top=code, bottom=escape(message)), code + + +def login_required(f): + """ + Decorate routes to require login. + + https://flask.palletsprojects.com/en/1.1.x/patterns/viewdecorators/ + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if session.get("user_id") is None: + return redirect("/login") + return f(*args, **kwargs) + return decorated_function + + +def lookup(symbol): + """Look up quote for symbol.""" + + # Contact API + try: + api_key = os.environ.get("API_KEY") + url = f"https://cloud.iexapis.com/stable/stock/{urllib.parse.quote_plus(symbol)}/quote?token={api_key}" + response = requests.get(url) + response.raise_for_status() + except requests.RequestException: + return None + + # Parse response + try: + quote = response.json() + return { + "name": quote["companyName"], + "price": float(quote["latestPrice"]), + "symbol": quote["symbol"] + } + except (KeyError, TypeError, ValueError): + return None + + +def usd(value): + """Format value as USD.""" + return f"${value:,.2f}" diff --git a/images/hfinance-logo.png b/images/hfinance-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7dae742f4a4fa0edd4b85ff0eed8876ce7a542bc GIT binary patch literal 5138 zcmb_geK?bC|G$(%iM!HWD%{nbZs{J8iM*7G#E=onycEgHHko;enfrO%Je9cJ^On&_ zHu5ql%*Lj4llRKJWM(l|Hk+4WY|L*xf8EFN9M5t5{&=qAxUTa&zvp>o{Y{`0&- zsDyHNumfuP4$mkC${`mZ7XaX8=FauNZ2+*B+Rf(IO$E< zf;_3}egV8Mzoyk?+cU#j=x^COCOv+lZei_Htx$+$Q5x^A7?OY9v(*iVhJCdPsh1v2 zJ)mkHvOQpn!p#5lIxOskD*?6VkH3e_3i2?Rk>O=JSP2N&U4VfRHKPbG@T#!@@M>^s z#`wgT63{c!7CHp2Orc4XfG>X~=1c^N0%1fw;a1>_^LQC!exgkoy&C}ZJ&89My=MYN z6WOyg05GP4F(cB*b}1MD(uq|?vUxl7-f$rRyg2g&AuOzq0f3MXy!CtIP zN53fQVDu@VRkiOPT5#Y^-{4CCAh;3Dj7&Gr$1>=u0I&(&!p`)$UVM&u834Sai}%g~ z$+&g@p$cWHpz1beG{&D3WJB-cdhzMKlqB{rXDiTrW3#Kye(GtZf1^^6y`j6%R4Xx`K0OEWcu&=4YB5jqQ>% z9t5aFf`^z&9cPuPD=CD~Pnq9Zb^^G){N&r&Gt46svp;Od1I7j=8gYHRhSZa95Eb3K zfFCcGZ+$Ah%&)hA^M6dEU#e!su>(GHKT2DF|@3FPDwvm+qQ50z5sAl#a6&usYC+^ z*p^}mB=7ry4*=I&f8PUShJTNRn3dT*NJk8EG*UqdoGVq*KC-~VP6jo5B0~< zSVLzt>5?Scqfg_TV{+QNhGFXFn3)BgFk5%3%o;8Q>qced)ObTR~pYt z&^%GsCdnZ*Q8wZl$=HlqYp4OM#gQ{~QPpBPWUi5hnO|npldf=9Kt`p#$VNf26FbQw z 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 BNOJ%H literal 0 HcmV?d00001 diff --git a/key.txt b/key.txt new file mode 100644 index 0000000..6e2ab3e --- /dev/null +++ b/key.txt @@ -0,0 +1 @@ +pk_5824394ae4834a1abfb912ed8ba1cbbb \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..96f2c19 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +cs50 +Flask +Flask-Session +gunicorn +psycopg2 +requests \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ea184db490ef93efb4c4d291ab67b3511c5563b4 GIT binary patch literal 15406 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 + {{ top }} +{% endblock %} diff --git a/templates/buy.html b/templates/buy.html new file mode 100644 index 0000000..97e853d --- /dev/null +++ b/templates/buy.html @@ -0,0 +1,14 @@ +{% extends "layout.html" %} + +{% block title %} + Buy +{% endblock %} + +{% block main %} +
+ + + +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/history.html b/templates/history.html new file mode 100644 index 0000000..3d05448 --- /dev/null +++ b/templates/history.html @@ -0,0 +1,29 @@ +{% extends "layout.html" %} + +{% block title %} + History +{% endblock %} + +{% block main %} + + + + + + + + + + + + + {% for transaction in transactions %} + + + + + + + + {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..20b1efa --- /dev/null +++ b/templates/index.html @@ -0,0 +1,45 @@ +{% extends "layout.html" %} + +{% block title %} + Portfolio +{% endblock %} + +{% block main %} +
SymbolSharesTypePriceTime
{{ transaction.symbol | upper() }}{{ transaction.shares }}{{ transaction.type }}{{ transaction.price }}{{ transaction.time }}
+ + + + + + + + + + + {% for asset in assets %} + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + +
SymbolNameSharesPriceTotal
{{ asset.symbol | upper() }}{{ asset.name }}{{ asset.shares }}{{ asset.price | usd }}{{ (asset.price * asset.shares) | usd }}
Cash{{current_cash | usd}}
Total{{ total | usd }}
+ +{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..8ab3caf --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + C$50 Finance: {% block title %}{% endblock %} + + + + + + + + {% if get_flashed_messages() %} +
+ +
+ {% endif %} + +
+ {% block main %}{% endblock %} +
+ +
+ Data provided by IEX +
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..7bbb36e --- /dev/null +++ b/templates/login.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} + +{% block title %} + Log In +{% endblock %} + +{% block main %} +
+
+ +
+
+ +
+ +
+{% endblock %} diff --git a/templates/quote.html b/templates/quote.html new file mode 100644 index 0000000..b739c85 --- /dev/null +++ b/templates/quote.html @@ -0,0 +1,13 @@ +{% extends "layout.html" %} + +{% block title %} + Quote +{% endblock %} + +{% block main %} +
+ + +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/quoted.html b/templates/quoted.html new file mode 100644 index 0000000..09be93d --- /dev/null +++ b/templates/quoted.html @@ -0,0 +1,10 @@ +{% extends "layout.html" %} + +{% block title %} + Quoted +{% endblock %} + +{% block main %} +

A share of {{ quote.name }} ({{quote.symbol}}) costs {{quote.price | usd}}.

+ +{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..bcf4ec8 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,18 @@ +{% extends "layout.html" %} + +{% block title %} + Register +{% endblock %} + +{% block main %} +
+
+ +
+
+ + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/sell.html b/templates/sell.html new file mode 100644 index 0000000..f6b61b5 --- /dev/null +++ b/templates/sell.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block title %} + Sell +{% endblock %} + +{% block main %} +
+ + + +
+ +{% endblock %} \ No newline at end of file