All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Adds a "Returns model" picker on /what-if that switches how the
simulator's `paths` (n_paths × n_years × 3) is built:
1. shiller (default) — current behaviour, block-bootstrap of the
Shiller 1871+ historical series (or its synthetic-calibrated
fallback when the CSV isn't mounted).
2. manual — every year of every path = the user's "real return %"
input. Deterministic, no fan, useful for sanity checks. New
helper `constant_real_return_paths` constructs the (n_paths,
n_years, 3) tensor with stock=bond=real, cpi=0 so the simulator's
`(1+nominal)/(1+cpi)-1` short-circuits to exactly the input.
3. wealthfolio — pulls daily_account_valuation from the wealthfolio_sync
PG mirror, sums total_value + net_contribution across accounts per
day (FX-adjusted), strips contribution deltas to isolate market
return, compounds daily returns into per-calendar-year samples,
block-bootstraps with block_size=1 (only ~6 distinct samples
available, no serial-correlation signal to preserve). Glide path
is a no-op in this mode — the user's actual blended portfolio is
treated as a single asset.
API: SimulateRequest gains `returns_mode` ("shiller"|"manual"|
"wealthfolio") + `manual_real_return_pct`. simulate.py's `_build_paths`
dispatches; wealthfolio mode opens a transient session against the
mirror DB.
UI: new Field on the form (next to Strategy / Glide path) with a
contextual hint that explains each option's tradeoff. The "About the
model" panel at the bottom now has a "Returns model" section
mirroring the same content. The Manual % input only shows when
returns_mode='manual'.
10 new tests on the Wealthfolio helper (contribution-stripping,
multi-account aggregation, FX, partial-year drop, TOTAL filter,
empty-input, plus 3 deterministic-paths tests). 198 backend tests +
7 frontend tests. mypy strict + ruff + tsc strict all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
259 lines
8.2 KiB
Python
259 lines
8.2 KiB
Python
"""Pydantic response/request schemas for the HTTP API.
|
||
|
||
Mirror the SQLAlchemy ORM but keep them de-coupled — the API surface is a
|
||
contract for the frontend; we don't want migrations to silently change
|
||
JSON shape.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
|
||
class _Base(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
# ── scenarios ────────────────────────────────────────────────────────
|
||
|
||
|
||
class ScenarioOut(_Base):
|
||
id: int
|
||
external_id: str
|
||
kind: str
|
||
name: str | None
|
||
description: str | None
|
||
parent_scenario_id: int | None
|
||
jurisdiction: str
|
||
strategy: str
|
||
leave_uk_year: int
|
||
glide_path: str
|
||
spending_gbp: Decimal
|
||
horizon_years: int
|
||
nw_seed_gbp: Decimal
|
||
savings_per_year_gbp: Decimal
|
||
config_json: dict[str, Any]
|
||
created_at: datetime
|
||
|
||
|
||
class ScenarioCreate(BaseModel):
|
||
"""Body for POST /scenarios — user-defined scenario."""
|
||
name: str = Field(min_length=1, max_length=200)
|
||
description: str | None = None
|
||
parent_scenario_id: int | None = None
|
||
jurisdiction: str
|
||
strategy: str
|
||
leave_uk_year: int = Field(ge=0, le=60)
|
||
glide_path: str
|
||
spending_gbp: Decimal = Field(gt=0)
|
||
horizon_years: int = Field(ge=5, le=100, default=60)
|
||
nw_seed_gbp: Decimal = Field(ge=0)
|
||
savings_per_year_gbp: Decimal = Field(ge=0, default=Decimal("0"))
|
||
config_json: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class ScenarioPatch(BaseModel):
|
||
"""Body for PATCH /scenarios/{id} — all fields optional."""
|
||
name: str | None = None
|
||
description: str | None = None
|
||
jurisdiction: str | None = None
|
||
strategy: str | None = None
|
||
leave_uk_year: int | None = None
|
||
glide_path: str | None = None
|
||
spending_gbp: Decimal | None = None
|
||
horizon_years: int | None = None
|
||
nw_seed_gbp: Decimal | None = None
|
||
savings_per_year_gbp: Decimal | None = None
|
||
config_json: dict[str, Any] | None = None
|
||
|
||
|
||
# ── projections ──────────────────────────────────────────────────────
|
||
|
||
|
||
class ProjectionPoint(_Base):
|
||
year_idx: int
|
||
p10_portfolio_gbp: Decimal
|
||
p25_portfolio_gbp: Decimal
|
||
p50_portfolio_gbp: Decimal
|
||
p75_portfolio_gbp: Decimal
|
||
p90_portfolio_gbp: Decimal
|
||
p50_withdrawal_gbp: Decimal
|
||
p50_tax_gbp: Decimal
|
||
survival_rate: Decimal
|
||
|
||
|
||
class ScenarioProjection(BaseModel):
|
||
"""Latest MC run + per-year fan-chart series for a scenario."""
|
||
scenario_id: int
|
||
external_id: str
|
||
mc_run_id: int
|
||
run_at: datetime
|
||
n_paths: int
|
||
success_rate: Decimal
|
||
p10_ending_gbp: Decimal
|
||
p50_ending_gbp: Decimal
|
||
p90_ending_gbp: Decimal
|
||
median_lifetime_tax_gbp: Decimal
|
||
median_years_to_ruin: Decimal | None
|
||
yearly: list[ProjectionPoint]
|
||
|
||
|
||
# ── net worth ────────────────────────────────────────────────────────
|
||
|
||
|
||
class AccountSnapshotOut(_Base):
|
||
account_id: str
|
||
account_name: str
|
||
account_type: str
|
||
currency: str
|
||
snapshot_date: date
|
||
market_value: Decimal
|
||
market_value_gbp: Decimal
|
||
cost_basis_gbp: Decimal | None
|
||
|
||
|
||
class NetWorthCurrent(BaseModel):
|
||
"""Snapshot at one point in time (latest by default)."""
|
||
snapshot_date: date
|
||
total_gbp: Decimal
|
||
accounts: list[AccountSnapshotOut]
|
||
|
||
|
||
class NetWorthHistoryPoint(BaseModel):
|
||
snapshot_date: date
|
||
total_gbp: Decimal
|
||
by_account: dict[str, Decimal]
|
||
|
||
|
||
class NetWorthHistory(BaseModel):
|
||
"""Per-day NW totals + per-account breakdown for a stacked area chart."""
|
||
points: list[NetWorthHistoryPoint]
|
||
|
||
|
||
# ── life events ──────────────────────────────────────────────────────
|
||
|
||
|
||
class LifeEventOut(_Base):
|
||
id: int
|
||
scenario_id: int
|
||
kind: str
|
||
name: str
|
||
year_start: int
|
||
year_end: int | None
|
||
delta_gbp_per_year: Decimal
|
||
one_time_amount_gbp: Decimal | None
|
||
enabled: bool
|
||
payload: dict[str, Any] | None
|
||
created_at: datetime
|
||
|
||
|
||
class LifeEventCreate(BaseModel):
|
||
kind: str
|
||
name: str = Field(min_length=1, max_length=200)
|
||
year_start: int = Field(ge=0, le=100)
|
||
year_end: int | None = Field(default=None, ge=0, le=100)
|
||
delta_gbp_per_year: Decimal = Decimal("0")
|
||
one_time_amount_gbp: Decimal | None = None
|
||
enabled: bool = True
|
||
payload: dict[str, Any] | None = None
|
||
|
||
|
||
class LifeEventPatch(BaseModel):
|
||
kind: str | None = None
|
||
name: str | None = None
|
||
year_start: int | None = None
|
||
year_end: int | None = None
|
||
delta_gbp_per_year: Decimal | None = None
|
||
one_time_amount_gbp: Decimal | None = None
|
||
enabled: bool | None = None
|
||
payload: dict[str, Any] | None = None
|
||
|
||
|
||
# ── goals ────────────────────────────────────────────────────────────
|
||
|
||
|
||
class GoalOut(_Base):
|
||
id: int
|
||
scenario_id: int
|
||
kind: str
|
||
name: str
|
||
target_amount_gbp: Decimal | None
|
||
target_year: int | None
|
||
comparator: str
|
||
success_threshold: Decimal
|
||
enabled: bool
|
||
payload: dict[str, Any] | None
|
||
created_at: datetime
|
||
|
||
|
||
class GoalCreate(BaseModel):
|
||
kind: str
|
||
name: str = Field(min_length=1, max_length=200)
|
||
target_amount_gbp: Decimal | None = None
|
||
target_year: int | None = Field(default=None, ge=0, le=100)
|
||
comparator: str = ">="
|
||
success_threshold: Decimal = Field(default=Decimal("0.95"), ge=0, le=1)
|
||
enabled: bool = True
|
||
payload: dict[str, Any] | None = None
|
||
|
||
|
||
# ── simulate / compare ───────────────────────────────────────────────
|
||
|
||
|
||
class LifeEventInput(BaseModel):
|
||
"""Engine-level event shape — same as the DB row's relevant fields."""
|
||
year_start: int = Field(ge=0, le=100)
|
||
year_end: int | None = Field(default=None, ge=0, le=100)
|
||
delta_gbp_per_year: Decimal = Decimal("0")
|
||
one_time_amount_gbp: Decimal | None = None
|
||
enabled: bool = True
|
||
|
||
|
||
class SimulateRequest(BaseModel):
|
||
"""Sync, non-persisted simulate. Used by the React UI for what-if."""
|
||
jurisdiction: str
|
||
strategy: str
|
||
leave_uk_year: int = Field(ge=0, le=60)
|
||
glide_path: str = "rising"
|
||
spending_gbp: Decimal = Field(gt=0)
|
||
nw_seed_gbp: Decimal = Field(ge=0)
|
||
savings_per_year_gbp: Decimal = Decimal("0")
|
||
horizon_years: int = Field(ge=5, le=100, default=60)
|
||
floor_gbp: Decimal | None = None
|
||
n_paths: int = Field(ge=100, le=50_000, default=5_000)
|
||
seed: int = 42
|
||
life_events: list[LifeEventInput] = Field(default_factory=list)
|
||
# Returns model — controls how `paths` (n_paths × n_years × 3) is built:
|
||
# "shiller" — block-bootstrap of Shiller 1871+ historical returns
|
||
# (or the synthetic Shiller-calibrated stream when the
|
||
# CSV isn't mounted). The default; broadest regime
|
||
# coverage including 1929/1973/2000/2008.
|
||
# "manual" — every year of every path = `manual_real_return_pct`.
|
||
# Deterministic, no fan, useful for sanity checks.
|
||
# "wealthfolio" — block-bootstrap of the user's actual blended real
|
||
# returns derived from wealthfolio_sync. Reflects the
|
||
# recent regime only (~6 years). Glide path is moot.
|
||
returns_mode: str = Field(default="shiller", pattern="^(shiller|manual|wealthfolio)$")
|
||
manual_real_return_pct: Decimal | None = None
|
||
|
||
|
||
class SimulateResult(BaseModel):
|
||
success_rate: Decimal
|
||
p10_ending_gbp: Decimal
|
||
p50_ending_gbp: Decimal
|
||
p90_ending_gbp: Decimal
|
||
median_lifetime_tax_gbp: Decimal
|
||
median_years_to_ruin: Decimal | None
|
||
elapsed_seconds: Decimal
|
||
yearly: list[ProjectionPoint]
|
||
|
||
|
||
class CompareRequest(BaseModel):
|
||
scenarios: list[SimulateRequest] = Field(min_length=2, max_length=5)
|
||
|
||
|
||
class CompareResult(BaseModel):
|
||
results: list[SimulateResult]
|