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:
parent
3b4787d8b9
commit
4572621f5d
4 changed files with 109 additions and 30 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue