examples: simulator response gains examples_overlay block
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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 <noreply@anthropic.com>
This commit is contained in:
parent
249991557b
commit
9b32247fea
3 changed files with 146 additions and 8 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue