Files
Personal-Finance-Management/app/services/health_score_service.py
T
2026-06-05 15:23:23 -04:00

246 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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']],
}