06/05 Optimize app

This commit is contained in:
2026-06-05 15:23:23 -04:00
parent db44d6057b
commit 9c9aa694c4
9 changed files with 781 additions and 19 deletions
+245
View File
@@ -0,0 +1,245 @@
"""
Financial Health Score — synthesises savings rate, budget adherence, goal
progress, and emergency fund coverage into a single 0100 score.
Each component is worth 25 points. Returns the total score plus a breakdown
so the UI can show per-component detail and suggestions.
"""
from datetime import date
from dateutil.relativedelta import relativedelta
from sqlalchemy import func
from app.extensions import db
from app.models.transaction import Transaction
from app.models.goal import Goal
# ── Component scorers ────────────────────────────────────────────────────────
def _score_savings(max_pts=25):
"""
Avg savings rate over the last 3 full months.
≥ 20% → full marks; 1020% → 18; 110% → 10; ≤ 0% → 0.
"""
today = date.today()
month_start = today.replace(day=1)
# Collect last 3 complete months
incomes, expenses = [], []
for i in range(1, 4):
mo_end = (month_start - relativedelta(days=1))
mo_start = mo_end.replace(day=1)
month_start = mo_start
inc = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0))
.filter(Transaction.transaction_type == 'income',
Transaction.date >= mo_start,
Transaction.date <= mo_end).scalar())
exp = float(db.session.query(func.coalesce(func.sum(Transaction.amount), 0))
.filter(Transaction.transaction_type == 'expense',
Transaction.date >= mo_start,
Transaction.date <= mo_end).scalar())
incomes.append(inc)
expenses.append(exp)
total_inc = sum(incomes)
total_exp = sum(expenses)
rate = ((total_inc - total_exp) / total_inc * 100) if total_inc > 0 else 0
if rate >= 20:
pts = max_pts
elif rate >= 10:
pts = round(max_pts * 0.72) # 18/25
elif rate > 0:
pts = round(max_pts * 0.40) # 10/25
else:
pts = 0
return {
'points': pts,
'max': max_pts,
'value': round(rate, 1),
'label': f'{rate:+.1f}% savings rate (3-mo avg)',
'tip': None if rate >= 20 else (
'Aim for 20%+ savings rate.' if rate < 10 else
'Good start — push toward 20%.'),
}
def _score_budgets(max_pts=25):
"""
What fraction of budgeted categories are currently under their limit?
All under → full marks; scales linearly.
"""
from app.services.budget_service import get_budget_summary
month_str = date.today().strftime('%Y-%m')
summary = [s for s in get_budget_summary(month_str) if s['has_budget']]
if not summary:
return {
'points': max_pts, # no budgets set → not penalised
'max': max_pts,
'value': None,
'label': 'No budgets set',
'tip': 'Set monthly budgets to track spending limits.',
}
under = sum(1 for s in summary if not s['is_over'])
ratio = under / len(summary)
pts = round(ratio * max_pts)
over_cats = [s['category'].name for s in summary if s['is_over']]
tip = None
if over_cats:
tip = f'Over budget: {", ".join(over_cats[:3])}{"" if len(over_cats) > 3 else ""}.'
return {
'points': pts,
'max': max_pts,
'value': round(ratio * 100, 1),
'label': f'{under}/{len(summary)} categories under budget',
'tip': tip,
}
def _score_goals(max_pts=25):
"""
Average completion % across active (non-completed) goals.
100% avg → full marks; scales linearly.
"""
goals = Goal.query.filter_by(is_completed=False).all()
if not goals:
completed = Goal.query.filter_by(is_completed=True).count()
return {
'points': max_pts if completed else round(max_pts * 0.5),
'max': max_pts,
'value': 100.0 if completed else 0.0,
'label': 'All goals completed!' if completed else 'No savings goals set',
'tip': None if completed else 'Create a savings goal to track progress.',
}
pcts = []
for g in goals:
target = float(g.target_amount)
if target > 0:
pcts.append(min(float(g.current_amount) / target * 100, 100))
avg = (sum(pcts) / len(pcts)) if pcts else 0
pts = round(avg / 100 * max_pts)
behind = [g.name for g, p in zip(goals, pcts) if p < 25]
tip = None
if behind:
tip = f'Behind on: {", ".join(behind[:2])}{"" if len(behind) > 2 else ""}.'
return {
'points': pts,
'max': max_pts,
'value': round(avg, 1),
'label': f'{round(avg, 0):.0f}% avg goal progress ({len(goals)} active)',
'tip': tip,
}
def _score_emergency_fund(max_pts=25):
"""
Liquid assets vs 3-month expense target.
≥ 3 months → full marks; scales linearly up to 3 months.
"""
from app.services.goal_service import get_emergency_fund_status
ef = get_emergency_fund_status()
liquid = ef['liquid_assets']
target3 = ef['target_3mo']
covered = ef['months_covered']
pct3 = ef['pct_3mo'] # 0100, capped
pts = round(pct3 / 100 * max_pts)
if covered >= 3:
tip = None
elif covered >= 1:
short = target3 - liquid
tip = f'Build to 3-month emergency fund (need ${short:,.0f} more).'
else:
tip = 'Start an emergency fund — aim for 1 month of expenses first.'
return {
'points': pts,
'max': max_pts,
'value': round(covered, 1),
'label': f'{covered:.1f} months emergency fund',
'tip': tip,
}
# ── Public API ───────────────────────────────────────────────────────────────
def compute_health_score():
"""
Compute the overall financial health score.
Returns:
{
score: int 0100
grade: str 'A' | 'B' | 'C' | 'D' | 'F'
color: str CSS color
components: list of component dicts
}
"""
components = {
'savings': _score_savings(),
'budgets': _score_budgets(),
'goals': _score_goals(),
'emergency_fund': _score_emergency_fund(),
}
score = sum(c['points'] for c in components.values())
if score >= 85:
grade, color = 'A', '#10b981'
elif score >= 70:
grade, color = 'B', '#3b82f6'
elif score >= 55:
grade, color = 'C', '#f59e0b'
elif score >= 40:
grade, color = 'D', '#f97316'
else:
grade, color = 'F', '#ef4444'
# Add display names for the template
labels = {
'savings': 'Savings Rate',
'budgets': 'Budget Adherence',
'goals': 'Goal Progress',
'emergency_fund': 'Emergency Fund',
}
icons = {
'savings': 'bi-piggy-bank',
'budgets': 'bi-pie-chart',
'goals': 'bi-bullseye',
'emergency_fund': 'bi-shield-check',
}
component_list = [
{
'key': key,
'name': labels[key],
'icon': icons[key],
'points': c['points'],
'max': c['max'],
'value': c['value'],
'label': c['label'],
'tip': c['tip'],
'pct': round(c['points'] / c['max'] * 100),
}
for key, c in components.items()
]
return {
'score': score,
'grade': grade,
'color': color,
'components': component_list,
'tips': [c['tip'] for c in component_list if c['tip']],
}