From 9b32247fea46e93adac11b179eee020012f5b67c Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 28 May 2026 22:45:15 +0000 Subject: [PATCH] examples: simulator response gains examples_overlay block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an informational `examples_overlay` field to SimulateResult, populated from `summary_for_country` for the scenario's target country. Never affects simulation paths — lookup failures are caught and logged, yielding overlay=None. Wired into both /simulate and /compare; the shared session in /compare is used sequentially because AsyncSession is not safe for concurrent use. Co-Authored-By: Claude Opus 4.7 --- fire_planner/api/schemas.py | 17 +++++++ fire_planner/api/simulate.py | 90 ++++++++++++++++++++++++++++++++---- tests/test_api_simulate.py | 47 +++++++++++++++++++ 3 files changed, 146 insertions(+), 8 deletions(-) diff --git a/fire_planner/api/schemas.py b/fire_planner/api/schemas.py index e2b2e01..1234e80 100644 --- a/fire_planner/api/schemas.py +++ b/fire_planner/api/schemas.py @@ -522,6 +522,19 @@ class SimulateRequest(BaseModel): col_target_city: str | None = None +class ExamplesOverlay(BaseModel): + """Informational layer on a simulator response — real-world Reddit + examples for the scenario's target country. Never affects paths.""" + + country: str + count: int + portfolio_gbp_median: Decimal | None = None + portfolio_gbp_p25: Decimal | None = None + portfolio_gbp_p75: Decimal | None = None + annual_exp_gbp_median: Decimal | None = None + sample_links: list[str] = Field(default_factory=list) + + class SimulateResult(BaseModel): success_rate: Decimal p10_ending_gbp: Decimal @@ -539,6 +552,10 @@ class SimulateResult(BaseModel): col_multiplier_applied: Decimal | None = None col_adjusted_spending_gbp: Decimal | None = None col_target_city: str | None = None + # Informational only — real-world Reddit examples for the scenario's + # target country. Never affects simulation behaviour; failures during + # lookup are swallowed so the simulator response is never sunk. + examples_overlay: ExamplesOverlay | None = None class CompareRequest(BaseModel): diff --git a/fire_planner/api/simulate.py b/fire_planner/api/simulate.py index 2003416..beabb3b 100644 --- a/fire_planner/api/simulate.py +++ b/fire_planner/api/simulate.py @@ -10,23 +10,27 @@ Returns a fan-chart series in the same shape as from __future__ import annotations import asyncio +import logging import time from decimal import Decimal from pathlib import Path import numpy as np -from fastapi import APIRouter, HTTPException -from sqlalchemy.ext.asyncio import async_sessionmaker +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from fire_planner.api.dependencies import get_session from fire_planner.api.schemas import ( CompareRequest, CompareResult, + ExamplesOverlay, GoalProbability, ProjectionPoint, SimulateRequest, SimulateResult, ) from fire_planner.col import compute_col_ratio, representative_city_for +from fire_planner.examples.service import summary_for_country from fire_planner.flex_spending import FlexRule as EngineFlexRule from fire_planner.glide_path import static from fire_planner.goals_eval import evaluate_goals @@ -48,8 +52,59 @@ from fire_planner.simulator import SimulationResult, build_fixed_paths, simulate router = APIRouter(tags=["simulate"]) +log = logging.getLogger(__name__) + _RETURNS_CSV = Path("/data/shiller_returns.csv") +# Maps `SimulateRequest.jurisdiction` (lowercase slug used throughout the +# planner — e.g. "thailand") to the country name as stored in +# `fire_example.country` (e.g. "Thailand"). The keys mirror +# `JURISDICTION_REPRESENTATIVE_CITY` so the overlay covers every +# jurisdiction with a fixed country. `nomad` has no fixed country and is +# intentionally absent. +_JURISDICTION_COUNTRY: dict[str, str] = { + "uk": "United Kingdom", + "cyprus": "Cyprus", + "bulgaria": "Bulgaria", + "uae": "United Arab Emirates", + "malaysia": "Malaysia", + "thailand": "Thailand", +} + + +def _resolve_target_country_for_examples(req: SimulateRequest) -> str | None: + return _JURISDICTION_COUNTRY.get(req.jurisdiction.lower()) + + +async def _build_examples_overlay( + session: AsyncSession, + req: SimulateRequest, +) -> ExamplesOverlay | None: + """Look up real-world Reddit examples for the scenario's target + country. Returns None when the jurisdiction has no fixed country + (e.g. nomad), when no examples are stored, or when the lookup + fails for any reason — examples are informational and must never + sink a successful simulation.""" + try: + country = _resolve_target_country_for_examples(req) + if country is None: + return None + summary = await summary_for_country(session, country) + if summary.count == 0: + return None + return ExamplesOverlay( + country=summary.country, + count=summary.count, + portfolio_gbp_median=summary.portfolio_gbp.median, + portfolio_gbp_p25=summary.portfolio_gbp.p25, + portfolio_gbp_p75=summary.portfolio_gbp.p75, + annual_exp_gbp_median=summary.annual_exp_gbp.median, + sample_links=summary.sample_links, + ) + except Exception: + log.warning("examples_overlay lookup failed", exc_info=True) + return None + def _resolve_col_adjustment( req: SimulateRequest, @@ -227,6 +282,7 @@ def _to_response( col_multiplier: Decimal | None = None, col_adjusted_spend: Decimal | None = None, col_target_city: str | None = None, + examples_overlay: ExamplesOverlay | None = None, ) -> SimulateResult: # portfolio_real has n_years+1 columns (year 0 = seed, year k = end-of-year k). # withdrawal_real / tax_real have n_years columns (year k = withdrawn in year k+1). @@ -282,11 +338,15 @@ def _to_response( col_adjusted_spending_gbp=(Decimal(str(round(float(col_adjusted_spend), 2))) if col_adjusted_spend is not None else None), col_target_city=col_target_city, + examples_overlay=examples_overlay, ) @router.post("/simulate", response_model=SimulateResult) -async def simulate_one(req: SimulateRequest) -> SimulateResult: +async def simulate_one( + req: SimulateRequest, + session: AsyncSession = Depends(get_session), +) -> SimulateResult: """Run one scenario synchronously, no DB write. ~1-3s for 5k paths.""" adjusted_req, mult, adj_spend, target_city = _resolve_col_adjustment(req) paths = await _build_paths(adjusted_req) @@ -294,20 +354,34 @@ async def simulate_one(req: SimulateRequest) -> SimulateResult: result, elapsed = await asyncio.to_thread(_project, adjusted_req, paths) except KeyError as e: raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None - return _to_response(result, elapsed, adjusted_req, mult, adj_spend, target_city) + overlay = await _build_examples_overlay(session, adjusted_req) + return _to_response( + result, elapsed, adjusted_req, mult, adj_spend, target_city, overlay) @router.post("/compare", response_model=CompareResult) -async def compare_scenarios(req: CompareRequest) -> CompareResult: +async def compare_scenarios( + req: CompareRequest, + session: AsyncSession = Depends(get_session), +) -> CompareResult: """Run 2-5 scenarios in parallel, return all results.""" - async def one(s: SimulateRequest) -> SimulateResult: + async def one(s: SimulateRequest) -> tuple[SimulationResult, float, SimulateRequest, + Decimal | None, Decimal | None, str | None]: adjusted_s, mult, adj_spend, target_city = _resolve_col_adjustment(s) paths = await _build_paths(adjusted_s) result, elapsed = await asyncio.to_thread(_project, adjusted_s, paths) - return _to_response(result, elapsed, adjusted_s, mult, adj_spend, target_city) + return result, elapsed, adjusted_s, mult, adj_spend, target_city try: - results = await asyncio.gather(*(one(s) for s in req.scenarios)) + projected = await asyncio.gather(*(one(s) for s in req.scenarios)) except KeyError as e: raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None + # Overlay lookups must run sequentially — AsyncSession is not safe for + # concurrent use. The lookup is fast (single SELECT) and informational + # only, so per-scenario serial cost is negligible. + results = [] + for result, elapsed, adjusted_s, mult, adj_spend, target_city in projected: + overlay = await _build_examples_overlay(session, adjusted_s) + results.append(_to_response( + result, elapsed, adjusted_s, mult, adj_spend, target_city, overlay)) return CompareResult(results=results) diff --git a/tests/test_api_simulate.py b/tests/test_api_simulate.py index 618f502..41cea0d 100644 --- a/tests/test_api_simulate.py +++ b/tests/test_api_simulate.py @@ -6,6 +6,7 @@ point, the point is the endpoint produces a valid response shape. from __future__ import annotations from collections.abc import AsyncIterator +from datetime import date from decimal import Decimal import pytest_asyncio @@ -14,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from fire_planner.api.dependencies import get_session from fire_planner.app import app +from fire_planner.db import FireExample @pytest_asyncio.fixture @@ -165,3 +167,48 @@ async def test_compare_rejects_single_scenario(client: AsyncClient) -> None: }, ) assert resp.status_code == 422 # pydantic validation + + +async def test_simulate_response_includes_examples_overlay( + client: AsyncClient, session: AsyncSession) -> None: + """When examples exist for the scenario's target country, the + simulator response surfaces them as `examples_overlay` — purely + informational, never affects the simulation path.""" + for i, amount in enumerate([300_000, 400_000, 500_000], start=1): + session.add(FireExample( + reddit_id=f"th{i:02d}", + source_sub="ExpatFIRE", + post_url=f"https://reddit.com/r/ExpatFIRE/comments/th{i:02d}", + post_date=date(2026, 1, 1), + post_title=f"Thailand FIRE {i}", + country="Thailand", + portfolio_gbp=Decimal(str(amount)), + annual_exp_gbp=Decimal("24000"), + fi_status="FIRE", + llm_model="qwen3-8b", + )) + await session.commit() + + resp = await client.post( + "/simulate", + json={ + "jurisdiction": "thailand", + "strategy": "trinity", + "leave_uk_year": 0, + "glide_path": "static_60_40", + "spending_gbp": "60000", + "nw_seed_gbp": "1500000", + "horizon_years": 30, + "n_paths": 100, + "seed": 42, + }, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert "examples_overlay" in body + overlay = body["examples_overlay"] + assert overlay is not None + assert overlay["country"] == "Thailand" + assert overlay["count"] == 3 + assert Decimal(overlay["portfolio_gbp_median"]) == Decimal("400000.00") + assert len(overlay["sample_links"]) == 3