All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Adds the read+write endpoints the frontend needs to drive a
ProjectionLab-style UX on top of the existing engine.
- /networth, /networth/history — NW total + per-account from
account_snapshot (frontend chart)
- /scenarios CRUD + projection — list/get/create/patch/delete user
scenarios; cartesian read-only
- /scenarios/{id}/life-events — life event CRUD nested under scenario
- /life-events/{id} — patch + delete by id
- /scenarios/{id}/goals,
/goals/{id} — retirement goal CRUD
- /simulate, /compare — sync, no-DB-write what-if endpoints
Auth: Bearer-token dependency on writes + simulate when API_BEARER_TOKEN
is set; reads always open (lock down via Authentik-fronted ingress in
prod). Existing /recompute keeps its bearer auth.
CORS middleware reads FRONTEND_ORIGINS (comma-separated) for the dev
SPA. Lifespan now provisions the SQLAlchemy engine + session_factory
on app.state and disposes them on shutdown.
40 new tests covering happy paths and validation. 172 tests total.
mypy strict + ruff clean (B008 ignore added — Depends() in defaults
is the canonical FastAPI pattern, not a bug).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
237 lines
7 KiB
Python
237 lines
7 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 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
|
|
|
|
|
|
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]
|