api: expand FastAPI surface for scenarios, networth, life-events, goals, simulate
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
This commit is contained in:
parent
31193faf08
commit
ee6ed1d3c4
15 changed files with 1570 additions and 74 deletions
237
fire_planner/api/schemas.py
Normal file
237
fire_planner/api/schemas.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"""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]
|
||||
Loading…
Add table
Add a link
Reference in a new issue