whatif: live data refresh, inflation-adjusted spending, legend fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Three follow-ups to the actualbudget integration: **Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until the user types into either field, every refetch (mount, window focus) updates the form value. Once they edit, we leave it alone. A small ↻ button next to each anchor input flips the edited flag back off so the user can re-snap to live data on demand. React Query configured with staleTime=0 + refetchOnMount='always' + refetchOnWindowFocus=true so the cache never serves stale numbers. NW provenance shows the snapshot date. **Inflation-adjusted spending.** Backend now revalues each trailing month's nominal pence forward to today's £ using monthly compounding of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline `total_gbp` is the real-£ figure — matches the simulator's real-GBP convention. Response also includes `nominal_total_gbp` and `inflation_pct` for transparency. New /spending/annual?inflation_pct= override param. 10/10 actualbudget tests pass. **FanChart legend.** The bottom-anchored legend was overlapping the x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28 so nothing collides. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3bfa46ad4f
commit
e12e8f9290
8 changed files with 263 additions and 68 deletions
|
|
@ -127,25 +127,49 @@ def _today() -> date:
|
|||
return date.today()
|
||||
|
||||
|
||||
def _months_between(older: str, newer: str) -> int:
|
||||
"""Calendar months from `older` to `newer` (both YYYY-MM)."""
|
||||
yo, mo = (int(p) for p in older.split("-"))
|
||||
yn, mn = (int(p) for p in newer.split("-"))
|
||||
return (yn - yo) * 12 + (mn - mo)
|
||||
|
||||
|
||||
def _inflation_factor(months_age: int, annual_inflation_pct: float) -> float:
|
||||
"""Multiplier to convert nominal £ at a point `months_age` months
|
||||
in the past into today's real £. Compounds monthly so the curve
|
||||
is smooth, not stair-stepped."""
|
||||
if months_age <= 0:
|
||||
return 1.0
|
||||
monthly = (1.0 + annual_inflation_pct) ** (1.0 / 12.0)
|
||||
return float(monthly ** months_age)
|
||||
|
||||
|
||||
async def fetch_trailing_spending(
|
||||
months: int,
|
||||
exclude_groups: frozenset[str],
|
||||
annual_inflation_pct: float = 0.03,
|
||||
client_factory: ActualBudgetClient | None = None,
|
||||
today: date | None = None,
|
||||
) -> tuple[list[MonthSpend], Decimal]:
|
||||
"""Fetch trailing-N-months spending and return (per-month breakdown,
|
||||
total in £).
|
||||
) -> tuple[list[MonthSpend], Decimal, Decimal]:
|
||||
"""Fetch trailing-N-months spending and return (per-month
|
||||
breakdown, nominal-£ total, today's-£ inflation-adjusted total).
|
||||
|
||||
`exclude_groups` is a set of category-group names whose spending
|
||||
should NOT count towards the consumption total — typical example
|
||||
is "Investments and Savings", which is a wealth transfer rather
|
||||
than consumption.
|
||||
|
||||
Months are fetched concurrently. Missing months (e.g. user joined
|
||||
actualbudget mid-year) come back with empty `by_group`.
|
||||
Months older than the current one are revalued to today's £ using
|
||||
`annual_inflation_pct` compounded monthly. The fire-planner
|
||||
simulator works entirely in REAL GBP, so the real-£ total is the
|
||||
one to feed back as the "Annual spending" default.
|
||||
|
||||
Missing months (e.g. user joined actualbudget mid-year) come back
|
||||
with empty `by_group`.
|
||||
"""
|
||||
cli = client_factory or ActualBudgetClient()
|
||||
target_months = _trailing_months(today or _today(), months)
|
||||
today_d = today or _today()
|
||||
target_months = _trailing_months(today_d, months)
|
||||
available_set = None # only fetch if the API surfaces fewer months than asked
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
|
|
@ -159,8 +183,18 @@ async def fetch_trailing_spending(
|
|||
for m in kept:
|
||||
spends.append(await cli.fetch_month(http, m))
|
||||
|
||||
total_pence = sum(
|
||||
amt for ms in spends for name, amt in ms.by_group.items() if name not in exclude_groups
|
||||
)
|
||||
total_gbp = (Decimal(total_pence) / Decimal(100)).quantize(Decimal("0.01"))
|
||||
return spends, total_gbp
|
||||
today_month = f"{today_d.year:04d}-{today_d.month:02d}"
|
||||
nominal_pence = 0
|
||||
real_pence_float = 0.0
|
||||
for ms in spends:
|
||||
age = _months_between(ms.month, today_month)
|
||||
factor = _inflation_factor(age, annual_inflation_pct)
|
||||
for name, amt in ms.by_group.items():
|
||||
if name in exclude_groups:
|
||||
continue
|
||||
nominal_pence += amt
|
||||
real_pence_float += amt * factor
|
||||
|
||||
nominal_gbp = (Decimal(nominal_pence) / Decimal(100)).quantize(Decimal("0.01"))
|
||||
real_gbp = (Decimal(str(round(real_pence_float / 100, 2)))).quantize(Decimal("0.01"))
|
||||
return spends, nominal_gbp, real_gbp
|
||||
|
|
|
|||
|
|
@ -147,18 +147,28 @@ class SpendingMonth(BaseModel):
|
|||
class AnnualSpending(BaseModel):
|
||||
"""Aggregated trailing-N-month spending pulled from actualbudget.
|
||||
|
||||
`total_gbp` is what we suggest as the "Annual spending" default in
|
||||
the WhatIf form: the sum of all category groups across the trailing
|
||||
window, *minus* whichever groups the caller asked to exclude
|
||||
(default: investments/savings transfers, which aren't consumption).
|
||||
`total_gbp` is the headline figure used as the "Annual spending"
|
||||
default in the WhatIf form. It is **inflation-adjusted to today's
|
||||
£** (each month's nominal pence revalued forward by
|
||||
`inflation_pct` compounded monthly), matching the simulator's
|
||||
real-£ convention.
|
||||
|
||||
`nominal_total_gbp` is the same window without inflation
|
||||
adjustment — for transparency / comparison.
|
||||
|
||||
`raw_total_gbp` is the nominal sum *including* groups that were
|
||||
excluded (e.g. investment transfers) — useful when you want to
|
||||
see your full cash outflow.
|
||||
"""
|
||||
months: int
|
||||
window_start: str # "YYYY-MM" (oldest month included)
|
||||
window_end: str # "YYYY-MM" (newest)
|
||||
excluded_groups: list[str]
|
||||
total_gbp: Decimal # the headline number
|
||||
raw_total_gbp: Decimal # before exclusions, for transparency
|
||||
by_group_total_gbp: dict[str, Decimal] # 12-mo group sums (incl. excluded)
|
||||
inflation_pct: Decimal # annual rate applied
|
||||
total_gbp: Decimal # inflation-adjusted, after exclusions
|
||||
nominal_total_gbp: Decimal # not adjusted, after exclusions
|
||||
raw_total_gbp: Decimal # nominal, before exclusions
|
||||
by_group_total_gbp: dict[str, Decimal] # nominal 12-mo group sums (incl. excluded)
|
||||
monthly: list[SpendingMonth]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ async def annual_spending(
|
|||
f"from the headline total. Defaults to: {','.join(DEFAULT_EXCLUDE_GROUPS)}."
|
||||
),
|
||||
),
|
||||
inflation_pct: float = Query(
|
||||
default=0.03, ge=0.0, le=0.30,
|
||||
description=(
|
||||
"Annual inflation rate used to revalue past months' spending "
|
||||
"into today's £. The simulator runs in real GBP, so the headline "
|
||||
"`total_gbp` is the inflation-adjusted (real) figure. Default ≈ "
|
||||
"UK CPI 2024-26."
|
||||
),
|
||||
),
|
||||
) -> AnnualSpending:
|
||||
excluded = (
|
||||
[g.strip() for g in exclude.split(",") if g.strip()]
|
||||
|
|
@ -48,9 +57,10 @@ async def annual_spending(
|
|||
else list(DEFAULT_EXCLUDE_GROUPS)
|
||||
)
|
||||
try:
|
||||
spends, total_gbp = await fetch_trailing_spending(
|
||||
spends, nominal_gbp, real_gbp = await fetch_trailing_spending(
|
||||
months=months,
|
||||
exclude_groups=frozenset(excluded),
|
||||
annual_inflation_pct=inflation_pct,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception("actualbudget unreachable")
|
||||
|
|
@ -93,8 +103,10 @@ async def annual_spending(
|
|||
window_start=monthly[0].month,
|
||||
window_end=monthly[-1].month,
|
||||
excluded_groups=excluded,
|
||||
total_gbp=total_gbp,
|
||||
total_gbp=real_gbp,
|
||||
nominal_total_gbp=nominal_gbp,
|
||||
raw_total_gbp=raw_total_gbp,
|
||||
inflation_pct=Decimal(str(inflation_pct)),
|
||||
by_group_total_gbp=by_group_gbp,
|
||||
monthly=monthly,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue