examples: simulator response gains examples_overlay block
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:
Viktor Barzin 2026-05-28 22:45:15 +00:00
parent 249991557b
commit 9b32247fea
3 changed files with 146 additions and 8 deletions

View file

@ -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)