ML predictions Phase 1 & 2: Budget Forecast tab and Cash Flow upgrades

Phase 1 — Budget Forecast: adds a dedicated first-class tab showing all
monthly budgets with velocity (£/day), forecast month-end total, dual
progress bars, and colour-coded overspend probability badges. Summary
bar shows budgets tracked / at-risk count / total forecast overspend.
Removes the old BudgetAlerts widget embedded in the Spending tab.

Phase 2 — Cash Flow: incorporates known recurring transactions into the
30-day projection (outflows hit on their predicted dates rather than
being smeared as averages), adds sqrt-of-time confidence bands to the
chart, and shows an upcoming recurring payments list with at-risk
highlighting for payments falling on or after the first negative-balance day.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-28 10:21:57 +00:00
parent 1a2c8efd01
commit 3b4787d8b9
3 changed files with 281 additions and 58 deletions

View file

@ -178,7 +178,9 @@ async def cashflow_forecast(
):
await _check_prediction_rate(redis, str(user.id))
from datetime import timedelta
import math
import numpy as np
from app.core.security import decrypt_field
# Historical daily cash flows (last 90 days)
hist_df = await get_daily_cash_flow(db, user.id, days=90)
@ -203,28 +205,95 @@ async def cashflow_forecast(
avg_inflow = float(hist_df["inflow"].mean())
avg_outflow = float(hist_df["outflow"].mean())
std_net = float((hist_df["inflow"] - hist_df["outflow"]).std())
if math.isnan(std_net):
std_net = 0.0
else:
avg_inflow = 0.0
avg_outflow = 0.0
std_net = 0.0
# Project 30 days forward
# Fetch recurring transactions (expenses)
rec_result = await db.execute(text("""
SELECT description_enc, amount::float, recurring_rule, date
FROM transactions
WHERE user_id = CAST(:uid AS uuid)
AND is_recurring = TRUE
AND type = 'expense'
AND deleted_at IS NULL
AND status != 'void'
ORDER BY date DESC
"""), {"uid": str(user.id)})
rec_rows = rec_result.fetchall()
_FREQ_DAYS = {"daily": 1, "weekly": 7, "fortnightly": 14, "monthly": 30, "quarterly": 91, "yearly": 365}
# Build a map of date_str -> extra outflow from recurring payments
today = date.today()
horizon = today + timedelta(days=30)
recurring_by_date: dict[str, float] = {}
upcoming_payments: list[dict] = []
# Track which (name, freq) pairs we've already scheduled to avoid duplicates
seen_recurring: set[tuple[str, str]] = set()
for desc_enc, amount, rule, last_date in rec_rows:
rule = rule or {}
freq = rule.get("frequency", "monthly")
interval_days = _FREQ_DAYS.get(freq, 30)
try:
name = decrypt_field(desc_enc) or "Recurring payment"
except Exception:
name = "Recurring payment"
dedup_key = (name.lower()[:30], freq)
if dedup_key in seen_recurring:
continue
seen_recurring.add(dedup_key)
# Walk forward from last_date until past the horizon
next_d = last_date + timedelta(days=interval_days)
while next_d <= horizon:
if next_d > today:
ds = next_d.strftime("%Y-%m-%d")
recurring_by_date[ds] = recurring_by_date.get(ds, 0.0) + abs(amount)
upcoming_payments.append({
"name": name,
"date": ds,
"amount": round(abs(amount), 2),
})
next_d += timedelta(days=interval_days)
upcoming_payments.sort(key=lambda x: x["date"])
# Project 30 days forward: baseline (avg) + known recurring hits + confidence bands
daily = []
running_balance = current_balance
for i in range(1, 31):
d = today + timedelta(days=i)
net = avg_inflow - avg_outflow
running_balance += net
ds = d.strftime("%Y-%m-%d")
base_net = avg_inflow - avg_outflow
known_outflow = recurring_by_date.get(ds, 0.0)
running_balance += base_net - known_outflow
# Confidence band widens with sqrt of days elapsed
band = std_net * math.sqrt(i) if std_net > 0 else 0.0
daily.append({
"date": d.strftime("%Y-%m-%d"),
"date": ds,
"balance": round(running_balance, 2),
"upper": round(running_balance + band, 2),
"lower": round(running_balance - band, 2),
"avg_inflow": round(avg_inflow, 2),
"avg_outflow": round(avg_outflow, 2),
"avg_outflow": round(avg_outflow + known_outflow, 2),
"negative_risk": running_balance < 0,
})
negative_days = [d["date"] for d in daily if d["negative_risk"]]
negative_day_set = set(negative_days)
# Flag upcoming payments that fall on or after the first negative-risk day
first_negative = min(negative_day_set) if negative_day_set else None
for p in upcoming_payments:
p["at_risk"] = first_negative is not None and p["date"] >= first_negative
return {
"current_balance": round(current_balance, 2),
@ -232,5 +301,6 @@ async def cashflow_forecast(
"avg_daily_outflow": round(avg_outflow, 2),
"forecast": daily,
"negative_risk_days": negative_days,
"upcoming_payments": upcoming_payments,
"history_days": len(hist_df),
}

View file

@ -61,17 +61,27 @@ export interface BudgetForecastResponse {
export interface CashFlowDay {
date: string;
balance: number;
upper: number;
lower: number;
avg_inflow: number;
avg_outflow: number;
negative_risk: boolean;
}
export interface UpcomingPayment {
name: string;
date: string;
amount: number;
at_risk: boolean;
}
export interface CashFlowResponse {
current_balance: number;
avg_daily_inflow: number;
avg_daily_outflow: number;
forecast: CashFlowDay[];
negative_risk_days: string[];
upcoming_payments: UpcomingPayment[];
history_days: number;
}

View file

@ -6,7 +6,7 @@ import {
} from "@/api/predictions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Sparkles, TrendingUp, BarChart3, Wallet, RefreshCw, Loader2 } from "lucide-react";
import { Sparkles, TrendingUp, BarChart3, Wallet, RefreshCw, Loader2, PiggyBank, AlertTriangle, CheckCircle2 } from "lucide-react";
import {
AreaChart, Area, BarChart, Bar, LineChart, Line,
XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine,
@ -16,16 +16,17 @@ import { cssVar } from "@/utils/cssVar";
import { TOOLTIP_STYLE, ACTIVE_DOT } from "@/utils/chartTheme";
const TABS = [
{ id: "budget", label: "Budget", icon: PiggyBank },
{ id: "cashflow", label: "Cash Flow", icon: Wallet },
{ id: "spending", label: "Spending", icon: BarChart3 },
{ id: "networth", label: "Net Worth", icon: TrendingUp },
{ id: "montecarlo", label: "Monte Carlo", icon: Sparkles },
{ id: "cashflow", label: "Cash Flow", icon: Wallet },
] as const;
type Tab = (typeof TABS)[number]["id"];
export default function PredictionsPage() {
const [tab, setTab] = useState<Tab>("spending");
const [tab, setTab] = useState<Tab>("budget");
return (
<div className="space-y-6">
@ -51,10 +52,153 @@ export default function PredictionsPage() {
))}
</div>
{tab === "budget" && <BudgetForecastTab />}
{tab === "cashflow" && <CashFlowTab />}
{tab === "spending" && <SpendingTab />}
{tab === "networth" && <NetWorthTab />}
{tab === "montecarlo" && <MonteCarloTab />}
{tab === "cashflow" && <CashFlowTab />}
</div>
);
}
// ─── Budget Forecast ─────────────────────────────────────────────────────────
function BudgetForecastTab() {
const { data, isLoading } = useQuery({ queryKey: ["pred-budget"], queryFn: getBudgetForecast });
if (isLoading) return <LoadingCard />;
if (!data || data.forecasts.length === 0) {
return (
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
<PiggyBank className="w-10 h-10 mx-auto mb-3 opacity-20" />
<p className="text-sm font-medium mb-1">No monthly budgets set</p>
<p className="text-xs">Set up budgets on the <a href="/budgets" className="text-primary underline underline-offset-2">Budgets page</a> to see forecasts here.</p>
</div>
);
}
const forecasts = data.forecasts;
const atRisk = forecasts.filter(f => f.probability_overspend > 0.5);
const onTrack = forecasts.filter(f => f.probability_overspend <= 0.5);
const totalForecastOverspend = atRisk.reduce((sum, f) => sum + Math.max(0, f.forecast_month_total - f.budget_amount), 0);
return (
<div className="space-y-4">
{/* Summary bar */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Budgets tracked</p>
<p className="text-2xl font-bold tabular-nums">{forecasts.length}</p>
</div>
<div className={cn("bg-card border rounded-xl p-4", atRisk.length > 0 ? "border-destructive/40" : "border-border")}>
<p className="text-xs text-muted-foreground mb-1">At risk this month</p>
<p className={cn("text-2xl font-bold tabular-nums", atRisk.length > 0 ? "text-destructive" : "text-success")}>
{atRisk.length}
</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Forecast overspend</p>
<p className={cn("text-2xl font-bold tabular-nums", totalForecastOverspend > 0 ? "text-destructive" : "text-success")}>
{totalForecastOverspend > 0 ? formatCurrency(totalForecastOverspend, "GBP") : "—"}
</p>
</div>
</div>
{atRisk.length === 0 && (
<div className="flex items-center gap-3 bg-success/10 border border-success/30 rounded-xl px-4 py-3">
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
<p className="text-sm text-success font-medium">All budgets on track for this month</p>
</div>
)}
{/* At-risk budgets */}
{atRisk.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-destructive" />
<p className="text-sm font-semibold">At Risk</p>
</div>
{atRisk.map(f => <BudgetForecastCard key={f.category_id} forecast={f} />)}
</div>
)}
{/* On-track budgets */}
{onTrack.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-success" />
<p className="text-sm font-semibold">On Track</p>
</div>
{onTrack.map(f => <BudgetForecastCard key={f.category_id} forecast={f} />)}
</div>
)}
</div>
);
}
function BudgetForecastCard({ forecast: f }: { forecast: import("@/api/predictions").BudgetForecastItem }) {
const spentPct = Math.min(100, (f.spent_so_far / f.budget_amount) * 100);
const forecastPct = Math.min(110, (f.forecast_month_total / f.budget_amount) * 100);
const isOverBudget = f.forecast_month_total > f.budget_amount;
const riskColor =
f.probability_overspend > 0.7
? "text-destructive"
: f.probability_overspend > 0.3
? "text-warning"
: "text-success";
const barColor =
f.probability_overspend > 0.7
? "bg-destructive"
: f.probability_overspend > 0.3
? "bg-warning"
: "bg-success";
return (
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-medium text-sm">{f.category_name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{f.days_remaining} days remaining · {formatCurrency(f.daily_velocity, "GBP")}/day
</p>
</div>
<span className={cn("text-xs font-bold px-2 py-1 rounded-full bg-card border", riskColor,
f.probability_overspend > 0.7 ? "border-destructive/40" :
f.probability_overspend > 0.3 ? "border-warning/40" : "border-success/40"
)}>
{(f.probability_overspend * 100).toFixed(0)}% risk
</span>
</div>
{/* Progress bar: spent so far */}
<div className="mb-1.5">
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Spent so far</span>
<span>{formatCurrency(f.spent_so_far, "GBP")} / {formatCurrency(f.budget_amount, "GBP")}</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all", barColor)} style={{ width: `${spentPct}%` }} />
</div>
</div>
{/* Forecast bar */}
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Forecast month-end</span>
<span className={cn("font-medium", isOverBudget ? riskColor : "text-foreground")}>
{formatCurrency(f.forecast_month_total, "GBP")}
{isOverBudget && <span className="ml-1">(+{formatCurrency(f.forecast_month_total - f.budget_amount, "GBP")})</span>}
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden relative">
<div className={cn("h-full rounded-full transition-all opacity-40", barColor)} style={{ width: `${Math.min(100, forecastPct)}%` }} />
{/* Budget marker at 100% */}
<div className="absolute top-0 right-0 h-full w-px bg-foreground/30" />
</div>
</div>
</div>
);
}
@ -125,48 +269,6 @@ function SpendingTab() {
)}
</div>
{/* Budget forecast alert cards */}
<BudgetAlerts />
</div>
);
}
function BudgetAlerts() {
const { data } = useQuery({ queryKey: ["pred-budget"], queryFn: getBudgetForecast });
if (!data?.forecasts.length) return null;
const atRisk = data.forecasts.filter(f => f.probability_overspend > 0.5);
if (!atRisk.length) return null;
return (
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-3">Budget Overspend Risk</p>
<div className="space-y-3">
{atRisk.slice(0, 5).map(f => {
const forecastPct = Math.min(140, (f.forecast_month_total / f.budget_amount) * 100);
return (
<div key={f.category_id}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{f.category_name}</span>
<span className={cn("text-xs font-medium", f.probability_overspend > 0.75 ? "text-destructive" : "text-warning")}>
{(f.probability_overspend * 100).toFixed(0)}% overspend risk
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden relative">
<div
className={cn("h-full rounded-full", f.probability_overspend > 0.75 ? "bg-destructive" : "bg-warning")}
style={{ width: `${Math.min(100, forecastPct)}%` }}
/>
<div className="absolute top-0 h-full w-0.5 bg-foreground/40" style={{ left: "100%" }} />
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-0.5">
<span>Spent: {formatCurrency(f.spent_so_far, "GBP")}</span>
<span>Forecast: {formatCurrency(f.forecast_month_total, "GBP")} / {formatCurrency(f.budget_amount, "GBP")}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
@ -425,6 +527,7 @@ function CashFlowTab() {
if (!data) return <EmptyCard message="No data available." />;
const hasRisk = data.negative_risk_days.length > 0;
const hasBands = data.forecast.some(d => d.upper !== d.balance);
return (
<div className="space-y-4">
@ -448,16 +551,23 @@ function CashFlowTab() {
{hasRisk && (
<div className="flex items-start gap-3 bg-destructive/10 border border-destructive/30 rounded-xl px-4 py-3">
<span className="text-destructive text-sm font-medium shrink-0"> Negative balance risk</span>
<p className="text-sm text-muted-foreground">
Balance may go negative on: {data.negative_risk_days.slice(0, 5).join(", ")}
{data.negative_risk_days.length > 5 && ` +${data.negative_risk_days.length - 5} more`}
</p>
<AlertTriangle className="w-4 h-4 text-destructive shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-destructive">Negative balance risk</p>
<p className="text-xs text-muted-foreground mt-0.5">
Balance may go negative on: {data.negative_risk_days.slice(0, 5).join(", ")}
{data.negative_risk_days.length > 5 && ` +${data.negative_risk_days.length - 5} more`}
</p>
</div>
</div>
)}
{/* Chart */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-1">30-Day Balance Forecast</p>
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-semibold">30-Day Balance Forecast</p>
{hasBands && <p className="text-xs text-muted-foreground">Shaded = uncertainty band</p>}
</div>
<p className="text-xs text-muted-foreground mb-4">Based on {data.history_days} days of transaction history</p>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={data.forecast} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
@ -469,12 +579,45 @@ function CashFlowTab() {
</defs>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => v.slice(5)} />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} width={55} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, "GBP")} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number, name: string) => [formatCurrency(v, "GBP"), name]} />
<ReferenceLine y={0} stroke="hsl(var(--destructive))" strokeDasharray="4 2" />
{/* Confidence band: upper then lower fills between them */}
{hasBands && (
<>
<Area type="monotone" dataKey="upper" stroke="transparent" fill="hsl(var(--primary) / 0.08)" fillOpacity={1} legendType="none" tooltipType="none" dot={false} activeDot={false} name="Upper" />
<Area type="monotone" dataKey="lower" stroke="transparent" fill="hsl(var(--card))" fillOpacity={1} legendType="none" tooltipType="none" dot={false} activeDot={false} name="Lower" />
</>
)}
<Area type="monotone" dataKey="balance" stroke="hsl(var(--primary))" fill="url(#balanceGrad)" strokeWidth={2} name="Balance" activeDot={ACTIVE_DOT} />
</AreaChart>
</ResponsiveContainer>
</div>
{/* Upcoming recurring payments */}
{data.upcoming_payments.length > 0 && (
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-3">Upcoming Recurring Payments</p>
<div className="space-y-2">
{data.upcoming_payments.map((p, i) => (
<div key={i} className={cn(
"flex items-center justify-between py-2 px-3 rounded-lg text-sm",
p.at_risk ? "bg-destructive/10 border border-destructive/20" : "bg-secondary/40"
)}>
<div className="flex items-center gap-2">
{p.at_risk && <AlertTriangle className="w-3.5 h-3.5 text-destructive shrink-0" />}
<span className={cn("font-medium", p.at_risk ? "text-destructive" : "")}>{p.name}</span>
</div>
<div className="flex items-center gap-4 text-muted-foreground">
<span className="text-xs">{p.date}</span>
<span className={cn("font-medium tabular-nums", p.at_risk ? "text-destructive" : "text-foreground")}>
-{formatCurrency(p.amount, "GBP")}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}