309 lines
11 KiB
Python
309 lines
11 KiB
Python
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,
|
|
fetch_price_history, fetch_day_change,
|
|
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()])
|
|
shares = DecimalField('Shares / Units', validators=[Optional(), NumberRange(min=0)],
|
|
places=8, default=Decimal('0'))
|
|
avg_cost_basis = DecimalField('Avg Cost per Share', validators=[Optional(), NumberRange(min=0)],
|
|
places=4, default=Decimal('0'))
|
|
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
|
|
# Manual override of shares/cost basis — only update if user provided values
|
|
if form.shares.data is not None:
|
|
inv.shares = form.shares.data
|
|
if form.avg_cost_basis.data is not None:
|
|
inv.avg_cost_basis = form.avg_cost_basis.data
|
|
db.session.commit()
|
|
flash('Investment updated.', 'success')
|
|
return redirect(url_for('investments.detail', id=inv.id))
|
|
# Pre-populate shares/cost for edit form
|
|
if request.method == 'GET':
|
|
form.shares.data = inv.shares
|
|
form.avg_cost_basis.data = inv.avg_cost_basis
|
|
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/edit investment form."""
|
|
ticker = ticker.upper().strip()
|
|
error = None
|
|
price = None
|
|
try:
|
|
price = fetch_price(ticker)
|
|
if price is None:
|
|
error = f'No data returned for {ticker}. Check the ticker format.'
|
|
except Exception as e:
|
|
error = str(e)
|
|
|
|
return jsonify({
|
|
'ticker': ticker,
|
|
'price': price,
|
|
'error': error,
|
|
})
|
|
|
|
|
|
@investments_bp.route('/api/daychange/<ticker>')
|
|
@login_required
|
|
def api_day_change(ticker):
|
|
"""
|
|
Lightweight endpoint: return today's open-to-current day change only.
|
|
Used by the portfolio page to load change badges quickly.
|
|
"""
|
|
data = fetch_day_change(ticker.upper().strip())
|
|
if data is None:
|
|
return jsonify({'error': f'No data for {ticker}'}), 404
|
|
return jsonify(data)
|
|
|
|
|
|
@investments_bp.route('/api/history/<ticker>')
|
|
@login_required
|
|
def api_price_history(ticker):
|
|
"""
|
|
Return OHLC history + day/period change for a ticker.
|
|
Query param: tf = 1W | 1M | 3M | 6M | 1Y (default 1M)
|
|
Used by the portfolio page inline charts.
|
|
"""
|
|
tf = request.args.get('tf', '1M').upper()
|
|
if tf not in ('1W', '1M', '3M', '6M', '1Y'):
|
|
tf = '1M'
|
|
|
|
data = fetch_price_history(ticker.upper().strip(), tf)
|
|
if data is None:
|
|
return jsonify({'error': f'No history data available for {ticker}'}), 404
|
|
|
|
return jsonify(data)
|