ML predictions Phase 4: SARIMA spending forecast with dual confidence bands

Replaces unused Prophet dependency (unrunnable without cmdstan) with
SARIMA (statsmodels SARIMAX) as the primary spending forecast algorithm.
Strategy: SARIMA(1,1,1)(1,0,1,12) for 12+ months of data, ARIMA(1,1,1)
for 6-11 months, Holt-Winters for 3-5 months, simple average below that.

Adds 95% confidence bands (1.96σ) alongside existing 80% (1.28σ).
Extends forecast horizon from 3 to 6 months and actuals display from
6 to 12 months. Each category now carries an algorithm field surfaced
as a badge in the UI. Frontend chart shows both confidence tiers as
stacked bar overlays with a 3-month summary grid below.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-28 10:30:26 +00:00
parent 3b4787d8b9
commit 4572621f5d
4 changed files with 109 additions and 30 deletions

View file

@ -4,8 +4,9 @@ export interface CategoryForecast {
category_id: string;
category_name: string;
monthly_avg: number;
algorithm: "sarima" | "holt_winters" | "average";
actuals: { date: string; amount: number }[];
forecast: { date: string; amount: number; lower: number; upper: number }[];
forecast: { date: string; amount: number; lower: number; upper: number; lower_95: number; upper_95: number }[];
}
export interface SpendingForecastResponse {

View file

@ -205,6 +205,12 @@ function BudgetForecastCard({ forecast: f }: { forecast: import("@/api/predictio
// ─── Spending Forecast ───────────────────────────────────────────────────────
const ALGO_LABELS: Record<string, string> = {
sarima: "SARIMA",
holt_winters: "Holt-Winters",
average: "Avg",
};
function SpendingTab() {
const { data, isLoading } = useQuery({ queryKey: ["pred-spending"], queryFn: getSpendingForecast });
const [selected, setSelected] = useState(0);
@ -218,8 +224,10 @@ function SpendingTab() {
...cat.forecast.map(p => ({
date: p.date.slice(0, 7),
forecast: p.amount,
lower: p.lower,
upper: p.upper,
lower_80: p.lower,
upper_80: p.upper,
lower_95: p.lower_95,
upper_95: p.upper_95,
})),
];
@ -246,29 +254,38 @@ function SpendingTab() {
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold">{cat.category_name} Spending Forecast</p>
<p className="text-xs text-muted-foreground">Shaded = 80% confidence interval</p>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">{cat.category_name} 6-Month Forecast</p>
<span className="text-xs bg-secondary text-muted-foreground px-2 py-0.5 rounded-full">
{ALGO_LABELS[cat.algorithm] ?? cat.algorithm}
</span>
</div>
<p className="text-xs text-muted-foreground">Dark = 80% · Light = 95% confidence</p>
</div>
<ResponsiveContainer width="100%" height={260}>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" />
<YAxis tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }} stroke="hsl(var(--muted-foreground))" tickFormatter={v => `£${v}`} width={55} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number) => formatCurrency(v, "GBP")} />
<Tooltip {...TOOLTIP_STYLE} formatter={(v: number, name: string) => [formatCurrency(v, "GBP"), name]} />
<Bar dataKey="actual" fill="hsl(var(--primary))" name="Actual" radius={[2, 2, 0, 0]} />
<Bar dataKey="forecast" fill="hsl(var(--primary) / 0.5)" name="Forecast" radius={[2, 2, 0, 0]} />
<Bar dataKey="forecast" fill="hsl(var(--primary) / 0.55)" name="Forecast" radius={[2, 2, 0, 0]} />
<Bar dataKey="upper_95" fill="hsl(var(--primary) / 0.10)" name="95% upper" radius={[2, 2, 0, 0]} legendType="none" />
<Bar dataKey="upper_80" fill="hsl(var(--primary) / 0.20)" name="80% upper" radius={[2, 2, 0, 0]} legendType="none" />
</BarChart>
</ResponsiveContainer>
{/* Confidence band as area overlay */}
{cat.forecast.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground text-center">
Forecast next 3 months: {cat.forecast.map(f =>
`${f.date.slice(0, 7)}: ${formatCurrency(f.amount, "GBP")} (${formatCurrency(f.lower, "GBP")}${formatCurrency(f.upper, "GBP")})`
).join(" · ")}
<div className="mt-3 grid grid-cols-3 gap-2">
{cat.forecast.slice(0, 3).map(f => (
<div key={f.date} className="bg-secondary/40 rounded-lg px-3 py-2 text-center">
<p className="text-xs text-muted-foreground mb-0.5">{f.date.slice(0, 7)}</p>
<p className="text-sm font-semibold tabular-nums">{formatCurrency(f.amount, "GBP")}</p>
<p className="text-xs text-muted-foreground">{formatCurrency(f.lower_95, "GBP")}{formatCurrency(f.upper_95, "GBP")}</p>
</div>
))}
</div>
)}
</div>
</div>
);
}