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