05/31 Phase 4

This commit is contained in:
2026-05-31 16:40:59 -04:00
parent 1560041cdb
commit 9c846159ef
9 changed files with 925 additions and 1 deletions
+2
View File
@@ -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 (
+249
View File
@@ -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('/<int:id>')
@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('/<int:id>/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('/<int:id>/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('/<int:id>/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/<int:id>/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/<ticker>')
@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})
+122
View File
@@ -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',
}
+1 -1
View File
@@ -197,7 +197,7 @@
</a>
<div class="sb-section">Growth</div>
<a href="#" class="sb-link">
<a href="{{ url_for('investments.index') }}" class="sb-link {% if request.blueprint == 'investments' %}active{% endif %}">
<i class="bi bi-graph-up-arrow"></i><span class="lt">Investments</span>
</a>
<a href="#" class="sb-link">
+130
View File
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}{{ inv.asset_name }}{% endblock %}
{% block page_title %}{{ inv.asset_name }}{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('investments.add_transaction', id=inv.id) }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Add Transaction</a>
<a href="{{ url_for('investments.edit', id=inv.id) }}" class="btn btn-sm btn-outline-secondary ms-1" style="font-size:12px;"><i class="bi bi-pencil me-1"></i>Edit</a>
{% endblock %}
{% block content %}
<!-- Holding summary -->
<div class="row g-3 mb-4">
<div class="col-12 col-md-5">
<div class="pcard h-100" style="border-left:4px solid {{ asset_colors.get(inv.asset_type,'#94a3b8') }};">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<div style="font-size:18px;font-weight:700;">{{ inv.asset_name }}</div>
<div style="font-size:12px;color:var(--muted);">
{% if inv.ticker %}<span class="mono">{{ inv.ticker }}</span> · {% endif %}
<span style="background:{{ asset_colors.get(inv.asset_type,'#94a3b8') }}22;color:{{ asset_colors.get(inv.asset_type,'#94a3b8') }};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">
{{ asset_labels.get(inv.asset_type, inv.asset_type) }}
</span>
</div>
</div>
<form method="POST" action="{{ url_for('investments.delete', id=inv.id) }}" onsubmit="return confirm('Remove this holding?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;">Remove</button>
</form>
</div>
<div class="row g-2 mt-1">
<div class="col-6">
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Shares</div>
<div class="mono" style="font-size:16px;font-weight:600;">{{ "%.6f"|format(inv.shares|float)|replace('000000','').rstrip('0').rstrip('.') }}</div>
</div>
<div class="col-6">
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Avg Cost</div>
<div class="mono" style="font-size:16px;font-weight:600;">{{ inv.avg_cost_basis | currency }}</div>
</div>
<div class="col-6">
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Current Price</div>
<div class="mono" style="font-size:16px;font-weight:600;">
{% if inv.current_price %}{{ inv.current_price | currency }}
{% else %}<span class="text-muted"></span>{% endif %}
</div>
{% if inv.last_price_update %}
<div style="font-size:10px;color:var(--muted);">{{ inv.last_price_update.strftime('%b %d, %H:%M') }}</div>
{% endif %}
</div>
<div class="col-6">
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Market Value</div>
<div class="mono text-invest" style="font-size:16px;font-weight:600;">{{ inv.current_value | currency }}</div>
</div>
</div>
<hr style="border-color:var(--border);">
<div class="d-flex justify-content-between">
<div>
<div style="font-size:11px;color:var(--muted);">Total Cost Basis</div>
<div class="mono" style="font-size:14px;">{{ inv.total_cost | currency }}</div>
</div>
<div class="text-end">
<div style="font-size:11px;color:var(--muted);">Unrealized P&amp;L</div>
<div class="mono {% if inv.unrealized_gain >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:14px;font-weight:600;">
{% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }}
<span style="font-size:11px;">({% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}%)</span>
</div>
</div>
</div>
{% if inv.notes %}
<div style="font-size:12px;color:var(--muted);margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">{{ inv.notes }}</div>
{% endif %}
</div>
</div>
<div class="col-12 col-md-7">
<div class="pcard p-0 h-100">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Transaction History</span>
<a href="{{ url_for('investments.add_transaction', id=inv.id) }}" class="btn btn-sm btn-outline-primary" style="font-size:11px;padding:2px 8px;"><i class="bi bi-plus-lg"></i></a>
</div>
{% if txns %}
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Date</th>
<th>Type</th>
<th class="text-end">Shares</th>
<th class="text-end">Price</th>
<th class="text-end">Total</th>
<th class="text-end" style="padding-right:20px;"></th>
</tr>
</thead>
<tbody>
{% for txn in txns %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d, %Y') }}</td>
<td>
<span class="badge" style="font-size:11px;{% if txn.transaction_type == 'buy' %}background:#d1fae5;color:#065f46;{% elif txn.transaction_type == 'sell' %}background:#fee2e2;color:#991b1b;{% elif txn.transaction_type == 'dividend' %}background:#dbeafe;color:#1e40af;{% else %}background:#f3e8ff;color:#6b21a8;{% endif %}">
{{ txn.transaction_type | title }}
</span>
</td>
<td class="text-end mono" style="font-size:12px;">{{ txn.shares }}</td>
<td class="text-end mono" style="font-size:12px;">{{ txn.price_per_share | currency }}</td>
<td class="text-end mono {% if txn.transaction_type == 'buy' %}text-expense{% else %}text-income{% endif %}" style="font-size:12px;font-weight:500;">
{{ txn.total_amount | currency }}
</td>
<td class="text-end" style="padding-right:20px;">
<form method="POST" action="{{ url_for('investments.delete_transaction', id=txn.id) }}" onsubmit="return confirm('Delete this transaction?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;padding:2px 6px;">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-5">
<p class="text-muted small mb-2">No transactions yet.</p>
<a href="{{ url_for('investments.add_transaction', id=inv.id) }}" class="btn btn-sm btn-primary">Record First Buy</a>
</div>
{% endif %}
</div>
</div>
</div>
<a href="{{ url_for('investments.index') }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Portfolio</a>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ form.asset_type.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.asset_type(class="form-select") }}
</div>
<div class="col-6">
{{ form.ticker.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
{{ form.ticker(class="form-control", placeholder="AAPL", id="tickerInput", style="text-transform:uppercase;") }}
<button type="button" class="btn btn-outline-secondary" id="checkTicker" style="font-size:12px;">Check</button>
</div>
<div id="tickerResult" style="font-size:12px;margin-top:4px;"></div>
<small class="text-muted" style="font-size:11px;">Yahoo Finance format. e.g. AAPL, BTC-USD, ETH-USD</small>
</div>
</div>
<div class="mb-4">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes") }}
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('investments.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.getElementById('checkTicker').addEventListener('click', function() {
const ticker = document.getElementById('tickerInput').value.trim().toUpperCase();
const result = document.getElementById('tickerResult');
if (!ticker) { result.textContent = ''; return; }
result.innerHTML = '<span class="text-muted">Fetching…</span>';
fetch('/investments/api/price/' + encodeURIComponent(ticker))
.then(r => r.json())
.then(data => {
if (data.price) {
result.innerHTML = '<span class="text-success"><i class="bi bi-check-circle me-1"></i>' + ticker + ' → {{ current_user.currency_symbol }}' + data.price.toFixed(4) + '</span>';
} else {
result.innerHTML = '<span class="text-danger"><i class="bi bi-x-circle me-1"></i>Ticker not found or no data</span>';
}
})
.catch(() => { result.innerHTML = '<span class="text-warning">Could not fetch price</span>'; });
});
</script>
{% endblock %}
+204
View File
@@ -0,0 +1,204 @@
{% extends "base.html" %}
{% block title %}Investments{% endblock %}
{% block page_title %}Investment Portfolio{% endblock %}
{% block topbar_actions %}
<form method="POST" action="{{ url_for('investments.refresh_prices') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;" title="Refresh prices for all tickers">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh Prices
</button>
</form>
<a href="{{ url_for('investments.new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Add Holding</a>
{% endblock %}
{% block content %}
{% if portfolio.count == 0 %}
<div class="pcard text-center py-5">
<i class="bi bi-graph-up-arrow text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">No investments yet</h5>
<p class="text-muted small mb-3">Track stocks, ETFs, crypto, real estate, and more.</p>
<a href="{{ url_for('investments.new') }}" class="btn btn-primary btn-sm">Add First Holding</a>
</div>
{% else %}
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Total Value</div>
<div class="stat-value text-invest">{{ portfolio.total_value | currency }}</div>
</div>
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-graph-up"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Total Cost</div>
<div class="stat-value">{{ portfolio.total_cost | currency }}</div>
</div>
<div class="stat-icon" style="background:#f1f5f9;color:#64748b;"><i class="bi bi-cash-stack"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Unrealized P&amp;L</div>
<div class="stat-value {% if portfolio.total_gain >= 0 %}text-income{% else %}text-expense{% endif %}">
{{ portfolio.total_gain | currency }}
</div>
</div>
<div class="stat-icon" style="background:{% if portfolio.total_gain >= 0 %}#d1fae5{% else %}#fee2e2{% endif %};color:{% if portfolio.total_gain >= 0 %}#065f46{% else %}#991b1b{% endif %};">
<i class="bi bi-{% if portfolio.total_gain >= 0 %}trending-up{% else %}trending-down{% endif %}"></i>
</div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Return</div>
<div class="stat-value {% if portfolio.total_gain_pct >= 0 %}text-income{% else %}text-expense{% endif %}">
{% if portfolio.total_gain_pct >= 0 %}+{% endif %}{{ portfolio.total_gain_pct }}%
</div>
</div>
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-percent"></i></div>
</div>
</div>
</div>
</div>
<!-- Chart + Allocation -->
<div class="row g-3 mb-4">
<div class="col-12 col-lg-5">
<div class="pcard h-100">
<div class="pcard-title mb-3">Allocation</div>
<div style="position:relative;height:220px;display:flex;align-items:center;justify-content:center;">
<canvas id="allocChart"></canvas>
</div>
<div class="mt-3">
{% for item in portfolio.allocation %}
<div class="d-flex justify-content-between align-items-center py-1" style="border-bottom:1px solid var(--border);">
<div class="d-flex align-items-center gap-2">
<div style="width:10px;height:10px;border-radius:2px;background:{{ item.color }};flex-shrink:0;"></div>
<span style="font-size:13px;">{{ item.type | replace('_',' ') | title }}</span>
</div>
<div class="d-flex gap-3">
<span class="mono text-muted" style="font-size:12px;">{{ item.value | currency }}</span>
<span class="mono" style="font-size:12px;width:40px;text-align:right;">{{ item.pct }}%</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Holdings table -->
<div class="col-12 col-lg-7">
<div class="pcard p-0 h-100">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Holdings</span>
</div>
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Asset</th>
<th class="text-end">Shares</th>
<th class="text-end">Price</th>
<th class="text-end">Value</th>
<th class="text-end" style="padding-right:20px;">P&amp;L</th>
</tr>
</thead>
<tbody>
{% for inv in portfolio.investments %}
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('investments.detail', id=inv.id) }}'">
<td style="padding-left:20px;">
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ asset_colors.get(inv.asset_type,'#94a3b8') }}22;color:{{ asset_colors.get(inv.asset_type,'#94a3b8') }};display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">
{{ (inv.ticker or inv.asset_name[:2]).upper()[:2] }}
</div>
<div>
<div style="font-size:13px;font-weight:500;">{{ inv.asset_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{% if inv.ticker %}<span class="mono">{{ inv.ticker }}</span> · {% endif %}
{{ asset_labels.get(inv.asset_type, inv.asset_type) }}
</div>
</div>
</div>
</td>
<td class="text-end mono" style="font-size:12px;color:var(--muted);">
{{ "%.4f"|format(inv.shares|float) | replace('.0000','') }}
</td>
<td class="text-end">
{% if inv.current_price %}
<span class="mono" style="font-size:12px;">{{ inv.current_price | currency }}</span>
{% else %}
<span class="text-muted" style="font-size:12px;"></span>
{% endif %}
</td>
<td class="text-end mono" style="font-size:13px;font-weight:600;">{{ inv.current_value | currency }}</td>
<td class="text-end" style="padding-right:20px;">
<div class="mono {% if inv.unrealized_gain >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:12px;font-weight:600;">
{% if inv.unrealized_gain >= 0 %}+{% endif %}{{ inv.unrealized_gain | currency }}
</div>
<div style="font-size:10px;color:var(--muted);">
{% if inv.unrealized_gain_pct >= 0 %}+{% endif %}{{ inv.unrealized_gain_pct }}%
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if portfolio.count > 0 %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function(){
const ctx = document.getElementById('allocChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: {{ chart_labels | tojson }},
datasets: [{
data: {{ chart_values | tojson }},
backgroundColor: {{ chart_colors | tojson }},
borderWidth: 2,
borderColor: '#ffffff',
hoverOffset: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '68%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(ctx) {
const sym = '{{ current_user.currency_symbol }}';
return ' ' + sym + ctx.parsed.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
}
}
}
}
}
});
})();
</script>
{% endif %}
{% endblock %}
@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
<!-- Holding mini-card -->
<div class="pcard pcard-sm mb-3 d-flex align-items-center gap-3">
<div style="width:36px;height:36px;border-radius:9px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;">
{{ (inv.ticker or inv.asset_name[:2]).upper()[:2] }}
</div>
<div>
<div style="font-size:14px;font-weight:600;">{{ inv.asset_name }}</div>
<div style="font-size:12px;color:var(--muted);">
{% if inv.ticker %}<span class="mono">{{ inv.ticker }}</span> · {% endif %}
{{ inv.shares|float|round(4) }} shares held
{% if inv.current_price %} · Current: {{ inv.current_price | currency }}{% endif %}
</div>
</div>
</div>
<div class="pcard">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.transaction_type.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.transaction_type(class="form-select", id="txnType") }}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ 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 %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="col-6">
{{ form.price_per_share.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:12px;">{{ current_user.currency_symbol }}</span>
{{ form.price_per_share(class="form-control", placeholder="0.0000", id="priceInput") }}
{% if inv.ticker %}
<button type="button" class="btn btn-outline-secondary" id="fillPrice" style="font-size:11px;" title="Fill current price"></button>
{% endif %}
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ form.fees.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:12px;">{{ current_user.currency_symbol }}</span>
{{ form.fees(class="form-control", placeholder="0.00") }}
</div>
</div>
<div class="col-6">
{{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.date(class="form-control") }}
</div>
</div>
<!-- Total preview -->
<div class="mb-3 p-3" style="background:#f8fafc;border-radius:8px;font-size:13px;">
<div class="d-flex justify-content-between">
<span class="text-muted">Estimated Total</span>
<span class="mono fw-bold" id="totalPreview">{{ current_user.currency_symbol }}0.00</span>
</div>
</div>
<div class="mb-4">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", placeholder="Optional notes") }}
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('investments.detail', id=inv.id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const sym = '{{ current_user.currency_symbol }}';
const sharesInput = document.getElementById('sharesInput');
const priceInput = document.getElementById('priceInput');
const totalPreview = document.getElementById('totalPreview');
function updateTotal() {
const s = parseFloat(sharesInput.value) || 0;
const p = parseFloat(priceInput.value) || 0;
totalPreview.textContent = sym + (s * p).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
}
sharesInput.addEventListener('input', updateTotal);
priceInput.addEventListener('input', updateTotal);
{% if inv.ticker %}
const fillBtn = document.getElementById('fillPrice');
if (fillBtn) {
fillBtn.addEventListener('click', function() {
fillBtn.textContent = '…';
fetch('/investments/api/price/{{ inv.ticker }}')
.then(r => r.json())
.then(data => {
if (data.price) {
priceInput.value = data.price.toFixed(4);
updateTotal();
}
fillBtn.textContent = '↓';
})
.catch(() => { fillBtn.textContent = '↓'; });
});
}
{% endif %}
</script>
{% endblock %}
+25
View File
@@ -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.')