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