From 9c846159ef1e53317a634617b8d78d6b5d663f48 Mon Sep 17 00:00:00 2001 From: Nguyen HP Laptop Date: Sun, 31 May 2026 16:40:59 -0400 Subject: [PATCH] 05/31 Phase 4 --- app/__init__.py | 2 + app/routes/investments.py | 249 ++++++++++++++++++ app/services/investment_service.py | 122 +++++++++ app/templates/base.html | 2 +- app/templates/investments/detail.html | 130 +++++++++ app/templates/investments/form.html | 69 +++++ app/templates/investments/index.html | 204 ++++++++++++++ .../investments/transaction_form.html | 123 +++++++++ scripts/fetch_prices.py | 25 ++ 9 files changed, 925 insertions(+), 1 deletion(-) create mode 100644 app/routes/investments.py create mode 100644 app/services/investment_service.py create mode 100644 app/templates/investments/detail.html create mode 100644 app/templates/investments/form.html create mode 100644 app/templates/investments/index.html create mode 100644 app/templates/investments/transaction_form.html create mode 100644 scripts/fetch_prices.py diff --git a/app/__init__.py b/app/__init__.py index 4782a30..53a067a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -25,6 +25,7 @@ def create_app(config_name=None): from app.routes.transactions import transactions_bp from app.routes.budgets import budgets_bp from app.routes.goals import goals_bp + from app.routes.investments import investments_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -33,6 +34,7 @@ def create_app(config_name=None): app.register_blueprint(transactions_bp) app.register_blueprint(budgets_bp) app.register_blueprint(goals_bp) + app.register_blueprint(investments_bp) with app.app_context(): from app.models import ( diff --git a/app/routes/investments.py b/app/routes/investments.py new file mode 100644 index 0000000..34a24fd --- /dev/null +++ b/app/routes/investments.py @@ -0,0 +1,249 @@ +from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify +from flask_login import login_required +from flask_wtf import FlaskForm +from wtforms import StringField, SelectField, DecimalField, DateField, TextAreaField, SubmitField +from wtforms.validators import DataRequired, Optional, NumberRange, Length +from app.extensions import db +from app.models.investment import Investment, InvestmentTransaction +from app.services.investment_service import ( + get_portfolio_summary, update_prices, fetch_price, + ASSET_COLORS, ASSET_TYPE_LABELS +) +from datetime import date +from decimal import Decimal + +investments_bp = Blueprint('investments', __name__, url_prefix='/investments') + +ASSET_TYPES = [ + ('stock', 'Stock'), + ('etf', 'ETF'), + ('crypto', 'Crypto'), + ('real_estate', 'Real Estate'), + ('bond', 'Bond'), + ('cash', 'Cash'), + ('other', 'Other'), +] + +TXN_TYPES = [ + ('buy', 'Buy'), + ('sell', 'Sell'), + ('dividend', 'Dividend'), + ('split', 'Split'), +] + + +class InvestmentForm(FlaskForm): + asset_name = StringField('Asset Name', validators=[DataRequired(), Length(1, 100)]) + ticker = StringField('Ticker Symbol', validators=[Optional(), Length(max=20)], + description='e.g. AAPL, BTC-USD, VNM') + asset_type = SelectField('Asset Type', choices=ASSET_TYPES, validators=[DataRequired()]) + notes = TextAreaField('Notes', validators=[Optional()]) + submit = SubmitField('Save') + + +class InvestmentTransactionForm(FlaskForm): + transaction_type = SelectField('Type', choices=TXN_TYPES, validators=[DataRequired()]) + shares = DecimalField('Shares / Units', validators=[DataRequired(), NumberRange(min=0.000001)], + places=8) + price_per_share = DecimalField('Price per Share', validators=[DataRequired(), NumberRange(min=0)], + places=4) + fees = DecimalField('Fees', validators=[Optional()], places=2, default=0) + date = DateField('Date', validators=[DataRequired()], default=date.today) + notes = StringField('Notes', validators=[Optional(), Length(max=255)]) + submit = SubmitField('Record Transaction') + + +def _recalc_holding(investment): + """ + Recalculate shares and avg_cost_basis from transaction log (FIFO approach). + """ + txns = investment.inv_transactions.order_by( + InvestmentTransaction.date.asc() + ).all() + + total_shares = Decimal('0') + total_cost = Decimal('0') + + for txn in txns: + shares = Decimal(str(txn.shares)) + price = Decimal(str(txn.price_per_share)) + fees = Decimal(str(txn.fees or 0)) + + if txn.transaction_type == 'buy': + total_cost += (shares * price) + fees + total_shares += shares + elif txn.transaction_type == 'sell': + if total_shares > 0: + avg = total_cost / total_shares + total_cost -= avg * min(shares, total_shares) + total_shares = max(total_shares - shares, Decimal('0')) + elif txn.transaction_type == 'dividend': + pass # dividends don't change cost basis + elif txn.transaction_type == 'split': + # treat split ratio as shares multiplier + total_shares += shares + + investment.shares = total_shares + investment.avg_cost_basis = (total_cost / total_shares) if total_shares > 0 else Decimal('0') + db.session.commit() + + +@investments_bp.route('/') +@login_required +def index(): + portfolio = get_portfolio_summary() + + # Build allocation chart data + chart_labels = [ASSET_TYPE_LABELS.get(a['type'], a['type']) for a in portfolio['allocation']] + chart_values = [round(a['value'], 2) for a in portfolio['allocation']] + chart_colors = [a['color'] for a in portfolio['allocation']] + + return render_template('investments/index.html', + portfolio=portfolio, + chart_labels=chart_labels, + chart_values=chart_values, + chart_colors=chart_colors, + asset_colors=ASSET_COLORS, + asset_labels=ASSET_TYPE_LABELS) + + +@investments_bp.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + form = InvestmentForm() + if form.validate_on_submit(): + inv = Investment( + asset_name=form.asset_name.data.strip(), + ticker=form.ticker.data.strip().upper() if form.ticker.data else None, + asset_type=form.asset_type.data, + shares=0, + avg_cost_basis=0, + notes=form.notes.data, + ) + db.session.add(inv) + db.session.commit() + # Fetch initial price if ticker provided + if inv.ticker: + price = fetch_price(inv.ticker) + if price: + inv.current_price = price + from datetime import datetime + inv.last_price_update = datetime.utcnow() + db.session.commit() + flash(f'"{inv.asset_name}" added. Record your first buy transaction.', 'success') + return redirect(url_for('investments.detail', id=inv.id)) + return render_template('investments/form.html', form=form, title='Add Investment') + + +@investments_bp.route('/') +@login_required +def detail(id): + inv = db.get_or_404(Investment, id) + txns = inv.inv_transactions.order_by( + InvestmentTransaction.date.desc() + ).all() + return render_template('investments/detail.html', inv=inv, txns=txns, + asset_labels=ASSET_TYPE_LABELS, + asset_colors=ASSET_COLORS) + + +@investments_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(id): + inv = db.get_or_404(Investment, id) + form = InvestmentForm(obj=inv) + if form.validate_on_submit(): + inv.asset_name = form.asset_name.data.strip() + inv.ticker = form.ticker.data.strip().upper() if form.ticker.data else None + inv.asset_type = form.asset_type.data + inv.notes = form.notes.data + db.session.commit() + flash('Investment updated.', 'success') + return redirect(url_for('investments.detail', id=inv.id)) + return render_template('investments/form.html', form=form, title='Edit Investment', inv=inv) + + +@investments_bp.route('//delete', methods=['POST']) +@login_required +def delete(id): + inv = db.get_or_404(Investment, id) + inv.is_active = False + db.session.commit() + flash(f'"{inv.asset_name}" removed.', 'info') + return redirect(url_for('investments.index')) + + +@investments_bp.route('//transactions/new', methods=['GET', 'POST']) +@login_required +def add_transaction(id): + inv = db.get_or_404(Investment, id) + form = InvestmentTransactionForm() + + if form.validate_on_submit(): + shares = form.shares.data + price = form.price_per_share.data + fees = form.fees.data or 0 + total = float(shares) * float(price) + + txn = InvestmentTransaction( + investment_id=inv.id, + transaction_type=form.transaction_type.data, + shares=shares, + price_per_share=price, + total_amount=total, + fees=fees, + date=form.date.data, + notes=form.notes.data, + ) + db.session.add(txn) + db.session.commit() + + # Recalculate holding from transaction log + _recalc_holding(inv) + + # Update current price if no price yet + if inv.current_price is None and inv.ticker: + p = fetch_price(inv.ticker) + if p: + from datetime import datetime + inv.current_price = p + inv.last_price_update = datetime.utcnow() + db.session.commit() + + flash(f'{form.transaction_type.data.title()} transaction recorded.', 'success') + return redirect(url_for('investments.detail', id=inv.id)) + + return render_template('investments/transaction_form.html', + form=form, inv=inv, + title=f'New Transaction — {inv.asset_name}') + + +@investments_bp.route('/transactions//delete', methods=['POST']) +@login_required +def delete_transaction(id): + txn = db.get_or_404(InvestmentTransaction, id) + inv = txn.investment + db.session.delete(txn) + db.session.commit() + _recalc_holding(inv) + flash('Transaction deleted.', 'info') + return redirect(url_for('investments.detail', id=inv.id)) + + +@investments_bp.route('/refresh-prices', methods=['POST']) +@login_required +def refresh_prices(): + updated = update_prices() + if updated: + flash(f'Updated prices for: {", ".join(updated.keys())}', 'success') + else: + flash('No prices updated (no tickers or fetch failed).', 'warning') + return redirect(url_for('investments.index')) + + +@investments_bp.route('/api/price/') +@login_required +def api_price(ticker): + """Live price lookup for a ticker — used in the add transaction form.""" + price = fetch_price(ticker) + return jsonify({'ticker': ticker.upper(), 'price': price}) diff --git a/app/services/investment_service.py b/app/services/investment_service.py new file mode 100644 index 0000000..203d8d2 --- /dev/null +++ b/app/services/investment_service.py @@ -0,0 +1,122 @@ +""" +Investment Service — price fetching via yfinance, portfolio calculations. +""" + +import logging +from datetime import datetime +from app.extensions import db +from app.models.investment import Investment + +log = logging.getLogger(__name__) + + +def fetch_price(ticker): + """ + Fetch latest price for a ticker via yfinance. + Returns float or None on failure. + """ + if not ticker: + return None + try: + import yfinance as yf + t = yf.Ticker(ticker.upper()) + hist = t.history(period='2d') + if hist.empty: + return None + return float(hist['Close'].iloc[-1]) + except Exception as e: + log.warning(f'[investment] price fetch failed for {ticker}: {e}') + return None + + +def update_prices(investment_ids=None): + """ + Update current_price for all (or specified) investments with a ticker. + Returns dict: {ticker: new_price} + """ + query = Investment.query.filter( + Investment.ticker != None, + Investment.ticker != '', + Investment.is_active == True, + ) + if investment_ids: + query = query.filter(Investment.id.in_(investment_ids)) + + investments = query.all() + updated = {} + + for inv in investments: + price = fetch_price(inv.ticker) + if price is not None: + inv.current_price = price + inv.last_price_update = datetime.utcnow() + updated[inv.ticker] = price + + if updated: + try: + db.session.commit() + except Exception as e: + db.session.rollback() + log.error(f'[investment] DB commit failed: {e}') + + return updated + + +def get_portfolio_summary(): + """ + Return portfolio-level aggregates across all active investments. + """ + investments = Investment.query.filter_by(is_active=True).all() + + total_cost = sum(i.total_cost for i in investments) + total_value = sum(i.current_value for i in investments) + total_gain = total_value - total_cost + total_gain_pct = round((total_gain / total_cost) * 100, 2) if total_cost > 0 else 0 + + # Group by asset type for allocation chart + type_totals = {} + for inv in investments: + t = inv.asset_type + type_totals[t] = type_totals.get(t, 0) + inv.current_value + + allocation = [] + for asset_type, value in sorted(type_totals.items(), key=lambda x: -x[1]): + pct = round((value / total_value * 100), 1) if total_value > 0 else 0 + allocation.append({ + 'type': asset_type, + 'value': value, + 'pct': pct, + 'color': ASSET_COLORS.get(asset_type, '#94a3b8'), + }) + + return { + 'investments': investments, + 'total_cost': total_cost, + 'total_value': total_value, + 'total_gain': total_gain, + 'total_gain_pct': total_gain_pct, + 'allocation': allocation, + 'count': len(investments), + } + + +# Consistent colors per asset type +ASSET_COLORS = { + 'stock': '#3b82f6', + 'etf': '#06b6d4', + 'crypto': '#f59e0b', + 'real_estate': '#10b981', + 'bond': '#8b5cf6', + 'cash': '#64748b', + 'other': '#ec4899', +} + +ASSET_TYPE_LABELS = { + 'stock': 'Stock', + 'etf': 'ETF', + 'crypto': 'Crypto', + 'real_estate': 'Real Estate', + 'bond': 'Bond', + 'cash': 'Cash', + 'other': 'Other', +} diff --git a/app/templates/base.html b/app/templates/base.html index cb054d9..cc94741 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -197,7 +197,7 @@
Growth
- + Investments diff --git a/app/templates/investments/detail.html b/app/templates/investments/detail.html new file mode 100644 index 0000000..4817a95 --- /dev/null +++ b/app/templates/investments/detail.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} +{% block title %}{{ inv.asset_name }}{% endblock %} +{% block page_title %}{{ inv.asset_name }}{% endblock %} + +{% block topbar_actions %} +Add Transaction +Edit +{% endblock %} + +{% block content %} + +
+
+
+
+
+
{{ inv.asset_name }}
+
+ {% if inv.ticker %}{{ inv.ticker }} · {% endif %} + + {{ asset_labels.get(inv.asset_type, inv.asset_type) }} + +
+
+
+ + +
+
+ +
+
+
Shares
+
{{ "%.6f"|format(inv.shares|float)|replace('000000','').rstrip('0').rstrip('.') }}
+
+
+
Avg Cost
+
{{ inv.avg_cost_basis | currency }}
+
+
+
Current Price
+
+ {% if inv.current_price %}{{ inv.current_price | currency }} + {% else %}{% endif %} +
+ {% if inv.last_price_update %} +
{{ inv.last_price_update.strftime('%b %d, %H:%M') }}
+ {% endif %} +
+
+
Market Value
+
{{ inv.current_value | currency }}
+
+
+ +
+
+
+
Total Cost Basis
+
{{ inv.total_cost | currency }}
+
+
+
Unrealized P&L
+
+ {% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }} + ({% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}%) +
+
+
+ + {% if inv.notes %} +
{{ inv.notes }}
+ {% endif %} +
+
+ +
+
+
+ Transaction History + +
+ {% if txns %} + + + + + + + + + + + + + {% for txn in txns %} + + + + + + + + + {% endfor %} + +
DateTypeSharesPriceTotal
{{ txn.date.strftime('%b %d, %Y') }} + + {{ txn.transaction_type | title }} + + {{ txn.shares }}{{ txn.price_per_share | currency }} + {{ txn.total_amount | currency }} + +
+ + +
+
+ {% else %} +
+

