05/31 Phase 4
This commit is contained in:
@@ -25,6 +25,7 @@ def create_app(config_name=None):
|
|||||||
from app.routes.transactions import transactions_bp
|
from app.routes.transactions import transactions_bp
|
||||||
from app.routes.budgets import budgets_bp
|
from app.routes.budgets import budgets_bp
|
||||||
from app.routes.goals import goals_bp
|
from app.routes.goals import goals_bp
|
||||||
|
from app.routes.investments import investments_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
@@ -33,6 +34,7 @@ def create_app(config_name=None):
|
|||||||
app.register_blueprint(transactions_bp)
|
app.register_blueprint(transactions_bp)
|
||||||
app.register_blueprint(budgets_bp)
|
app.register_blueprint(budgets_bp)
|
||||||
app.register_blueprint(goals_bp)
|
app.register_blueprint(goals_bp)
|
||||||
|
app.register_blueprint(investments_bp)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="sb-section">Growth</div>
|
<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>
|
<i class="bi bi-graph-up-arrow"></i><span class="lt">Investments</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class="sb-link">
|
<a href="#" class="sb-link">
|
||||||
|
|||||||
@@ -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&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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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&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&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 %}
|
||||||
@@ -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.')
|
||||||
Reference in New Issue
Block a user