06/05 Optimize app
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Financial Health Score — synthesises savings rate, budget adherence, goal
|
||||
progress, and emergency fund coverage into a single 0–100 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; 10–20% → 18; 1–10% → 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'] # 0–100, 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 0–100
|
||||
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']],
|
||||
}
|
||||
Reference in New Issue
Block a user