No transactions yet.

+ Record First Buy +
+ {% endif %} +
+
+
+ +Back to Portfolio +{% endblock %} diff --git a/app/templates/investments/form.html b/app/templates/investments/form.html new file mode 100644 index 0000000..a026b8a --- /dev/null +++ b/app/templates/investments/form.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.asset_name.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.asset_name(class="form-control" + (" is-invalid" if form.asset_name.errors else ""), + placeholder="e.g. Apple Inc., Bitcoin, Vanguard S&P 500") }} + {% for e in form.asset_name.errors %}
{{ e }}
{% endfor %} +
+ +
+
+ {{ form.asset_type.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.asset_type(class="form-select") }} +
+
+ {{ form.ticker.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ form.ticker(class="form-control", placeholder="AAPL", id="tickerInput", style="text-transform:uppercase;") }} + +
+
+ Yahoo Finance format. e.g. AAPL, BTC-USD, ETH-USD +
+
+ +
+ {{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/investments/index.html b/app/templates/investments/index.html new file mode 100644 index 0000000..3791948 --- /dev/null +++ b/app/templates/investments/index.html @@ -0,0 +1,204 @@ +{% extends "base.html" %} +{% block title %}Investments{% endblock %} +{% block page_title %}Investment Portfolio{% endblock %} + +{% block topbar_actions %} +
+ + +
+Add Holding +{% endblock %} + +{% block content %} +{% if portfolio.count == 0 %} +
+ +
No investments yet
+

Track stocks, ETFs, crypto, real estate, and more.

+ Add First Holding +
+{% else %} + + +
+
+
+
+
+
Total Value
+
{{ portfolio.total_value | currency }}
+
+
+
+
+
+
+
+
+
+
Total Cost
+
{{ portfolio.total_cost | currency }}
+
+
+
+
+
+
+
+
+
+
Unrealized P&L
+
+ {{ portfolio.total_gain | currency }} +
+
+
+ +
+
+
+
+
+
+
+
+
Return
+
+ {% if portfolio.total_gain_pct >= 0 %}+{% endif %}{{ portfolio.total_gain_pct }}% +
+
+
+
+
+
+
+ + +
+
+
+
Allocation
+
+ +
+
+ {% for item in portfolio.allocation %} +
+
+
+ {{ item.type | replace('_',' ') | title }} +
+
+ {{ item.value | currency }} + {{ item.pct }}% +
+
+ {% endfor %} +
+
+
+ + +
+
+
+ Holdings +
+ + + + + + + + + + + + {% for inv in portfolio.investments %} + + + + + + + + {% endfor %} + +
AssetSharesPriceValueP&L
+
+
+ {{ (inv.ticker or inv.asset_name[:2]).upper()[:2] }} +
+
+
{{ inv.asset_name }}
+
+ {% if inv.ticker %}{{ inv.ticker }} · {% endif %} + {{ asset_labels.get(inv.asset_type, inv.asset_type) }} +
+
+
+
+ {{ "%.4f"|format(inv.shares|float) | replace('.0000','') }} + + {% if inv.current_price %} + {{ inv.current_price | currency }} + {% else %} + + {% endif %} + {{ inv.current_value | currency }} +
+ {% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }} +
+
+ {% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}% +
+
+
+
+
+{% endif %} +{% endblock %} + +{% block extra_js %} +{% if portfolio.count > 0 %} + + +{% endif %} +{% endblock %} diff --git a/app/templates/investments/transaction_form.html b/app/templates/investments/transaction_form.html new file mode 100644 index 0000000..6527894 --- /dev/null +++ b/app/templates/investments/transaction_form.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block page_title %}{{ title }}{% endblock %} + +{% block content %} +
+
+ + +
+
+ {{ (inv.ticker or inv.asset_name[:2]).upper()[:2] }} +
+
+
{{ inv.asset_name }}
+
+ {% if inv.ticker %}{{ inv.ticker }} · {% endif %} + {{ inv.shares|float|round(4) }} shares held + {% if inv.current_price %} · Current: {{ inv.current_price | currency }}{% endif %} +
+
+
+ +
+
+ {{ form.hidden_tag() }} + +
+ {{ form.transaction_type.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.transaction_type(class="form-select", id="txnType") }} +
+ +
+
+ {{ form.shares.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.shares(class="form-control" + (" is-invalid" if form.shares.errors else ""), + placeholder="0.00", id="sharesInput") }} + {% for e in form.shares.errors %}
{{ e }}
{% endfor %} +
+
+ {{ form.price_per_share.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.price_per_share(class="form-control", placeholder="0.0000", id="priceInput") }} + {% if inv.ticker %} + + {% endif %} +
+
+
+ +
+
+ {{ form.fees.label(class="form-label fw-medium", style="font-size:13px;") }} +
+ {{ current_user.currency_symbol }} + {{ form.fees(class="form-control", placeholder="0.00") }} +
+
+
+ {{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.date(class="form-control") }} +
+
+ + +
+
+ Estimated Total + {{ current_user.currency_symbol }}0.00 +
+
+ +
+ {{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }} + {{ form.notes(class="form-control", placeholder="Optional notes") }} +
+ +
+ {{ form.submit(class="btn btn-primary") }} + Cancel +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/scripts/fetch_prices.py b/scripts/fetch_prices.py new file mode 100644 index 0000000..28d210d --- /dev/null +++ b/scripts/fetch_prices.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +""" +Cron script: update investment prices via yfinance. +Run by systemd timer pfm-prices.timer at 4PM weekdays. +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app import create_app +from app.services.investment_service import update_prices + +app = create_app() + +if __name__ == '__main__': + with app.app_context(): + print('[fetch_prices] Starting price update...') + updated = update_prices() + if updated: + for ticker, price in updated.items(): + print(f' {ticker}: {price:.4f}') + print(f'[fetch_prices] Updated {len(updated)} ticker(s).') + else: + print('[fetch_prices] No tickers updated.')