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 0000000..cdf2d19 Binary files /dev/null and b/finance.db differ diff --git a/finance.zip b/finance.zip new file mode 100644 index 0000000..3526419 Binary files /dev/null and b/finance.zip differ diff --git a/flask_session/2029240f6d1128be89ddc32729463129 b/flask_session/2029240f6d1128be89ddc32729463129 new file mode 100644 index 0000000..8b04914 Binary files /dev/null and b/flask_session/2029240f6d1128be89ddc32729463129 differ diff --git a/flask_session/b40c21b292afe27c0b3163683abd2fb4 b/flask_session/b40c21b292afe27c0b3163683abd2fb4 new file mode 100644 index 0000000..654c6db Binary files /dev/null and b/flask_session/b40c21b292afe27c0b3163683abd2fb4 differ 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 0000000..7dae742 Binary files /dev/null and b/images/hfinance-logo.png differ 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 0000000..ea184db Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..e30cfff --- /dev/null +++ b/static/styles.css @@ -0,0 +1,23 @@ +/* Size for brand */ +nav .navbar-brand +{ + font-size: xx-large; +} + +/* Colors for brand */ +nav .navbar-brand .blue +{ + color: #537fbe; +} +nav .navbar-brand .red +{ + color: #ea433b; +} +nav .navbar-brand .yellow +{ + color: #f5b82e; +} +nav .navbar-brand .green +{ + color: #2e944b; +} diff --git a/templates/apology.html b/templates/apology.html new file mode 100644 index 0000000..6a5a0e6 --- /dev/null +++ b/templates/apology.html @@ -0,0 +1,10 @@ +{% extends "layout.html" %} + +{% block title %} + Apology +{% endblock %} + +{% block main %} + + +{% 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 %} +Symbol | + +Shares | +Type | +Price | +Time | +
{{ transaction.symbol | upper() }} | +{{ transaction.shares }} | +{{ transaction.type }} | +{{ transaction.price }} | +{{ transaction.time }} | +
Symbol | +Name | +Shares | +Price | +Total | +
{{ asset.symbol | upper() }} | +{{ asset.name }} | +{{ asset.shares }} | +{{ asset.price | usd }} | +{{ (asset.price * asset.shares) | usd }} | +
Cash | +{{current_cash | usd}} | +|||
Total | +{{ total | usd }} | + +
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