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
|
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):
|
class SimulateResult(BaseModel):
|
||||||
success_rate: Decimal
|
success_rate: Decimal
|
||||||
p10_ending_gbp: Decimal
|
p10_ending_gbp: Decimal
|
||||||
|
|
@ -539,6 +552,10 @@ class SimulateResult(BaseModel):
|
||||||
col_multiplier_applied: Decimal | None = None
|
col_multiplier_applied: Decimal | None = None
|
||||||
col_adjusted_spending_gbp: Decimal | None = None
|
col_adjusted_spending_gbp: Decimal | None = None
|
||||||
col_target_city: str | 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):
|
class CompareRequest(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -10,23 +10,27 @@ Returns a fan-chart series in the same shape as
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from fire_planner.api.dependencies import get_session
|
||||||
from fire_planner.api.schemas import (
|
from fire_planner.api.schemas import (
|
||||||
CompareRequest,
|
CompareRequest,
|
||||||
CompareResult,
|
CompareResult,
|
||||||
|
ExamplesOverlay,
|
||||||
GoalProbability,
|
GoalProbability,
|
||||||
ProjectionPoint,
|
ProjectionPoint,
|
||||||
SimulateRequest,
|
SimulateRequest,
|
||||||
SimulateResult,
|
SimulateResult,
|
||||||
)
|
)
|
||||||
from fire_planner.col import compute_col_ratio, representative_city_for
|
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.flex_spending import FlexRule as EngineFlexRule
|
||||||
from fire_planner.glide_path import static
|
from fire_planner.glide_path import static
|
||||||
from fire_planner.goals_eval import evaluate_goals
|
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"])
|
router = APIRouter(tags=["simulate"])
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_RETURNS_CSV = Path("/data/shiller_returns.csv")
|
_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(
|
def _resolve_col_adjustment(
|
||||||
req: SimulateRequest,
|
req: SimulateRequest,
|
||||||
|
|
@ -227,6 +282,7 @@ def _to_response(
|
||||||
col_multiplier: Decimal | None = None,
|
col_multiplier: Decimal | None = None,
|
||||||
col_adjusted_spend: Decimal | None = None,
|
col_adjusted_spend: Decimal | None = None,
|
||||||
col_target_city: str | None = None,
|
col_target_city: str | None = None,
|
||||||
|
examples_overlay: ExamplesOverlay | None = None,
|
||||||
) -> SimulateResult:
|
) -> SimulateResult:
|
||||||
# portfolio_real has n_years+1 columns (year 0 = seed, year k = end-of-year k).
|
# 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).
|
# 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)))
|
col_adjusted_spending_gbp=(Decimal(str(round(float(col_adjusted_spend), 2)))
|
||||||
if col_adjusted_spend is not None else None),
|
if col_adjusted_spend is not None else None),
|
||||||
col_target_city=col_target_city,
|
col_target_city=col_target_city,
|
||||||
|
examples_overlay=examples_overlay,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/simulate", response_model=SimulateResult)
|
@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."""
|
"""Run one scenario synchronously, no DB write. ~1-3s for 5k paths."""
|
||||||
adjusted_req, mult, adj_spend, target_city = _resolve_col_adjustment(req)
|
adjusted_req, mult, adj_spend, target_city = _resolve_col_adjustment(req)
|
||||||
paths = await _build_paths(adjusted_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)
|
result, elapsed = await asyncio.to_thread(_project, adjusted_req, paths)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None
|
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)
|
@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."""
|
"""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)
|
adjusted_s, mult, adj_spend, target_city = _resolve_col_adjustment(s)
|
||||||
paths = await _build_paths(adjusted_s)
|
paths = await _build_paths(adjusted_s)
|
||||||
result, elapsed = await asyncio.to_thread(_project, adjusted_s, paths)
|
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:
|
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:
|
except KeyError as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None
|
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)
|
return CompareResult(results=results)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ point, the point is the endpoint produces a valid response shape.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest_asyncio
|
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.api.dependencies import get_session
|
||||||
from fire_planner.app import app
|
from fire_planner.app import app
|
||||||
|
from fire_planner.db import FireExample
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
|
|
@ -165,3 +167,48 @@ async def test_compare_rejects_single_scenario(client: AsyncClient) -> None:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422 # pydantic validation
|
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