"""click CLI entrypoint. Sub-commands: - migrate — alembic upgrade head - ingest [wealthfolio] — pull from wealthfolio_sync PG mirror into account_snapshot - simulate — run a single scenario, pretty-print - recompute-all — run the 120-scenario Cartesian, persist all - serve — run the FastAPI on-demand /recompute server """ from __future__ import annotations import asyncio import logging import os import subprocess import sys import time from datetime import date from decimal import Decimal from pathlib import Path import click import numpy as np from sqlalchemy.ext.asyncio import async_sessionmaker from fire_planner.db import create_engine_from_env, make_session_factory from fire_planner.glide_path import get as get_glide from fire_planner.ingest.wealthfolio import upsert_snapshots from fire_planner.ingest.wealthfolio_pg import ( create_wf_sync_engine_from_env, read_account_snapshots_from_pg, ) from fire_planner.reporters.cli import format_scenario from fire_planner.reporters.pg import write_run from fire_planner.returns.bootstrap import block_bootstrap from fire_planner.returns.shiller import load_from_csv, synthetic_returns from fire_planner.scenarios import ( ScenarioSpec, build_regime_schedule, build_strategy, cartesian_scenarios, ) from fire_planner.simulator import simulate log = logging.getLogger(__name__) @click.group() def cli() -> None: logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO")) @cli.command() def migrate() -> None: """Run `alembic upgrade head`.""" rc = subprocess.run(["alembic", "upgrade", "head"], check=False) sys.exit(rc.returncode) @cli.command("col-seed") @click.option("--ttl-days", type=int, default=365, help="Cache TTL in days (default 365 — matches Viktor's 1y choice).") def col_seed(ttl_days: int) -> None: """Seed `col_snapshot` from baseline.py BASELINES. Idempotent — uses upsert on (city_slug, source_name). Run once after the alembic migration creates the table. Subsequent live-scrape refreshes (Phase 3 CronJob) supersede these rows; the baseline fallback remains as a last-resort source. """ asyncio.run(_col_seed(ttl_days)) async def _col_seed(ttl_days: int) -> None: from fire_planner.col.baseline import BASELINES from fire_planner.col.cache import upsert as col_upsert engine = create_engine_from_env() factory = make_session_factory(engine) try: async with factory() as sess: for slug, idx in BASELINES.items(): # Tag the source as `baseline` rather than `numbeo` so a # later live scrape (source_name='numbeo') doesn't conflict # on the (city_slug, source_name) unique constraint. tagged = idx.model_copy( update={"source": idx.source.model_copy(update={"name": "baseline"})} ) await col_upsert(sess, tagged, ttl_days=ttl_days) click.echo(f" seeded {slug:20s} total={idx.total_single_with_rent_gbp} GBP") finally: await engine.dispose() click.echo(f"\ncol-seed: {len(BASELINES)} cities upserted (ttl_days={ttl_days}).") @cli.command("col-refresh-stale") @click.option("--within-days", type=int, default=7, help="Refresh rows whose expires_at is within this many days.") @click.option("--ttl-days", type=int, default=365, help="TTL for re-written rows (default 365).") def col_refresh_stale(within_days: int, ttl_days: int) -> None: """Re-scrape COL rows that are within `within_days` of expiry. Designed for the weekly CronJob. Walks every distinct city_slug in `col_snapshot` whose newest row will expire within the window, calls Numbeo+Expatistan via `service.lookup_city_cached`, which upserts the result. Idempotent — no-op for fresh rows. """ asyncio.run(_col_refresh_stale(within_days, ttl_days)) async def _col_refresh_stale(within_days: int, ttl_days: int) -> None: from sqlalchemy import select, text from fire_planner.col.service import lookup_city_cached from fire_planner.db import ColSnapshot engine = create_engine_from_env() factory = make_session_factory(engine) threshold = f"NOW() + INTERVAL '{int(within_days)} days'" refreshed = 0 failed = 0 try: async with factory() as sess: # Find distinct city_slug whose freshest row expires within window. stmt = ( select(ColSnapshot.city_slug, ColSnapshot.country) .distinct() .where(text(f"expires_at <= {threshold}")) ) rows = (await sess.execute(stmt)).all() click.echo(f"col-refresh-stale: {len(rows)} city(ies) need refresh " f"(within_days={within_days})") for slug, country in rows: try: # lookup_city_cached upserts on cache miss, which is # what "stale" means here — read_fresh returns None. idx = await lookup_city_cached(sess, slug, country=country or "") click.echo(f" refreshed {slug:20s} → {idx.source.name:10s} " f"total={idx.total_single_with_rent_gbp}") refreshed += 1 except Exception as e: # broad — log and continue per-city click.echo(f" FAILED {slug}: {e}", err=True) failed += 1 finally: await engine.dispose() click.echo(f"\ncol-refresh-stale done: refreshed={refreshed} failed={failed} " f"ttl_days={ttl_days}") @cli.command("ingest") @click.option("--source", type=click.Choice(["wealthfolio"]), default="wealthfolio", help="Data source — currently only wealthfolio is wired.") @click.option("--as-of", type=click.DateTime(formats=["%Y-%m-%d"]), default=None, help="Valuation date to read; defaults to MAX(valuation_date) in the mirror.") def ingest(source: str, as_of: date | None) -> None: """Pull external state into fire_planner.account_snapshot.""" if source == "wealthfolio": asyncio.run(_ingest_wealthfolio(as_of)) async def _ingest_wealthfolio(as_of: date | None) -> None: """Read account snapshots from wealthfolio_sync PG mirror, upsert.""" wf_engine = create_wf_sync_engine_from_env() try: wf_factory = async_sessionmaker(wf_engine, expire_on_commit=False) async with wf_factory() as wf_sess: rows = await read_account_snapshots_from_pg(wf_sess, as_of=as_of) finally: await wf_engine.dispose() if not rows: click.echo( "warning: no rows read — wealthfolio_sync mirror is empty or " "no rows on the requested date", err=True, ) engine = create_engine_from_env() factory = make_session_factory(engine) try: async with factory() as sess: n = await upsert_snapshots(sess, rows) await sess.commit() click.echo(f"wealthfolio ingest: {n} rows upserted") finally: await engine.dispose() def _build_paths(seed: int, n_paths: int, n_years: int, returns_csv: Path | None) -> np.ndarray: """Load returns from CSV (production) or synthetic (smoke tests).""" if returns_csv and returns_csv.exists(): bundle = load_from_csv(returns_csv) else: bundle = synthetic_returns(seed=42) rng = np.random.default_rng(seed) return block_bootstrap(bundle, n_paths=n_paths, n_years=n_years, block_size=5, rng=rng) @cli.command("simulate") @click.option("--scenario", required=True, help="external_id, e.g. cyprus-vpw-leave-y3-glide-rising") @click.option("--n-paths", type=int, default=10_000) @click.option("--horizon", type=int, default=60) @click.option("--spending", type=float, default=100_000.0) @click.option("--nw-seed", type=float, default=1_000_000.0) @click.option("--savings", type=float, default=0.0) @click.option("--floor", type=float, default=None, help="Real-GBP floor for vpw_floor strategy (e.g. 40000).") @click.option("--returns-csv", type=click.Path(path_type=Path), default=None) @click.option("--seed", type=int, default=42) @click.option("--write-db/--no-write-db", default=False, help="Persist results to fire_planner DB.") def simulate_cmd( scenario: str, n_paths: int, horizon: int, spending: float, nw_seed: float, savings: float, floor: float | None, returns_csv: Path | None, seed: int, write_db: bool, ) -> None: """Run one scenario by external_id and pretty-print the result.""" parts = scenario.split("-") if len(parts) < 6 or parts[2] != "leave" or parts[4] != "glide": raise click.UsageError(f"bad scenario id: {scenario!r} " "(expected jurisdiction-strategy-leave-yN-glide-NAME)") jurisdiction = parts[0] # strategy may include underscore (e.g. guyton_klinger), so rebuild strategy_end = scenario.index("-leave-") strategy_name = scenario[len(jurisdiction) + 1:strategy_end] leave_year = int(parts[parts.index("leave") + 1].lstrip("y")) glide_name = scenario.split("-glide-")[1] spec = ScenarioSpec( jurisdiction=jurisdiction, strategy=strategy_name, leave_uk_year=leave_year, glide_path=glide_name, spending_gbp=Decimal(str(spending)), nw_seed_gbp=Decimal(str(nw_seed)), horizon_years=horizon, savings_per_year_gbp=Decimal(str(savings)), ) paths = _build_paths(seed, n_paths, horizon, returns_csv) annual_savings = (np.full(horizon, savings, dtype=np.float64) if savings else None) started = time.perf_counter() result = simulate( paths=paths, initial_portfolio=nw_seed, spending_target=spending, glide=get_glide(glide_name), strategy=build_strategy(strategy_name, floor=floor), regime=build_regime_schedule(jurisdiction, leave_year), horizon_years=horizon, annual_savings=annual_savings, ) elapsed = time.perf_counter() - started click.echo(format_scenario(spec, result)) if write_db: asyncio.run(_persist(spec, result, seed=seed, elapsed_seconds=elapsed)) click.echo(f"simulate: elapsed={elapsed:.2f}s") async def _persist(spec: ScenarioSpec, result: object, *, seed: int, elapsed_seconds: float) -> None: engine = create_engine_from_env() factory = make_session_factory(engine) try: async with factory() as sess: from fire_planner.simulator import SimulationResult # local to avoid cycle assert isinstance(result, SimulationResult) await write_run(sess, spec, result, seed=seed, elapsed_seconds=elapsed_seconds) await sess.commit() finally: await engine.dispose() @cli.command("recompute-all") @click.option("--n-paths", type=int, default=10_000) @click.option("--horizon", type=int, default=60) @click.option("--spending", type=float, default=100_000.0) @click.option("--nw-seed", type=float, default=1_000_000.0) @click.option("--savings", type=float, default=0.0) @click.option("--floor", type=float, default=None, help="Real-GBP floor — applied to vpw_floor scenarios in the sweep.") @click.option("--returns-csv", type=click.Path(path_type=Path), default=None) @click.option("--seed", type=int, default=42) def recompute_all(n_paths: int, horizon: int, spending: float, nw_seed: float, savings: float, floor: float | None, returns_csv: Path | None, seed: int) -> None: """Run the full Cartesian (default 120 scenarios) and persist.""" asyncio.run( _recompute_all(n_paths, horizon, spending, nw_seed, savings, floor, returns_csv, seed)) async def _recompute_all( n_paths: int, horizon: int, spending: float, nw_seed: float, savings: float, floor: float | None, returns_csv: Path | None, seed: int, ) -> None: paths = _build_paths(seed, n_paths, horizon, returns_csv) specs = cartesian_scenarios( spending_gbp=Decimal(str(spending)), nw_seed_gbp=Decimal(str(nw_seed)), savings_per_year_gbp=Decimal(str(savings)), horizon_years=horizon, ) annual_savings = (np.full(horizon, savings, dtype=np.float64) if savings else None) engine = create_engine_from_env() factory = make_session_factory(engine) successes = 0 try: async with factory() as sess: for spec in specs: started = time.perf_counter() result = simulate( paths=paths, initial_portfolio=nw_seed, spending_target=spending, glide=get_glide(spec.glide_path), strategy=build_strategy(spec.strategy, floor=floor), regime=build_regime_schedule(spec.jurisdiction, spec.leave_uk_year), horizon_years=horizon, annual_savings=annual_savings, ) elapsed = time.perf_counter() - started await write_run(sess, spec, result, seed=seed, elapsed_seconds=elapsed) successes += 1 click.echo(f"{spec.external_id}: success={result.success_rate*100:.1f}% " f"elapsed={elapsed:.2f}s") await sess.commit() finally: await engine.dispose() click.echo(f"recompute-all done: {successes}/{len(specs)} scenarios written") @cli.command() def serve() -> None: """Run the FastAPI on-demand /recompute server.""" import uvicorn uvicorn.run("fire_planner.app:app", host="0.0.0.0", port=8080) if __name__ == "__main__": cli()