fire-planner/fire_planner/api/schemas.py
Viktor Barzin 00ec874889
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
returns: 3 models — Shiller bootstrap (default), manual %, Wealthfolio history
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>
2026-05-10 01:04:25 +00:00

259 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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]