05/31 Phase 4
This commit is contained in:
@@ -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})
|
||||
Reference in New Issue
Block a user